Compare commits

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

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

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

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

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

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

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

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

The tests that turned SSO on through the user unit now pin the refusal
and reach the same end state through the system path. The copy tests
start the Console on real scratch prerequisites and pin each refusal.
The run sheet, TOOLS.md and the accounts design record the ruling and
the migration order.
2026-11-10 21:38:17 -06:00
jason.woltje 554bc5116d feat(cli): require dedicated host uid for accounts system units (#1873) 2026-11-10 21:38:17 -06:00
jason.woltje 6db8fb2dce feat(cli): enforce stamped-copy and deployed-instance boundaries (#1873) 2026-11-10 21:38:17 -06:00
jason.woltje 85122dd2bc feat(webui): separate account sessions from root authority (#1873) 2026-11-10 21:38:17 -06:00
62 changed files with 2574 additions and 414 deletions

No files matched your search

+84 -6
View File
@@ -807,13 +807,52 @@ the port and the backup's `SHA256SUMS`. Then it makes
Roles come from the target copy's `roles/`, as the live bus reads them.
- `steps.log` holds the same PASS and FAIL lines the run prints, with times;
`host.log`, `console.log` and, after a migration, `migrate.log` sit beside it.
- When the live config has `console.sso` (M3, #1873), the scratch copy keeps
its provider ids, client ids, algorithms and subjects and moves the public
origin to `rehearsal.invalid`, a name that never resolves. For a stamped
target each issuer becomes `ORIGIN/ID/` at the rehearsal provider below;
otherwise it moves to `rehearsal.invalid` too. Each client secret slot is a 0600 file under `config/secrets/mosaic-stack/`
holding `rehearsal-placeholder-secret`; the live secret is never read.
A stamped target copy (one with `.stack-build.json`) serves only with `--sso`,
so its rehearsal needs the live instance's human-made SSO steps. Without a live
`console.sso` it refuses `sso-not-prepared`; without the live `accounts.json`
beside the config it refuses `accounts-not-initialized`. Both name the run-sheet
step. Otherwise it copies the live `accounts.json` into `config/`, writes
`data/.deployed` with the target copy's own `writeDeployment`, prints
`PASS deployment`, and starts the Console with `--sso`.
Before that, a stamped run starts a rehearsal provider and prints
`PASS provider-start`. It is a node process on `127.0.0.1` port 0 that serves
HTTPS with a certificate made for this run, under `provider/` (mode 0700). For
each live provider id it serves discovery and a JWKS whose public key it made
in memory for the first algorithm it can make from that provider's list. Its
authorize and token routes answer 403 to any method and query: it issues no
code and no token, and
it reads no live secret. It logs each request to `provider/requests.log`,
deletes its TLS key file once it has read it, and exits on SIGTERM or when
the rehearsal's process is gone. `provider-start` fails when it exits early,
writes no origin within the timeout, writes an origin that is not
`https://127.0.0.1:PORT`, or leaves its key file. Only the scratch Console gets
`NODE_EXTRA_CA_CERTS` naming `provider/cert.pem`; the host does not.
The scratch Console runs every startup check against that provider. The gate
adds `sso-ready`: `/healthz` must report each provider `ready`. After the gate,
`provider-discovery` requires a 200 discovery and JWKS request per provider and
no authorize or token request. The end stops the provider with the Console and
host and prints `provider-stopped`. The live `accounts.json` joins the end check
`live-config-unchanged`. The rehearsal adds no subject and no account.
A guard then loads the scratch config with the target's code and refuses
with a named check: `data-root-live` when the scratch data root resolves to
the live one, `sessions-live` when a `sessions.json` there names a running
pid (the run never deletes the file), `data-root-scratch`, `notify-off`,
`tracker-configured` when `bootConfig` still sets a tracker, and
`credential-ref-live` for an env reference or a file outside `placeholders/`.
`tracker-configured` when `bootConfig` still sets a tracker,
`credential-ref-live` for an env reference or a file outside `placeholders/`,
and `sso-isolated` when a `console.sso` origin is not on `rehearsal.invalid`,
an issuer is not `ORIGIN/ID/` at the rehearsal provider (not on
`rehearsal.invalid` for an unstamped target), or a slot is not its scratch
placeholder.
When the backup's schema differs from the target's, the run checks that the
target refuses the store, migrates it, opens it, migrates it again as a
no-op, and that the pointer's copy refuses the migrated store.
@@ -822,7 +861,8 @@ It starts `scripts/mosaic bus start BUSINESS --no-launch` and the Console's
`serve --port N --business BUSINESS` from the target copy, without `--send`.
The gate needs the host's `launches disabled` line, a live `bus status` host
with this run's pid and a boot from this start, the Console's address line,
`/healthz` 200, and both command lines on the target copy. Then it checks
`/healthz` 200 (and `sso-ready` for a stamped target), and both command lines
on the target copy. Then it checks
that the host wrote no `sessions.json` and no `launches`, stops the Console
and the host (SIGTERM, then SIGKILL and a FAIL after the timeout), checks that
the host released `writer.lock`, and compares the backup and the live config
@@ -834,6 +874,21 @@ Usage exits 4. Nothing prunes rehearsal directories.
starts no thread ticker, and leaves an earlier host's `sessions.json` and
its runs alone. No unit passes it, and `--pm` with it is a usage error.
An accounts instance runs system units (M3, #1873). Without `--units external`,
`activate`, `rollback` and `pm-on` refuse `accounts-user-unit`, before
anything stops and before `--plan` prints, when the target copy holds a
`.stack-build.json` (a stamped copy), when `accounts.json` sits beside the
config, when the config holds `console.sso`, or when `<dataRoot>/.deployed`
exists. A config that cannot be read as JSON refuses as well. The message
names the first sign found and says root renders the units with
`case2-unit.sh`. For `activate` and `rollback` it adds that the run takes
`--units external`. For `pm-on` it says the PM is a flag in root's bus unit.
So a user-unit run never turns `--sso` on any more. The `--sso`, `sso-slot`
and `--no-sso` text above describes the pointers that pre-M3 hosts left, and
the Console's own start checks the slot (`readSlot` in
`packages/webui/src/sso.mjs`). A rollback or `--no-sso` from such a pointer
runs once `console.sso` is gone and no other sign is present.
#### Root-owned units (`--units external`, row 232)
On a host whose bus and Console are root's system units, the stack user runs
@@ -894,8 +949,9 @@ the target migrates the store and the run lacks `--migrate` (row 233). Under
```
scripts/stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal]
[--rehearse-port N] [--timeout S]
scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
[--rehearse-port N] [--next-args] [--timeout S]
scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--next-args]
[--timeout S]
```
Root runs it from a root-owned checkout of the reviewed commit, never from a
@@ -935,7 +991,13 @@ user's clone `<home>/src/mosaic-stack`:
(`args-differ`), and
`activate SHA BUSINESS --units external --plan --expect-from OLD` exits 0
(`plan-refused`, which covers `open-activation`, `migrate-required` and
`pointer-differs`).
`pointer-differs`). Then the render checks (#1873 M3):
`case2-unit.sh --check` renders OLD from the args file and compares the
installed units with it, so a rollback could render them, then checks
SHA's render the same way. Either refusal is `render-refused`. A re-pin
that changes the serve flags fails the second check, because an
installed unit must equal the render but for the copy id (row 195); it
takes `--next-args`, below.
4. The rehearsal, `rehearse SHA BUSINESS --backup last --port 7339`
(`--rehearse-port`). Exit 0 goes on. Exit 3 means no backup in the log
and goes on only with `--no-rehearsal`, logging `rehearsal-skipped`;
@@ -950,6 +1012,22 @@ user's clone `<home>/src/mosaic-stack`:
still names the old one, and root moves the new file (the result line
names it) into place by hand.
`--next-args` changes the serve flags, as the SSO migration does
(`docs/guides/host-run-sheet.md` section 7). The run reads
`/etc/mosaic-stack/dev.args.next` as it reads the args file
(`args-unreadable`). It must equal the args file before `--` but for
`--copy`'s value (`next-args-differ`) and differ from it after `--`
(`next-args-same`). The target's check is `case2-unit.sh --check-new`, a
first install's checks from the next args, without the installed units'.
After A3, root moves the installed unit files into a new 0700 directory
`/etc/mosaic-stack/dev.replaced.XXXXXX` (step `units-aside`), so A4 renders
as a first install. A failure from there moves them back unchanged
(`units-back`), runs `daemon-reload` and starts the old units with no render.
After A5 the old args file is copied into the replaced directory, the next
args file is removed (step `next-args`), and the new args file, made from
the next args, moves into place. The result line names the replaced
directory. Nothing removes it.
A failure in A2 or a stage that exits 3 starts the old units again and gates
them (A2f). A later failure stops both units, runs `revert`, renders the old
copy and runs `confirm --rollback OLD --cause A3|A4|A5`. `rollback` is the
+80 -8
View File
@@ -386,10 +386,15 @@ then the bus, then the proxy, re-render all three onto the selected copy, and st
then the Console. Recovery after a failed upgrade restores all three to the old copy. A missing or
disagreeing proxy unit, or an installed proxy without `--proxy` in the args file, refuses before anything stops.
For the `library` profile, the serve argv after `--` keeps the proxy-preview trio and required proof pair
but omits `--send`. That Console is read-only until Case 2 gains real OIDC in #412; `case2-unit.sh`
explicitly refuses `--send`. Proxy preview and proof-protected reads remain available. The `factory`
profile and its private tester socket are unchanged.
A stamped copy, which is every copy `stack-copy.sh` makes, signs in through SSO accounts in both profiles
(M3, #1873; Jason's ruling 2026-11-10). After `--`, the `factory` profile takes `--sso --send` with its
private tester socket. The `library` profile takes a bare `--sso`, still without `--send`, so its Console
stays read-only. A stamped library copy refuses the proxy-preview trio (`--public-host`, `--auth-header`,
`--allow-user`) and the proof pair (`--proof-header`, `--proof-file`): proxy-preview admission does not
apply to a stamped copy. Only an unstamped library copy, which no host install makes, keeps the old
proxy-preview argv and refuses `--sso`. Before writing anything, `case2-unit.sh` refuses a stamped copy
that lacks `--sso` or any prerequisite and names each missing step. The migration below lists them in
order.
A library host installed before #385 has `--send` in two places, because the earlier `case2-unit.sh`
required it: a `--send` line after `--` in the args file, and `--send` in the installed Console unit's
@@ -418,10 +423,77 @@ grep -n '^ExecStart=' /etc/systemd/system/mosaic-webui.service | grep -c -- ' --
lead with the command's output. Removing only the args line does not help: the run then refuses
`console-unit-legacy`.
The Console stays read-only after the upgrade, until #412 (#385).
The library Console stays read-only after the upgrade (#385). From M3 it signs in through SSO
accounts, as the migration below sets up.
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` or `console-unit-legacy` on any of these
rules, before it stops a unit.
### Migrating a host to SSO accounts (M3, #1873)
A host re-pinned onto an M3 copy migrates first, in this order. The order is the same for the `factory`
(Case 1 and .53) and `library` (.54) profiles. Each step is a live step that needs Mos's go. Nothing in the
stack picks or invents a subject: the subjects come from `console.sso`, which a human prepares, and
`accounts.json` comes only from the human verb `mosaic accounts init`.
1. Check or create `.deployed` through the installer. The stack's user's `case2-install.sh` (section 6)
writes `<dataRoot>/.deployed` naming the instance at its step 2. It never replaces an existing one, and
it refuses one that is invalid or names another instance. Check it read-only:
`stat -c '%U %a' <dataRoot>/.deployed` prints `mosaic 444`.
2. Prepare SSO. A human adds `console.sso` to `/srv/mosaic/.config/mosaic-dev/config.json`, with its
providers, their subjects and its client-secret slots, per
`docs/plans/2026-11-10_accounts-design.md`. Jason places the client secrets (section 9).
3. Run `mosaic accounts init --name <name>` as `mosaic`. This is a human verb, run from a human shell and
never by an agent. It creates the one operator account from the configured `console.sso` subjects and
refuses if `accounts.json` exists.
4. Provision the dedicated uid. `mosaic` must keep section 1's shape: a non-login shell, the only member
of its primary group, and owner of its config directory and data root. An accounts instance never runs
under a human or development seat uid, and never under the `webui-service.sh` user units.
5. Re-pin with `--next-args`. Leave `/etc/mosaic-stack/dev.args` as it is. Write the new flags to
`/etc/mosaic-stack/dev.args.next`, root's, mode 0640: the same lines before `--`, and after `--` the
serve flags with `--sso` added (`factory`: `--sso --send`). For `library`, also remove the
proxy-preview trio (`--public-host`, `--auth-header`, `--allow-user`) and the proof pair
(`--proof-header`, `--proof-file`). Then run
`stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA --next-args`.
A plain re-pin refuses `render-refused` here, because an installed unit must equal the render but for
the copy id. With `--next-args`, before anything stops, the driver checks the installed copy against
`dev.args` and the target as a first install from `dev.args.next`. It refuses `next-args-differ` when
the files differ before `--` (other than `--copy`), and `next-args-same` when they do not differ after
it. After the stage it moves the installed unit files into a new 0700 directory
`/etc/mosaic-stack/dev.replaced.XXXXXX` and renders the target as a first install. A failure from
there moves them back unchanged and starts the old copy again. After `confirm` passes,
`dev.args.next` becomes `dev.args`, and the replaced directory keeps the old unit files and the old
`dev.args`. The result line names the directory. Nothing removes it; root removes it by hand once no
rollback needs it.
To roll back past the migration, put the replaced args back as the next args and roll back with
`--next-args`:
```sh
install -m 0640 -o root -g root /etc/mosaic-stack/dev.replaced.XXXXXX/dev.args /etc/mosaic-stack/dev.args.next
stack-upgrade.sh rollback --instance dev --to <old copy id> --next-args
```
For `library` this is the only rollback to a copy older than M3: `case2-unit.sh` refuses `--sso` for
an unstamped library copy (#416), so the old copy comes back with its old flags, proxy-preview
admission included, as before the migration. A `factory` rollback may instead keep `--sso --send`
with a plain `rollback`.
If a step is missing, `case2-unit.sh` refuses before it writes a unit and prints the missing steps,
numbered in this order, for example:
```text
case2-unit: refusing the units for <sha>: a stamped copy's Console starts only with SSO accounts; missing steps, in order:
1. check or create .deployed: mosaic runs case2-install.sh, whose deployment step writes <dataRoot>/.deployed
2. prepare SSO: console.sso in mosaic's system config /srv/mosaic/.config/mosaic-dev/config.json, with its client secret slots
3. run `mosaic accounts init` as mosaic, a human verb: no /srv/mosaic/.config/mosaic-dev/accounts.json
4. add --sso to the serve flags, then re-pin
```
Without `console.sso`, the render does not check `accounts.json`, because its subjects name providers SSO
has not configured yet. The next render after step 2 checks it. A rollback never removes accounts.json,
its receipts or `.deployed` (`docs/plans/2026-11-10_accounts-design.md`, section 7).
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable`, `console-unit-legacy`, `render-refused`,
`next-args-differ` or `next-args-same` on any of these rules, before it stops a unit.
## 8. Enrollment, once per host
@@ -450,7 +522,7 @@ file with `stat -c '%U %a %n' PATH`, never `cat`. `<business>` is the host's bus
| Path | File | Mode | Owner | Profile | Precheck line |
|---|---|---|---|---|---|
| `/srv/mosaic/.config/mosaic-dev/secrets/` | `claude-session.env`, one line `ZAI_API_KEY=...` | 0600 | mosaic | library | `<path> sets ZAI_API_KEY` |
| `/srv/mosaic/.config/mosaic-dev/secrets/` | the Console's proof file, the path the args file's `--proof-file` names: one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. |
| `/srv/mosaic/.config/mosaic-dev/secrets/` | before M3 only, and an unstamped copy only: the Console's proof file, the path the args file's `--proof-file` names. A stamped copy refuses `--proof-file` (section 7). The file is one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. |
| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | After a unit start, wait for loopback `/healthz` with the bounded retry in section 10 before `case2-precheck.sh --business <business> --library-status`: `library-last-error library-credential-missing` until it's placed, then `library <project> last fetched ...` |
| `/srv/mosaic/.config/mosaic-dev/secrets/agents/<business>/<agent>/` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. |
+28 -5
View File
@@ -108,7 +108,10 @@ configured subject (`packages/cli/src/config.mjs:141`), and
`packages/webui/src/cli.mjs serve` from that copy. Its flag set is
`--port`, `--business`, `--board`, `--sso`, `--send` and the header
preview set (`webui-service.sh:98`). It has no dev-owner flag.
`scripts/case2-unit.sh` renders Case 2 units the same way.
`scripts/case2-unit.sh` renders Case 2 units the same way. At BASE its
library profile takes the preview set and refuses `--sso`. Under the
owner ruling of 2026-11-10 a stamped library copy signs in through SSO
accounts instead (section 6, item 5).
### Organizations and who may read them
@@ -1231,7 +1234,13 @@ The executable boundary:
this instance, and accounts.json loads. The Case 1 and Case 2 installers
write `.deployed`.
5. **The unit renderers have no dev flag.** `webui-service.sh` and
`case2-unit.sh` accept no dev flag today (`webui-service.sh:98`). M3 adds
`case2-unit.sh` accept no dev flag today (`webui-service.sh:98`). For a
stamped copy, `case2-unit.sh` renders either profile, factory or library,
only with `--sso`, `console.sso`, a loading accounts.json and a valid
`.deployed`, and refuses before any write, naming each missing step. A
stamped library unit refuses proxy-preview admission and its proof flags:
proxy-preview does not apply to a stamped copy. The dev and unstamped
paths keep the preview set. M3 adds
a test that pins it.
The instance boundary, which also covers a checkout started by mistake on a
@@ -1289,13 +1298,22 @@ business file.
### Rollback
Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off.
Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off,
except the library rollback past the migration below.
- The supported rollback is a pinned code and config rollback: install the
earlier stack copy with the same unit flags, including `--sso`, through
`webui-service.sh install --copy <earlier id>`, as the unit script
already describes. Leave accounts.json, its receipts and `.deployed` in
`stack-upgrade.sh rollback --instance NAME --to <earlier id>` for the
dedicated system units. The accounts instance must not use the
`webui-service.sh` user-unit path. Leave accounts.json, its receipts and `.deployed` in
place. BASE code ignores them, and the next roll-forward needs them.
- A library host rolled back to a copy older than M3 is the exception.
`case2-unit.sh` refuses `--sso` for an unstamped library copy (#416), so
that rollback renders the old copy from the args file the migration
replaced, through `stack-upgrade.sh rollback ... --next-args`
(`docs/guides/host-run-sheet.md` section 7). The old copy comes back with
its old flags, proxy-preview admission included, as before the migration.
accounts.json and `.deployed` stay in place.
- Before the first accounts org exists, that rollback restores today's
behavior completely. `console.sso` owners sign in as before, and FED R6
users keep their root grants. Account-only users and pending invites stop
@@ -1450,6 +1468,9 @@ Acceptance tests, against a fake OIDC provider on port 0:
- `--dev-local-owner` from a checkout refuses with accounts.json present,
with `console.sso`, and with `.deployed`.
- `webui-service.sh render` and `case2-unit.sh` refuse a dev flag.
- For a stamped copy, `case2-unit.sh` renders a library or factory unit
only with `--sso` and the prerequisites, names each missing step before
any write, and refuses the proxy-preview and proof flags.
- `case2-unit.sh` accepts the host run sheet's `mosaic` account fixture only
when its shell is non-login, its primary group has no other members, and
it owns the config directory and data root. Fixtures for a human login and
@@ -1666,6 +1687,8 @@ and draft 3 applies them. No question from draft 2 remains open.
| May an invite bind to an email domain? | "agreed" | No domain binding. Section 4 binding, test in M6. |
| May members read the board and write tasks? | "Similar to 1. grantable right via RBAC. Default owner R/W, member R" | The `board.read` and `task.write` rights. `member` holds `board.read` only. Section 3, slices M1, M5 and M7. |
The library Console signs in through SSO accounts like every deployed Console; proxy-preview admission does not apply to a stamped copy.
### Choices made under the rulings
These are decided in this draft, not open. Each one is a single reviewed
@@ -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";
+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.
+3
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";
@@ -614,8 +615,10 @@ async function bus(rest, io, deps) {
const parsed = parse(args, { flags: ["--pm", "--no-launch", "--dev-local-owner", "--detach", "--internal-host"] });
if (parsed.positional.length !== 1) throw usage("bus start takes one organization");
if (parsed.flags.has("--no-launch") && parsed.flags.has("--pm")) throw usage("bus start takes --pm or --no-launch, not both");
assertBuildStart({ devOwner: parsed.flags.has("--dev-local-owner") });
if (parsed.flags.has("--dev-local-owner")) devOwnerInstance(io.env);
const ctx = organizationContext(io, deps);
if (parsed.flags.has("--dev-local-owner")) assertDevInstance(ctx.system, io.env);
const businessId = parsed.positional[0];
if (!parsed.flags.has("--internal-host")) {
const receipt = await busStart(ctx, businessId, { detach: parsed.flags.has("--detach"), devLocalOwner: parsed.flags.has("--dev-local-owner"), pm: parsed.flags.has("--pm"), noLaunch: parsed.flags.has("--no-launch") });
+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); }
}
+32
View File
@@ -69,3 +69,35 @@ test('host boundary and receipt use effective uid, not real uid (current design
try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); }
finally { process.getuid = realUid; }
});
// The uid identifies this process, not the human who invoked it. The instance
// uid can rewrite its receipt; this is neither human attribution nor tamper evidence.
test('M3 CLI integration refuses foreign effective uid before any account mutation, and receipts identify the process', async t => {
const { spawnSync } = await import('node:child_process'); const { fixture } = await import('./helpers.mjs');
const root=mkdtempSync(join(tmpdir(),'ai-')); t.after(()=>rmSync(root,{recursive:true,force:true})); const f=fixture(root);
const config=JSON.parse(readFileSync(f.env.MOSAIC_CONFIG)); config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'pocket',issuer:'https://id.test',clientId:'test',clientSecret:{slot:'pocket'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}};
writeFileSync(f.env.MOSAIC_CONFIG,JSON.stringify(config));
const prelude=join(root,'uid.mjs'); const cli=new URL('../src/cli.mjs',import.meta.url).pathname;
const run=args=>spawnSync(process.execPath,['--import',prelude,cli,'accounts',...args],{env:{...process.env,...f.env},encoding:'utf8',timeout:10000});
writeFileSync(prelude,`process.geteuid=()=>${process.geteuid()+1};`); const refused=run(['init']); assert.equal(refused.status,3,refused.stderr); assert.match(refused.stderr,/accounts-unsafe: (a config directory ancestor|config directory) must belong to (root or )?uid \d+/);
for(const name of ['accounts.json','accounts.lock','accounts-receipts.jsonl']) assert.equal(existsSync(join(root,'config',name)),false);
// Different real uid, same effective uid: the effective identity is recorded.
writeFileSync(prelude,`process.getuid=()=>${process.geteuid()+2};`); const ok=run(['init']); assert.equal(ok.status,0,ok.stderr);
const receipt=JSON.parse(readFileSync(join(root,'config/accounts-receipts.jsonl'),'utf8')); assert.equal(receipt.uid,process.geteuid());
});
// org-acct-anchor (#1873): `mosaic accounts init` refuses under an ancestor another user could
// rename, before any lock or write, and the refusal names the rule, never the path.
test('org-acct-anchor: init, list and remove refuse a 0777 ancestor before any lock; 1777 passes', async t => {
const top = mkdtempSync(join(tmpdir(), 'a-')); t.after(() => { chmodSync(top, 0o700); rmSync(top, { recursive: true, force: true }); });
const parent = join(top, 'sk-test-NOT-A-SECRET-1234'), configDir = join(parent, 'cfg'); mkdirSync(configDir, { recursive: true, mode: 0o700 });
let output = ''; const io = { env: {}, stdout: { write: s => output += s }, stderr: { write: () => {} } };
const deps = { configDir, instance: 'test', system: { console: { sso: { providers } } } };
chmodSync(parent, 0o777);
for (const args of [['init'], ['list'], ['user', 'remove', 'u-000000000001', '--revision', '1']]) {
await assert.rejects(accountsCommand(args, io, deps), e => e.exitCode === 3 && /^refused: accounts-unsafe: a config directory ancestor must belong to root or uid \d+ and not be writable by others without the sticky bit$/.test(e.message) && !e.message.includes('sk-test') && !e.message.includes('/'));
for (const name of ['accounts.json', 'accounts.lock', 'accounts-receipts.jsonl', 'org-runtime.lock']) assert.equal(existsSync(join(configDir, name)), false, name);
}
chmodSync(parent, 0o1777);
await accountsCommand(['init'], io, deps); assert.match(output, /accounts init: u-[0-9a-f]{12}, revision 1/);
});
+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);
});
+258 -3
View File
@@ -66,7 +66,7 @@ function hub(t, { uid = UID } = {}) {
const bin = join(root, "bin");
const log = join(root, "systemctl.log");
script(join(bin, "getent"), `case "$1" in
passwd) [ "$2" = mosaic ] && echo "mosaic:x:${uid}:${GID}::${home}:/bin/bash" ;;
passwd) { [ -z "$2" ] || [ "$2" = mosaic ]; } && echo "mosaic:x:${uid}:${GID}::${home}:/usr/sbin/nologin" ;;
group) [ "$2" = "${GID}" ] && echo "mosaic:x:${GID}:" ;;
*) exit 2 ;;
esac`);
@@ -364,7 +364,7 @@ test("the unit takes --org or --business on either side of --, renders --busines
test("the unit refuses a bad selector in either scope, and an outer and serve pair that differ, before writing a unit", (t) => {
const h = hub(t);
const serve = ["--port", "443", "--business", "acme"];
const usage = "usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n";
const usage = "usage: case2-unit.sh [--check|--check-new] --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n";
const head = ["--user", "mosaic", "--home", h.home, "--copy", COPY_A, "--node", USR_NODE, "--profile", "factory"];
for (const [label, argv, code, err] of [
["outer unequal, old first", [...head, "--business", "acme", "--org", "beta", "--", ...serve], 4, "case2-unit: --business acme and --org beta conflict\n"],
@@ -415,6 +415,8 @@ test("the read-only library profile explicitly refuses --send before writing a u
test("PATH starts with the --node directory, for a node outside the default list and for /usr/bin/node", (t) => {
const h = hub(t);
const node = outsideNode();
// This metadata-only test uses a root-owned executable as a path stand-in.
writeFileSync(join(h.root, 'query-output'), JSON.stringify({ dataRoot: h.dataRoot, socket: true })); writeFileSync(join(h.root, 'query-status'), '0');
assert.equal(unit(h, { node }).code, 0);
for (const f of [busFile(h), webuiFile(h)]) assert.deepEqual(lines(read(f), "Environment"), [`PATH=${dirname(node)}:/usr/local/bin:/usr/bin:/bin`]);
assert.match(lines(read(webuiFile(h)), "ExecStart")[0], new RegExp(`^${node} `));
@@ -550,6 +552,7 @@ test("a --node that is relative, not root-owned or under <home> refuses", (t) =>
test("a re-install with a different --node or different serve flags refuses", (t) => {
const h = hub(t);
assert.equal(unit(h).code, 0);
writeFileSync(join(h.root, 'query-output'), JSON.stringify({ dataRoot: h.dataRoot, socket: true })); writeFileSync(join(h.root, 'query-status'), '0');
// A new node moves PATH too, so the installed unit fails its own Environment= check first.
refuses(unit(h, { node: outsideNode() }), /installed .*: the Environment= lines must be exactly/);
const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f));
@@ -705,7 +708,9 @@ test("#415 factory --sso refuses before any unit write when the stack user's con
noUnits(h);
});
test("#416 library still refuses --sso, alone and with --send, before writing a unit", (t) => {
// M3 (#1873; Jason's ruling 2026-11-10): only an unstamped library copy keeps proxy preview. The
// stamped copies below sign in through SSO accounts.
test("#416 an unstamped library copy still refuses --sso, alone and with --send, before writing a unit", (t) => {
const h = hub(t);
ssoConfig(h);
refuses(unit(h, { flags: [...libraryFlags(h), "--sso"] }), /refusing --sso: the Console signs people in through the proxy/);
@@ -713,6 +718,233 @@ test("#416 library still refuses --sso, alone and with --send, before writing a
noUnits(h);
});
// ---- M3 (#1873): stamped copies ----
// A stamped copy (stack-copy.sh's read-only .stack-build.json) serves only with --sso, console.sso,
// an accounts.json that loads and a .deployed naming the default instance. The render asks the
// stack user's copy through the bounded sudo query, with the copy's real deployment.mjs and
// organization accounts.mjs, and refuses before any write, naming every missing step in order.
// No real host, config or credential: the issuer is on .invalid and nothing connects to it.
const ISSUER = "https://idp.invalid/a";
const STAMP_ACCOUNTS = { accountsVersion: 1, revision: 1, users: [{ id: "u-000000000001", subjects: [{ provider: "a", sub: "owner" }], name: "Owner", instanceRole: "operator", state: "active", authGen: 1, created: "2026-11-10T00:00:00Z" }], memberships: [], invites: [] };
const accountsFile = (h) => join(h.home, ".config", "mosaic-dev", "accounts.json");
const deployedFile = (h) => join(h.dataRoot, ".deployed");
function stamp(h, id = COPY_A) {
const dir = copyDir(h, id);
cpSync(join(REPO, "packages/cli/src/deployment.mjs"), join(dir, "packages/cli/src/deployment.mjs"));
for (const pkg of ["organization", "seat"]) symlinkSync(join(REPO, "packages", pkg), join(dir, "packages", pkg));
writeFileSync(join(dir, ".stack-build.json"), JSON.stringify({ commit: id }) + "\n");
chmodSync(join(dir, ".stack-build.json"), 0o444);
}
const stampSso = (h) => writeJson(h.query, { dataRoot: h.dataRoot, console: { sso: { publicOrigin: "https://console.invalid", providers: [{ id: "a", issuer: ISSUER }] } } });
function stampAccounts(h, doc = STAMP_ACCOUNTS) {
writeFileSync(accountsFile(h), JSON.stringify(doc));
chmodSync(accountsFile(h), 0o600);
}
function stampDeployed(h, instance = "dev") {
writeFileSync(deployedFile(h), JSON.stringify({ instance }) + "\n");
chmodSync(deployedFile(h), 0o444);
}
function stamped(t, { sso = true, accounts = true, deployed = true } = {}) {
const h = hub(t);
stamp(h);
if (sso) stampSso(h);
if (accounts) stampAccounts(h);
if (deployed) stampDeployed(h);
return h;
}
const STAMP_LIBRARY = ["--port", "443", "--business", "acme", "--sso"];
const STAMP_FACTORY = ["--port", "443", "--business", "acme", "--tester-socket", "--sso", "--send"];
const STEP = {
deployed: (h) => `check or create .deployed: mosaic runs case2-install.sh, whose deployment step writes ${h.dataRoot}/.deployed`,
deployedBad: (h) => `check .deployed: ${h.dataRoot}/.deployed is invalid or names another instance; case2-install.sh's deployment step reports why and never replaces it`,
sso: (h) => `prepare SSO: console.sso in mosaic's system config ${h.home}/.config/mosaic-dev/config.json, with its client secret slots`,
accounts: (h) => `run \`mosaic accounts init\` as mosaic, a human verb: no ${accountsFile(h)}`,
accountsBad: (h) => `fix ${accountsFile(h)}: it does not load (its owner or mode, an active operator, or a subject console.sso does not name); a human fixes it with the accounts verbs`,
library: () => "add --sso to the serve flags, then re-pin",
factory: () => "add --sso --send to the serve flags, then re-pin",
};
// The exact refusal: one header line, then the steps numbered in the run sheet's order.
function refusesSteps(h, r, steps) {
assert.equal(r.code, 1, `expected a refusal, got ${r.code}: ${r.out}${r.err}`);
const want = [`case2-unit: refusing the units for ${COPY_A}: a stamped copy's Console starts only with SSO accounts; missing steps, in order:`, ...steps.map((s, i) => ` ${i + 1}. ${s(h)}`)].join("\n") + "\n";
assert.equal(r.err, want);
}
// Nothing the render must not write: no unit, no systemctl, and none of the prerequisites.
function wroteNothing(h, { accounts = false, deployed = false } = {}) {
noUnits(h);
assert.equal(existsSync(accountsFile(h)), accounts, "the render never writes accounts.json");
assert.equal(existsSync(deployedFile(h)), deployed, "the render never writes .deployed");
}
test("M3 a stamped library unit renders only with --sso and the prerequisites, and runs the Console without proxy preview", (t) => {
const h = stamped(t);
const r = unit(h, { flags: STAMP_LIBRARY });
assert.equal(r.code, 0, r.out + r.err);
const webui = read(webuiFile(h));
assert.deepEqual(lines(webui, "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve --port 443 --business acme --sso`]);
assert.doesNotMatch(webui.split("\n").filter((l) => !l.startsWith("#")).join("\n"), /--public-host|--auth-header|--allow-user|--proof-header|--proof-file|--send/);
assert.deepEqual(lines(read(busFile(h)), "ExecStart"), [`${copyDir(h)}/scripts/mosaic bus start %i`]);
// The prerequisites were asked as the stack user, through the copy's own modules.
const sudo = read(h.sudoLog);
assert.match(sudo, new RegExp(`^-u mosaic -H env -i HOME=${h.home} PATH=`, "m"));
assert.match(sudo, /a\.loadAccounts\(.*d\.assertDeployed\(s,dir\)/);
assert.ok(sudo.includes(`${copyDir(h)}/packages/organization/src/accounts.mjs`));
assert.match(r.out, /enabled and started mosaic-bus@acme\.service, then mosaic-webui\.service\n$/);
// The render read them and left each as it was.
assert.deepEqual(JSON.parse(read(accountsFile(h))), STAMP_ACCOUNTS);
assert.equal(statSync(deployedFile(h)).mode & 0o777, 0o444);
});
test("M3 a stamped library unit refuses proxy-preview admission, its proof and --send, before any write", (t) => {
const h = stamped(t);
for (const [flag, value] of [["--public-host", "books.example"], ["--auth-header", "X-Auth-User"], ["--allow-user", "melanie"], ["--proof-header", "X-Proxy-Proof"], ["--proof-file", h.proof]]) {
const r = unit(h, { flags: [...STAMP_LIBRARY, flag, value] });
assert.equal(r.code, 1, flag);
assert.equal(r.err, `case2-unit: refusing ${flag}: ${COPY_A} is a stamped copy, and proxy-preview admission and its proof do not apply to a stamped copy; its Console signs in through SSO accounts with --sso\n`);
}
// The whole old preview set refuses at its first flag, with or without --sso.
refuses(unit(h, { flags: libraryFlags(h) }), /refusing --public-host: .* is a stamped copy, and proxy-preview admission and its proof do not apply/);
refuses(unit(h, { flags: [...STAMP_LIBRARY, "--send"] }), /the library profile is read-only; refusing --send \(a stamped library Console signs in with --sso and stays read-only\)/);
wroteNothing(h, { accounts: true, deployed: true });
assert.equal(existsSync(h.sudoLog), false, "a flag refusal comes before any query");
});
test("M3 a stamped library unit without its prerequisites refuses before any write and names every missing step in order", (t) => {
const none = stamped(t, { sso: false, accounts: false, deployed: false });
refusesSteps(none, unit(none, { flags: ["--port", "443", "--business", "acme"] }), [STEP.deployed, STEP.sso, STEP.accounts, STEP.library]);
wroteNothing(none);
refusesSteps(none, unit(none, { flags: STAMP_LIBRARY }), [STEP.deployed, STEP.sso, STEP.accounts]);
wroteNothing(none);
// Each prerequisite alone.
for (const [name, opts, steps] of [
["no .deployed", { deployed: false }, [STEP.deployed]],
["no console.sso", { sso: false }, [STEP.sso]],
["no accounts.json", { accounts: false }, [STEP.accounts]],
]) {
const h = stamped(t, opts);
refusesSteps(h, unit(h, { flags: STAMP_LIBRARY }), steps);
wroteNothing(h, { accounts: opts.accounts !== false, deployed: opts.deployed !== false });
}
const flagless = stamped(t);
refusesSteps(flagless, unit(flagless, { flags: ["--port", "443", "--business", "acme"] }), [STEP.library]);
wroteNothing(flagless, { accounts: true, deployed: true });
});
test("M3 a stamped copy refuses an invalid .deployed or accounts.json with the step that names it", (t) => {
const cases = [
[".deployed names another instance", (h) => { rmSync(deployedFile(h)); stampDeployed(h, "other"); }, [STEP.deployedBad]],
[".deployed writable", (h) => chmodSync(deployedFile(h), 0o644), [STEP.deployedBad]],
[".deployed broken link", (h) => { rmSync(deployedFile(h)); symlinkSync(join(h.root, "missing"), deployedFile(h)); }, [STEP.deployedBad]],
["accounts without an active operator", (h) => stampAccounts(h, { ...STAMP_ACCOUNTS, users: [{ ...STAMP_ACCOUNTS.users[0], state: "disabled" }] }), [STEP.accountsBad]],
["accounts subject on an unconfigured provider", (h) => stampAccounts(h, { ...STAMP_ACCOUNTS, users: [{ ...STAMP_ACCOUNTS.users[0], subjects: [{ provider: "b", sub: "owner" }] }] }), [STEP.accountsBad]],
["accounts writable by group", (h) => chmodSync(accountsFile(h), 0o620), [STEP.accountsBad]],
["accounts broken link", (h) => { rmSync(accountsFile(h)); symlinkSync(join(h.root, "missing"), accountsFile(h)); }, [STEP.accountsBad]],
];
for (const [name, change, steps] of cases) {
for (const [profile, flags] of [["library", STAMP_LIBRARY], ["factory", STAMP_FACTORY]]) {
const h = stamped(t);
change(h);
refusesSteps(h, unit(h, { profile, flags }), steps);
noUnits(h);
}
}
});
test("M3 a stamped factory or Case 1 unit renders only with --sso --send and the prerequisites, and names each missing step", (t) => {
const h = stamped(t);
const r = unit(h, { profile: "factory", flags: STAMP_FACTORY });
assert.equal(r.code, 0, r.out + r.err);
assert.deepEqual(lines(read(webuiFile(h)), "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${STAMP_FACTORY.join(" ")}`]);
const none = stamped(t, { sso: false, accounts: false, deployed: false });
refusesSteps(none, unit(none, { profile: "factory" }), [STEP.deployed, STEP.sso, STEP.accounts, STEP.factory]);
wroteNothing(none);
refusesSteps(none, unit(none, { profile: "factory", flags: STAMP_FACTORY }), [STEP.deployed, STEP.sso, STEP.accounts]);
wroteNothing(none);
// A stamped copy without the flag pair names it as the last step, with the prerequisites present.
const flagless = stamped(t);
refusesSteps(flagless, unit(flagless, { profile: "factory" }), [STEP.factory]);
refusesSteps(flagless, unit(flagless, { profile: "factory", flags: [...factoryFlags(), "--send"] }), [STEP.factory]);
wroteNothing(flagless, { accounts: true, deployed: true });
// The pair stays a pair: --sso alone still refuses, as for an unstamped copy.
refuses(unit(flagless, { profile: "factory", flags: [...factoryFlags(), "--sso"] }), /refusing --sso without --send in the factory profile/);
noUnits(flagless);
});
test("M3 a stamped copy's query refuses on noise or a failed query, before any write", (t) => {
// The copy's accounts module stands in for the stack user's code: noise on stdout, a failed exit.
for (const [body, pattern] of [
['console.log("forged");export const loadAccounts=()=>{};', /stamped copy query has invalid facts/],
["process.exit(1);export const loadAccounts=()=>{};", /stamped copy query inner-command/],
]) {
const h = stamped(t);
const org = join(copyDir(h), "packages/organization");
rmSync(org);
mkdirSync(join(org, "src"), { recursive: true });
writeFileSync(join(org, "src/accounts.mjs"), body);
refuses(unit(h, { flags: STAMP_LIBRARY }), pattern);
noUnits(h);
}
});
// #1873 M3: stack-upgrade.sh checks each render before it stops anything. --check runs a render's
// checks, the installed units' included, and writes nothing; --check-new skips only the installed
// units' checks, for --next-args, which moves them aside before its render.
const checkArgv = (h, { mode = "--check", profile = "library", copy = COPY_A, flags } = {}) =>
[mode, "--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", copy, "--node", USR_NODE, "--profile", profile, "--",
...(flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h)))];
const unitsNow = (h) => [busFile(h), webuiFile(h)].map((f) => (existsSync(f) ? read(f) : null));
const logNow = (h) => (existsSync(h.log) ? read(h.log) : "");
test("#1873 --check passes on a matching install and on a first install, refuses an edited unit, and never writes or calls systemctl", (t) => {
const h = hub(t);
const empty = unit(h, { argv: checkArgv(h) });
assert.equal(empty.code, 0, empty.out + empty.err);
assert.equal(empty.out, `case2-unit: check passed: the units for copy ${COPY_A} render, and the installed ones match them; nothing was written\n`);
noUnits(h);
assert.equal(unit(h).code, 0);
const units = unitsNow(h), log = logNow(h);
for (const copy of [COPY_A, COPY_B]) {
const r = unit(h, { argv: checkArgv(h, { copy }) });
assert.equal(r.code, 0, r.out + r.err);
assert.deepEqual(unitsNow(h), units, copy);
assert.equal(logNow(h), log, `${copy}: no systemctl call`);
}
// The installed-unit match stays: an edit, or other serve flags, refuses as the render would.
writeFileSync(busFile(h), units[0].replace(/^Description=.*$/m, "Description=edited"));
refuses(unit(h, { argv: checkArgv(h, { copy: COPY_B }) }), /differs from the render in more than the copy id/);
writeFileSync(busFile(h), units[0]);
const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f));
refuses(unit(h, { argv: checkArgv(h, { copy: COPY_B, flags }) }), /installed .*: ExecStart= must be exactly once/);
assert.deepEqual(unitsNow(h), units);
assert.equal(logNow(h), log);
});
for (const profile of ["library", "factory"]) {
test(`#1873 an installed pre-M3 ${profile} unit: --check and the render refuse a stamped copy's SSO flags, and --check-new passes with the prerequisites, writing nothing`, (t) => {
const h = hub(t);
assert.equal(unit(h, { profile }).code, 0);
stamp(h, COPY_B);
stampSso(h);
stampAccounts(h);
stampDeployed(h);
const flags = profile === "factory" ? STAMP_FACTORY : STAMP_LIBRARY;
const units = unitsNow(h), log = logNow(h);
refuses(unit(h, { argv: checkArgv(h, { profile, copy: COPY_B, flags }) }), /installed .*: ExecStart= must be exactly once/);
refuses(unit(h, { profile, copy: COPY_B, flags }), /installed .*: ExecStart= must be exactly once/);
const r = unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B, flags }) });
assert.equal(r.code, 0, r.out + r.err);
assert.equal(r.out, `case2-unit: check passed: the units for copy ${COPY_B} render as a first install; the installed ones were not compared; nothing was written\n`);
// --check-new still runs the stamped copy's checks: the old flags, or a missing prerequisite, refuse.
refuses(unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B }) }), /a stamped copy's Console starts only with SSO accounts|is a stamped copy, and proxy-preview admission/);
rmSync(accountsFile(h));
refuses(unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B, flags }) }), /run `mosaic accounts init` as mosaic, a human verb/);
assert.deepEqual(unitsNow(h), units);
assert.equal(logNow(h), log, "no systemctl call");
assert.equal(existsSync(accountsFile(h)), false, "the check never writes accounts.json");
assert.equal(statSync(deployedFile(h)).mode & 0o777, 0o444);
});
}
test("row253 factory refuses retired TCP and a value after the socket marker", (t) => {
const h = hub(t);
refuses(unit(h, { profile: "factory", flags: ['--port','443','--business','acme','--tester-port','7333'] }), /case2-unit.sh retires tester TCP; use --tester-socket/);
@@ -1320,3 +1552,26 @@ test('row334 proxy installed path rejects symlink and non-regular file', t => {
mkdirSync(proxyFile(h));
refuses(proxyUnit(h), /is not a regular file/);
});
// M3: the managed uid is a dedicated non-login service identity. These are
// database fixtures only: no real passwd/group or instance directories change.
test('M3 dedicated host account: login shells, other primary/group members and wrong directory owners refuse', t => {
for (const [kind, shell, extraPasswd, members] of [
['human login','/bin/bash','',''], ['interactive seat','/usr/bin/zsh','',''],
['another primary member','/usr/sbin/nologin',`human:x:${UID+1}:${GID}::/fictional:/bin/bash`,''],
['supplementary primary-group member','/usr/sbin/nologin','','human'],
]) {
const h = hub(t);
script(join(h.bin,'getent'), `case "$1" in
passwd) echo 'mosaic:x:${UID}:${GID}::${h.home}:${shell}'; [ -n "$2" ] || echo '${extraPasswd}';;
group) echo 'mosaic:x:${GID}:${members}';;
esac`);
const r = unit(h); assert.notEqual(r.code,0,kind); assert.match(r.err,/non-login|sole.member/,kind); assert.equal(existsSync(webuiFile(h)),false);
}
const h = hub(t); for (const flag of ['--dev-local-owner','--dev-insecure-bind','--dev-allow-host']) { const r = unit(h, { flags: [...libraryFlags(h), flag, '127.0.0.1'] }); assert.notEqual(r.code,0); assert.match(r.err,/dev-local-owner|serve flag/); }
});
test('M3 host config and data directories belong to the selected uid and both units use it', t => {
const h = hub(t); const ok = unit(h); assert.equal(ok.code,0,ok.err);
for (const file of [busFile(h),webuiFile(h)]) { assert.deepEqual(lines(read(file),'User'),['mosaic']); assert.deepEqual(lines(read(file),'Group'),['mosaic']); }
const wrong = hub(t,{uid:UID+1}); const r = unit(wrong); assert.notEqual(r.code,0); assert.match(r.err,/owned by.*uid|directory.*owner/);
});
@@ -0,0 +1,94 @@
// M3 executable boundary: copies and instances are fixtures only, no live ports.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { cpSync, mkdirSync, mkdtempSync, writeFileSync, chmodSync, rmSync, symlinkSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { fixture } from './helpers.mjs';
const REPO = fileURLToPath(new URL('../../../', import.meta.url));
const SHA = 'a'.repeat(40);
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'db-')); t.after(() => rmSync(root, { recursive: true, force: true }));
const copy = join(root, SHA); mkdirSync(copy);
for (const dir of ['packages', 'scripts', 'roles', 'adapters']) cpSync(join(REPO, dir), join(copy, dir), { recursive: true, filter: p => !p.split('/').some(x => ['tests', 'node_modules'].includes(x)) });
writeFileSync(join(copy, 'package.json'), '{"type":"module"}');
const stamp = join(copy, '.stack-build.json'); writeFileSync(stamp, JSON.stringify({ commit: SHA }), { mode: 0o444 }); chmodSync(stamp, 0o444);
const f = fixture(join(root, 'instance')); const home = join(root, 'home'); const dir = join(home, '.config/mosaic-test'); mkdirSync(dir, { recursive: true });
const config = join(dir, 'config.json'); cpSync(f.env.MOSAIC_CONFIG, config);
writeFileSync(join(f.dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'test' }));
const env = { ...process.env, HOME: home, MOSAIC_CONFIG: config };
const run = (args, from = copy) => spawnSync(process.execPath, [join(from, args[0]), ...args.slice(1)], { env, encoding: 'utf8', timeout: 5000 });
return { root, copy, stamp, f, env, config, dir, run };
}
const serve = flags => ['packages/webui/src/cli.mjs', 'serve', '--port', '0', ...flags];
test('stamped serve refuses dev owner, LAN flags, missing --sso and missing .deployed before listening', t => {
const s = setup(t);
for (const flags of [['--dev-local-owner'], ['--dev-insecure-bind','0.0.0.0'], ['--dev-allow-host','test.example'], []]) {
const r = s.run(serve(flags)); assert.equal(r.status, 2); assert.match(r.stderr, /stamped build/); assert.doesNotMatch(r.stdout, /Mosaic Console:/);
}
const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /\.deployed/);
const bus = s.run(['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']); assert.notEqual(bus.status, 0); assert.match(bus.stderr, /stamped build/);
});
test('writable, linked, malformed, wrong-commit and directory stamps refuse every start', t => {
const s = setup(t);
for (const kind of ['writable','symlink','wrong','malformed','directory']) {
rmSync(s.stamp, { force: true, recursive: true });
if (kind === 'symlink') { const other = join(s.root, 'stamp'); writeFileSync(other, JSON.stringify({ commit: SHA }), { mode: 0o444 }); symlinkSync(other, s.stamp); }
else if (kind === 'directory') mkdirSync(s.stamp);
else { writeFileSync(s.stamp, kind === 'malformed' ? '{' : JSON.stringify({ commit: kind === 'wrong' ? 'b'.repeat(40) : SHA })); chmodSync(s.stamp, kind === 'writable' ? 0o644 : 0o444); }
for (const args of [serve([]), serve(['--sso']), ['packages/cli/src/cli.mjs','bus','start','acme']]) { const r = s.run(args); assert.notEqual(r.status, 0, kind); assert.match(r.stderr, /build stamp/, `${kind}: ${r.stderr}`); }
}
});
test('stamped serve requires 0444 metadata naming its instance and a loadable accounts file', t => {
const s = setup(t), file = join(s.f.dataRoot, '.deployed');
for (const [mode, instance] of [[0o644,'test'], [0o444,'other']]) {
writeFileSync(file, JSON.stringify({ instance })); chmodSync(file, mode);
const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /\.deployed/);
}
chmodSync(file, 0o644); writeFileSync(file, JSON.stringify({ instance: 'test' })); chmodSync(file, 0o444);
const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /accounts/);
});
test('checkout dev owner refuses accounts, SSO or deployment presence, including dangling links', t => {
const s = setup(t);
for (const name of ['accounts.json', '.deployed']) {
const file = join(name === 'accounts.json' ? s.dir : s.f.dataRoot, name); symlinkSync(join(s.root, 'missing'), file);
for (const args of [serve(['--dev-local-owner']), ['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']]) { const r = s.run(args, REPO); assert.notEqual(r.status, 0); assert.match(r.stderr, /dev-local-owner.*refuses/); }
rmSync(file);
}
const config = JSON.parse((awaitRead(s.config))); config.console = { sso: { publicOrigin: 'https://console.test', providers: [{ id:'pocket', issuer:'https://id.test',clientId:'test',clientSecret:{slot:'test'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}] }] } };
writeFileSync(s.config, JSON.stringify(config));
for (const args of [serve(['--dev-local-owner']), ['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']]) { const r = s.run(args, REPO); assert.notEqual(r.status, 0); assert.match(r.stderr, /dev-local-owner.*refuses.*console.sso/); }
});
import { readFileSync as awaitRead } from 'node:fs';
test('deployment writer preserves matching metadata and refuses linked or foreign destinations', t => {
const s = setup(t), system = { dataRoot: s.f.dataRoot }, file = join(s.f.dataRoot, '.deployed');
rmSync(system.dataRoot, { recursive: true, force: true });
writeDeployment(system, s.dir); assert.deepEqual(JSON.parse(awaitRead(file)), { instance: 'test' });
assert.equal(statSync(system.dataRoot).mode & 0o777, 0o700);
assert.equal(statSync(file).mode & 0o777, 0o444); const inode = statSync(file).ino; writeDeployment(system, s.dir); assert.equal(statSync(file).ino, inode);
rmSync(file); symlinkSync(join(s.root, 'absent'), file); assert.throws(() => writeDeployment(system, s.dir), /\.deployed/); rmSync(file);
writeFileSync(file, JSON.stringify({ instance: 'other' }), { mode: 0o444 }); assert.throws(() => writeDeployment(system, s.dir), /\.deployed/);
});
import { statSync } from 'node:fs';
import { writeDeployment } from '../src/deployment.mjs';
test('valid stamped accounts Console starts with --sso and answers health on its own port zero', async t => {
const { spawn } = await import('node:child_process');
const { once } = await import('node:events');
const { fakeOidc } = await import('../../webui/tests/helpers/fake-oidc.mjs');
const s = setup(t), op = await fakeOidc(); t.after(() => op.close()); const iss = op.issuer('a');
symlinkSync(join(REPO, 'node_modules'), join(s.copy, 'node_modules'));
const config = JSON.parse(awaitRead(s.config)); config.console = { sso: { publicOrigin: 'https://console.test', providers: [{ id:'a',issuer:iss.issuer,clientId:iss.clientId,clientSecret:{slot:'a'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}] }] } };
writeFileSync(s.config, JSON.stringify(config)); mkdirSync(join(s.dir, 'secrets/mosaic-stack'), { recursive: true, mode: 0o700 });
writeFileSync(join(s.dir, 'secrets/mosaic-stack/a'), iss.clientSecret, { mode: 0o600 });
writeFileSync(join(s.dir, 'accounts.json'), JSON.stringify({ accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'owner'}],name:'Owner',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}], memberships:[],invites:[] }), { mode: 0o600 });
writeDeployment({ dataRoot: s.f.dataRoot }, s.dir); const ca = join(s.root, 'ca.pem'); writeFileSync(ca, op.cert);
const child = spawn(process.execPath, [join(s.copy,'packages/webui/src/cli.mjs'), 'serve','--port','0','--sso','--board','http://127.0.0.1:9'], { env: { ...s.env, NODE_EXTRA_CA_CERTS: ca } });
let out = '', err = ''; child.stderr.on('data', c => err += c);
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { const done = once(child, 'close'); child.kill(); await done; } });
await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('start timeout: '+err)), 10000); child.stdout.on('data', c => { out += c; if(out.includes('Ctrl-C')) {clearTimeout(timer);resolve();} }); child.on('exit', () => {clearTimeout(timer);reject(new Error(err));}); });
const port = Number(out.match(/127\.0\.0\.1:(\d+)\//)[1]); assert.ok(port); assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status, 200);
});
+15 -4
View File
@@ -58,18 +58,20 @@ test('project id mismatch omits the file value in config-command', t => {
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 is omitted`, t => {
]) 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); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
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 omits the validated value`, t => {
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.doesNotMatch(r.stderr, /read/);
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');
@@ -89,3 +91,12 @@ test('CLI boot project id mismatch omits the configured value and keeps exit 3',
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/);
});
@@ -1,3 +1,4 @@
import { ownerAccounts } from '../../webui/tests/helpers/owner-accounts.mjs';
import assert from 'node:assert/strict';
import { fork } from 'node:child_process';
import { once } from 'node:events';
@@ -119,7 +120,7 @@ export async function lifecycleFixture(t) {
const issuer = op.issuer('fixture');
sso = await createSso({ publicOrigin: ORIGIN, providers: [{ id: 'fixture', issuer: issuer.issuer, clientId: issuer.clientId,
clientSecret: { slot: 'fixture' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'jason' }] }] },
{ secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} });
{ accounts: ownerAccounts({ providers: [{ id: 'fixture', subjects: [{ sub: 'owner', name: 'jason' }] }] }), secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} });
const transport = async (verb, args, ctx) => {
try { return await rpc('console', { verb, args, subject: ctx.subject }); }
catch (e) { appendFileSync(join(root, 'console-errors.jsonl'), JSON.stringify({ verb, code: e.code, detail: e.detail })+'\n'); throw new BusReadError(e.code ?? 'outcome-unknown', 503); }
+22 -2
View File
@@ -12,6 +12,8 @@ import {createOwnedRunner} from './fixtures/owned-child.mjs';
import {pathToFileURL} from 'node:url';
import {fixture,notifyConfig,BUSINESSES} from './helpers.mjs';
import {reserveOrg,activateOrg} from '../../business/src/org-registry.mjs';
import {writeDeployment} from '../src/deployment.mjs';
import {fakeOidc} from '../../webui/tests/helpers/fake-oidc.mjs';
const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,'');
const git=(cwd,...args)=>execFileSync('git',['-c','user.name=fixture','-c','user.email=fixture@invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','-C',cwd,...args],{encoding:'utf8'}).trim();
export function setup(t, realPin=false){
@@ -58,6 +60,7 @@ test('a copy carries every file the harness modules read from it',async t=>{
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',async t=>{
const s=setup(t),r=(await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]));assert.equal(r.status,0,r.stderr);
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
assert.deepEqual(JSON.parse(readFileSync(join(copy,'.stack-build.json'),'utf8')), {commit:s.id}); assert.equal(statSync(join(copy,'.stack-build.json')).mode&0o222,0);
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/library/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs','packages/processing-control/src/state.mjs','packages/processing-control/src/containers.mjs','packages/processing-control/src/effects.mjs'])assert.ok(existsSync(join(copy,f)),f);
const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755);
const existing=(await s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]));assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm);
@@ -139,7 +142,7 @@ async function childResult(command,args,env,input){
const timer=setTimeout(()=>p.kill('SIGKILL'),15000);
try{const [status]=await once(p,'close');return {status,stdout,stderr};}finally{clearTimeout(timer)}
}
test('real pinned install loads bus and Console; same-copy mosaic talk passes proof, other trees refuse', {timeout:180000},async t=>{
test('real pinned install loads bus and Console; the Console starts only with --sso and its deployed prerequisites; same-copy mosaic talk passes proof, other trees refuse', {timeout:180000},async t=>{
const s=setup(t,true);
const copied=(await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]));assert.equal(copied.status,0,copied.stderr);
const root=join(s.copies,s.id),url=f=>pathToFileURL(join(root,f)).href;
@@ -156,7 +159,24 @@ test('real pinned install loads bus and Console; same-copy mosaic talk passes pr
host.stdout.on('data',c=>hostOut+=c);host.stderr.on('data',c=>hostErr+=c);
try{await until(()=>hostOut.includes('\"org\":\"acme\"')||host.exitCode!==null);assert.match(hostOut,/\"org\":\"acme\"/,hostErr);}
finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');const stop=spawnSync(join(root,'scripts/mosaic'),['bus','stop','acme'],{env,encoding:'utf8'});assert.equal(stop.status,0,stop.stderr);await done;}}
const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
// The copy is stamped (M3): its Console starts only with --sso, console.sso and its slot, .deployed and accounts.json.
const serveArgs=sso=>[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9',...(sso?['--sso']:[])];
const op=await fakeOidc();t.after(()=>op.close());const iss=op.issuer('a');
const ca=join(s.root,'ca.pem');writeFileSync(ca,op.cert);const webEnv={...env,NODE_EXTRA_CA_CERTS:ca};
const startRefused=async(sso,why)=>{const r=await childResult(process.execPath,serveArgs(sso),webEnv,'');assert.equal(r.status,2,r.stderr);assert.match(r.stderr,why);assert.doesNotMatch(r.stdout,/Mosaic Console:/);};
await startRefused(false,/stamped build serve requires --sso/);
await startRefused(true,/\.deployed is missing or invalid/);
writeDeployment({dataRoot:f.dataRoot},configDir);
await startRefused(true,/accounts file not found/);
writeFileSync(join(configDir,'accounts.json'),JSON.stringify({accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'owner'}],name:'Owner',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}],memberships:[],invites:[]}),{mode:0o600});
await startRefused(true,/must be a configured provider/);
const config=JSON.parse(readFileSync(env.MOSAIC_CONFIG,'utf8'));
config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'a',issuer:iss.issuer,clientId:iss.clientId,clientSecret:{slot:'a'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}};
writeFileSync(env.MOSAIC_CONFIG,JSON.stringify(config));
await startRefused(true,/client secret slot is missing/);
mkdirSync(join(configDir,'secrets','mosaic-stack'),{recursive:true,mode:0o700});
const slot=join(configDir,'secrets','mosaic-stack','a');writeFileSync(slot,iss.clientSecret,{mode:0o600});chmodSync(slot,0o600);
const web=spawn(process.execPath,serveArgs(true),{env:webEnv,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c);
try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);}
finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}}
+530 -145
View File
@@ -1555,51 +1555,110 @@ function setEdit(set,name,from,to,sums=true){
if(sums){const s=join(set,'SHA256SUMS');chmodSync(s,0o600);writeFileSync(s,readFileSync(s,'utf8').replace(new RegExp(`^[0-9a-f]{64}( \\./${name.replace(/[.@]/g,'\\$&')})$`,'m'),`${sha(f)}$1`))}
}
// M3 (#1873): console.sso marks an accounts instance, which runs system units, so a user-unit run
// with it refuses accounts-user-unit before anything stops. An --sso pointer now comes two ways:
// - a pre-M3 host's user-unit run. LEGACY is the fixture repo with the M3 base's stack-release.sh,
// webui-service.sh and stack-copy.sh, so legacy(e) builds the state such a host left behind;
// - the system path: root stops the units, stack-release stages with --units external, root
// installs the units and starts them, and confirm pins the pointer to root's Console flags.
const M3_BASE='c3c192677defa518eabeb324aac82f0757e398c3';
const LEGACY=join(base,'legacy-repo');
cpSync(repo,LEGACY,{recursive:true});
for(const f of ['scripts/stack-release.sh','scripts/webui-service.sh','scripts/stack-copy.sh'])writeFileSync(join(LEGACY,f),execFileSync('git',['-C',ROOT,'show',`${M3_BASE}:${f}`]),{mode:0o755});
const legacy=(e)=>({...e,sr:join(LEGACY,'scripts','stack-release.sh')});
const X=(e)=>({...e,flags:[...e.flags,'--units','external']});
const AUU=(e,op)=>`${e.cfg} holds console.sso; an accounts instance runs system units, so root renders them with case2-unit.sh and the ${op} runs with --units external`;
// Root's part. rootUp installs the bus unit for COPY and writes the Console unit case2-unit.sh
// renders for COPY with FLAGS (webui-service.sh refuses an accounts instance), then starts both.
const rootDown=(e)=>{sysctl(e,'stop',e.con);sysctl(e,'stop',e.bus)};
function rootUp(e,copy,flags){
execFileSync('bash',[join(repo,'scripts','bus-service.sh'),'install',...e.flags,'--copy',copy,'--copies',COPIES,'--claude',e.claude],{env:e.env,stdio:'ignore'});
const f=join(e.units,e.con),s=readFileSync(f,'utf8'),node=/^ExecStart=(\S+) /m.exec(s)[1],dir=join(COPIES,copy);
writeFileSync(f,s.replace(/^WorkingDirectory=.*$/m,`WorkingDirectory=${dir}`).replace(/^Documentation=.*$/m,`Documentation=file://${dir}/packages/webui/README.md`)
.replace(/^ExecStart=.*$/m,`ExecStart=${node} ${dir}/packages/webui/src/cli.mjs serve --checkout ${repo} ${flags.join(' ')}`));
execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'0'}});sysctl(e,'start',e.con);
}
// One system-path run: OP (activate TO, or rollback --to TO) planned and staged from the pointer's
// copy, root's units for TO with FLAGS, confirm. The plan, the staged set and both log lines are this
// script's output; the unit flags are root's render (case2-unit.test.mjs pins that render), so the
// pointer pinning them is confirm's read of root's unit, not a conversion of this script's.
async function viaSystem(e,op,to,flags,...more){
const x=X(e),from=pointer(e).copy,p0=readFileSync(e.pointer,'utf8'),w0=sha(join(e.bin,e.wrapper));
const args=op==='activate'?['activate',to,'acme']:['rollback','acme','--to',to],inst=e.flags.length?`${e.flags.join(' ')} `:'';
rootDown(e);
const con0=sha(join(e.units,e.con));
const out=await planOnly(x,0,...args,...more),plan=out.join('\n');
assert.deepEqual([out.find((l)=>l.startsWith('plan: ')),out.find((l)=>l.startsWith('from: ')),out.find((l)=>l.startsWith('to: '))],[`plan: ${op} for acme`,`from: ${from}`,`to: ${to}`],plan);
assert.ok(out.includes(`units: external; root installs the units for ${to} and starts them, then confirm gates them`),plan);
assert.ok(out.includes(`CLI: scripts/stack-copy.sh wrapper ${to} ${inst}--copies ${COPIES} --bin ${e.bin}`),plan);
// Root renders the units, so the plan names no unit, Console flag or refusal of this script's.
assert.deepEqual(out.filter((l)=>/^(bus|pm|proof|send|sso|Console):|^(drop|refuses) /.test(l)),[],plan);
let r=await prun(x,...args,...more);assert.equal(r.code,0,r.out+r.err);
const st=logLines(e).at(-1),act=st.activation,rs=setOf(e,act);
assert.deepEqual([st.op,st.from,st.to,st.result,st.units],[op,from,to,'started','external']);
assert.equal(r.out.split('\n').at(-2),`stack-release: ${op} staged ${to} for acme as ${act}; root installs and starts the units, then runs confirm ${to} acme --units external`);
// Staged, not live: the pointer stays; the set holds the pointer, the wrapper and the Console unit root stopped.
assert.equal(readFileSync(e.pointer,'utf8'),p0);
assert.equal(readFileSync(join(rs,'AWAITING-UNITS'),'utf8'),`${to}\n`);
assert.equal(readFileSync(join(rs,'pointer'),'utf8'),p0);assert.equal(sha(join(rs,'wrapper')),w0);
assert.ok(readFileSync(join(rs,'UNITS.sha256'),'utf8').includes(`${con0} ${join(e.units,e.con)}\n`));
assert.ok(readFileSync(join(e.bin,e.wrapper),'utf8').includes(join(COPIES,to)),'the wrapper names the target copy');
rootUp(e,to,flags);
r=await prun(x,'confirm',to,'acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(r.out.split('\n').at(-2),`stack-release: ${op} ok: ${to} is live for acme`);
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result,l.units,l.activation],[op,from,to,'ok','external',act]);
assert.ok(!existsSync(join(rs,'AWAITING-UNITS')));
assert.deepEqual([pointer(e).copy,pointer(e).console],[to,flags]);
return act;
}
// --plan with an accounts sign: exit 3 with the refusal alone; nothing written, started or stopped, and no log line.
async function planRefused(e,args,code,msg){
const before=fp(e),data=tree(e.data),n=calls(e).length,logs=logLines(e).length;
const r=await prun(e,...args,'--plan');assert.equal(r.code,3,r.out+r.err);assert.equal(r.out,'');
assert.equal(r.err,`stack-release: refusing ${code}: ${msg}\n`);
assert.deepEqual(fp(e),before);assert.deepEqual(tree(e.data),data);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
}
// The user-unit run and its --plan both refuse accounts-user-unit, before anything stops.
async function auu(e,args,op,msg=AUU(e,op)){await refused(e,args,'accounts-user-unit',msg);await planRefused(e,args,'accounts-user-unit',msg)}
const rootStart=(e)=>{execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'0'}});sysctl(e,'start',e.con)};
const shown=(e,act,show=SHOW)=>readFileSync(join(setOf(e,act),show),'utf8');
test('T57 row 200: activate --sso, its refusals before any stop, and the rollback to a Console without --sso',async(t)=>{
await t.test('item 1: a pre-SSO activation drops legacy send; --sso then drops preview and proof before the tester port',async(t)=>{
await t.test('item 1 (M3): a pre-SSO activation drops legacy send; with console.sso a user-unit --sso refuses accounts-user-unit, and the system path pins root\'s --sso Console',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),f=await proofFile(e),pre=noSend(base);
let r=await prun(e,'activate',B,'acme','--tester-port','7333',...pair(f));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--tester-port','7333']);
await ssoConfig(e);
await auu(e,['activate',C1,'acme','--sso'],'activate');
// Root renders the stamped Console with --sso and no preview or proof flags (case2-unit.test.mjs pins that render).
const want=['--port',e.port,'--business','acme','--sso','--tester-port','7333'];
const out=await planOnly(e,0,'activate',C1,'acme','--sso');
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: dropped; --sso drops header x-console-proof','send: off',
`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --tester-port 7333`]);
assert.equal(out[out.findIndex((l)=>l.startsWith('sso:'))+1],`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const act=await viaSystem(e,'activate',C1,want);
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
const steps=stepText(e);
assert.ok(steps.includes(`Z drop --proof-header x-console-proof --proof-file ${f}; --sso was given\n`),steps);
assert.ok(steps.includes(`Z turn on --sso; drop ${DROPS}\n`),steps);
// The activation's set holds the proxy-preview Console it replaced, pair and tester port included.
assert.ok(shown(e,act).includes(` ${[...pre,...pair(f),'--tester-port','7333'].join(' ')}`),shown(e,act));
await noLeak(e);
});
await t.test('item 1: --sso may re-pin the live copy once; later activates carry it; --no-send still drops --send',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
let out=await planOnly(e,0,'activate',C1,'acme','--sso');
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: off','send: off',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme`]);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
assert.doesNotMatch(stepText(e),/proof/);
await refused(e,['activate',C1,'acme','--sso'],'already-active',`${C1} is the pointer's copy`);
await refused(e,['activate',C1,'acme'],'already-active',`${C1} is the pointer's copy`);
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
out=await planOnly(e,0,'activate',C,'acme');assert.deepEqual(ssoLines(out),['sso: carried']);
r=await prun(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.out+r.err);
// Carried, not turned on: no stamped step names sso (the install output's serve line still shows --sso).
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);assert.doesNotMatch(stepText(e),/^\S+Z .*sso/m);
assert.match(stepText(e),/^serve: .* --sso --send$/m);
r=await prun(e,'activate',C,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await t.test('item 1 (M3): while console.sso is set every user-unit activate refuses accounts-user-unit; the system path carries root\'s --sso --send Console to another copy',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--send']);await ssoConfig(e);
for(const args of [['activate',C1,'acme'],['activate',C1,'acme','--sso'],['activate',C1,'acme','--send'],['activate',C1,'acme','--sso','--no-send'],['activate',C,'acme','--migrate']])await auu(e,args,'activate');
const sso=['--port',e.port,'--business','acme','--sso','--send'];
await viaSystem(e,'activate',C1,sso);
assert.deepEqual(pointer(e).console,sso);
await auu(e,['activate',C1,'acme','--no-send'],'activate');
await auu(e,['rollback','acme'],'rollback');
const act=await viaSystem(e,'activate',C,sso,'--migrate');
assert.equal(pointer(e).copy,C);assert.deepEqual(pointer(e).console,sso);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${sso.join(' ')}`),conExec(e));
assert.match(shown(e,act),/ --sso --send /);
let r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
// Root's unit without --send: the pointer pins what root rendered.
const g=await mk(t);await seedPreview(g,undefined,['--send']);await ssoConfig(g);
r=await prun(g,'activate',C1,'acme','--sso','--no-send');assert.equal(r.code,0,r.out+r.err);
await viaSystem(g,'activate',C1,['--port',g.port,'--business','acme','--sso']);
assert.deepEqual(pointer(g).console,['--port',g.port,'--business','acme','--sso']);
assert.ok(stepText(g).includes('Z drop --send; --no-send was given\n'));
r=await prun(g,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
for(const x of [e,g])await noLeak(x);
});
await t.test('item 1: --sso with a pair or --no-proof, or on any verb but activate, is a usage error',async(t)=>{
@@ -1613,53 +1672,49 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
await t.test('item 2: a target without --sso, a missing or bad console.sso, and each slot problem refuse before any stop',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e,{slot:false});
const slot=slotPath(e),put=(mode=0o600,text='x\n')=>{rmSync(slot,{force:true,recursive:true});writeFileSync(slot,text,{mode});chmodSync(slot,mode)};
put();
await t.test('item 2 (M3): without console.sso, a target without --sso and the missing key refuse before any stop; with console.sso, valid or not and whatever its slot, the user-unit run refuses accounts-user-unit first',async(t)=>{
const e=await mk(t);await seedPreview(e);
await refused(e,['activate',B,'acme','--sso'],'sso-target',`${B}'s Console does not support --sso`);
setKey(e,undefined);
// N4: from another copy, the refusal names the re-pin without --sso as the first step.
await refused(e,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C1} without --sso first, then write console.sso, then run activate ${C1} --sso`);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
await refused(e,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config`);
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
r=await prun(e,'activate',C1,'acme','--sso','--plan');assert.equal(r.code,0,r.out+r.err);
await refused(e,['activate',C,'acme','--sso'],'sso-config',`${C}'s Console would refuse its --sso start: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`);
setKey(e,SSO_KEY);
const bad=(why)=>`console.sso provider a client secret slot ${slot} ${why}`;
rmSync(slot);
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is missing'));
const elsewhere=join(e.root,'elsewhere');writeFileSync(elsewhere,'x\n',{mode:0o600});symlinkSync(elsewhere,slot);
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is a symbolic link'));
rmSync(slot);mkdirSync(slot,{mode:0o700});
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is not a regular file'));
for(const mode of [0o644,0o640,0o604]){put(mode);await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad(`has mode ${mode.toString(8)}, which gives group or other permission bits`))}
put(0o600,'');
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is empty'));
// The slot directory resolving into the repository: the file is no link, but its real path is.
const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
const secrets=dirname(slot);rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets);
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is inside the repository or the copies'));
// --plan lists the sso refusal with the rest and writes nothing.
const out=await planOnly(e,3,'activate',C,'acme','--sso','--tester-port','80');
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-invalid: --tester-port 80 is not a whole port from 1024 to 65535 other than 7332 and the Console's --port ${e.port}`,`refuses sso-slot: ${bad('is inside the repository or the copies')}`]);
rmSync(secrets);mkdirSync(secrets,{mode:0o700});put();
r=await prun(e,'activate',C,'acme','--sso','--migrate');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-invalid: --tester-port 80 is not a whole port from 1024 to 65535 other than 7332 and the Console's --port ${e.port}`,`refuses sso-config: ${C}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C} without --sso first, then write console.sso, then run activate ${C} --sso`]);
// With console.sso the slot is the Console start's check (packages/webui/tests/sso.test.mjs, readSlot);
// no slot state and no bad key gets a user-unit run past accounts-user-unit.
await ssoConfig(e,{slot:false});
const slot=slotPath(e),secrets=dirname(slot),put=(mode=0o600,text='x\n')=>{rmSync(slot,{force:true,recursive:true});writeFileSync(slot,text,{mode});chmodSync(slot,mode)};
const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
const elsewhere=join(e.root,'elsewhere');writeFileSync(elsewhere,'x\n',{mode:0o600});
const states=[()=>{},()=>symlinkSync(elsewhere,slot),()=>{rmSync(slot);mkdirSync(slot,{mode:0o700})},()=>put(0o644),()=>put(0o640),()=>put(0o604),()=>put(0o600,''),
()=>{rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets)},()=>{rmSync(secrets);mkdirSync(secrets,{mode:0o700});put()}];
// A bad tester port is a usage error outside --plan (row 180), so that case runs --plan only:
// accounts-user-unit stops the plan before it lists tester-port-invalid.
for(const s of states){s();for(const args of [['activate',C,'acme','--sso'],['activate',B,'acme','--sso']])await auu(e,args,'activate');await planRefused(e,['activate',C,'acme','--sso','--tester-port','80'],'accounts-user-unit',AUU(e,'activate'))}
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
await auu(e,['activate',C,'acme','--sso'],'activate');
setKey(e,undefined);
r=await prun(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.out+r.err);
await noLeak(e);
});
await t.test('item 3: a config whose console key the target loader refuses stops an activate or a rollback before any stop, naming the first step',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
await t.test('item 3 (M3): a config whose console key the target loader refuses stops an external activate or rollback before any stop, naming the first step; a user-unit run refuses accounts-user-unit first',async(t)=>{
const e=await mk(t);await seedPreview(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const why='mosaic-config: unsupported configuration key: "console"';
await refused(e,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why));
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback',why));
const out=await planOnly(e,3,'rollback','acme');
await ssoConfig(e);
await auu(e,['activate',B,'acme'],'activate');await auu(e,['rollback','acme'],'rollback');
// Root stopped the units; the staged run checks the config before it writes anything.
rootDown(e);
const x=X(e),why='mosaic-config: unsupported configuration key: "console"';
await refused(x,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why));
await refused(x,['rollback','acme','--to',B],'config-console-key',KEY_MSG(e,B,'rollback',why));
const out=await planOnly(x,3,'rollback','acme','--to',B);
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses config-console-key: ${KEY_MSG(e,B,'rollback',why)}`]);
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
await refused(e,['activate',C,'acme'],'config-console-key',KEY_MSG(e,C,'activate',`mosaic-config: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`));
setKey(e,undefined);
await refused(x,['activate',C,'acme'],'config-console-key',KEY_MSG(e,C,'activate',`mosaic-config: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`));
setKey(e,undefined);rootStart(e);
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
await noLeak(e);
});
@@ -1676,17 +1731,17 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
await noLeak(e);
});
await t.test('item 3: a rollback from --sso to a Console without it takes the flags from the set of the activation onto the copy, past a same-copy re-pin (sage-1389)',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
await t.test('item 3 (M3): from a pre-M3 --sso pointer the user-unit rollback refuses accounts-user-unit; without console.sso it takes the flags from the set of the activation onto the copy, past a same-copy re-pin (sage-1389)',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const set=arrivalSet(e,C1);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
// A pre-M3 host turned --sso on in place, then re-pinned twice.
await ssoConfig(e);
for(const f of ['--sso','--send','--no-send']){r=await prun(legacy(e),'activate',C1,'acme',f);assert.equal(r.code,0,r.out+r.err)}
// The latest set holds the --sso Console; the arrival set holds B's.
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
assert.match(shown(e,pointer(e).activation),/ --sso /);
assert.doesNotMatch(readFileSync(join(set,SHOW),'utf8'),/ --sso /);
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback','mosaic-config: unsupported configuration key: "console"'));
await auu(e,['rollback','acme'],'rollback');
setKey(e,undefined);
const want=[...noSend(base.slice(0,-2)),'--tester-port','7333'];
const out=await planOnly(e,0,'rollback','acme');
@@ -1701,15 +1756,17 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
const g=await mk(t),gbase=await seedPreview(g,undefined,['--send']),f=await proofFile(g);
r=await prun(g,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
await ssoConfig(g);
r=await prun(g,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
r=await prun(legacy(g),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
await auu(g,['rollback','acme'],'rollback');
setKey(g,undefined);
r=await prun(g,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(g).console,[...noSend(gbase),...pair(f)]);
for(const x of [e,g])await noLeak(x);
});
await t.test('item 3: a rollback set that is missing, fails its sums, holds no argv or already ran --sso refuses sso-rollback-flags',async(t)=>{
await t.test('item 3 (M3): from a pre-M3 --sso pointer, a rollback set that is missing, fails its sums, holds no argv or already ran --sso refuses sso-rollback-flags',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
await auu(e,['rollback','acme'],'rollback');
setKey(e,undefined);
const set=arrivalSet(e,C1),show=join(set,SHOW),text=readFileSync(show,'utf8');
setEdit(set,SHOW,' --public-host ',' --sso --public-host ');
@@ -1731,17 +1788,18 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
await noLeak(e);
});
await t.test('round 2 (webby-0028 N1): run through a symlinked repository path, a slot or a proof file inside the repository still refuses',async(t)=>{
await t.test('round 2 (webby-0028 N1, M3): run through a symlinked repository path, a proof file inside the repository still refuses, and console.sso with a slot inside the repository refuses accounts-user-unit',async(t)=>{
const link=join(base,'repo-link.200');if(!existsSync(link))symlinkSync(repo,link);
const e=await mk(t);e.sr=join(link,'scripts','stack-release.sh');await seedPreview(e);
const f=await proofFile(e,'proof.200',0o600,mkdtempSync(join(repo,'t57-proof-')));t.after(()=>rmSync(dirname(f),{recursive:true,force:true}));
await refused(e,['activate',C1,'acme',...pair(f)],'proof-file-invalid',`--proof-file ${f} is inside the repository or the copies`);
await ssoConfig(e,{slot:false});
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
await ssoConfig(e,{slot:false});
const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
const secrets=dirname(slotPath(e));rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets);
await refused(e,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(e)} is inside the repository or the copies`);
// The Console's start refuses the slot (sso.test.mjs: inside the repository or a stack copy).
await auu(e,['activate',C1,'acme','--sso'],'activate');
await noLeak(e);
});
await t.test('round 2 (webby-0028 N2): a config.json that does not parse stops the run before anything is read or stopped',async(t)=>{
@@ -1758,24 +1816,21 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
const r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
await noLeak(e);
});
await t.test('round 3 (sage-1556): a named instance turns --sso on with its own config, slot and Console unit, and rolls back from its own set',async(t)=>{
await t.test('round 3 (sage-1556, M3): a named instance refuses a user-unit --sso with its own config, turns --sso on through the system path on its own Console unit, and rolls back from its own set',async(t)=>{
const e=await mk(t),a=await named(e,'alpha');await seed(a);
const show=`show-${a.con}.txt`,unit=()=>readFileSync(join(a.units,a.con),'utf8').split('\n').find((l)=>l.startsWith('ExecStart='));
assert.ok(slotPath(a).startsWith(join(a.env.HOME,'.config','mosaic-alpha','secrets','mosaic-stack')+'/'),slotPath(a));
await ssoConfig(a,{slot:false});
await refused(a,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(a)} is missing`);
await proofFile(a,SLOT,0o600,dirname(slotPath(a)));
await ssoConfig(a);
await auu(a,['activate',C1,'acme','--sso'],'activate');
const want=['--port',a.port,'--business','acme','--sso'];
const out=await planOnly(a,0,'activate',C1,'acme','--sso');
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --instance alpha --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
let r=await prun(a,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const act=await viaSystem(a,'activate',C1,want);
assert.equal(pointer(a).copy,C1);assert.deepEqual(pointer(a).console,want);
assert.ok(unit().endsWith(` ${want.join(' ')}`),unit());
assert.ok(readFileSync(join(a.units,a.con),'utf8').includes(`\nEnvironment=MOSAIC_CONFIG=${a.cfg}\n`));
assert.ok(!existsSync(join(e.units,CON)),'the default Console unit was written');
r=await prun(a,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
let r=await prun(a,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
setKey(a,undefined);
const set=arrivalSet(a,C1);assert.ok(existsSync(join(set,show)),show);
const set=setOf(a,act);assert.equal(arrivalSet(a,C1),set);assert.ok(existsSync(join(set,show)),show);
assert.deepEqual(ssoLines(await planOnly(a,0,'rollback','acme')),[`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${show}, with --tester-port as the pointer has it`]);
r=await prun(a,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(a).copy,B);assert.deepEqual(pointer(a).console,want.slice(0,-1));
@@ -1794,44 +1849,35 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
// Row 273 (#1778, mos-1246 F39): activate --send turns send on only beside --sso, given or carried, and
// activate --no-sso turns --sso off with the Console flags from the set of the activation that turned
// it on, dropping --send. The tester follows the pointer.
// it on, dropping --send. The tester follows the pointer. M3 (#1873): with console.sso these are
// accounts-user-unit refusals; a pre-M3 --sso pointer comes from legacy(e), and --no-sso runs once
// console.sso is gone.
const SEND_MSG="--send needs --sso in the run's Console flags, given or carried; under forward auth any same-uid process could send (F39)";
const NOT_ON_MSG="the pointer's Console flags hold no --sso, so --no-sso has nothing to turn off";
const OFF_MSG=(why)=>`the Console flags from before --sso cannot be read: ${why}`;
const OFF_PLAN=(set,restores)=>`sso: off; Console flags from ${set}/${SHOW}; restores ${restores}`;
const NOTE=(to)=>`note: ${to} is the pointer's copy; the run re-pins it and only the Console flags change`;
test('T58 row 273: activate --send and --no-sso',async(t)=>{
await t.test('T1: --sso --send turns send on after --sso and before the tester port, and the plan says so',async(t)=>{
await t.test('T1 (M3): a user-unit --sso --send refuses accounts-user-unit; the system path pins root\'s --sso --send before the tester port',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--tester-port','7333']);await ssoConfig(e);
await auu(e,['activate',C1,'acme','--sso','--send'],'activate');
const want=['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333'];
const out=await planOnly(e,0,'activate',C1,'acme','--sso','--send');
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: on; --send was given',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send --tester-port 7333`]);
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
await viaSystem(e,'activate',C1,want);
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
const steps=stepText(e);
assert.ok(steps.includes('Z turn on --send; --send was given\n'),steps);assert.ok(steps.includes(`Z turn on --sso; drop ${DROPS}\n`),steps);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
// The same copy with send on again is already active.
await refused(e,['activate',C1,'acme','--send'],'already-active',`${C1} is the pointer's copy`);
const r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await auu(e,['activate',C1,'acme','--send'],'activate');
await noLeak(e);
});
await t.test('T1: --send beside a carried --sso re-pins the live copy once, and the plan names the carried --sso',async(t)=>{
await t.test('T1 (M3): beside a pre-M3 carried --sso, a user-unit --send refuses accounts-user-unit; the system path carries root\'s --sso --send to another copy',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
const out=await planOnly(e,0,'activate',C1,'acme','--send');
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: on; --send was given','sso: carried']);
assert.ok(out.includes(NOTE(C1)),out.join('\n'));
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
await auu(e,['activate',C1,'acme','--send'],'activate');
await auu(e,['activate',C,'acme','--send','--migrate'],'activate');
const want=['--port',e.port,'--business','acme','--sso','--send'];
assert.deepEqual(pointer(e).console,want);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
assert.ok(stepText(e).includes('Z turn on --send; --send was given\n'));assert.doesNotMatch(stepText(e),/^\S+Z .*sso/m);
await refused(e,['activate',C1,'acme','--send'],'already-active',`${C1} is the pointer's copy`);
// Another copy carries both.
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C,'acme','--send')),['send: carried']);
await viaSystem(e,'activate',C,want,'--migrate');
assert.equal(pointer(e).copy,C);assert.deepEqual(pointer(e).console,want);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
@@ -1848,11 +1894,12 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
await refused(g,['activate',B,'acme','--send'],'already-active',`${B} is the pointer's copy`);
for(const x of [e,g])await noLeak(x);
});
await t.test('T2 (sage-2315 B1): a rollback from an --sso --send pointer to a Console without --sso ends with neither flag',async(t)=>{
await t.test('T2 (sage-2315 B1, M3): from a pre-M3 --sso --send pointer the user-unit rollback refuses accounts-user-unit; without console.sso the rollback to a Console without --sso ends with neither flag',async(t)=>{
const e=await mk(t),base=await seedPreview(e);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
let r=await prun(legacy(e),'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
const set=arrivalSet(e,C1);
await auu(e,['rollback','acme'],'rollback');
setKey(e,undefined);
const out=await planOnly(e,0,'rollback','acme');
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: dropped; the run drops --sso, and --send needs it (F39)',ROLLBACK_PLAN(set)]);
@@ -1866,7 +1913,7 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
});
await t.test('T2 and T4: --send with --no-send, --no-sso with --sso, --send, a pair or --no-proof, and either flag on another verb, is a usage error',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e);const f=await proofFile(e);
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
let r=await prun(legacy(e),'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
for(const args of [['activate',C1,'acme','--send','--no-send'],['activate',C1,'acme','--no-send','--send','--plan'],['activate',C1,'acme','--sso','--send','--no-send'],
['activate',C1,'acme','--no-sso','--sso'],['activate',C1,'acme','--sso','--no-sso','--plan'],['activate',C1,'acme','--no-sso','--send'],
@@ -1879,15 +1926,18 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
await t.test('T3: --no-sso on the same copy returns the unit and the pointer to the flags from before --sso, without --send',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
await t.test('T3 (M3): --no-sso refuses accounts-user-unit while console.sso is set; without it, on the same copy it returns the unit and the pointer to the flags from before --sso, without --send',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const pre=conExec(e),want=noSend(base);
assert.ok(pre.endsWith(` ${want.join(' ')}`),pre);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
await ssoConfig(e);
r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const set=setOf(e,pointer(e).activation);
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
r=await prun(legacy(e),'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333']);
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
setKey(e,undefined);
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: off','send: dropped; --no-sso drops --send (F39)',OFF_PLAN(set,want.slice(0,-2).join(' '))]);
assert.ok(out.includes(NOTE(C1)),out.join('\n'));
@@ -1904,14 +1954,16 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
await refused(e,['activate',C1,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
await noLeak(e);
});
await t.test('T3: the set walk passes later re-pins that ran with --sso, and the tester follows the pointer',async(t)=>{
await t.test('T3 (M3): from pre-M3 re-pins, the set walk passes later re-pins that ran with --sso, and the tester follows the pointer',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
// The activation that turns --sso on also moves the tester to 7334, so the set and the pointer differ.
let r=await prun(e,'activate',C1,'acme','--sso','--tester-port','7334');assert.equal(r.code,0,r.out+r.err);
let r=await prun(legacy(e),'activate',C1,'acme','--sso','--tester-port','7334');assert.equal(r.code,0,r.out+r.err);
const set=setOf(e,pointer(e).activation);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
r=await prun(legacy(e),'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.match(shown(e,pointer(e).activation),/ --sso /);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--tester-port','7334']);
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
setKey(e,undefined);
const restores=base.slice(0,-3),want=[...restores,'--tester-port','7334'];
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: off; --no-sso was given and the pointer holds no --send',OFF_PLAN(set,restores.join(' '))]);
@@ -1921,17 +1973,20 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('T3 (sage-2305 a): after --sso, two later re-pins that carry it, then --no-sso restores the flags from before --sso byte for byte',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--tester-port','7333']);await ssoConfig(e);
await t.test('T3 (sage-2305 a, M3): after a pre-M3 --sso and two re-pins that carry it, --no-sso without console.sso restores the flags from before --sso byte for byte',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--tester-port','7333']);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const pre=conExec(e);assert.ok(pre.endsWith(` ${base.join(' ')}`),pre);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
await ssoConfig(e);
r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const set=setOf(e,pointer(e).activation);
// Two same-copy re-pins carry --sso, so the two newest sets both hold a Console with it.
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
r=await prun(legacy(e),'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
r=await prun(legacy(e),'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
const acts=logLines(e).filter((l)=>l.result==='ok'&&l.op==='activate').map((l)=>l.activation).slice(-2);
for(const a of acts)assert.match(readFileSync(join(setOf(e,a),SHOW),'utf8'),/ --sso /);
for(const a of acts)assert.match(shown(e,a),/ --sso /);
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
setKey(e,undefined);
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
assert.deepEqual(ssoLines(out),[OFF_PLAN(set,base.slice(0,-2).join(' '))]);
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
@@ -1939,27 +1994,31 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('T4 (sage-2305 b): a log in which every set holds a Console with --sso refuses before any unit stops',async(t)=>{
await t.test('T4 (sage-2305 b, M3): a log in which every set holds a Console with --sso refuses before any unit stops',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--send']);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const first=setOf(e,pointer(e).activation);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
r=await prun(legacy(e),'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.match(shown(e,pointer(e).activation),/ --sso /);
// The set of the activation that turned --sso on now claims its Console ran with it too, sums intact.
setEdit(first,SHOW,' --public-host ',' --sso --public-host ');
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
setKey(e,undefined);
const why=OFF_MSG(`the Console in ${join(first,SHOW)} already ran with --sso, and the log names no earlier activation`);
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',why);
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-off-flags: ${why}`]);
await noLeak(e);
});
await t.test('T4: --no-sso refuses before any stop on a pointer without --sso, a tampered or missing set, a set that ran with --sso, an ok line with no activation, and a log with no set',async(t)=>{
await t.test('T4 (M3): --no-sso refuses before any stop on a pointer without --sso, a tampered or missing set, a set that ran with --sso, an ok line with no activation, and a log with no set',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--send']);
await refused(e,['activate',B,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
await refused(e,['activate',C1,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-not-on: ${NOT_ON_MSG}`]);
// One set, from the activation that turned --sso on.
// One set, from the pre-M3 activation that turned --sso on.
await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
setKey(e,undefined);
const set=setOf(e,pointer(e).activation),show=join(set,SHOW),text=readFileSync(show,'utf8');
// An ok line with no activation stops the walk (sage-2315 N1); skipping it would reach the set above.
const logText=readFileSync(e.log,'utf8');
@@ -1985,6 +2044,62 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
});
});
// M3 (#1873) item 2: each accounts sign refuses a user-unit activate, rollback or pm-on before
// anything stops; without them the user path runs, and with them the system path runs.
const AUU_TAIL=(op)=>`; an accounts instance runs system units, so root renders them with case2-unit.sh and the ${op} runs with --units external`;
test('M3 accounts-user-unit: each accounts sign refuses a user-unit run before any stop; the user path without them and the system path with them both run',async(t)=>{
await t.test('a stamped target, accounts.json, console.sso and .deployed each refuse activate and rollback, run and --plan',async(t)=>{
const e=await mk(t);await seedPreview(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const accounts=join(dirname(e.cfg),'accounts.json'),deployed=join(e.data,'.deployed');
const stamp=(copy)=>{const d=join(COPIES,copy),f=join(d,'.stack-build.json'),m=statSync(d).mode&0o777;chmodSync(d,0o755);writeFileSync(f,'{}\n');chmodSync(d,m);
return ()=>{chmodSync(d,0o755);rmSync(f);chmodSync(d,m)}};
const signs=[
[()=>stamp(C),()=>stamp(B),`${join(COPIES,C)} is a stamped copy`,`${join(COPIES,B)} is a stamped copy`],
[()=>{writeFileSync(accounts,'{}\n',{mode:0o600});return ()=>rmSync(accounts)},null,`${accounts} is present`],
[()=>{setKey(e,SSO_KEY);return ()=>setKey(e,undefined)},null,`${e.cfg} holds console.sso`],
[()=>{writeFileSync(deployed,'{}\n');return ()=>rmSync(deployed)},null,`${deployed} is present`],
];
for(const [on,onRb,why,whyRb] of signs){
let off=on();t.after(()=>{try{off()}catch{}});
await auu(e,['activate',C,'acme','--migrate'],'activate',why+AUU_TAIL('activate'));
if(onRb){off();off=onRb()}
await auu(e,['rollback','acme'],'rollback',(whyRb??why)+AUU_TAIL('rollback'));
off();
}
// No sign left: the user path runs both ways.
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,C1);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('pm-on on an accounts instance refuses before it captures or stops anything; without the sign it runs',async(t)=>{
const e=await mk(t);await pending(e);biz(e,HARNESS);
const accounts=join(dirname(e.cfg),'accounts.json');writeFileSync(accounts,'{}\n',{mode:0o600});
// The harness is set, so only the accounts sign stands between pm-on and the user bus unit.
await refused(e,['pm-on','acme'],'accounts-user-unit',`${accounts} is present; an accounts instance runs system units, so root renders them with case2-unit.sh, and the PM is a flag in root's bus unit`);
rmSync(accounts);
const r=await run(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
assert.ok(r.out.endsWith(`stack-release: pm-on ok: the PM is live on ${C1} for acme\n`),r.out);
});
await t.test('with accounts.json and .deployed the system path activates, confirms and rolls back, and the user path still refuses',async(t)=>{
const e=await mk(t),base=await seedPreview(e);
writeFileSync(join(dirname(e.cfg),'accounts.json'),'{}\n',{mode:0o600});writeFileSync(join(e.data,'.deployed'),'{}\n');
await ssoConfig(e);
const sso=['--port',e.port,'--business','acme','--sso','--send'];
await viaSystem(e,'activate',C1,sso);
assert.deepEqual([pointer(e).copy,pointer(e).console],[C1,sso]);
let r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
// B's loader refuses the console key, so a rollback onto it drops console.sso first; the other signs stay.
setKey(e,undefined);
await auu(e,['rollback','acme'],'rollback',`${join(dirname(e.cfg),'accounts.json')} is present`+AUU_TAIL('rollback'));
await viaSystem(e,'rollback',B,base);
assert.deepEqual([pointer(e).copy,pointer(e).console],[B,base]);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
});
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
@@ -2009,6 +2124,7 @@ function rHost(){
}
if(a[0]!=='bus'||a[1]!=='start'||!a.slice(3).every((f)=>flags.includes(f))){console.error(`fixture host: ${a.join(' ')}`);process.exit(4)}
fs.appendFileSync(path.join(home,'host-args.log'),`${a.join(' ')}\n`);
fs.appendFileSync(path.join(home,'host-ca.log'),`${process.env.NODE_EXTRA_CA_CERTS??'none'}\n`);
if(fault(`host-fails-${id}`)){console.error('fixture host: refused');process.exit(1)}
const b=a[2],off=a.includes('--no-launch'),lock=path.join(data,'bus','writer.lock'),at=new Date().toISOString();
fs.writeFileSync(lock,String(process.pid),{flag:'wx'});
@@ -2029,14 +2145,39 @@ function rHost(){
}
// The Console: serve on the given loopback port; /healthz answers 200 unless healthz-<copy> is set.
function rConsole(){
// M3 (#1873): under --sso it runs its copy's real createSso on the scratch console.sso, with the
// scratch slot placeholders, as the real Console does, and /healthz carries sso.health(). Faults:
// console-hold waits for $HOME/release before it starts; console-no-sso reports every provider
// ready without asking any; console-asks-token POSTs to provider a's token endpoint, then asks its
// authorize endpoint with a query, once each.
async function rConsole(){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),http=process.getBuiltinModule('node:http');
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
const c=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8'));
fs.appendFileSync(path.join(home,'console-args.log'),`${a.join(' ')}\n`);
fs.appendFileSync(path.join(home,'console-context.jsonl'),JSON.stringify({argv:a,dataRoot:JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot})+'\n');
fs.appendFileSync(path.join(home,'console-context.jsonl'),JSON.stringify({argv:a,dataRoot:c.dataRoot,ca:process.env.NODE_EXTRA_CA_CERTS??null})+'\n');
if(a[0]!=='serve'){console.error('fixture Console: refused');process.exit(1)}
const s=http.createServer((q,r)=>{r.writeHead(q.url==='/healthz'&&!fault(`healthz-${id}`)?200:500);r.end()});
while(fault('console-hold')&&!fs.existsSync(path.join(home,'release')))await new Promise((r)=>setTimeout(r,50));
let health=null;
if(a.includes('--sso')){
const ps=c.console.sso.providers;
if(fault('console-no-sso'))health=()=>Object.fromEntries(ps.map((p)=>[p.id,'ready']));
else{
const {createSso}=await import(path.join(import.meta.dirname,'sso.mjs'));
const secrets=new Map(ps.map((p)=>[p.id,fs.readFileSync(path.join(path.dirname(process.env.MOSAIC_CONFIG),'secrets','mosaic-stack',p.clientSecret.slot),'utf8')]));
let sso;try{sso=await createSso(c.console.sso,{secrets,log:()=>{}})}catch(err){console.error(`fixture Console: ${err.message}`);process.exit(2)}
health=()=>sso.health();
}
if(fault('console-asks-token')){
await fetch(`${ps[0].issuer}token`,{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:'grant_type=authorization_code&code=x'}).catch(()=>{});
await fetch(`${ps[0].issuer}authorize?client_id=rehearsal&response_type=code`).catch(()=>{});
}
}
const s=http.createServer((q,r)=>{
const ok=q.url==='/healthz'&&!fault(`healthz-${id}`);r.writeHead(ok?200:500,{'content-type':'application/json'});
r.end(ok&&health?JSON.stringify({ok:true,sso:health()}):'');
});
s.listen(Number(a[a.indexOf('--port')+1]),'127.0.0.1',()=>console.log(`Mosaic Console: http://127.0.0.1:${s.address().port}/\nBoard: fixture`));
process.on('SIGTERM',()=>process.exit(0));
}
@@ -2079,6 +2220,24 @@ for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],
for(const [f,text] of [['packages/cli/src/cli.mjs',id===A?'// fixture host from before row 163\nprocess.exit(1);\n':script(rHost)],['packages/webui/src/cli.mjs',script(rConsole)],['packages/bus/src/store.mjs',rStore(v,froms)],['packages/bus/src/migrate-schema.mjs',script(rMigrate)],['packages/bus/src/broker.mjs','export class BusError extends Error { constructor(code) { super(code); this.code = code; } }\nexport function requestArchived() { return false; }\n']]){mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text)}
execFileSync('chmod',['-R','a-w',d]);
}
// M3 (#1873): SCOPIES holds B as a stamped copy: RCOPIES's B with a read-only .stack-build.json
// naming its commit, the Console's sso-config.mjs the config loader needs for a console key, its
// sso.mjs the stand-in Console runs, and the deployment.mjs whose writeDeployment the rehearsal runs.
const SCOPIES=join(base,'scopies');mkdirSync(SCOPIES);
{
const d=join(SCOPIES,B);cpSync(join(RCOPIES,B),d,{recursive:true});execFileSync('chmod',['-R','u+w',d]);
for(const p of ['packages/webui/src/sso-config.mjs','packages/webui/src/sso.mjs','packages/cli/src/deployment.mjs'])cpSync(join(repo,p),join(d,p));
// sso.mjs imports oauth4webapi.
symlinkSync(join(ROOT,'node_modules'),join(d,'node_modules'));
writeFileSync(join(d,'.stack-build.json'),JSON.stringify({commit:B}),{mode:0o444});
execFileSync('chmod',['-R','a-w',d]);
}
// UCOPIES holds B unstamped but able to load a console key: SCOPIES's B without .stack-build.json.
const UCOPIES=join(base,'ucopies');mkdirSync(UCOPIES);
{
const d=join(UCOPIES,B);cpSync(join(SCOPIES,B),d,{recursive:true,verbatimSymlinks:true});execFileSync('chmod',['-R','u+w',d]);
rmSync(join(d,'.stack-build.json'));execFileSync('chmod',['-R','a-w',d]);
}
const TESTER='7499';
// Vikunja #782: this leases a port, closes it and hands the number to `stack-release.sh rehearse`, which
@@ -2091,12 +2250,13 @@ const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync
// A root for one rehearsal: a stopped live store at `store`, a pointer by hand on `copy` with the
// Console on e.port and the tester listener on TESTER, and a backup as backup_store writes one.
// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG, are killed after the test.
// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG or a rehearsal path in their command
// line (the rehearsal provider), are killed after the test.
async function rmk(t,{copy=B,store='3g'}={}){
const e=await mk(t);
t.after(()=>{
for(const pid of readdirSync('/proc').filter((n)=>/^\d+$/.test(n)))
try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{}
try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`)||readFileSync(`/proc/${pid}/cmdline`,'utf8').includes(`${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{}
});
const db=new DatabaseSync(e.store);
db.exec('PRAGMA journal_mode=WAL');
@@ -2121,7 +2281,7 @@ async function rmk(t,{copy=B,store='3g'}={}){
const untouched=(e)=>JSON.stringify([...[e.store,e.cfg,e.biz,e.pointer].map((f)=>[f,sha(f)]),existsSync(e.log),...readdirSync(e.backup,{recursive:true}).sort().map((f)=>join(e.backup,f)).filter((f)=>statSync(f).isFile()).map((f)=>[f,sha(f)])]);
async function rehearse(e,id,...args){
const ch=spawn('bash',[e.sr??SR,'rehearse',id,'acme',...args,'--copies',RCOPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
const ch=spawn('bash',[e.sr??SR,'rehearse',id,'acme',...args,'--copies',e.copies??RCOPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d});
e.during?.(ch);
const timer=setTimeout(()=>ch.kill('SIGKILL'),120000);
@@ -2137,7 +2297,7 @@ function ownScript(e,from,to){
const s=readFileSync(e.sr,'utf8');assert.ok(s.includes(from),`${e.sr} has no ${from}`);writeFileSync(e.sr,s.replace(from,()=>to));
}
const rDirs=(e)=>existsSync(join(e.data,'rehearsals'))?readdirSync(join(e.data,'rehearsals')):[];
const PRE=['PASS copy','PASS port','PASS backup','PASS scratch-root','PASS data-root-live','PASS sessions-live','PASS data-root-scratch','PASS notify-off','PASS tracker-configured','PASS credential-ref-live'];
const PRE=['PASS copy','PASS port','PASS backup','PASS scratch-root','PASS data-root-live','PASS sessions-live','PASS data-root-scratch','PASS notify-off','PASS tracker-configured','PASS credential-ref-live','PASS sso-isolated'];
const POST=['PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged'];
test('R1 rehearse passes on the same schema; the scratch root holds its files and the live files stay byte-identical',async(t)=>{
@@ -2374,6 +2534,229 @@ test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops t
}
});
// --- M3 (#1873): rehearsing a stamped copy (the brief's item 1) ---
// A stamped copy serves only with --sso, console.sso, accounts.json and a .deployed naming its
// instance. The rehearsal gives its scratch Console all four: the live console.sso with its origin
// on rehearsal.invalid and each issuer at the rehearsal's own provider, a placeholder per slot, a
// copy of the live accounts.json, and a .deployed from the copy's writeDeployment. The live slot
// has a read watch: it is never opened.
const ACCOUNTS={accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'subject-1'}],name:'Jason',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}],memberships:[],invites:[]};
async function smk(t,{sso=true,accounts=true}={}){
const e=await rmk(t);e.copies=SCOPIES;
if(sso)await ssoConfig(e);
e.accounts=join(dirname(e.cfg),'accounts.json');
if(accounts)writeFileSync(e.accounts,JSON.stringify(ACCOUNTS),{mode:0o600});
return e;
}
const SPRE=[...PRE.slice(0,3),'PASS provider-start',PRE[3],'PASS deployment',...PRE.slice(4)];
const SPOST=['PASS console-stopped','PASS host-stopped','PASS provider-stopped',...POST.slice(2)];
// The rehearsal provider's origin and pid, from its provider-start line.
const provOf=(out)=>{const m=/^PASS provider-start: pid (\d+) at (https:\/\/127\.0\.0\.1:\d+): /m.exec(out);return m?{pid:Number(m[1]),origin:m[2]}:null};
// A real stamped copy of this checkout, as deployment-boundary.test.mjs builds one.
function stampedCopy(t){
const sha40='a'.repeat(40),root=mkdtempSync(join(tmpdir(),'rs-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
const copy=join(root,sha40);mkdirSync(copy);
for(const dir of ['packages','scripts','roles','adapters'])cpSync(join(ROOT,dir),join(copy,dir),{recursive:true,filter:(q)=>!q.split('/').some((x)=>['tests','node_modules'].includes(x))});
writeFileSync(join(copy,'package.json'),'{"type":"module"}');
writeFileSync(join(copy,'.stack-build.json'),JSON.stringify({commit:sha40}),{mode:0o444});chmodSync(join(copy,'.stack-build.json'),0o444);
symlinkSync(join(ROOT,'node_modules'),join(copy,'node_modules'));
return copy;
}
// Starts the copy's real Console on port 0 against the rehearsal's scratch config. Resolves with
// {code,out,err} on exit, or with the bound port once it prints its address line.
async function realServe(t,copy,e,dir,more={}){
const ch=spawn(process.execPath,[join(copy,'packages/webui/src/cli.mjs'),'serve','--port','0','--sso','--board','http://127.0.0.1:9'],{env:{HOME:e.env.HOME,PATH:e.env.PATH,TMPDIR:e.env.TMPDIR,MOSAIC_CONFIG:join(dir,'config','config.json'),...more},stdio:['ignore','pipe','pipe']});
t.after(async()=>{if(ch.exitCode===null&&ch.signalCode===null){const done=once(ch,'close');ch.kill('SIGKILL');await done}});
let out='',err='';ch.stderr.on('data',(d)=>{err+=d});
return new Promise((res)=>{
const timer=setTimeout(()=>{ch.kill('SIGKILL');res({code:'timeout',out,err})},20000);
ch.stdout.on('data',(d)=>{out+=d;const m=/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//.exec(out);if(m&&out.includes('Ctrl-C')){clearTimeout(timer);res({port:Number(m[1]),out,err,ch})}});
ch.on('close',(code)=>{clearTimeout(timer);res({code,out,err})});
});
}
test('M3 R16 a stamped copy rehearses with --sso against its own provider, a scratch accounts copy and its own .deployed; the live files stay as they were',async(t)=>{
const st=(f)=>{const x=statSync(f);return [x.ino,x.size,x.mtimeMs,x.mode]};
const e=await smk(t),before=untouched(e),accounts=sha(e.accounts),slot=st(slotPath(e)),port=await freePort();
const r=await rehearse(e,B,'--backup',e.act,'--port',port,'--timeout','10');
assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(r.names,[...SPRE,'PASS migration','PASS gate','PASS no-session','PASS provider-discovery',...SPOST]);
const R=r.dir,prov=provOf(r.out);assert.ok(prov,r.out);
assert.ok(r.out.includes(`PASS provider-start: pid ${prov.pid} at ${prov.origin}: discovery and a JWKS from keys made for this run, no code and no token; its certificate ${join(R,'provider','cert.pem')} is the trust the scratch Console alone gets\n`),r.out);
assert.ok(r.out.includes(`PASS deployment: ${B} is stamped: ${join(R,'data','.deployed')} from its writeDeployment, the live accounts.json copied to ${join(R,'config')}, the Console starts with --sso\n`),r.out);
assert.ok(r.out.includes(`PASS sso-isolated: 1 provider(s), each at the rehearsal provider ${prov.origin} with a placeholder secret\n`),r.out);
assert.ok(r.out.includes(`PASS gate: host-up, bus-status, console-up, healthz, sso-ready and copy-match on ${B}\n`),r.out);
assert.ok(r.out.includes(`PASS provider-discovery: the Console fetched each provider's discovery and JWKS from ${prov.origin} and asked for no code or token\n`),r.out);
// The Console started with --sso, only on the scratch config, and alone trusts the provider's
// certificate; the host gets no added trust.
assert.deepEqual(lines(rh(e,'console-args.log')),[`serve --port ${port} --business acme --sso`]);
assert.deepEqual(lines(rh(e,'console-context.jsonl')).map((l)=>JSON.parse(l).ca),[join(R,'provider','cert.pem')]);
assert.deepEqual(lines(rh(e,'host-ca.log')),['none']);
// The provider served discovery and the JWKS, got no other request, kept no TLS key and is gone.
assert.deepEqual(lines(join(R,'provider','requests.log')).sort(),['GET /a/.well-known/openid-configuration 200','GET /a/jwks 200']);
assert.deepEqual(readdirSync(join(R,'provider')).sort(),['cert.pem','origin','provider.log','requests.log']);
assert.equal(statSync(join(R,'provider')).mode&0o777,0o700);
assert.equal(existsSync(`/proc/${prov.pid}`),false,`provider pid ${prov.pid} is still there`);
// The scratch config: the live one with dataRoot, origin and issuer moved; provider id, client id,
// algorithms, slot name and subjects as they were. No subject is added.
const live=JSON.parse(readFileSync(e.cfg,'utf8')),sc=JSON.parse(readFileSync(join(R,'config','config.json'),'utf8'));
const want=structuredClone(live);want.dataRoot=join(R,'data');want.console.sso.publicOrigin='https://rehearsal.invalid';want.console.sso.providers[0].issuer=`${prov.origin}/a/`;
assert.deepEqual(sc,want);assert.deepEqual(sc.console.sso.providers[0].subjects,SSO_KEY.sso.providers[0].subjects);
// The slot is a scratch placeholder, 0600 in a 0700 directory.
const ph=join(R,'config','secrets','mosaic-stack',SLOT);
assert.equal(readFileSync(ph,'utf8'),'rehearsal-placeholder-secret\n');assert.equal(statSync(ph).mode&0o777,0o600);
assert.equal(statSync(dirname(ph)).mode&0o777,0o700);assert.deepEqual(readdirSync(dirname(ph)),[SLOT]);
// accounts.json is the live file's bytes; .deployed names the instance the scratch config dir gives.
assert.equal(readFileSync(join(R,'config','accounts.json'),'utf8'),readFileSync(e.accounts,'utf8'));
const dep=join(R,'data','.deployed');
assert.deepEqual(JSON.parse(readFileSync(dep,'utf8')),{instance:'dev'});assert.equal(statSync(dep).mode&0o777,0o444);
// The live store, config, business, pointer, backup, accounts and slot are as they were; the run
// never opened the live slot and its sentinel is nowhere under the data root.
assert.equal(untouched(e),before);assert.equal(sha(e.accounts),accounts);assert.deepEqual(st(slotPath(e)),slot);
assert.ok(readFileSync(join(R,'live.before'),'utf8').includes(e.accounts),'the end check sums the live accounts.json');
await noLeak(e);
});
test('M3 R17 the real stamped Console finds the rehearsal provider ready with the scratch prerequisites, starts degraded once it is gone, and refuses without .deployed or accounts.json',async(t)=>{
const e=await smk(t);rfault(e,'console-hold');
const copy=stampedCopy(t);
// While the stand-in Console holds, the rehearsal provider is up: the real Console of a stamped
// copy passes every startup check (stamp, --sso, .deployed, accounts, console.sso and its slot)
// and, trusting only the provider's certificate, finds provider a ready.
let during=null;
e.during=async()=>{
try{
for(let i=0;i<400&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));
const dir=join(e.data,'rehearsals',rDirs(e)[0]);
const up=await realServe(t,copy,e,dir,{NODE_EXTRA_CA_CERTS:join(dir,'provider','cert.pem')});
during={up:up.port?await (await fetch(`http://127.0.0.1:${up.port}/healthz`)).json():null,why:`${up.code} ${up.out} ${up.err}`};
if(up.ch){const done=once(up.ch,'close');up.ch.kill('SIGTERM');await done}
}finally{writeFileSync(rh(e,'release'),'')}
};
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30');
assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(during?.up,{ok:true,sso:{a:'ready'}},during?.why);
// After the rehearsal its provider is gone: the same Console serves degraded and /healthz says so.
const up=await realServe(t,copy,e,r.dir);
assert.ok(up.port,`${up.code} ${up.out} ${up.err}`);
const h=await fetch(`http://127.0.0.1:${up.port}/healthz`);
assert.equal(h.status,200);assert.deepEqual(await h.json(),{ok:true,sso:{a:'unreachable'}});
const done=once(up.ch,'close');up.ch.kill('SIGTERM');await done;
// The same start without each prerequisite refuses before it listens.
const dep=join(r.dir,'data','.deployed'),acc=join(r.dir,'config','accounts.json'),keep=readFileSync(dep);
rmSync(dep);
let q=await realServe(t,copy,e,r.dir);assert.equal(q.code,2,q.out+q.err);assert.match(q.err,/\.deployed is missing or invalid/);assert.doesNotMatch(q.out,/Mosaic Console:/);
writeFileSync(dep,keep,{mode:0o444});rmSync(acc);
q=await realServe(t,copy,e,r.dir);assert.equal(q.code,2,q.out+q.err);assert.match(q.err,/accounts/);assert.doesNotMatch(q.out,/Mosaic Console:/);
});
test('M3 R18 a stamped copy refuses sso-not-prepared without a live console.sso and accounts-not-initialized without a live accounts.json, before any rehearsal directory',async(t)=>{
for(const [opts,code,msg] of [
[{sso:false},'sso-not-prepared',(e)=>`${B} is a stamped copy, which serves only with --sso, and ${e.cfg} has no console.sso; prepare SSO for this instance (run sheet) before re-pinning to it`],
[{accounts:false},'accounts-not-initialized',(e)=>`${B} is a stamped copy, which needs an accounts file, and there is no ${e.accounts}; a human runs mosaic accounts init (run sheet) before re-pinning to it`],
]){
const e=await smk(t,opts),before=untouched(e);
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
assert.equal(r.code,1,code+r.out+r.err);
assert.ok(r.out.includes(`FAIL ${code}: ${msg(e)}\nREHEARSAL_FAIL\n`),r.out);
assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),[`FAIL ${code}`]);
assert.deepEqual(rDirs(e),[]);assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
assert.equal(existsSync(e.accounts),code!=='accounts-not-initialized');
assert.equal(untouched(e),before);
}
// An unstamped copy needs neither: B from RCOPIES rehearses without console.sso or accounts.json,
// and its Console gets no --sso.
const u=await rmk(t),port=await freePort();
const r=await rehearse(u,B,'--backup',u.act,'--port',port,'--timeout','10');
assert.equal(r.code,0,r.out+r.err);assert.ok(!r.names.includes('PASS deployment'));
assert.ok(r.out.includes('PASS sso-isolated: no console.sso\n'),r.out);
assert.deepEqual(lines(rh(u,'console-args.log')),[`serve --port ${port} --business acme`]);
assert.equal(existsSync(join(r.dir,'data','.deployed')),false);
});
test('M3 R19 sso-isolated fails before the host starts when the scratch origin, an issuer or a slot is not the rehearsal\'s',async(t)=>{
// stamped: the issuer must be the rehearsal provider's; unstamped (no provider): on rehearsal.invalid.
for(const [stamped,from,to,why] of [
[true,'sso.publicOrigin="https://rehearsal.invalid";','','console.sso.publicOrigin is https://console.example.test, not https://rehearsal.invalid'],
[true,'p.issuer=`${op||','p.issuer=p.issuer||`${op||','console.sso provider a issuer is https://op.example.test/, not ORIGIN/a/ at the rehearsal provider'],
[false,'p.issuer=`${op||"https://rehearsal.invalid"}','p.issuer=`${op||"https://op.example.test"}','console.sso provider a issuer is https://op.example.test/a, not on rehearsal.invalid'],
[true,'"rehearsal-placeholder-secret\\n",{mode:0o600,flag:"wx"}','"other\\n",{mode:0o600,flag:"wx"}','console.sso provider a has no scratch placeholder secret'],
]){
let e;if(stamped)e=await smk(t);else{e=await rmk(t);e.copies=UCOPIES;await ssoConfig(e)}
ownScript(e,from,to);
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
assert.equal(r.code,1,why+r.out+r.err);
const prov=provOf(r.out);assert.equal(!!prov,stamped,r.out);
assert.ok(r.out.includes(`FAIL sso-isolated: ${why.replace('ORIGIN',prov?.origin)}\n`),r.out);
assert.deepEqual(r.names.slice(stamped?-4:-3),['FAIL sso-isolated',...(stamped?['PASS provider-stopped']:[]),'PASS backup-unchanged','PASS live-config-unchanged']);
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
if(prov)assert.equal(existsSync(`/proc/${prov.pid}`),false);
await noLeak(e);
}
});
test('M3 R20 the rehearsal provider\'s failure controls: no start, no trust, no discovery, a token request, a signal and a SIGKILL each fail and stop it',async(t)=>{
const ends=['PASS backup-unchanged','PASS live-config-unchanged'];
// The provider cannot start, leaves its TLS key, offers a non-loopback origin or never writes one:
// provider-start fails before the scratch build, and no host or Console starts.
for(const [from,to,why] of [
['spawnSync("openssl"','spawnSync("openssl-missing-for-r20"','the rehearsal provider exited: rehearsal provider: cannot make the certificate: openssl exit ENOENT'],
['finally{fs.rmSync(tls,{force:true})}','finally{}','the rehearsal provider left its TLS key at RDIR/provider/tls-key.pem'],
['origin=`https://127.0.0.1:${','origin=`https://localhost:${','the rehearsal provider\'s origin https://localhost:PORT is not https on 127.0.0.1'],
['fs.renameSync(path.join(dir,"origin.tmp"),path.join(dir,"origin"));','','the rehearsal provider wrote no origin within 3s'],
]){
const e=await smk(t);ownScript(e,from,to);
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
assert.equal(r.code,1,why+r.out+r.err);assert.ok(r.dir,r.out+r.err);
const line=r.out.split('\n').find((l)=>l.startsWith('FAIL provider-start: '))??'';
const re=new RegExp(`^FAIL provider-start: ${why.replace(/[.*+?^${}()|[\]\\]/g,'\\$&').replace('RDIR',r.dir.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).replace('PORT','\\d+')}$`);
assert.match(line,re,r.out);
assert.deepEqual(r.names,[...PRE.slice(0,3),'FAIL provider-start',...(why.includes('exited')?[]:['PASS provider-stopped']),...ends],r.out);
assert.equal(existsSync(join(r.dir,'config')),false);
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
}
// The Console cannot reach the provider (its discovery drops the connection, or the Console has
// no trust for its certificate): it starts degraded and the gate fails sso-ready.
for(const [from,to] of [
[' send(200,{issuer,authorization_endpoint',' return q.socket.destroy();send(200,{issuer,authorization_endpoint'],
['r_ca=(NODE_EXTRA_CA_CERTS="$R/provider/cert.pem")','r_ca=()'],
]){
const e=await smk(t);ownScript(e,from,to);
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','5');
assert.equal(r.code,1,from+r.out+r.err);
assert.ok(r.out.includes('FAIL sso-ready: provider a is unreachable, not ready\n'),r.out);
assert.deepEqual(r.names,[...SPRE,'PASS migration','FAIL sso-ready',...SPOST],r.out);
assert.equal(existsSync(`/proc/${provOf(r.out).pid}`),false);
}
// A Console that reports ready without asking the provider, or asks it for a token: the gate
// passes and provider-discovery fails.
for(const [f,why] of [
['console-no-sso','no GET /a/.well-known/openid-configuration 200; no GET /a/jwks 200'],
['console-asks-token','a request for POST /a/token 403; a request for GET /a/authorize?client_id=rehearsal&response_type=code 403'],
]){
const e=await smk(t);rfault(e,f);
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','10');
assert.equal(r.code,1,f+r.out+r.err);
assert.ok(r.out.includes(`FAIL provider-discovery: ${why}; see ${join(r.dir,'provider','requests.log')}\n`),r.out);
assert.deepEqual(r.names,[...SPRE,'PASS migration','PASS gate','PASS no-session','FAIL provider-discovery',...SPOST],r.out);
}
// A signal while the gate waits stops the Console, host and provider.
const e=await smk(t);rfault(e,`healthz-${B}`);
e.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill('SIGTERM')};
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30');
assert.equal(r.code,1,r.out+r.err);
assert.deepEqual(r.names,[...SPRE,'PASS migration','FAIL signal',...SPOST],r.out);
assert.equal(existsSync(`/proc/${provOf(r.out).pid}`),false);
// SIGKILL leaves no trap to run: the provider sees its parent gone and exits by itself.
const k=await smk(t);rfault(k,`healthz-${B}`);
k.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(k,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill('SIGKILL')};
const rk=await rehearse(k,B,'--backup',k.act,'--port',await freePort(),'--timeout','30');
assert.equal(rk.code,null,rk.out+rk.err);
const pk=provOf(rk.out);assert.ok(pk,rk.out);
for(let i=0;i<60&&existsSync(`/proc/${pk.pid}`);i++)await new Promise((r)=>setTimeout(r,100));
assert.equal(existsSync(`/proc/${pk.pid}`),false,`provider pid ${pk.pid} outlived the killed rehearsal`);
});
// --- Row 233 (#1735): --backup last, the newest backup the log records (sage-1650, sage-1653) ---
// The log's lines, as the runs write them, with only the fields from_log reads set.
@@ -2601,7 +2984,8 @@ test('row196 explicit socket conversion refuses inherited TCP before stop and ro
assert.equal(r.code,3,r.out+r.err);assert.match(r.err,/tester-socket-path/);assert.deepEqual(startsStops(long,before),[]);
});
// Row196 merge guard: without --send, SSO insertion must still precede the socket flag.
// Row196 merge guard, M3 (#1873): a user-unit --sso --tester-socket refuses accounts-user-unit; root's
// unit for the copy carries --sso before the socket flag, and the pointer and status pin that order.
test('row196 SSO socket conversion pins pointer and unit argv order without send',async t=>{
const e=await mk(t);await seedPreview(e,{copy:B});await ssoConfig(e);
const file=join(COPIES,C1,'packages/webui/src/cli.mjs'),original=readFileSync(file,'utf8');
@@ -2611,8 +2995,9 @@ test('row196 SSO socket conversion pins pointer and unit argv order without send
chmodSync(dirname(module),0o755);
writeFileSync(module,'export const inspectInterpreter=()=>({path:"/usr/bin/python3",sha256:"fixture"});export const verifyInterpreterReceipt=()=>true;\n');chmodSync(module,0o444);chmodSync(dirname(module),0o555);
t.after(()=>{chmodSync(dirname(module),0o755);rmSync(module);chmodSync(dirname(module),0o555);});
const r=await prun(e,'activate',C1,'acme','--sso','--tester-socket');assert.equal(r.code,0,r.out+r.err);
await auu(e,['activate',C1,'acme','--sso','--tester-socket'],'activate');
const want=['--port',e.port,'--business','acme','--sso','--tester-socket'];
await viaSystem(e,'activate',C1,want);
assert.deepEqual(pointer(e).console,want);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
assert.doesNotMatch(conExec(e),/ --send(?: |$)|--tester-port|--public-host|--proof-header/);
+127 -7
View File
@@ -47,12 +47,19 @@ function fakeSudo(K){
// case2-unit.sh: logs its argv, then installs the copy's units as the fixture's user units (its
// WorkingDirectory under the stack's copies) and starts the optional proxy, bus, then Console,
// as case2-unit.sh does. The business is --business's value, or --org's when that is the spelling. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
// as case2-unit.sh does. The business is --business's value, or --org's when that is the spelling. A first
// --check or --check-new (#1873) logs its argv and exits 1 under faults/check-fails-<copy>, else 0,
// writing nothing. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
// probe.sh, a second driver, and records its exit and stderr.
function fakeRender(K){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process');
const a=process.argv.slice(2),sd=process.env.SD_STATE,f=(n)=>path.join(sd,'faults',n);
fs.appendFileSync(path.join(sd,'render.log'),JSON.stringify(a)+'\n');
if(a[0]==='--check'||a[0]==='--check-new'){
const c=a[a.indexOf('--copy')+1];
if(fs.existsSync(f(`check-fails-${c}`))){console.error(`fixture render: ${a[0]} ${c} fails`);process.exit(1)}
process.exit(0);
}
const cut=a.indexOf('--'),pre=a.slice(0,cut),serve=a.slice(cut+1),get=(k)=>pre[pre.indexOf(k)+1];
const copy=get('--copy'),copies=path.join(get('--home'),'.local','share','mosaic-stack','copies');
if(fs.existsSync(f(`render-probe-${copy}`))){
@@ -147,7 +154,7 @@ const arm=(e,key,text)=>writeFileSync(join(e.sd,'arm',key),text);
// The step log: every line `<time> <step> rc=N ...` or the result line, root:root 0640 (here the
// test user's), and nothing the stack's user printed. Returns the lines without the time.
const STEP=/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ (start|fetch|ff|copy|hostcheck|units|plan|rehearse|rehearse-store|rehearsal-skipped|stop-console|stop-bus|stop-proxy|wait-down|stage|render|confirm|args|revert|render-old|confirm-rollback|gate|start-proxy|start-bus|start-console|stop-units) rc=\d+ |^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ result exit=[0-35] [a-z-]+ /;
const STEP=/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ (start|fetch|ff|copy|hostcheck|units|plan|rehearse|rehearse-store|rehearsal-skipped|render-old-check|render-check|stop-console|stop-bus|stop-proxy|wait-down|units-aside|units-back|daemon-reload|next-args|stage|render|confirm|args|revert|render-old|confirm-rollback|gate|start-proxy|start-bus|start-console|stop-units) rc=\d+ |^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ result exit=[0-35] [a-z-]+ /;
function slog(e){
const l=lines(e.slog);
for(const x of l){assert.match(x,STEP);assert.ok(!/stack-release:|fixture rehearse|PASS |FAIL |REHEARSAL/.test(x),x)}
@@ -165,7 +172,7 @@ const live=(e)=>[units(e)[e.bus].state,units(e)[e.con].state,units(e)[e.bus].wd.
// Nothing the run did reached the args file: the same bytes and mode, and no temporary file left.
const argsKept=(e)=>{assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.equal(statSync(e.args).mode&0o777,0o640);assert.deepEqual(readdirSync(e.etc),['dev.args'])};
const argsCopy=(e)=>{const l=readFileSync(e.args,'utf8').split('\n');return l[l.indexOf('--copy')+1]};
const PRE=['start','fetch','ff','copy','hostcheck','units','plan','rehearse','rehearse-store'];
const PRE=['start','fetch','ff','copy','hostcheck','units','plan','render-old-check','render-check','rehearse','rehearse-store'];
const A2=['stop-console','stop-bus','wait-down','wait-down'];
const R=[...A2,'revert','render-old','confirm-rollback'];
@@ -394,7 +401,7 @@ test('row 233: sequence 7: two upgrades and a rollback --to the previous copy wi
const rb=await drive(e,C,'rollback','--instance','dev','--to',C1,'--restore',a2,...TO);assert.equal(rb.code,0,rb.all);
assert.equal(rb.out.split('\n').at(-2),`stack-upgrade: rollback ok: ${C1} is live for acme`);
// A rollback fetches, copies and rehearses nothing.
assert.deepEqual(names(e).slice(n),['start','hostcheck','units','plan',...A2,'stage','render','confirm','args','result']);
assert.deepEqual(names(e).slice(n),['start','hostcheck','units','plan','render-old-check','render-check',...A2,'stage','render','confirm','args','result']);
const ra=lastAct(e),l3=final(e,ra);assert.deepEqual([l3.op,l3.result,l3.from,l3.to,l3.backup],['rollback','ok',C,C1,a2]);
assert.deepEqual([pointer(e).copy,pointer(e).schema],[C1,'3g']);assert.equal(storeVersion(e.store,true),'3g');
assert.equal(argsCopy(e),C1);assert.deepEqual(live(e),['active','active',C1,C1]);
@@ -632,7 +639,9 @@ test('rename r2: an upgrade from an args file that spells both selectors --org k
const after=e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`);assert.notEqual(after,e.argsText);
assert.equal(readFileSync(e.args,'utf8'),after);
assert.equal(statSync(e.args).mode&0o777,0o640);assert.deepEqual(readdirSync(e.etc),['dev.args']);
assert.deepEqual(lines(join(e.sd,'render.log')).map((x)=>JSON.parse(x)),[after.trimEnd().split('\n')]);
// #1873: before anything stops, --check renders the installed copy, then the target, from the same lines.
const al=after.trimEnd().split('\n');
assert.deepEqual(lines(join(e.sd,'render.log')).map((x)=>JSON.parse(x)),[['--check',...e.argsText.trimEnd().split('\n')],['--check',...al],al]);
// The stack's user's steps get the business as a word, never a spelling.
for(const k of ['activate-plan','rehearse','activate','confirm'])assert.ok(JSON.parse(lines(join(e.sd,'sudo.log')).find((x)=>JSON.parse(x).key===k)).cmd.includes('acme'),k);
assert.ok(!lines(join(e.sd,'sudo.log')).some((x)=>JSON.parse(x).cmd.includes('--org')));
@@ -718,12 +727,25 @@ const baseCase2=()=>{
// read. args(copy, extra) is a library args file's lines without --send; legacy() renders base's pair into dir.
function case2Host(e,dir){
const home=e.env.HOME,secrets=join(home,'.config','mosaic-dev','secrets');mkdirSync(secrets,{recursive:true,mode:0o700});
copyFileSync(e.cfg,join(home,'.config','mosaic-dev','config.json'));
const proof=join(secrets,'proxy-proof');writeFileSync(proof,'made-up-proof\n');chmodSync(proof,0o600);
const c2=join(e.root,'case2'),bin=join(c2,'bin'),log=join(c2,'systemctl.log');mkdirSync(bin,{recursive:true});mkdirSync(dir,{recursive:true});
const uid=process.getuid(),gid=process.getgid();
writeFileSync(join(bin,'getent'),`#!/bin/sh\ncase "$1 $2" in\n "passwd fixture") echo "fixture:x:${uid}:${gid}::${home}:/bin/bash" ;;\n "group ${gid}") echo "fixture:x:${gid}:" ;;\n *) exit 2 ;;\nesac\n`,{mode:0o755});
writeFileSync(join(bin,'getent'),`#!/bin/sh\ncase "$1 $2" in\n "passwd fixture"|"passwd ") echo "fixture:x:${uid}:${gid}::${home}:/usr/sbin/nologin" ;;\n "group ${gid}") echo "fixture:x:${gid}:" ;;\n *) exit 2 ;;\nesac\n`,{mode:0o755});
writeFileSync(join(bin,'systemctl'),`#!/bin/sh\necho "$*" >> '${log}'\n[ "$1" != is-active ] || exit 3\n`,{mode:0o755});
const env={CASE2_UNIT_DIR:dir,CASE2_GETENT:join(bin,'getent'),CASE2_SYSTEMCTL:join(bin,'systemctl')};
// M3: the current renderer queries the config under the selected uid for every
// profile. These copies deliberately stub the CLI; give that query its own
// fixture loader and sudo rather than invoking the machine's sudo.
for(const id of [B,C1,C]){
const d=join(COPIES,id),packages=join(d,'packages'),cli=join(packages,'cli'),src=join(cli,'src');
if(!existsSync(join(src,'config.mjs'))){
chmodSync(d,0o755);chmodSync(packages,0o755);mkdirSync(src,{recursive:true});
writeFileSync(join(src,'config.mjs'),`import fs from 'node:fs';import os from 'node:os';import path from 'node:path';export const loadSystem=()=>JSON.parse(fs.readFileSync(path.join(os.homedir(),'.config/mosaic-dev/config.json'),'utf8'));`);
chmodSync(join(src,'config.mjs'),0o444);chmodSync(src,0o555);chmodSync(cli,0o555);chmodSync(packages,0o555);chmodSync(d,0o555);
}
}
writeFileSync(join(bin,'sudo'),'#!/bin/sh\n[ "$1" = -u ] && [ "$2" = fixture ] && [ "$3" = -H ] || exit 97\nshift 3\nexec "$@"\n',{mode:0o755});
const env={PATH:`${bin}:${e.env.PATH}`,CASE2_UNIT_DIR:dir,CASE2_GETENT:join(bin,'getent'),CASE2_SYSTEMCTL:join(bin,'systemctl')};
const serve=['--port','443','--business','acme','--public-host','books.example','--auth-header','X-Auth-User','--allow-user','melanie','--proof-header','X-Proxy-Proof','--proof-file',proof];
const args=(copy=B,extra=[])=>['--user','fixture','--home',home,'--business','acme','--copy',copy,'--node',RENDER_NODE,...extra,'--',...serve];
const render=(script,a)=>{const r=spawnSync('bash',[script,...a],{env:{PATH:e.env.PATH,...env},encoding:'utf8'});return {code:r.status,out:r.stdout,err:r.stderr}};
@@ -829,3 +851,101 @@ test('row 233: usage exits 4 before any log or lock',async(t)=>{
}
assert.ok(!existsSync(dirname(e.slog)));assert.ok(!existsSync(e.run));
});
// --- #1873 M3: render checks before anything stops, and --next-args ---
// The installed units' bytes, for the byte-for-byte checks across a moved-aside render.
const unitBytes=(e)=>Object.fromEntries([e.tmpl,e.con].map((u)=>[u,readFileSync(join(e.units,u),'utf8')]));
const renders=(e)=>lines(join(e.sd,'render.log')).map((l)=>JSON.parse(l));
const copyOf=(a)=>a[a.indexOf('--copy')+1];
// The next args: the args file's lines with the serve flags in another order, root's 0640.
const nextText=(e)=>e.argsText.replace(`--\n--port\n${e.port}\n--business\nacme\n`,`--\n--business\nacme\n--port\n${e.port}\n`);
const writeNext=(e,text=nextText(e),mode=0o640)=>{const f=join(e.etc,'dev.args.next');writeFileSync(f,text);chmodSync(f,mode);return f};
const replacedDirs=(e)=>readdirSync(e.etc).filter((n)=>/^dev\.replaced\.[A-Za-z0-9]{6}$/.test(n)).sort();
test('#1873: a target whose render check fails refuses render-refused, naming --next-args, before anything stops',async(t)=>{
const e=await useed(t);
fault(e,`check-fails-${C1}`);
const r=await upgrade(e,C1);assert.equal(r.code,3,r.all);
assert.deepEqual(names(e),['start','fetch','ff','copy','hostcheck','units','plan','render-old-check','render-check','result']);
assert.match(result(e),new RegExp(`^result exit=3 render-refused case2-unit.sh --check refuses ${C1}'s units from .*dev\\.args; .*takes --next-args .*\\(nothing was stopped\\)$`));
assert.deepEqual(renders(e).map((a)=>[a[0],copyOf(a)]),[['--check',B],['--check',C1]]);
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);argsKept(e);
});
test('#1873: an installed copy whose render check fails refuses at render-old-check, before the target\'s check',async(t)=>{
const e=await useed(t);
fault(e,`check-fails-${B}`);
const r=await upgrade(e,C1);assert.equal(r.code,3,r.all);
assert.deepEqual(names(e),['start','fetch','ff','copy','hostcheck','units','plan','render-old-check','result']);
assert.match(result(e),new RegExp(`^result exit=3 render-refused case2-unit.sh --check refuses ${B}'s units from .*dev\\.args, so a rollback could not render them`));
assert.deepEqual(renders(e).map((a)=>[a[0],copyOf(a)]),[['--check',B]]);
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);argsKept(e);
});
test('#1873: --next-args moves the old units aside, renders the new flags as a first install, keeps the old units and args, and rolls back the same way',async(t)=>{
const e=await useed(t);
const before=unitBytes(e),next=nextText(e);writeNext(e);
const r=await upgrade(e,C1,'--next-args');assert.equal(r.code,0,r.all);
assert.deepEqual(names(e),[...PRE,...A2,'stage','units-aside','render','confirm','next-args','args','result']);
const [d]=replacedDirs(e);assert.ok(d,readdirSync(e.etc).join(' '));
assert.equal(result(e),`result exit=0 ok upgrade ok: ${C1} is live for acme; the replaced units and args file are in ${join(e.etc,d)}`);
assert.deepEqual(readdirSync(e.etc).sort(),['dev.args',d]);
assert.equal(readFileSync(e.args,'utf8'),next.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`));assert.equal(statSync(e.args).mode&0o777,0o640);
const rd=join(e.etc,d);assert.equal(statSync(rd).mode&0o777,0o700);
assert.deepEqual(readdirSync(rd).sort(),['dev.args',e.tmpl,e.con].sort());
for(const [u,text] of Object.entries(before))assert.equal(readFileSync(join(rd,u),'utf8'),text,u);
assert.equal(readFileSync(join(rd,'dev.args'),'utf8'),e.argsText);assert.equal(statSync(join(rd,'dev.args')).mode&0o777,0o640);
const serve=(a)=>a.slice(a.indexOf('--')+1);
const rl=renders(e);assert.deepEqual(rl.map((a)=>[a[0],copyOf(a)]),[['--check',B],['--check-new',C1],['--user',C1]]);
assert.deepEqual(serve(rl[0]),['--port',String(e.port),'--business','acme']);
assert.deepEqual(serve(rl[1]),['--business','acme','--port',String(e.port)]);assert.deepEqual(serve(rl[2]),serve(rl[1]));
assert.deepEqual(live(e),['active','active',C1,C1]);
// The run sheet's rollback: the replaced args file as the next args, then rollback --next-args.
copyFileSync(join(rd,'dev.args'),join(e.etc,'dev.args.next'));chmodSync(join(e.etc,'dev.args.next'),0o640);
const after=unitBytes(e),m=slog(e).length,n=renders(e).length;
const rb=await drive(e,C1,'rollback','--instance','dev','--to',B,'--next-args',...TO);assert.equal(rb.code,0,rb.all);
assert.deepEqual(names(e).slice(m),['start','hostcheck','units','plan','render-old-check','render-check',...A2,'stage','units-aside','render','confirm','next-args','args','result']);
assert.deepEqual(renders(e).slice(n).map((a)=>[a[0],copyOf(a)]),[['--check',C1],['--check-new',B],['--user',B]]);
assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.deepEqual(live(e),['active','active',B,B]);
const d2=replacedDirs(e).find((x)=>x!==d);assert.ok(d2);assert.deepEqual(readdirSync(e.etc).sort(),['dev.args',d,d2].sort());
for(const [u,text] of Object.entries(after))assert.equal(readFileSync(join(e.etc,d2,u),'utf8'),text,u);
assert.equal(readFileSync(join(e.etc,d2,'dev.args'),'utf8'),next.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`));
});
test('#1873: a failed render under --next-args moves the old units back unchanged and restarts them, with no render of the old copy: exit 1',async(t)=>{
const e=await useed(t);
const before=unitBytes(e),next=nextText(e);writeNext(e);
fault(e,`render-fails-${C1}`);
const r=await upgrade(e,C1,'--next-args');assert.equal(r.code,1,r.all);
assert.deepEqual(names(e),[...PRE,...A2,'stage','units-aside','render',...A2,'revert','units-back','daemon-reload','start-bus','start-console','confirm-rollback','result']);
assert.match(result(e),new RegExp(`^result exit=1 failed A4 failed; rolled back to ${B}`));
assert.deepEqual(renders(e).map((a)=>[a[0],copyOf(a)]),[['--check',B],['--check-new',C1],['--user',C1]]);
for(const [u,text] of Object.entries(before))assert.equal(readFileSync(join(e.units,u),'utf8'),text,u);
assert.deepEqual(readdirSync(e.etc).sort(),['dev.args','dev.args.next']);
assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.equal(readFileSync(join(e.etc,'dev.args.next'),'utf8'),next);
assert.equal(final(e,lastAct(e)).rollback,'ok');assert.deepEqual(live(e),['active','active',B,B]);
});
test('#1873: the next args must be root\'s, equal before -- but for --copy, differ after it, and render as a first install',async(t)=>{
const e=await useed(t);
const tryNext=async(text,mode,code,why,at='plan')=>{
if(text===null)rmSync(join(e.etc,'dev.args.next'),{force:true});else writeNext(e,text,mode);
const n=existsSync(e.slog)?slog(e).length:0;
const r=await upgrade(e,C1,'--next-args');assert.equal(r.code,3,`${why}: ${r.all}`);
assert.match(slog(e).at(-1),new RegExp(`^result exit=3 ${code} `),why);
const ran=names(e).slice(n);assert.ok(!ran.some((x)=>/^(stop|stage|units-aside|render$|confirm)/.test(x)),`${why}: ${ran}`);
if(at)assert.equal(ran.at(-2),at,`${why}: ${ran}`);
};
await tryNext(null,0,'args-unreadable','no next file','start');
await tryNext(nextText(e),0o660,'args-unreadable','group write','start');
await tryNext(nextText(e).replace('--home\n','--pm\n--home\n'),0o640,'next-args-differ','a flag before --','start');
await tryNext(nextText(e).replace('--user\nfixture\n','--user\nother\n'),0o640,'next-args-differ','another user','start');
await tryNext(e.argsText,0o640,'next-args-same','the same lines','start');
await tryNext(e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C}\n`),0o640,'next-args-same','only --copy differs','start');
fault(e,`check-fails-${C1}`);
await tryNext(nextText(e),0o640,'render-refused','the first-install check','render-check');
assert.match(result(e),new RegExp(`^result exit=3 render-refused case2-unit.sh --check-new refuses ${C1}'s units from .*dev\\.args\\.next`));
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.deepEqual(replacedDirs(e),[]);
});
+5 -5
View File
@@ -72,8 +72,8 @@ function checkUser(user, at, ctx, humans, seenSubjects) {
requireObject(user, at);
rejectUnknownKeys(user, ["id", "home", "subjects"], at);
requireId(user.id, `${at}.id`);
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be a bus principal`);
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human`);
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)}`);
}
@@ -105,7 +105,7 @@ function checkScope(value, where, check) {
const seen = new Set();
list.forEach((entry, i) => {
check(entry, `${where}[${i}]`);
if (seen.has(entry)) refuse(`duplicate entry in ${where}`);
if (seen.has(entry)) refuse(`duplicate entry in ${where}: ${JSON.stringify(entry)}`);
seen.add(entry);
});
}
@@ -122,7 +122,7 @@ function checkGrant(grant, at, ctx, users, seenGrants) {
requireObject(grant, at);
rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);
requireId(grant.id, `${at}.id`);
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant 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`);
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
@@ -153,7 +153,7 @@ export function checkAccess(document, context) {
const seenSubjects = new Set();
requireArray(document.users, "users").forEach((user, i) => {
checkUser(user, `users[${i}]`, ctx, humans, seenSubjects);
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id`);
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id: ${user.id}`);
users.add(user.id);
});
const seenGrants = new Set();
+8 -14
View File
@@ -1,9 +1,10 @@
// Phase M2 (#1873): instance accounts, independent of Console sessions and bus authority.
import { constants, lstatSync } from 'node:fs';
import { constants } from 'node:fs';
import * as fs from 'node:fs/promises';
import { dirname, join, parse, resolve } from 'node:path';
import { dirname, join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { OrgError, readRegistryLocked } from './org-registry.mjs';
import { assertTrustedConfigDir } from './config-dir-trust.mjs';
import { readJsonFile, requireObject, rejectUnknownKeys, requireString, requirePositiveInt, requireId, requireDistinctList, deepFreeze, canonicalJson } from './util.mjs';
import { RIGHTS, ROLE_RIGHTS, normalizeRights } from './org-rights.mjs';
@@ -128,23 +129,16 @@ export function accountsRepairs(doc) {
return result;
}
// Check the host boundary before any read or lock. Every ancestor must be a real
// directory; configDir itself belongs to this uid and is not publicly writable.
// Check the host boundary before any read or lock: every ancestor a real directory
// that only root or this uid can change, and configDir this uid's and not publicly
// writable (config-dir-trust.mjs). Refusals name the rule and uid, never the path.
export function assertAccountsOwner(configDir, { uid = process.geteuid() } = {}) {
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('accounts-unsafe', 'config directory must be an absolute normalized path');
let current = parse(configDir).root;
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
current = join(current, part); const st = lstatSync(current);
if (!st.isDirectory() || st.isSymbolicLink()) fail('accounts-unsafe', 'config path contains a symlink or non-directory');
}
const st = lstatSync(configDir);
if (st.uid !== uid) fail('accounts-unsafe', `config directory must belong to uid ${uid}: ${configDir}`);
if (st.mode & 0o022) fail('accounts-unsafe', 'config directory must not be writable by group or other');
assertTrustedConfigDir(configDir, { uid, code: 'accounts-unsafe' });
}
export function loadAccounts(file, context, { uid = process.geteuid() } = {}) {
assertAccountsOwner(dirname(file), { uid });
const doc = readJsonFile(file, 'accounts file', st => {
if (st.uid !== uid) fail('accounts-unsafe', `accounts file must belong to uid ${uid}: ${file}`);
if (st.uid !== uid) fail('accounts-unsafe', `accounts file must belong to uid ${uid}`);
if (st.mode & 0o022) fail('accounts-unsafe', 'accounts file must not be writable by group or other');
});
return checkAccounts(doc, context);
@@ -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.
@@ -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');
}
+8 -8
View File
@@ -40,11 +40,11 @@ function checkAuthority(authority) {
for (const key of ["withinRole", "crossRole"]) {
lists[key] = requireDistinctList(authority[key], `role authority.${key}`, (action) => {
if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}; expected an action from the role vocabulary`);
if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists an action which is always gated`);
if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists ${action}, which is always gated`);
});
}
const both = lists.withinRole.filter((a) => lists.crossRole.includes(a));
if (both.length > 0) refuse(`action listed as both withinRole and crossRole`);
if (both.length > 0) refuse(`action listed as both withinRole and crossRole: ${both.join(", ")}`);
return lists;
}
@@ -87,13 +87,13 @@ function checkHireable(hireable, file) {
const dir = dirname(file);
return requireDistinctList(hireable, "role hireable", (name) => {
requireId(name, "role hireable entry");
if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names a creative definition only the human adds`);
if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names ${name}, a creative definition only the human adds`);
const path = join(dir, `${name}.json`);
if (!existsSync(path)) refuse(`hireable names a definition which has no role file`);
const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false });
if (named.roleVersion !== 2) refuse(`hireable names a definition which is role version 1`);
const outside = unhireableAction(named);
if (outside !== null) refuse(`hireable names a definition which holds an action outside the hireable ceiling`);
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside} outside the hireable ceiling`);
});
}
@@ -106,7 +106,7 @@ function checkGiteaScopes(scopes) {
}, { nonEmpty: true });
const categories = list.map((s) => s.split(":")[1]);
const twice = categories.filter((c, i) => categories.indexOf(c) !== i);
if (twice.length > 0) refuse(`gitea scopes name a category twice; a token holds one level per category`);
if (twice.length > 0) refuse(`gitea scopes name ${twice[0]} twice; a token holds one level per category`);
return list;
}
@@ -135,7 +135,7 @@ function checkCredentials(credentials) {
if (!host && !SERVICES.includes(entry.service)) {
refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE].join(", ")}`);
}
if (seen.has(entry.service)) refuse(`role credentials name a service twice`);
if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`);
seen.add(entry.service);
if (host) return { service: HOST_SERVICE };
const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
@@ -167,12 +167,12 @@ function checkContract(contract, file) {
// docs: its authority is withinRole CREATIVE_ACTIONS at most, and its one credential is gitea.
function checkCreative(role) {
const wider = role.tools.filter((tool) => !CREATIVE_TOOLS.includes(tool));
if (wider.length > 0) refuse(`creative role ${role.name} may not name tools outside the allowed set (allowed: ${CREATIVE_TOOLS.join(", ")})`);
if (wider.length > 0) refuse(`creative role ${role.name} may not name tools ${wider.join(", ")} (allowed: ${CREATIVE_TOOLS.join(", ")})`);
if (role.authority.crossRole.length > 0 || role.authority.withinRole.some((a) => !CREATIVE_ACTIONS.includes(a))) {
refuse(`creative role ${role.name} may hold only withinRole ${CREATIVE_ACTIONS.join(" and ")}`);
}
const services = role.credentials.map((c) => c.service).filter((service) => service !== CREATIVE_SERVICE);
if (services.length > 0) refuse(`creative role ${role.name} may hold only the ${CREATIVE_SERVICE} credential`);
if (services.length > 0) refuse(`creative role ${role.name} may hold only the ${CREATIVE_SERVICE} credential (got ${services.join(", ")})`);
if (role.messaging === null) refuse(`creative role ${role.name} requires "messaging"`);
for (const key of ["sendTo", "receiveFrom"]) {
if (role.messaging[key].some((entry) => entry !== "human")) {
+2 -2
View File
@@ -108,8 +108,8 @@ test("users: unknown keys and the id rule", () => {
});
test("users: human, tester and any business's human are refused", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be a bus principal/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be a bus principal/);
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human", a bus principal/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester", a bus principal/);
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human/);
// The human of a business the grant doesn't name is refused too.
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human/);
+74 -2
View File
@@ -1,9 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, unlinkSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, unlinkSync, existsSync, readdirSync, rmSync, statSync, lstatSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { checkAccounts, loadAccounts, accountsRepairs, withAccounts, withAccountsLock, withAccountsLocked } from '../src/accounts.mjs';
import { checkAccounts, loadAccounts, accountsRepairs, assertAccountsOwner, withAccounts, withAccountsLock, withAccountsLocked } from '../src/accounts.mjs';
import { templateRender } from '../src/org-service.mjs';
import { loadBusiness } from '../src/organization-config.mjs';
import { REPO_ROLES } from './helpers.mjs';
@@ -178,3 +178,75 @@ test('review P3-6: a real business file and accounts file load with the same hum
const s = setup(t), doc = await templateRender('acme'); doc.human = OP; mkdirSync(join(s.configDir, 'businesses')); writeFileSync(join(s.configDir, 'businesses', 'acme.json'), JSON.stringify(doc), { mode: 0o600 });
assert.equal(loadBusiness('acme', { dir: s.configDir, rolesDir: REPO_ROLES }).human, OP); assert.equal(loadAccounts(s.file, s).users[0].id, OP);
});
// org-acct-anchor (#1873, A2 review P2-1): every ancestor of the config directory must belong to root
// or this uid, and be writable by others only with the sticky bit. Otherwise another user could rename
// the directory away and put their own accounts.json in its place. No refusal quotes the path.
const SECRETISH = 'sk-test-NOT-A-SECRET-1234';
function nested(t) {
const top = mkdtempSync(join(tmpdir(), 'a-')); t.after(() => { chmodSync(top, 0o700); rmSync(top, { recursive: true, force: true }); });
const parent = join(top, SECRETISH), configDir = join(parent, 'cfg'); mkdirSync(configDir, { recursive: true, mode: 0o700 }); chmodSync(parent, 0o700);
const accountsFile = join(configDir, 'accounts.json'); writeFileSync(accountsFile, JSON.stringify(document()), { mode: 0o600 });
return { top, parent, configDir, instance: 'test', ...context, file: accountsFile };
}
const unsafe = rule => e => e.code === 'accounts-unsafe' && rule.test(e.message) && !e.message.includes(SECRETISH) && !e.message.includes('/');
const ANCESTOR = /ancestor must belong to root or uid \d+ and not be writable by others without the sticky bit/;
test('org-acct-anchor: a 0777 ancestor refuses load, init and the lock; 1777 passes', async t => {
const s = nested(t), initDir = join(s.parent, 'new'); mkdirSync(initDir, { mode: 0o700 });
const init = { ...s, configDir: initDir, file: join(initDir, 'accounts.json') };
for (const dir of [s.parent, s.top]) {
chmodSync(dir, 0o777);
try {
assert.throws(() => loadAccounts(s.file, s), unsafe(ANCESTOR));
await assert.rejects(withAccounts(init, { revision: 0, verb: 'init', init: true }, d => d.users.push(user())), unsafe(ANCESTOR));
let entered = false;
await assert.rejects(withAccountsLock(s, async () => { entered = true; }), unsafe(ANCESTOR));
assert.equal(entered, false);
for (const c of [s, init]) for (const name of ['accounts.lock', 'accounts-receipts.jsonl']) assert.equal(existsSync(join(c.configDir, name)), false, name);
assert.equal(existsSync(init.file), false);
chmodSync(dir, 0o1777);
assert.equal(loadAccounts(s.file, s).revision, 1);
await withAccountsLock(s, async () => { entered = true; }); assert.equal(entered, true);
await withAccounts(init, { revision: 0, verb: 'init', init: true }, d => d.users.push(user()));
assert.equal(loadAccounts(init.file, init).revision, 1);
rmSync(init.file); rmSync(join(init.configDir, 'accounts-receipts.jsonl'));
} finally { chmodSync(dir, 0o700); }
}
// A group-writable ancestor without the sticky bit refuses too.
chmodSync(s.parent, 0o770);
try { assert.throws(() => loadAccounts(s.file, s), unsafe(ANCESTOR)); } finally { chmodSync(s.parent, 0o700); }
});
test('org-acct-anchor: an ancestor owned by another uid refuses; root-owned ancestors pass', t => {
const s = nested(t), other = process.geteuid() + 1;
// Simulated as the agent-levels tests do: as another uid, this uid's directories are foreign.
assert.throws(() => assertAccountsOwner(s.configDir, { uid: other }), unsafe(new RegExp(`ancestor must belong to root or uid ${other} `)));
assert.throws(() => loadAccounts(s.file, s, { uid: other }), unsafe(ANCESTOR));
// The real ancestors above the scratch root include root-owned ones (at least /), which pass.
assert.equal(lstatSync('/').uid, 0);
assertAccountsOwner(s.configDir);
assert.equal(loadAccounts(s.file, s).revision, 1);
});
test('org-acct-anchor: a symlinked ancestor refuses, and leaf refusals name no path', async t => {
const s = nested(t), link = join(s.top, 'link'); symlinkSync(s.parent, link);
const via = { ...s, configDir: join(link, 'cfg'), file: join(link, 'cfg', 'accounts.json') };
assert.throws(() => loadAccounts(via.file, via), unsafe(/symbolic link or non-directory/));
await assert.rejects(withAccountsLock(via, async () => {}), unsafe(/symbolic link or non-directory/));
assert.equal(existsSync(join(s.configDir, 'accounts.lock')), false);
// The leaf checks are unchanged, but no longer quote the path.
const other = process.geteuid() + 1;
for (const mode of [0o720, 0o702]) {
chmodSync(s.configDir, mode);
try { assert.throws(() => loadAccounts(s.file, s), unsafe(/^config directory must not be writable by group or other$/)); } finally { chmodSync(s.configDir, 0o700); }
}
// As another uid the ancestors refuse first; the leaf's uid rule is the same no-path form (source check below).
assert.throws(() => assertAccountsOwner(s.configDir, { uid: process.geteuid() + 1 }), e => !e.message.includes(SECRETISH) && !e.message.includes('/'));
const source = readFileSync(new URL('../src/config-dir-trust.mjs', import.meta.url), 'utf8') + readFileSync(new URL('../src/accounts.mjs', import.meta.url), 'utf8');
assert.match(source, /`config directory must belong to uid \$\{uid\}`/);
assert.match(source, /`accounts file must belong to uid \$\{uid\}`/);
assert.doesNotMatch(source, /must belong to uid \$\{uid\}: \$\{/);
assert.throws(() => loadAccounts(join(s.configDir, 'missing', 'accounts.json'), s), unsafe(/^config directory is missing$/));
assert.throws(() => assertAccountsOwner(`${s.configDir}/../cfg`), unsafe(/absolute normalized/));
});
@@ -53,22 +53,29 @@ for (const [field, edit] of [
assert.throws(() => validateRoleDocument(d, join(root, 'r.json')), safe(field, BusinessError, { exitCode: 2 }, secret.toLowerCase()));
});
// This branch is reachable only with already-validated service names; a sentinel service
// is refused earlier. Pin the same no-value rule with an admissible service.
test('creative service refusal omits the validated service list', t => {
// is refused earlier. A known service is already displayed in the role's rails.
test('creative service refusal names the validated service list', t => {
const root = scratch(t); writeFileSync(join(root, 'companion.md'), '# R\n\n## Authority\n');
const d = { ...role(), name: 'companion', contract: 'companion.md', messaging: { sendTo: ['human'], receiveFrom: ['human'] }, credentials: [{ service: 'vikunja', scopes: { tasks: ['read_one'] } }] };
assert.throws(() => validateRoleDocument(d, join(root, 'companion.json')), safe('credential', BusinessError, { exitCode: 2 }, 'vikunja'));
assert.throws(() => validateRoleDocument(d, join(root, 'companion.json')), named('credential', 'vikunja'));
});
test('role duplicate skill omits the accepted list value', t => {
const root = scratch(t); writeFileSync(join(root, 'r.md'), '# R\n\n## Authority\n');
const d = { ...role(), skills: [secret, secret] };
assert.throws(() => validateRoleDocument(d, join(root, 'r.json')), safe('role skills', BusinessError, { exitCode: 2 }));
});
for (const at of ['user', 'grant', 'scope', 'human']) test(`access ${at} duplicate/reserved refusal omits accepted ids`, () => {
for (const at of ['user', 'grant', 'scope', 'human']) test(`access ${at} duplicate/reserved refusal names accepted ids`, () => {
const value = secret.toLowerCase(), d = access(), ctx = structuredClone(context);
if (at === 'user') { d.users[0].id = value; d.users.push({ ...structuredClone(d.users[0]), subjects: [{ provider: 'test', sub: 'second' }] }); }
if (at === 'grant') { d.grants[0].id = value; d.grants.push(structuredClone(d.grants[0])); }
if (at === 'scope') { ctx.businesses.acme.instances = [value]; d.grants[0].instances = [value, value]; }
if (at === 'human') { ctx.businesses.acme.human = value; d.users[0].id = value; }
assert.throws(() => checkAccess(d, ctx), safe(at === 'scope' ? 'instances' : '.id', BusinessError, { exitCode: 2 }, value));
assert.throws(() => checkAccess(d, ctx), named(at === 'scope' ? 'instances' : '.id', value));
});
function named(label, value) { return error => {
assert.ok(error instanceof BusinessError); assert.equal(error.exitCode, 2);
assert.ok(error.message.includes(label), error.message);
assert.ok(error.message.includes(value), error.message);
return true;
}; }
+7 -7
View File
@@ -189,7 +189,7 @@ test("credentials: Gitea scopes", () => {
refuses(() => gitea(["write:admin"]), /unsupported gitea scope/);
refuses(() => gitea(["all"]), /unsupported gitea scope/);
refuses(() => gitea(["sudo:repository"]), /unsupported gitea scope/);
refuses(() => gitea(["read:issue", "write:issue"]), /name a category twice/);
refuses(() => gitea(["read:issue", "write:issue"]), /name issue twice/);
refuses(() => gitea([]), /must not be empty/);
});
@@ -255,7 +255,7 @@ test("M1: a creative role file wider than read and answer is refused", () => {
};
assert.doesNotThrow(creative("ideas", { tools: ["read", "grep", "find", "ls", "web"] }));
for (const tool of ["write", "edit", "bash"]) {
refuses(creative("ideas", { tools: ["read", tool] }), /^creative role ideas may not name tools outside the allowed set /);
refuses(creative("ideas", { tools: ["read", tool] }), /^creative role ideas may not name tools (?:write|edit|bash) \(allowed: /);
}
const gitea = [{ service: "gitea", scopes: ["read:user", "write:repository"] }];
assert.doesNotThrow(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: gitea }));
@@ -268,9 +268,9 @@ test("M1: a creative role file wider than read and answer is refused", () => {
refuses(creative("proofreader", { authority: { withinRole: ["message.send"], crossRole: ["library.write"] } }), only);
// Row 285: library.write with any credential but gitea refuses.
refuses(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: [...gitea, { service: "vikunja", scopes: { tasks: ["read_one"] } }] }),
/^creative role ideas may hold only the gitea credential$/);
/^creative role ideas may hold only the gitea credential \(got vikunja\)$/);
refuses(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: [{ service: "host" }] }),
/^creative role ideas may hold only the gitea credential$/);
/^creative role ideas may hold only the gitea credential \(got host\)$/);
refuses(creative("continuity", { messaging: undefined }), /^creative role continuity requires "messaging"$/);
refuses(creative("research", { messaging: { sendTo: ["human", "pm"], receiveFrom: ["human"] } }), /^creative role research messaging.sendTo may name only human$/);
refuses(creative("research", { messaging: { sendTo: ["human"], receiveFrom: ["assistant"] } }), /^creative role research messaging.receiveFrom may name only human$/);
@@ -307,14 +307,14 @@ test("hireable: a list naming a definition that holds any refused action refuses
for (const action of REFUSED_FOR_HIRE) {
for (const key of ["withinRole", "crossRole"]) {
const dir = hireDir({ chief: { hireable: ["worker"] }, worker: { [key]: [action], ...(key === "crossRole" ? { withinRole: [] } : {}) } });
refuses(() => loadRole(dir, "chief"), /hireable names a definition which holds an action outside the hireable ceiling$/);
refuses(() => loadRole(dir, "chief"), /hireable names worker, which holds [a-z.]+ outside the hireable ceiling$/);
}
}
});
test("hireable: a mixed definition refuses, a clean list loads, and the named file's own hireable isn't read", () => {
const mixed = hireDir({ chief: { hireable: ["worker"] }, worker: { withinRole: ["message.send", "role.launch"] } });
refuses(() => loadRole(mixed, "chief"), /hireable names a definition which holds an action outside the hireable ceiling/);
refuses(() => loadRole(mixed, "chief"), /hireable names worker, which holds [a-z.]+ outside the hireable ceiling/);
const clean = hireDir({
chief: { hireable: ["worker", "helper"] },
worker: { withinRole: [...HIRE_ALLOWED] },
@@ -331,7 +331,7 @@ test("hireable: a missing, version 1, duplicate or badly named entry refuses, an
refuses(() => loadRole(hireDir({ chief: { hireable: ["old"] }, old: 1 }), "chief"), /hireable names a definition which is role version 1/);
// Row 213 (M10): a creative definition refuses by name, before its file is read.
for (const name of CREATIVE_DEFINITIONS) {
refuses(() => loadRole(hireDir({ chief: { hireable: [name] } }), "chief"), /hireable names a creative definition only the human adds$/);
refuses(() => loadRole(hireDir({ chief: { hireable: [name] } }), "chief"), /hireable names [a-z-]+, a creative definition only the human adds$/);
}
refuses(() => loadRole(hireDir({ chief: { hireable: ["w", "w"] }, w: {} }), "chief"), /duplicate entry in role hireable/);
refuses(() => loadRole(hireDir({ chief: { hireable: ["../w"] } }), "chief"), /role hireable entry/);
@@ -0,0 +1,89 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { validateRoleDocument } from '../src/role.mjs';
import { checkAccess } from '../src/access.mjs';
import { BusinessError } from '../src/errors.mjs';
const bad = 'sk-test-NOT-A-SECRET-1234';
const badId = `${bad.toLowerCase()} "`;
function refusal(fn, names = [], omitted = bad, validation = null) {
assert.throws(fn, error => {
assert.ok(error instanceof BusinessError);
assert.equal(error.exitCode, 2);
for (const name of names) assert.ok(error.message.includes(name), error.message);
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET/i);
assert.ok(!error.message.includes(omitted), error.message);
if (validation !== null) assert.match(error.message, validation);
return true;
});
}
function role(t, edit = () => {}) {
const root = mkdtempSync(join(tmpdir(), 'names-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
writeFileSync(join(root, 'r.md'), '# R\n\n## Authority\n');
writeFileSync(join(root, 'companion.md'), '# R\n\n## Authority\n');
const doc = { roleVersion: 2, name: 'r', title: 'R', contract: 'r.md', tools: ['read'], network: 'none',
authority: { withinRole: [], crossRole: [] }, credentials: [] };
edit(doc, root);
return () => validateRoleDocument(doc, join(root, `${doc.name}.json`));
}
for (const key of ['withinRole', 'crossRole']) test(`gated ${key} names the known action; unknown action stays unnamed`, t => {
refusal(role(t, d => { d.authority[key] = ['deploy']; }), ['authority.' + key, 'deploy']);
refusal(role(t, d => { d.authority[key] = [bad]; }));
});
test('overlapping authority lists name every known action; invalid entries stop first', t => {
refusal(role(t, d => { d.authority = { withinRole: ['message.send', 'record.write'], crossRole: ['message.send', 'record.write'] }; }), ['message.send', 'record.write']);
refusal(role(t, d => { d.authority = { withinRole: [bad], crossRole: [bad] }; }));
});
for (const kind of ['creative', 'outside']) test(`hireable ${kind} names a known definition; invalid name stops first`, t => {
const edit = (d, root) => {
const name = kind === 'creative' ? 'companion' : 'helper';
d.hireable = [name];
if (kind === 'outside') writeFileSync(join(root, 'helper.json'), JSON.stringify({ ...d, name, hireable: [], authority: { withinRole: ['role.hire'], crossRole: [] } }));
};
refusal(role(t, edit), kind === 'creative' ? ['companion'] : ['helper', 'role.hire']);
refusal(role(t, d => { d.hireable = [badId]; }), [], badId, /role hireable entry must match/);
});
test('hireable wider action rejects unknown action before naming it', t => {
refusal(role(t, (d, root) => {
d.hireable = ['helper'];
writeFileSync(join(root, 'helper.json'), JSON.stringify({ ...d, name: 'helper', hireable: [], authority: { withinRole: [bad], crossRole: [] } }));
}));
});
test('gitea category conflict names the checked category; invalid scope stops first', t => {
refusal(role(t, d => { d.credentials = [{ service: 'gitea', scopes: ['read:issue', 'write:issue'] }]; }), ['issue']);
refusal(role(t, d => { d.credentials = [{ service: 'gitea', scopes: [bad] }]; }));
});
test('duplicate credential names the checked service; invalid service stops first', t => {
refusal(role(t, d => { d.credentials = [{ service: 'gitea', scopes: ['read:issue'] }, { service: 'gitea', scopes: ['read:issue'] }]; }), ['gitea']);
refusal(role(t, d => { d.credentials = [{ service: bad }, { service: bad }]; }));
});
for (const field of ['tools', 'credentials']) test(`creative ${field} names the checked wider grant; invalid grant stops first`, t => {
const edit = d => { d.name = 'companion'; d.contract = 'companion.md'; d.messaging = { sendTo: ['human'], receiveFrom: ['human'] }; };
refusal(role(t, d => { edit(d); if (field === 'tools') d.tools.push('bash'); else d.credentials = [{ service: 'vikunja', scopes: { tasks: ['read_one'] } }]; }), [field === 'tools' ? 'bash' : 'vikunja']);
refusal(role(t, d => { edit(d); if (field === 'tools') d.tools = [bad]; else d.credentials = [{ service: bad }]; }));
});
const context = () => ({ providers: ['test'], peers: [], businesses: { acme: { human: 'jason', instances: ['coder'] } }, ssoSubjects: [] });
const access = () => ({ accessVersion: 1, revision: 1,
users: [{ id: 'bean', home: 'local', subjects: [{ provider: 'test', sub: 'one' }] }],
grants: [{ id: 'grant', user: 'bean', business: 'acme', projects: ['*'], instances: ['coder'], level: 'viewer' }] });
for (const kind of ['reserved', 'human', 'user', 'grant', 'project', 'instance']) test(`access ${kind} conflict names checked id; malformed replacement stops first`, () => {
function run(value) {
const d = access(), ctx = context();
if (kind === 'reserved' || kind === 'human') d.users[0].id = value;
if (kind === 'user') d.users.push({ ...structuredClone(d.users[0]), id: value, subjects: [{ provider: 'test', sub: 'two' }] });
if (kind === 'grant') { d.grants[0].id = value; d.grants.push(structuredClone(d.grants[0])); }
if (kind === 'project') d.grants[0].projects = [value, value];
if (kind === 'instance') d.grants[0].instances = [value, value];
return () => checkAccess(d, ctx);
}
const value = { reserved: 'human', human: 'jason', user: 'bean', grant: 'g-known-1', project: '11111111-2222-4333-8444-555555555555', instance: 'coder' }[kind];
refusal(run(value), [value]);
const validation = kind === 'project' ? /lowercase UUID/ : kind === 'instance' ? /declared instance/ : /\.id must match/;
refusal(run(badId), [], badId, validation);
});
+21 -21
View File
@@ -346,7 +346,7 @@ export function validateRow(row) {
}
if (row.claim !== null) {
keysExactly(row.claim, ["seat", "op"], `${w} claim`);
if (row.claim.seat !== row.owner) throw refuse(`${w} claim seat is not the owner`);
if (row.claim.seat !== row.owner) throw refuse(`${w} claim seat is not the owner ${row.owner}`);
checkCallerOpId(row.claim.op, `${w} claim op`);
const s = row.state === "blocked" ? row.previousState : row.state;
if (!CLAIM_KEPT.has(s)) throw refuse(`${w} is claimed but ${row.state}`);
@@ -517,8 +517,8 @@ const isPriv = (by) => PRIVILEGED.has(by);
function ownerOrPriv(row, by, doing) {
if (by === row.owner || isPriv(by)) return;
const claimed = row.claim !== null ? `row ${row.id} is claimed; ` : "";
throw refuse(`${claimed}only the owner or a privileged actor may ${doing} row ${row.id}`);
const claimed = row.claim !== null ? `row ${row.id} is claimed by ${row.claim.seat}; ` : "";
throw refuse(`${claimed}only the owner (${row.owner}) or a privileged actor may ${doing} row ${row.id}`);
}
function requirePriv(by, doing) {
@@ -580,7 +580,7 @@ function unresolvedAttempts(row) {
function refuseUnresolved(row, doing) {
const open = unresolvedAttempts(row);
if (open.length) throw refuse(`row ${row.id} has an unresolved review request; resolve or abandon it before ${doing}`);
if (open.length) throw refuse(`row ${row.id} has an unresolved review request: ${open.join(", ")}; resolve or abandon it before ${doing}`);
}
// A comment round leaves in-review, for done or for Jason's gate, only on
@@ -589,7 +589,7 @@ function requireApprovals(row, cur, to) {
if (row.reviewers.length === 0) throw refuse(`row ${row.id} lists no reviewers now; a privileged actor sets them before it ${to === "done" ? "closes" : `moves to ${to}`}`);
const approved = new Set(cur.receipts.filter((r) => r.verdict === "approve").map((r) => r.reviewer));
const missing = row.reviewers.filter((s) => !approved.has(s));
if (missing.length) throw refuse(`row ${row.id} round ${cur.n} has no approval recorded by every listed reviewer`);
if (missing.length) throw refuse(`row ${row.id} round ${cur.n} has no approval recorded by ${missing.join(", ")}`);
}
// The request round an attempt belongs to, and the attempt.
@@ -645,7 +645,7 @@ function applyReview(rows, row, entry) {
const cur = currentCommentRound(row, "review request");
const live = cur.attempts.filter((x) => x.state !== "failed" && x.state !== "abandoned");
refuseUnresolved(row, "a new request");
if (live.length) throw refuse(`row ${row.id} round ${cur.n} already has a posted request`);
if (live.length) throw refuse(`row ${row.id} round ${cur.n} already has a posted request (${live[0].op}, comment ${live[0].comment})`);
const rounds = row.review.rounds.map((r) => (r.n === cur.n ? { ...r, attempts: [...r.attempts, newAttempt(entry)] } : r));
return {
row: { ...row, review: { rounds } },
@@ -657,8 +657,8 @@ function applyReview(rows, row, entry) {
if (entry.op !== `${a.attempt}.outcome`) throw refuse(`an outcome's op is its attempt's op plus .outcome`);
const found = findAttempt(row, a.attempt);
const att = found.attempt;
if (by !== att.by) throw refuse(`only the actor who made the request records its outcome`);
if (att.transport !== null) throw refuse(`request already has its outcome`);
if (by !== att.by) throw refuse(`only ${att.by}, who made request ${att.op}, records its outcome`);
if (att.transport !== null) throw refuse(`request ${att.op} already has its outcome`);
const t = { outcome: a.outcome, status: a.status, comment: a.comment, detail: a.detail };
const state = outcomeState(att, t);
const comment = state === "posted" ? (att.comment ?? t.comment) : null;
@@ -678,7 +678,7 @@ function applyReview(rows, row, entry) {
else requirePriv(by, "abandon a review request");
const found = findAttempt(row, a.attempt);
const att = found.attempt;
if (!UNRESOLVED.has(att.state)) throw refuse(`request is ${att.state}; only a requesting, uncertain or conflict request can be ${resolve ? "resolved" : "abandoned"}`);
if (!UNRESOLVED.has(att.state)) throw refuse(`request ${att.op} is ${att.state}; only a requesting, uncertain or conflict request can be ${resolve ? "resolved" : "abandoned"}`);
const res = { verb: resolve ? "resolve" : "abandon", op: entry.op, by, at: entry.at, comment: resolve ? a.comment : null, reason: resolve ? null : a.reason };
const state = resolve ? "posted" : "abandoned";
const next = withAttempt(row, found, { ...att, state, comment: resolve ? a.comment : null, resolutions: [...att.resolutions, res] }, resolve ? {} : { duplicateRisk: true });
@@ -689,11 +689,11 @@ function applyReview(rows, row, entry) {
};
}
case "review-record": {
if (!row.reviewers.includes(by) || by === row.owner) throw refuse(`only a listed reviewer other than the owner may record a verdict on row ${row.id}`);
if (!row.reviewers.includes(by) || by === row.owner) throw refuse(`only a listed reviewer other than the owner may record a verdict on row ${row.id} (reviewers: ${fmt(row.reviewers)})`);
if (!row.review) throw refuse(`row ${row.id} has no review round`);
const cur = currentCommentRound(row, "review record");
if (a.candidate !== cur.candidate.digest) throw refuse(`candidate is not round ${cur.n}'s candidate`);
if (cur.receipts.some((r) => r.reviewer === by)) throw refuse(`reviewer already recorded a verdict for row ${row.id} round ${cur.n}`);
if (cur.receipts.some((r) => r.reviewer === by)) throw refuse(`${by} already recorded a verdict for row ${row.id} round ${cur.n}`);
const rec = { reviewer: by, op: entry.op, at: entry.at, verdict: a.verdict, comment: a.comment, candidate: a.candidate };
const rounds = row.review.rounds.map((r) => (r.n === cur.n ? { ...r, receipts: [...r.receipts, rec] } : r));
return {
@@ -759,14 +759,14 @@ function applyMove(rows, row, entry, resolved) {
} else if (from === "queued" && to === "briefed") {
requirePriv(by, "accept a brief (queued→briefed)");
} else if (from === "briefed" && to === "in-progress") {
if (by !== row.owner) throw refuse(`only the owner may start row ${row.id}`);
if (by !== row.owner) throw refuse(`only the owner (${row.owner}) may start row ${row.id}`);
const missing = afterSatisfied(rows, row);
if (missing.length) throw refuse(`row ${row.id} waits on ${missing.join(", ")}`);
next.claim = { seat: row.owner, op: entry.op };
} else if (from === "in-progress" && to === "briefed") {
throw refuse(`use \`queue release ${row.id}\` to give up a claim`);
} else if (from === "in-progress" && to === "in-review") {
if (row.claim === null || by !== row.claim.seat) throw refuse(`only the claimant may request review of row ${row.id}`);
if (row.claim === null || by !== row.claim.seat) throw refuse(`only the claimant (${row.claim?.seat ?? "nobody"}) may request review of row ${row.id}`);
if (candidate === null) throw refuse("in-progress→in-review needs --candidate <commit|manifest>");
revIssue = reviewIssue(row, issue);
cand = checkCandidate(resolved.candidate);
@@ -797,7 +797,7 @@ function applyMove(rows, row, entry, resolved) {
next.claim = null;
} else if (from === "in-review" && to === "done") {
if (row.gateOwner === "jason") throw refuse(`row ${row.id}'s gate is Jason's; it goes through waiting-on-jason`);
if (by !== row.gateOwner && !isPriv(by)) throw refuse(`only the gate owner or a privileged actor may close row ${row.id}`);
if (by !== row.gateOwner && !isPriv(by)) throw refuse(`only the gate owner (${row.gateOwner}) or a privileged actor may close row ${row.id}`);
refuseUnresolved(row, "closing it");
const cur = row.review?.rounds.at(-1);
if (cur?.request === "comment") {
@@ -834,7 +834,7 @@ function applySet(rows, row, entry, resolved) {
switch (field) {
case "piece": case "gate": case "reviewers": case "issues":
requirePriv(by, `change ${field}`);
if (field === "reviewers" && value.includes(row.owner)) throw refuse(`row ${row.id} owner can't be a listed reviewer`);
if (field === "reviewers" && value.includes(row.owner)) throw refuse(`row ${row.id} owner ${row.owner} can't be a listed reviewer`);
next[field] = value;
// N10: closes follows the issues only if nobody narrowed it. A logged
// narrowing keeps its intersection with the new issues.
@@ -908,7 +908,7 @@ export function applyEntry(state, entry, resolved) {
const id = highWater + 1;
const owner = a.owner ?? by;
const reviewers = a.reviewers ?? [];
if (reviewers.includes(owner)) throw refuse(`row ${id} owner can't be a listed reviewer`);
if (reviewers.includes(owner)) throw refuse(`row ${id} owner ${owner} can't be a listed reviewer`);
highWater = id;
const required = a.required ?? false;
const row = {
@@ -947,7 +947,7 @@ export function applyEntry(state, entry, resolved) {
case "release": {
const row = getRow(rows, a.id);
if (row.state !== "in-progress") throw refuse(`row ${row.id} is ${row.state}; release applies to in-progress rows`);
if (!(row.claim && by === row.claim.seat) && !isPriv(by)) throw refuse(`only the claimant or a privileged actor may release row ${row.id}`);
if (!(row.claim && by === row.claim.seat) && !isPriv(by)) throw refuse(`only the claimant (${row.claim?.seat ?? "nobody"}) or a privileged actor may release row ${row.id}`);
rows.set(row.id, touch({ ...row, state: "briefed", claim: null }, entry));
result = { row: row.id, from: "in-progress", to: "briefed", receipt: receipt(entry, rev, `row ${row.id} in-progress→briefed`) };
break;
@@ -956,8 +956,8 @@ export function applyEntry(state, entry, resolved) {
const row = getRow(rows, a.id);
requirePriv(by, "assign a row");
if (row.state === "done") throw refuse(`row ${row.id} is done; done rows never change`);
if (row.owner === a.seat) throw refuse(`row ${row.id} is already owned by that seat`);
if (row.reviewers.includes(a.seat)) throw refuse(`can't assign row ${row.id}: seat is one of its reviewers`);
if (row.owner === a.seat) throw refuse(`row ${row.id} is already owned by ${row.owner}`);
if (row.reviewers.includes(a.seat)) throw refuse(`can't assign row ${row.id}: ${a.seat} is one of its reviewers`);
const claim = row.claim ? { seat: a.seat, op: entry.op } : null;
rows.set(row.id, touch({ ...row, owner: a.seat, claim }, entry));
result = { row: row.id, field: "owner", from: row.owner, to: a.seat, receipt: receipt(entry, rev, `row ${row.id} owner: ${row.owner}→${a.seat}`) };
@@ -966,7 +966,7 @@ export function applyEntry(state, entry, resolved) {
case "note": {
const row = getRow(rows, a.id);
if (row.state === "done" || row.state === "parked") throw refuse(`row ${row.id} is ${row.state}; notes are closed`);
if (by !== row.owner && !row.reviewers.includes(by) && !isPriv(by)) throw refuse(`only the owner, a listed reviewer or a privileged actor may note row ${row.id}`);
if (by !== row.owner && !row.reviewers.includes(by) && !isPriv(by)) throw refuse(`only the owner (${row.owner}), a listed reviewer or a privileged actor may note row ${row.id}`);
// On a blocked row the note is the reason (8.7: "update the reason
// with note"); elsewhere it is the row's note, and "" clears it.
const field = row.state === "blocked" ? "blockedReason" : "note";
@@ -1135,7 +1135,7 @@ export function loadDoc(bytes) {
doc.log.forEach(checkEntryShape);
const ops = new Set();
for (const e of doc.log) {
if (ops.has(e.op)) throw refuse(`op id appears twice in the log`);
if (ops.has(e.op)) throw refuse(`op id ${e.op} appears twice in the log`);
ops.add(e.op);
}
if (doc.revision !== doc.log.length - 1) throw refuse(`revision is not the last rev ${doc.log.length - 1}`);
+6 -6
View File
@@ -119,8 +119,8 @@ test("add: defaults for an ordinary seat, privileged extras, refusals", () => {
refused(() => step(d, "add", { ...add, owner: "rocko" }, "dewey", { brief: brief() }), /only a privileged actor may set owner/);
refused(() => step(d, "add", { ...add, reviewers: ["rocko"] }, "dewey", { brief: brief() }), /reviewers/);
refused(() => step(d, "add", { ...add, required: true }, "dewey", { brief: brief() }), /required/);
refused(() => step(d, "add", { ...add, owner: "rocko", reviewers: ["rocko"] }, "sage", { brief: brief() }), /row 12 owner can't be a listed reviewer/);
refused(() => step(d, "add", { ...add, reviewers: ["sage"] }, "sage", { brief: brief() }), /row 12 owner can't be a listed reviewer/);
refused(() => step(d, "add", { ...add, owner: "rocko", reviewers: ["rocko"] }, "sage", { brief: brief() }), /row 12 owner rocko can't be a listed reviewer/);
refused(() => step(d, "add", { ...add, reviewers: ["sage"] }, "sage", { brief: brief() }), /row 12 owner sage can't be a listed reviewer/);
const d2 = step(d, "add", { ...add, owner: "rocko", gateOwner: "filbert", after: [{ id: 9, when: "done" }], reviewers: ["dewey"], required: true }, "sage", { brief: brief() });
const r2 = row(d2, 12);
assert.deepEqual([r2.owner, r2.gateOwner, r2.required, r2.after], ["rocko", "filbert", true, [{ id: 9, when: "done" }]]);
@@ -134,7 +134,7 @@ test("matrix: queued→briefed privileged; briefed→in-progress owner with afte
let d = step(genesisDoc(), "add", { piece: "N", gate: "g", brief: "docs/plans/brief-b.md#Queue" }, "dewey", { brief: brief() });
refused(() => step(d, "move", mv(12, "briefed"), "dewey"), /privileged/);
d = step(d, "move", mv(12, "briefed"), "sage");
refused(() => step(d, "move", mv(12, "in-progress"), "sage"), /only the owner may start/);
refused(() => step(d, "move", mv(12, "in-progress"), "sage"), /only the owner \(dewey\) may start/);
d = step(d, "move", mv(12, "in-progress"), "dewey");
assert.deepEqual(row(d, 12).claim.seat, "dewey");
refused(() => step(d, "move", mv(12, "in-progress"), "dewey"), /not a transition/);
@@ -391,8 +391,8 @@ test("field edits: who may change what", () => {
const set = (id, field, value, reason = null) => ({ id, field, value, reason });
refused(() => step(d, "set", set(11, "piece", "x"), "dewey"), /privileged/);
assert.match(step(d, "set", set(11, "piece", "Renamed"), "sage").log.at(-1).result.receipt, /row 11 piece: Brief template→Renamed$/);
refused(() => step(d, "set", set(9, "reviewers", ["darkwing"]), "sage"), /row 9 owner can't be a listed reviewer/);
refused(() => step(d, "set", set(9, "reviewers", ["filbert", "darkwing"]), "sage"), /row 9 owner can't be a listed reviewer/);
refused(() => step(d, "set", set(9, "reviewers", ["darkwing"]), "sage"), /row 9 owner darkwing can't be a listed reviewer/);
refused(() => step(d, "set", set(9, "reviewers", ["filbert", "darkwing"]), "sage"), /row 9 owner darkwing can't be a listed reviewer/);
assert.deepEqual(row(step(d, "set", set(9, "reviewers", ["dewey"]), "sage"), 9).reviewers, ["dewey"]);
refused(() => step(d, "set", set(9, "after", []), "sage"), /only jason may change after on a required row/);
step(d, "set", set(9, "after", []), "jason");
@@ -586,7 +586,7 @@ test("note: owner, listed reviewer or privileged; empty clears", () => {
test("assign moves the claim with the owner; done clears it", () => {
let d = row9Started();
refused(() => step(d, "assign", { id: 9, seat: "dewey" }, "darkwing"), /privileged/);
refused(() => step(d, "assign", { id: 9, seat: "filbert" }, "sage"), /can't assign row 9: seat is one of its reviewers/);
refused(() => step(d, "assign", { id: 9, seat: "filbert" }, "sage"), /can't assign row 9: filbert is one of its reviewers/);
d = step(d, "assign", { id: 9, seat: "dewey" }, "sage", {}, "assign-row-9-dewey");
assert.deepEqual(row(d, 9).claim, { seat: "dewey", op: "assign-row-9-dewey" });
assert.match(d.log.at(-1).result.receipt, /owner: darkwing→dewey$/);
+63 -1
View File
@@ -72,6 +72,7 @@ function operation(verb, args, edit = () => {}, options = {}) {
return applyEntry(state, entry, options.resolved ?? {});
}
const ownerValue = row => { row.owner = seatValue; if (row.claim) row.claim.seat = seatValue; };
const restored = new Set(['claimed owner authority', 'start owner', 'review claimant', 'close gate owner', 'set owner reviewer', 'release claimant', 'assign existing owner', 'assign reviewer', 'note owner', 'add owner reviewer', 'unresolved request', 'missing approval', 'posted request', 'outcome actor', 'duplicate outcome', 'settled resolution', 'reviewer list', 'duplicate verdict']);
for (const [name, field, fn, omitted = seatValue] of [
['canonical duplicate reviewer', 'repeated value', () => canonArgs('set', { id: 6, field: 'reviewers', value: [seatValue, seatValue] })],
['canonical state', 'state', () => canonArgs('move', { id: 6, to: secret }), secret],
@@ -112,7 +113,7 @@ for (const [name, field, fn, omitted = seatValue] of [
// outcomeState also defends direct callers with an inconsistent transport record.
['inconsistent outcome record', 'outcome', () => operation('review-outcome', { attempt: seatValue, outcome: 'posted', status: 201, comment: 1, detail: 'fixture' }, r => reviewRow(r, 'failed'),
{ by: 'darkwing', op: seatValue + '.outcome', unchecked: true })],
]) test(`queue replay refusal ${name} omits stored values`, () => assert.throws(fn, safe(field, QueueError, { code: 2 }, omitted)));
]) test(`queue replay refusal ${name} ${restored.has(name) ? 'names validated identifiers' : 'omits stored values'}`, () => assert.throws(fn, restored.has(name) ? named(field, omitted, name === 'posted request' ? 'comment 1' : null) : safe(field, QueueError, { code: 2 }, omitted)));
test('migration map shape omits an unchecked row id', () => assert.throws(() => parseMigrationMap(mapText([{ id: secret }])), safe('migration map row', QueueError, { code: 2 })));
test('migration map brief shape omits an unchecked row id', () => {
@@ -127,3 +128,64 @@ test('BASE control: genesis expected claim already omits the stored op', () => {
const d = doc(); d.log[0].op = seatValue; d.log[0].result.receipt = genesisReceipt(seatValue, d.rows.length);
assert.throws(() => loadDoc(Buffer.from(serialize(d))), safe('claim', QueueError, { code: 2 }, seatValue));
});
function named(label, value, additional = null) { return error => {
assert.ok(error instanceof QueueError); assert.equal(error.code, 2);
assert.ok(error.message.includes(label), error.message);
assert.ok(error.message.includes(value), error.message);
if (additional !== null) assert.ok(error.message.includes(additional), error.message);
return true;
}; }
// Each restored replay refusal is behind row validation or canonical arguments
// on the store's write path. Replace the relevant identifier at that boundary.
for (const [site, edit] of [
['claimed owner authority', r => { r.owner = secret; }],
['start owner', r => { r.owner = secret; }],
['review claimant', r => { r.claim.seat = secret; }],
['close gate owner', r => { r.gateOwner = secret; }],
['set owner reviewer', r => { r.owner = secret; }],
['release claimant', r => { r.claim.seat = secret; }],
['assign existing owner', r => { r.owner = secret; }],
['assign reviewer', r => { r.reviewers = [secret]; }],
['note owner', r => { r.owner = secret; }],
['unresolved request', r => { reviewRow(r); r.review.rounds[0].attempts[0].op = secret + '/'; }],
['missing approval', r => { reviewRow(r, 'posted'); r.reviewers = [secret]; }],
['posted request', r => { reviewRow(r, 'posted'); r.review.rounds[0].attempts[0].op = secret + '/'; }],
['posted request comment', r => { reviewRow(r, 'posted'); r.review.rounds[0].attempts[0].comment = secret; }],
['outcome actor', r => { reviewRow(r); r.review.rounds[0].attempts[0].by = secret; }],
['duplicate outcome', r => { reviewRow(r, 'posted'); r.review.rounds[0].attempts[0].op = secret + '/'; }],
['settled resolution', r => { reviewRow(r, 'posted'); r.review.rounds[0].attempts[0].op = secret + '/'; }],
['reviewer list', r => { r.reviewers = [secret]; }],
['duplicate verdict', r => { reviewRow(r, 'posted'); r.review.rounds[0].receipts = [{ reviewer: secret, op: 'receipt-op-0001', at, verdict: 'approve', comment: 1, candidate: digestValue }]; }],
]) test(`queue ${site} rejects malformed identifier before replay refusal`, () => {
const row = doc().rows.find(r => r.id === 6); edit(row);
assert.throws(() => validateRow(row), safe('', QueueError, { code: 2 }));
});
test('add owner reviewer rejects invalid owner before the conflict refusal', () => {
assert.throws(() => canonArgs('add', { piece: 'piece', gate: 'gate', brief: 'docs/brief.md#Brief', owner: secret, reviewers: [secret] }), safe('owner', QueueError, { code: 2 }));
});
test('claim mismatch names only the validated owner', () => {
const row = doc().rows.find(r => r.id === 6); row.claim.seat = secret;
assert.throws(() => validateRow(row), error => {
assert.ok(safe('claim seat', QueueError, { code: 2 })(error));
assert.ok(error.message.includes('darkwing'), error.message); return true;
});
});
test('duplicate logged op names the validated op; malformed op stops first', () => {
for (const op of ['genesis-op-0001', secret + '/']) {
const d = doc(); d.log[0].op = op; d.log.push({ ...structuredClone(d.log[0]), rev: 1 }); d.revision = 1;
assert.throws(() => loadDoc(Buffer.from(serialize(d))), op === 'genesis-op-0001' ? named('op id', op) : safe('op', QueueError, { code: 2 }));
}
});
test('unresolved request refusal names every open request', () => {
assert.throws(() => operation('review-request', {}, row => {
reviewRow(row);
row.review.rounds[0].attempts.push({ ...structuredClone(row.review.rounds[0].attempts[0]), op: 'second-request-01' });
}), named('unresolved review request', seatValue, 'second-request-01'));
});
test('approval refusal names every missing approver', () => {
assert.throws(() => operation('move', { to: 'done' }, row => {
reviewRow(row, 'posted'); row.gateOwner = 'sage'; row.reviewers = ['filbert', 'rocko'];
}), named('approval', 'filbert', 'rocko'));
});
+15 -15
View File
@@ -240,8 +240,8 @@ test("an unresolved request blocks a new request, a new round, waiting-on-jason
const head = s.head();
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request/);
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "77", "--candidate", head, "--op", "record-9-filbert1"], "filbert"));
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /unresolved review request; resolve or abandon it before closing it/);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /unresolved review request; resolve or abandon it before moving it to waiting-on-jason/);
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /unresolved review request: .*; resolve or abandon it before closing it/);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /unresolved review request: .*; resolve or abandon it before moving it to waiting-on-jason/);
assert.equal(s.posts().length, 1);
// A POST that lands after the row reached waiting-on-jason: abandoned,
@@ -256,7 +256,7 @@ test("an unresolved request blocks a new request, a new round, waiting-on-jason
};
assert.equal(s3.mut({ verb: "move", op: "review-9-00001", args: { id: 9, to: "in-review", candidate: "HEAD" }, by: "darkwing" }, { hook }).code, 3);
assert.deepEqual([s3.row(9).state, s3.round(9, 1).attempts[0].state], ["waiting-on-jason", "conflict"]);
no(s3.run(["move", "9", "done", "--op", "done-9-000002"], "jason"), 2, /unresolved review request; resolve or abandon it before closing it/);
no(s3.run(["move", "9", "done", "--op", "done-9-000002"], "jason"), 2, /unresolved review request: .*; resolve or abandon it before closing it/);
// Back to in-progress is allowed; the next round is not.
const s2 = await ready(t);
@@ -264,7 +264,7 @@ test("an unresolved request blocks a new request, a new round, waiting-on-jason
no(s2.run(move9(), "darkwing"), 3);
s2.rules([]);
ok(s2.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
no(s2.run(["move", "9", "in-review", "--candidate", "HEAD", "--op", "review-9-00002"], "darkwing"), 2, /unresolved review request; resolve or abandon it before a new round/);
no(s2.run(["move", "9", "in-review", "--candidate", "HEAD", "--op", "review-9-00002"], "darkwing"), 2, /unresolved review request: .*; resolve or abandon it before a new round/);
assert.equal(s2.posts().length, 1);
});
@@ -407,7 +407,7 @@ test("resolve checks the comment: issue, markers, round, candidate and author",
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing"), 1, /failed or had no answer/);
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "darkwing", { MOSAIC_GITEA_CREDENTIAL_FILE: s.tokens.dewey }), 2, /cannot check comment 7001: .*not darkwing's token file/);
const before = s.calls().length;
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "rocko"), 2, /^queue: row 9 is claimed; only the owner or a privileged actor may resolve a request on row 9$/m);
no(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "7001", "--op", "resolve-9-00001"], "rocko"), 2, /^queue: row 9 is claimed by darkwing; only the owner \(darkwing\) or a privileged actor may resolve a request on row 9$/m);
assert.equal(s.calls().length, before, "a refused actor makes no request");
assert.equal(s.doc().revision, 4);
s.rules([{ method: "GET", path: "comments/7001$", http: 200, body: good }]);
@@ -463,9 +463,9 @@ test("request, changes, a new candidate, approval: every round pinned; no review
no(s.run(["review", "record", "9", "--verdict", "maybe", "--comment", "88", "--candidate", c1, "--op", "record-9-filb01"], "filbert"), 2, /approve or changes/);
no(s.run(["review", "record", "9", "--verdict", "changes", "--candidate", c1, "--op", "record-9-filb01"], "filbert"), 4, /--comment N is required/);
ok(s.run(["review", "record", "9", "--verdict", "changes", "--comment", "88", "--candidate", c1, "--op", "record-9-filb01"], "filbert"), /row 9 round 1 changes by filbert \(comment 88\)$/m);
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /reviewer already recorded a verdict for row 9 round 1/);
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /filbert already recorded a verdict for row 9 round 1/);
ok(cli(s.repo, ["next", "filbert"]), /^nothing$/m);
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /round 1 has no approval recorded by every listed reviewer/);
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /round 1 has no approval recorded by (?:filbert|rocko)(?:, rocko)?/);
ok(s.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /is in-progress; review record applies to in-review rows/);
writeFileSync(join(s.repo.root, "packages/fix.mjs"), "export {};\n");
@@ -477,7 +477,7 @@ test("request, changes, a new candidate, approval: every round pinned; no review
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /candidate is not round 2's candidate/);
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c2, "--op", "record-9-filb02"], "filbert"));
no(s.run(["move", "9", "done", "--evidence", `comment=89,round=2,candidate=${c2}`, "--op", "done-9-000001"], "filbert"), 2, /closes on its recorded verdicts; drop --evidence/);
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "rocko"), 2, /only the gate owner or a privileged actor/);
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "rocko"), 2, /only the gate owner \(filbert\) or a privileged actor/);
ok(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), /in-review→done round 2$/m);
const rounds = s.row(9).review.rounds;
assert.deepEqual(rounds.map((r) => [r.n, r.candidate.digest, r.attempts.map((a) => [a.op, a.state, a.comment]), r.receipts.map((x) => [x.reviewer, x.verdict, x.comment, x.candidate])]), [
@@ -569,7 +569,7 @@ test("semantics: v1 entries replay as before; review entries need v2", async (t)
// An attempt takes one outcome, even one that agrees with it.
const full = queue.replay(d.log);
const again = { ...outcome, rev: full.revision + 1 };
assert.throws(() => queue.applyEntry(full, again, {}), /request already has its outcome/);
assert.throws(() => queue.applyEntry(full, again, {}), /request review-9-00001 already has its outcome/);
// review-outcome is the one entry a done row takes.
const at = "2026-09-27T00:00:00.000Z";
// The request was abandoned and the row closed before the POST's answer came.
@@ -585,7 +585,7 @@ test("semantics: v1 entries replay as before; review entries need v2", async (t)
assert.deepEqual([late.state.rows.get(9).state, a.state, a.transport.comment], ["done", "conflict", 1000]);
const lateOk = (verb, op, args, by) => queue.applyEntry(st, entry(st, verb, op, args, by), {});
assert.throws(() => lateOk("review-resolve", "resolve-9-00001", { id: 9, attempt: "review-9-00001", comment: 5 }), /done rows never change/);
assert.throws(() => queue.applyEntry(mid, entry(mid, "review-outcome", "review-9-00001.outcome", { id: 9, attempt: "review-9-00001", outcome: "failed", status: 403, comment: null, detail: "x" }, "sage"), {}), /only the actor who made the request records its outcome/);
assert.throws(() => queue.applyEntry(mid, entry(mid, "review-outcome", "review-9-00001.outcome", { id: 9, attempt: "review-9-00001", outcome: "failed", status: 403, comment: null, detail: "x" }, "sage"), {}), /only darkwing, who made request review-9-00001, records its outcome/);
assert.throws(() => queue.canonArgs("review-outcome", { id: 9, attempt: "review-9-00001", outcome: "failed", status: 500, comment: null, detail: "x" }), /a failed outcome's status is one of 400, 401, 403, 404, 422/);
assert.throws(() => queue.canonArgs("review-outcome", { id: 9, attempt: "review-9-00001", outcome: "posted", status: 201, comment: null, detail: "x" }), /posted exactly when HTTP 201 returned a comment id/);
assert.throws(() => queue.canonArgs("review-outcome", { id: 9, attempt: "review-9-00001", outcome: "posted", status: 200, comment: 4, detail: "x" }), /posted exactly when HTTP 201/);
@@ -593,7 +593,7 @@ test("semantics: v1 entries replay as before; review entries need v2", async (t)
test("set reviewers refuses the row's owner (2026-09-28)", async (t) => {
const s = await ready(t);
no(s.run(["set", "9", "reviewers", "filbert,darkwing", "--op", "reviewers-9-self"], "sage"), 2, /row 9 owner can't be a listed reviewer/);
no(s.run(["set", "9", "reviewers", "filbert,darkwing", "--op", "reviewers-9-self"], "sage"), 2, /row 9 owner darkwing can't be a listed reviewer/);
assert.deepEqual(s.row(9).reviewers, ["filbert"]);
});
@@ -665,17 +665,17 @@ test("a Jason-gated row reaches waiting-on-jason only on every reviewer's approv
ok(s.run(move9(), "darkwing"), /posted comment 1000$/m);
assert.deepEqual(s.round(9, 1).receipts, []);
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "darkwing"), 2, /gate is Jason's; it goes through waiting-on-jason/);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 1 has no approval recorded by every listed reviewer$/m);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "sage"), 2, /no approval recorded by every listed reviewer$/m);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 1 has no approval recorded by (?:filbert|rocko)(?:, rocko)?$/m);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "sage"), 2, /no approval recorded by (?:filbert|rocko)(?:, rocko)?$/m);
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "88", "--candidate", head, "--op", "record-9-filb01"], "filbert"));
ok(s.run(["review", "record", "9", "--verdict", "changes", "--comment", "89", "--candidate", head, "--op", "record-9-rock01"], "rocko"));
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /no approval recorded by every listed reviewer$/m);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /no approval recorded by (?:filbert|rocko)(?:, rocko)?$/m);
// Changes go back to in-progress, as before.
ok(s.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
ok(s.run(move9("review-9-00002"), "darkwing"), /posted comment 1001$/m);
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "91", "--candidate", head, "--op", "record-9-rock02"], "rocko"));
// Filbert's round 1 approval doesn't carry into round 2.
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 2 has no approval recorded by every listed reviewer$/m);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 2 has no approval recorded by (?:filbert|rocko)(?:, rocko)?$/m);
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "90", "--candidate", head, "--op", "record-9-filb02"], "filbert"));
ok(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), /in-review→waiting-on-jason$/m);
ok(s.run(["move", "9", "done", "--op", "done-9-000001"], "jason"), /waiting-on-jason→done$/m);
+3 -3
View File
@@ -245,11 +245,11 @@ test("claims and add defaults through the CLI; candidates are manifests or reach
test("add, set reviewers and assign refuse the row's owner as a reviewer", (t) => {
const repo = ready(t);
const before = doc(repo);
no(cli(repo, ["add", "--op", "add-owner-reviewer", "--piece", "x", "--gate", "g", "--brief", "docs/plans/brief-b.md#Template", "--owner", "rocko", "--gate-owner", "filbert", "--reviewer", "rocko"], { by: "sage" }), 2, /row 12 owner can't be a listed reviewer/);
no(cli(repo, ["add", "--op", "add-owner-reviewer", "--piece", "x", "--gate", "g", "--brief", "docs/plans/brief-b.md#Template", "--owner", "rocko", "--gate-owner", "filbert", "--reviewer", "rocko"], { by: "sage" }), 2, /row 12 owner rocko can't be a listed reviewer/);
assert.deepEqual(doc(repo), before);
no(cli(repo, ["set", "9", "reviewers", "filbert,darkwing", "--op", "set-9-owner-reviewer"], { by: "sage" }), 2, /row 9 owner can't be a listed reviewer/);
no(cli(repo, ["set", "9", "reviewers", "filbert,darkwing", "--op", "set-9-owner-reviewer"], { by: "sage" }), 2, /row 9 owner darkwing can't be a listed reviewer/);
assert.deepEqual(doc(repo), before);
no(cli(repo, ["assign", "9", "filbert", "--op", "assign-9-filbert"], { by: "sage" }), 2, /can't assign row 9: seat is one of its reviewers/);
no(cli(repo, ["assign", "9", "filbert", "--op", "assign-9-filbert"], { by: "sage" }), 2, /can't assign row 9: filbert is one of its reviewers/);
assert.deepEqual(doc(repo), before);
// The unchanged accept paths: a reviewer list or a new owner that does not collide with the other.
ok(cli(repo, ["set", "9", "reviewers", "filbert,dewey", "--op", "set-9-ok"], { by: "sage" }));
+22
View File
@@ -226,6 +226,28 @@ design is `agents/sage/work/q175-sso/DESIGN.md`. `--sso` can't be combined
with `--public-host`, `--auth-header`, `--allow-user` or `--proof-*`; `--send`
requires `--sso`. Header-preview mode cannot enable the human writer.
Phase M3 sign-in requires an active account for account authority. Run
`mosaic accounts init` before the first accounts sign-in; configured SSO subjects
without accounts are refused. Legacy access.json users can still reach the root
business under their live grants. A session records account, legacy user and root
ownership separately. Only an active operator whose subject is configured as a
root owner gets the root human transport; an account alone gets 404 on root data
routes. Restarting the Console requires the instance operator role.
Authentication generations are checked on every request: disable/re-enable,
role or subject changes and `user signout` invalidate earlier account sessions.
Legacy root grants remain independent. There are at most 512 sessions and 128
pending logins, with eight sessions per account; a ninth evicts that account's
oldest. Sign-out removes the current session entirely.
Every stack copy carries a read-only `.stack-build.json` naming its commit.
A stamped Console requires `--sso`, a valid mode-0444 `.deployed` naming its
instance, and a loadable accounts file. A malformed stamp refuses every start.
Stamped Console and bus starts refuse dev flags. Checkout development still
supports the named local dev owner, but refuses it if accounts.json, console.sso
or `.deployed` is present. Accounts instances use `case2-unit.sh` system units;
`webui-service.sh` user units remain a checkout development path.
- The client secret is read once at start from
`<config dir>/secrets/mosaic-stack/<slot>`. The start refuses a slot that is
a symbolic link, not a regular file, owned by another user, has any group or
+9 -1
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import { assertBuildStart, assertDevInstance, assertDeployed } from '../../cli/src/deployment.mjs';
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
import { createChat } from './chat.mjs';
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
@@ -21,6 +22,7 @@ import { loadRootAgents } from '../../organization/src/agent-levels.mjs';
import { consoleReads, redactor } from './reads.mjs';
import { createDevOwner } from './dev-owner.mjs';
import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.mjs';
import { loadAccounts } from '../../organization/src/accounts.mjs';
import { createSso, readSlot } from './sso.mjs';
import { readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
@@ -98,6 +100,7 @@ try {
} else if (flag === '--dev-allow-host') devNames.push(value);
else throw new Error(`unknown option: ${flag}`);
}
const stamp = assertBuildStart({ serve: true, sso: ssoOn, devOwner, devBind, devNames });
if (selector.given) options.business = selector.value;
if (devOwner && (ssoOn || testerSocket || Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--dev-local-owner cannot be combined with --sso, preview, proof or --tester-socket');
const lan = devLanOptions({ devOwner, bind: devBind, names: devNames });
@@ -118,6 +121,11 @@ try {
let processing = null, ownerCall = null;
try {
system = loadSystem({ instance });
if (devOwner) assertDevInstance(system);
if (stamp) {
assertDeployed(system);
loadAccounts(join(configDir(), 'accounts.json'), { providers: system.console?.sso?.providers ?? [] });
}
const { dataRoot } = system;
const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
// FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs).
@@ -151,7 +159,7 @@ try {
load: () => loadAccess(accessFilePath(configDir()), accessContextFor(system, process.env)),
business: busBusiness({ dataRoot: system.dataRoot, business: options.business ?? null }),
});
sso = await createSso(system.console.sso, { secrets, users });
sso = await createSso(system.console.sso, { secrets, users, accounts: { load: () => loadAccounts(join(configDir(), 'accounts.json'), { providers: system.console.sso.providers }) } });
// Each request of a granted user's session gets the bus options over that user's own
// user-cli transport. The human transport above serves only the chat's `plugins` read (C6).
userOptions = userOptionsFor({ owner: ownerCall, send: sendOn, transport: user => userCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, user }) });
+2 -2
View File
@@ -22,9 +22,9 @@ async function emptyBody(req) {
return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0 ? null : 'the body is {}';
}
export async function consoleRestartHTTP(req, res, { session = null, owner = false, send = false, restart = null, trail = null, boot, log = () => {} }) {
export async function consoleRestartHTTP(req, res, { session = null, operator = false, send = false, restart = null, trail = null, boot, log = () => {} }) {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'restarting the Console is the owner\'s' });
if (!operator || !session) return json(res, 403, { error: 'owner-only', message: 'restarting the Console requires an instance operator' });
if (!send) return json(res, 403, { error: 'read-only', message: 'start the Console with --send to make changes' });
const invalid = await emptyBody(req);
if (invalid) return json(res, invalid === 'JSON body required' ? 415 : 400, { error: 'invalid-request', message: invalid });
+2 -2
View File
@@ -1,8 +1,8 @@
import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs';
const session = Object.freeze({ ...DEV_OWNER, name: 'Local operator' });
const session = Object.freeze({ ...DEV_OWNER, name: 'Local operator', rootOwner: true, instanceRole: 'operator' });
export function createDevOwner() {
return Object.freeze({ devOwner: true, session: () => session, formTargets: () => [],
return Object.freeze({ devOwner: true, mode: 'dev', session: () => session, formTargets: () => [],
handle: (_req, res) => { res.writeHead(404); res.end(); },
summary: () => [], health: () => ({}), close: () => {} });
}
+18 -9
View File
@@ -790,6 +790,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// names (without a port) a remote browser may use; only the dev owner takes them, and only with
// them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback.
if (!Array.isArray(devAllowHosts) || !devAllowHosts.every(devHostName)) throw new Error('dev allowed hosts must be host names or IPv4 addresses without a port');
// The CLI also refuses SSO/dev-owner combinations and LAN flags without the
// dev owner. Keep this guard for callers that construct a session source here.
if ((sso?.mode === 'accounts') && (sso?.devOwner || devAllowHosts.length)) throw new Error('accounts mode refuses the dev LAN same-origin path');
if (devAllowHosts.length && !sso?.devOwner) throw new Error('dev allowed hosts need the dev local owner');
const devLan = Boolean(sso?.devOwner) && devAllowHosts.length > 0;
if (sso?.devOwner && host !== '127.0.0.1' && !(devLan && isIP(host) === 4)) throw new Error(devLan ? 'dev local owner binds an IPv4 address' : 'dev local owner must bind 127.0.0.1');
@@ -935,10 +938,15 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
}
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: localOrigin ?? sso.publicOrigin };
// Row 252: only the owner's session answers a decision. A granted user stops at userRoute below.
if (session && session.user === undefined) ssoSession = session;
if (session?.rootOwner === true) ssoSession = session;
// FED R6 (#1800): a granted user. The Console's gate first; behind it no human-transport
// object is left, so a route past the gate reaches the broker only as this user.
if (session?.user !== undefined) {
const instanceAction = originalPath === '/api/console/restart';
if (session && !session.rootOwner && session.user === undefined && orgId === null && !orgList && !hook?.publicRead && !instanceAction && path !== '/api/config' && path !== '/healthz' && !files.has(path)) return json(res, 404, { error: 'not-found' });
if (session && !session.rootOwner && session.user === undefined) {
bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = upstream = null;
}
if (session?.user !== undefined && !session.rootOwner && !instanceAction) {
if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
if (!organizations && userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
if (!organizations) ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
@@ -982,11 +990,12 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
return json(res, 403, { error: 'unavailable', sent: false });
}
if (hook) {
if (!hook.publicRead && (!currentSession || !named || (granted && req.method !== 'GET'))) return json(res, 403, { error: 'owner-only' });
const instanceAction = originalPath === '/api/console/restart';
if (!hook.publicRead && (!currentSession || !named || (granted && req.method !== 'GET' && !instanceAction))) return json(res, 403, { error: 'owner-only' });
const origin = localOrigin ?? sso?.publicOrigin;
if (req.method !== 'GET' && one(req, 'origin') !== origin) return json(res, 403, { error: 'cross-origin request refused' });
if (req.method !== 'GET' && one(req, 'sec-fetch-site') !== 'same-origin') return json(res, 403, { error: 'cross-site request refused' });
return hook.handler(req, res, { path: originalPath, search, session: ssoSession, orgId, origin, owner: !tester && !granted && named && sso !== null, send: owner.send !== null });
return hook.handler(req, res, { path: originalPath, search, session: currentSession, orgId, origin, owner: !tester && !granted && named && currentSession?.rootOwner === true, operator: !tester && named && currentSession?.instanceRole === 'operator', send: owner.send !== null });
}
const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer;
// F3: the owner's routing and agent changes go to the action trail too (who, when, what).
@@ -1012,7 +1021,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (decisionPath) {
if (tester) return decisionRefuse(res, 'not-found');
const decide = decisionWrite && send && named && sso ? { call: decisionWrite, origin: localOrigin ?? sso.publicOrigin } : null;
return decisionHTTP(req, res, { path, decide, session: ssoSession, live: () => sso.session(req), confirmations, log });
return decisionHTTP(req, res, { path, decide, session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, confirmations, log });
}
// #797 / #1839 Task 3: the owner's request intake controls. The tester listener never has them, and a
// granted user stopped at userRoute above. Reads need the read transport; the writes also --send.
@@ -1023,18 +1032,18 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (!(named && sso !== null))
return requestHTTP(req, res, { path, requestRead: null, requestWrite: null, session: null, live: () => null, origin: null, log });
return requestHTTP(req, res, { path, requestRead, requestWrite: send ? requestWrite : null,
session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin, log });
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin, log });
}
if (remotePath) {
if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' });
return projectRemoteHTTP(req, res, { path, read: requestRead, write: send ? requestWrite : null,
session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin });
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin });
}
// Phase P2 (#1873): the owner's System types read and writes on the selected organization.
if (systemTypesPath) {
if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' });
return systemTypesHTTP(req, res, { path, read: requestRead, write: send ? systemTypesWrite : null,
session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin });
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin });
}
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/system-types.js', '/system-types.css'].includes(path)) return json(res, 403, { error: 'owner-only' });
// Row 194 (#1692, D4.1): the tester also launches and terminates.
@@ -1185,7 +1194,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && !granted && named && sso !== null && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && named && currentSession?.instanceRole === 'operator' && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
+32 -18
View File
@@ -19,10 +19,10 @@ export const LOGIN_COOKIE = '__Host-mosaic_login';
const ATTRS = 'Path=/; Secure; HttpOnly; SameSite=Lax';
const LOGIN_MAX_AGE = 600;
const SESSION_ABSOLUTE = 8 * 3600 * 1000, SESSION_IDLE = 3600 * 1000;
const MAX_SESSIONS = 64, MAX_LOGINS = 32;
const MAX_SESSIONS = 512, MAX_LOGINS = 128, MAX_ACCOUNT_SESSIONS = 8;
const FETCH_TIMEOUT = 10000;
const RETRY_CAP = 300000;
const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'user-revoked', 'discovery-failed', 'jwks-failed']);
const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'user-revoked', 'account-revoked', 'discovery-failed', 'jwks-failed']);
const sha256 = value => createHash('sha256').update(value).digest('hex');
const escape = text => String(text).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
@@ -125,7 +125,7 @@ async function discover(p, fetchOptions) {
// `retryFirst` is for tests too; production waits 5 s, doubling, capped at 5 minutes.
// `users` (FED R6, #1800) is consoleAccess() from access.mjs, or null: a subject that is not the
// owner's signs in as the access-file user with a live grant, and each request checks it again.
export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000, users = null } = {}) {
export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000, users = null, accounts = null } = {}) {
checkConsole({ sso: config });
oauth ??= await import('oauth4webapi');
const publicOrigin = config.publicOrigin;
@@ -280,18 +280,25 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo
try { header = JSON.parse(Buffer.from(result.id_token.split('.')[0], 'base64url').toString('utf8')); } catch { header = null; }
if (!header || !entry.config.algs.includes(header.alg)) { refuse('alg-refused', pid); return failed(res, clear); }
if (claims.iss !== entry.config.issuer) { refuse('issuer-mismatch', pid); return failed(res, clear); }
let name = typeof claims.sub === 'string' ? entry.subjects.get(claims.sub) : undefined;
// FED R6: console.sso subjects and access-file subjects are disjoint (R1), so the owner's
// list is asked first and a user is asked only on a miss.
const user = name === undefined && typeof claims.sub === 'string' ? users?.find({ provider: pid, sub: claims.sub }) ?? null : null;
if (user !== null) name = user;
if (name === undefined) {
// Account authority and the legacy root grant are independent. A configured
// owner subject alone is never an account and cannot bypass a refused store.
let account = null;
try { account = accounts?.load().users.find(u => u.state === 'active' && u.subjects.some(s => s.provider === pid && s.sub === claims.sub)) ?? null; } catch { /* fail closed for accounts */ }
const user = typeof claims.sub === 'string' ? users?.find({ provider: pid, sub: claims.sub }) ?? null : null;
const rootOwner = account?.instanceRole === 'operator' && entry.subjects.has(claims.sub);
if (!account && user === null) {
refuse('subject-unknown', pid, ` iss=${JSON.stringify(String(claims.iss).slice(0, 255))} sub=${JSON.stringify(String(claims.sub).slice(0, 255))}`);
return failed(res, clear);
}
// Step 7: the session, then the return path.
const name = account?.name ?? user;
const id = randomBytes(32).toString('base64url'), now = Date.now();
remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, ...(user !== null ? { user } : {}), idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS);
if (account) {
const own = [...sessions].filter(([, s]) => s.account === account.id);
while (own.length >= MAX_ACCOUNT_SESSIONS) sessions.delete(own.shift()[0]);
}
remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name,
...(account ? { account: account.id, instanceRole: account.instanceRole, authGen: account.authGen } : {}),
rootOwner, ...(user !== null ? { user } : {}), idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS);
return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]);
}
@@ -304,13 +311,20 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo
if (!s) return null;
const now = Date.now();
if (now - s.created >= SESSION_ABSOLUTE || now - s.seen >= SESSION_IDLE) { sessions.delete(key); return null; }
// FED R6 (design 3.1 D5): a user's grant is read again on every request, so a user revoked,
// removed or expired since sign-in has no session from the next request on.
if (s.user !== undefined && !users?.check(s)) {
sessions.delete(key);
refuse('user-revoked', s.provider, ` user=${JSON.stringify(s.user)}`);
return null;
if (s.account !== undefined) {
let account;
try { account = accounts?.load().users.find(u => u.id === s.account); } catch { /* refuse */ }
if (!account || account.state !== 'active' || account.authGen !== s.authGen || account.instanceRole !== s.instanceRole || !account.subjects.some(x => x.provider === s.provider && x.sub === s.sub)) {
refuse('account-revoked', s.provider);
// Never reacquire account authority on this cookie, even after re-enable.
delete s.account; delete s.instanceRole; delete s.authGen; s.rootOwner = false;
}
}
if (s.user !== undefined && !users?.check(s)) {
refuse('user-revoked', s.provider, ` user=${JSON.stringify(s.user)}`);
delete s.user;
}
if (s.account === undefined && s.user === undefined) { sessions.delete(key); return null; }
if (touch) s.seen = now;
return s;
}
@@ -351,7 +365,7 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo
}
return {
publicOrigin, publicHost, sessions, logins, handle, session, returnURL,
mode: 'accounts', publicOrigin, publicHost, sessions, logins, handle, session, returnURL,
chatGrants: session => users?.chatGrants?.(session) ?? [],
health: () => Object.fromEntries([...providers].map(([id, e]) => [id, e.state])),
formTargets: () => [...new Set([...providers.values()].filter(e => e.as?.end_session_endpoint).map(e => new URL(e.as.end_session_endpoint).origin))],
@@ -0,0 +1,94 @@
// M3: real accounts store, real OIDC code flow, isolated port-zero providers.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadAccounts, withAccounts } from '../../organization/src/accounts.mjs';
import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
import { startServer } from '../src/serve.mjs';
import { consoleAccess } from '../src/access.mjs';
import { fakeOidc } from './helpers/fake-oidc.mjs';
import { close } from './fixture.mjs';
const HOST = 'accounts.example.test', ORIGIN = `https://${HOST}`;
function call(port, path, { cookie, method = 'GET', body } = {}) {
return new Promise((resolve, reject) => {
const req = request({ host: '127.0.0.1', port, path, method, headers: { host: HOST, ...(cookie ? { cookie } : {}), ...(body ? { origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } : {}) } }, res => {
let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text }));
}); req.on('error', reject); req.end(body);
});
}
async function setup(t, { legacy = false } = {}) {
const dir = mkdtempSync(join(tmpdir(), 'as-')); chmodSync(dir, 0o700); t.after(() => rmSync(dir, { recursive: true, force: true }));
const op = await fakeOidc(); t.after(() => op.close()); const iss = op.issuer('a');
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'Owner' }, { sub: 'drift', name: 'Drift' }] }] };
const ctx = { configDir: dir, providers: config.providers }, file = join(dir, 'accounts.json');
const row = (id, sub, instanceRole) => ({ id, subjects: [{ provider: 'a', sub }], name: sub, instanceRole, state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' });
await withAccounts(ctx, { init: true, revision: 0, verb: 'init' }, d => { d.users.push(row('u-000000000001', 'owner', 'operator'), row('u-000000000002', 'pat', 'user'), row('u-000000000003', 'op', 'operator')); });
let access = { users: [{ id: 'legacy', subjects: [{ provider: 'a', sub: 'pat' }] }], grants: legacy ? [{ user: 'legacy', business: 'demo', projects: ['*'], instances: ['*'], level: 'collaborator' }] : [] };
const lines = [], sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), accounts: { load: () => loadAccounts(file, ctx) }, users: consoleAccess({ load: () => access, business: () => 'demo' }) });
t.after(() => sso.close()); let restarts = 0;
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, bus: { inbox: async () => [] }, send: async () => ({}), restart: () => restarts++, trail: () => {}, userOptions: () => ({ bus: { inbox: async () => [] }, chat: { projects: async () => [] } }), log: () => {} }); t.after(() => close(web)); const port = web.address().port;
async function login(sub = 'pat') {
const begin = await call(port, '/auth/login'); const loc = begin.headers.location; const grant = op.grant(iss, loc, { sub });
const r = await call(port, `/auth/callback?${new URLSearchParams({ code: grant.code, state: new URL(loc).searchParams.get('state') })}`, { cookie: begin.headers['set-cookie'][0].split(';')[0] });
const cookie = r.headers['set-cookie']?.find(c => c.startsWith(SESSION_COOKIE + '=') && !c.includes('Max-Age=0'))?.split(';')[0]; return { ...r, cookie };
}
const read = () => loadAccounts(file, ctx);
const change = (fn, verb = 'user.update') => withAccounts(ctx, { revision: read().revision, verb }, fn);
return { login, sso, lines, file, port, read, change, revoke: () => { access.grants = []; }, restarts: () => restarts };
}
test('account sign-in records separate authority; unknown and drifted owner subjects refuse', async t => {
const s = await setup(t); const p = await s.login(); assert.equal(p.status, 303);
const session = s.sso.session({ headers: { cookie: p.cookie } }); assert.equal(session.account, 'u-000000000002'); assert.equal(session.authGen, 1); assert.equal(session.instanceRole, 'user'); assert.equal(session.rootOwner, false); assert.equal(session.user, undefined);
for (const sub of ['unknown', 'drift']) { const r = await s.login(sub); assert.equal(r.status, 403); assert.equal(r.cookie, undefined); assert.match(s.lines.at(-1), /subject-unknown/); }
const owner = await s.login('owner'); assert.equal(s.sso.session({ headers: { cookie: owner.cookie } }).rootOwner, true);
});
test('disabled, removed, signout, changed subject and role generations invalidate without resurrection', async t => {
for (const mutation of ['disable', 'disable-enable', 'role-roundtrip', 'signout', 'subject', 'remove', 'missing', 'invalid']) await t.test(mutation, async t => {
const s = await setup(t), r = await s.login(); assert.ok(r.cookie, 'account sign-in required before revocation');
if (mutation.startsWith('disable')) { await s.change(d => d.users[1].state = 'disabled'); if (mutation.endsWith('enable')) await s.change(d => d.users[1].state = 'active'); }
if (mutation === 'role-roundtrip') { await s.change(d => d.users[1].instanceRole = 'operator'); await s.change(d => d.users[1].instanceRole = 'user'); }
if (mutation === 'signout') await s.change(d => d.users[1].authGen++, 'user.signout');
if (mutation === 'subject') await s.change(d => d.users[1].subjects[0].sub = 'changed');
if (mutation === 'remove') await s.change(d => d.users.splice(1, 1), 'user.remove');
if (mutation === 'missing') rmSync(s.file); if (mutation === 'invalid') writeFileSync(s.file, '{}');
assert.equal(s.sso.session({ headers: { cookie: r.cookie } }), null);
assert.equal((await call(s.port, '/api/bus/inbox', { cookie: r.cookie })).status, 401);
});
});
test('the ninth account session evicts only that account oldest; global caps are 512/128', async t => {
const s = await setup(t), other = await s.login('owner'), cookies = [];
for (let i = 0; i < 9; i++) cookies.push((await s.login()).cookie);
assert.equal(s.sso.session({ headers: { cookie: cookies[0] } }), null);
for (const cookie of [...cookies.slice(1), other.cookie]) assert.ok(s.sso.session({ headers: { cookie } }));
for (let i = 0; i < 129; i++) await call(s.port, '/auth/login'); assert.equal(s.sso.logins.size, 128);
});
test('root routes refuse account-only sessions and restart checks operator independently of root ownership', async t => {
const s = await setup(t), pat = await s.login(), op = await s.login('op');
for (const path of ['/api/bus/inbox', '/api/settings', '/api/chat/projects', '/board']) assert.equal((await call(s.port, path, { cookie: pat.cookie })).status, 404, path);
assert.equal((await call(s.port, '/api/console/restart', { cookie: pat.cookie, method: 'POST', body: '{}' })).status, 403);
assert.equal(s.restarts(), 0);
assert.equal((await call(s.port, '/api/console/restart', { cookie: op.cookie, method: 'POST', body: '{}' })).status, 202);
assert.equal(s.restarts(), 1);
});
test('legacy root grant and account authority revoke independently and never return on an old cookie', async t => {
const s = await setup(t, { legacy: true }), r = await s.login();
let session = s.sso.session({ headers: { cookie: r.cookie } }); assert.equal(session.user, 'legacy'); assert.equal(session.account, 'u-000000000002');
assert.equal((await call(s.port, '/api/chat/projects', { cookie: r.cookie })).status, 200);
await s.change(d => d.users[1].state = 'disabled');
session = s.sso.session({ headers: { cookie: r.cookie } }); assert.ok(session); assert.equal(session.user, 'legacy'); assert.equal(session.account, undefined); assert.equal(session.instanceRole, undefined);
assert.equal((await call(s.port, '/api/chat/projects', { cookie: r.cookie })).status, 200);
await s.change(d => d.users[1].state = 'active'); assert.equal(s.sso.session({ headers: { cookie: r.cookie } }).account, undefined);
s.revoke(); assert.equal(s.sso.session({ headers: { cookie: r.cookie } }), null);
assert.equal((await call(s.port, '/api/chat/projects', { cookie: r.cookie })).status, 401);
const fresh = await s.login(); assert.ok(fresh.cookie); assert.equal(s.sso.session({ headers: { cookie: fresh.cookie } }).user, undefined);
assert.equal((await call(s.port, '/api/chat/projects', { cookie: fresh.cookie })).status, 404);
});
test('accounts mode refuses any start that could reach the dev LAN same-origin fake', async () => {
const { createDevOwner } = await import('../src/dev-owner.mjs');
await assert.rejects(startServer({ port: 0, sso: { ...createDevOwner(), mode: 'accounts' }, devAllowHosts: ['accounts.test'] }), /accounts mode refuses the dev LAN/);
await assert.rejects(startServer({ port: 0, sso: { ...createDevOwner(), mode: 'accounts' } }), /accounts mode refuses the dev LAN/);
});
@@ -23,7 +23,8 @@ async function fixture(t, { send = true, granted = false } = {}) {
const rolesDir = rolesCopy(root);
const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
const trail = [];
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner-sub', name: 'jason' };
// M3 (#1873): the hook fence's owner flag is the session's root authority (rootOwner), as sso.mjs sets it.
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner-sub', name: 'jason', rootOwner: true };
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => session, handle: () => {}, health: () => ({}) };
const organizations = {
list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
+1 -1
View File
@@ -80,7 +80,7 @@ test('C2-09a metadata stays behind the SSO session check and a GET never refresh
const headers = { host: sso.publicHost };
assert.equal((await get(server, { headers })).status, 401);
assert.equal(reads, 0);
session = { name: 'jason' };
session = { name: 'jason', rootOwner: true };
assert.equal((await get(server, { headers })).status, 200);
assert.equal(reads, 1);
assert.deepEqual(touches, [false, false]);
+1
View File
@@ -309,6 +309,7 @@ test('configured Console process injects chat through its existing human transpo
clientSecret: { slot: issuer.name }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }],
}] } };
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify(config));
writeFileSync(join(dirname(env.MOSAIC_CONFIG), 'accounts.json'), JSON.stringify({ accountsVersion: 1, revision: 1, users: [{ id: 'u-000000000001', subjects: [{ provider: issuer.name, sub: 'owner-sub' }], name: 'jason', instanceRole: 'operator', state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' }], memberships: [], invites: [] }), { mode: 0o600 });
const secrets = join(dirname(env.MOSAIC_CONFIG), 'secrets', 'mosaic-stack');
mkdirSync(secrets, { recursive: true });
const secret = join(secrets, issuer.name); writeFileSync(secret, `${issuer.clientSecret}\n`, { mode: 0o600 }); chmodSync(secret, 0o600);
+2 -1
View File
@@ -1,3 +1,4 @@
import { ownerAccounts } from './helpers/owner-accounts.mjs';
// Row 252 (#1753, design sections 4 and 5): the Console decision routes on the human listener, signed
// in through the fake OP in helpers/fake-oidc.mjs on loopback. The bus transport is a recording stub,
// so this covers the listener's gates and the session it passes; decision-http.test.mjs covers the
@@ -53,7 +54,7 @@ async function setup(t, server = {}, access = null) {
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'user-a', name: 'person-a' }] }] };
const lines = [];
const users = access && { users: consoleAccess({ load: () => access, business: () => 'demo' }) };
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), ...users });
const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), ...users });
t.after(() => sso.close());
const calls = [];
const decisionWrite = async (verb, args, ctx) => {
+2 -1
View File
@@ -1,3 +1,4 @@
import { ownerAccounts } from './helpers/owner-accounts.mjs';
// FED R6 (#1800, federation design 3.1 D3-D5, 3.4 and 3.7): a granted user signs in to the
// Console. Every provider is the fake OP in helpers/fake-oidc.mjs on loopback; no test reaches a
// real provider, the live Console or a real config, slot or access file. The broker is an
@@ -92,7 +93,7 @@ async function setup(t, { users = [ALICE], grants = [grant()] } = {}) {
const lines = [];
const log = line => lines.push(line);
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] };
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log, users: consoleAccess({ load, business: () => 'demo' }) });
const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log, users: consoleAccess({ load, business: () => 'demo' }) });
t.after(() => sso.close());
const stub = name => async () => { counts.owner++; return name === 'send' ? { id: 'msg-0001' } : [{ verb: name }]; };
const ownerBus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'names', 'instances', 'routes', 'thread.claims'].map(v => [v, stub(v)]));
@@ -0,0 +1,5 @@
// Existing OIDC regression fixtures explicitly initialize their configured owners.
// Production never infers account authority from console.sso alone.
export const ownerAccounts = config => ({ load: () => ({ users: config.providers.flatMap((p, i) => p.subjects.map((s, j) => ({
id: `u-${(i * 64 + j + 1).toString(16).padStart(12, '0')}`, subjects: [{ provider: p.id, sub: s.sub }], name: s.name, state: 'active', instanceRole: 'operator', authGen: 1,
}))) }) });
@@ -1,3 +1,4 @@
import { ownerAccounts } from './owner-accounts.mjs';
// Q284 / #385: real OIDC sessions for writer-route tests. The provider is the existing
// in-process fake and is shared only within one test-file process; each test gets its own
// createSso instance and completes the real login, code and callback flow.
@@ -58,6 +59,7 @@ export async function createSsoSession(t, {
subjects: configuredSubjects,
}],
}, {
accounts: ownerAccounts({ providers: [{ id: issuer.name, subjects: configuredSubjects }] }),
secrets: new Map([[issuer.name, issuer.clientSecret]]),
fetch: op.fetch,
log,
@@ -34,7 +34,7 @@ export async function realRemoteFixture(t, mutate = () => {}) {
};
await attach();
const subject = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner' };
const session = { ...subject, name: 'jason' };
const session = { rootOwner: true, instanceRole: 'operator', ...subject, name: 'jason' };
let active = true;
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) };
const transport = (verb, args, ctx) => new Client({ path, console: { subject: ctx.subject } }).call(verb, args);
+115 -15
View File
@@ -2,11 +2,14 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { once } from 'node:events';
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fixture } from '../../cli/tests/helpers.mjs';
import { writeDeployment } from '../../cli/src/deployment.mjs';
import { fakeOidc } from './helpers/fake-oidc.mjs';
import { request as httpRequest } from 'node:http';
import { consoleReads } from '../src/reads.mjs';
// Row 77 (#1571, lead decision 112): `scripts/webui-service.sh copy` and
@@ -75,7 +78,7 @@ test('copy writes the Console paths at the full id, read-only, and leaves an exi
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm'));
assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind');
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts', 'tools']);
assert.deepEqual(readdirSync(target).sort(), ['.stack-build.json', 'RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts', 'tools']);
assert.deepEqual(readdirSync(join(target, 'scripts')), ['case2-persona-import.mjs', 'mosaic', 'mosaic-config.mjs']);
assert.ok(existsSync(join(target, 'packages', 'webui', 'README.md')), "the unit's Documentation=");
assert.equal(statSync(join(target, 'packages', 'webui', 'README.md')).mode & 0o222, 0, 'README.md is read-only');
@@ -227,36 +230,133 @@ test('a copy that meets an empty target directory replaces it by rename, not by
// serve from a copy: /healthz, the config check, roles and RELEASE from the
// copy, and the queue read from --checkout or, without it, from the copy.
// A copy is stamped, so it starts only with --sso and the deployed
// prerequisites (M3): console.sso and its slot, .deployed and accounts.json.
async function serve(t, cli, args, env) {
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => child.kill());
const exited = once(child, 'exit');
// Cleanup waits for the Console to exit, so no child outlives the test.
t.after(async () => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); await exited; });
let out = '', err = '';
child.stderr.on('data', (c) => { err += c; });
const timer = setTimeout(() => child.kill('SIGKILL'), 60_000);
const port = await new Promise((resolve, reject) => {
child.stdout.on('data', (c) => { out += c; const m = out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\/$/m); if (m && out.includes('Ctrl-C')) resolve(Number(m[1])); });
child.on('exit', (code) => reject(new Error(`exited ${code}: ${out}${err}`)));
});
const get = async (path) => { const res = await fetch(`http://127.0.0.1:${port}${path}`); return { status: res.status, body: await res.json() }; };
return { out, get };
child.on('exit', (code, signal) => reject(new Error(`exited ${code ?? signal} before listening: ${out}${err}`)));
}).finally(() => clearTimeout(timer));
return { out, port };
}
test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout', async (t) => {
// A start that refuses: exit 2, the refusal on stderr, and no listener line.
// A start that listens instead is killed at its listener line (or after 60s),
// so a removed check fails here rather than holding the file open.
async function refusedStart(cli, args, env) {
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
let out = '', err = '';
const timer = setTimeout(() => child.kill('SIGKILL'), 60_000);
child.stdout.on('data', (c) => { out += c; if (/Mosaic Console:/.test(out)) child.kill('SIGKILL'); });
child.stderr.on('data', (c) => { err += c; });
const [code] = await once(child, 'close');
clearTimeout(timer);
assert.doesNotMatch(out, /Mosaic Console:/);
return { code, err };
}
test('a stamped copy refuses the former unauthenticated Console start', async t => {
const { dir, script, head } = fixtureRepo(t);
const copies = join(dir, 'copies');
assert.equal((await run(dir, script, ['copy', 'refactor', '--copies', copies])).status, 0);
const cli = join(copies, head, 'packages/webui/src/cli.mjs');
const { env } = fixture(join(dir, 'home'));
const { code, err } = await refusedStart(cli, ['--business', 'acme'], env);
assert.equal(code, 2);
assert.match(err, /stamped build serve requires --sso/);
});
const PUBLIC = 'console.test';
// Each request is bounded: a stall, an aborted or incomplete response, or a
// body that is not JSON rejects with the path instead of hanging the test.
function request(port, path, headers = {}) {
return new Promise((resolve, reject) => {
const req = httpRequest({ host: '127.0.0.1', port, path, headers: { host: PUBLIC, ...headers } }, (res) => {
let text = '';
res.setEncoding('utf8');
res.on('data', (c) => { text += c; });
res.on('aborted', () => reject(new Error(`${path}: response aborted`)));
res.on('error', reject);
res.on('end', () => {
if (!res.complete) return reject(new Error(`${path}: incomplete response`));
let body = null;
try { body = text ? JSON.parse(text) : null; } catch (e) { return reject(new Error(`${path}: ${res.statusCode} body is not JSON: ${text.slice(0, 200)}`)); }
resolve({ status: res.statusCode, headers: res.headers, body });
});
});
req.setTimeout(10_000, () => req.destroy(new Error(`${path}: no response within 10s`)));
req.on('error', reject); req.end();
});
}
// The owner signs in through the fake provider; reads carry the session on the public Host.
async function signIn(port, op, iss) {
const login = await request(port, '/auth/login');
assert.equal(login.status, 303);
const cookie = [login.headers['set-cookie']].flat()[0].split(';')[0];
const grant = op.grant(iss, login.headers.location, { sub: 'owner' });
const state = new URL(login.headers.location).searchParams.get('state');
const callback = await request(port, `/auth/callback?${new URLSearchParams({ code: grant.code, state })}`, { cookie });
assert.equal(callback.status, 303);
const session = [callback.headers['set-cookie']].flat().find((v) => v.startsWith('__Host-mosaic_session=') && !v.includes('Max-Age=0')).split(';')[0];
return async (path) => { const r = await request(port, path, { cookie: session }); return { status: r.status, body: r.body }; };
}
test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout', { timeout: 240_000 }, async (t) => {
const { dir, script, head } = fixtureRepo(t);
const copies = join(dir, 'copies');
assert.equal((await run(dir, script, ['copy', 'refactor', '--copies', copies])).status, 0);
const cli = join(copies, head, 'packages', 'webui', 'src', 'cli.mjs');
// A deployed copy gets node_modules from npm ci; the fixture links this tree's (the SSO client needs it).
const mode = statSync(join(copies, head)).mode & 0o777; chmodSync(join(copies, head), mode | 0o200); symlinkSync(join(REPO, 'node_modules'), join(copies, head, 'node_modules')); chmodSync(join(copies, head), mode);
// A checkout whose queue read says where it ran.
const checkout = join(dir, 'checkout');
mkdirSync(join(checkout, 'packages', 'webui', 'src'), { recursive: true });
writeFileSync(join(checkout, 'packages', 'webui', 'src', 'queue-read.mjs'), `process.stdout.write(JSON.stringify({ rows: [], notes: [process.cwd() === ${JSON.stringify(checkout)} ? 'read in the checkout' : 'read elsewhere'] }));\n`);
const { env } = fixture(join(dir, 'home'));
const f = fixture(join(dir, 'home'));
const env = { ...f.env };
delete env.MOSAIC_ROLES_DIR;
const { out, get } = await serve(t, cli, ['--business', 'acme', '--checkout', checkout], env);
const args = ['--business', 'acme', '--sso', '--checkout', checkout];
// Each prerequisite missing in turn refuses the --sso start before it listens.
const op = await fakeOidc(); t.after(() => op.close());
const iss = op.issuer('a');
const ca = join(dir, 'ca.pem'); writeFileSync(ca, op.cert);
env.NODE_EXTRA_CA_CERTS = ca;
const cfgDir = dirname(f.config);
let r = await refusedStart(cli, args, env);
assert.equal(r.code, 2, r.err); assert.match(r.err, /\.deployed is missing or invalid/);
writeDeployment({ dataRoot: f.dataRoot }, cfgDir);
r = await refusedStart(cli, args, env);
assert.equal(r.code, 2, r.err); assert.match(r.err, /accounts file not found/);
writeFileSync(join(cfgDir, '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 });
r = await refusedStart(cli, args, env);
// Without console.sso no provider is configured, so the accounts file's subject has none.
assert.equal(r.code, 2, r.err); assert.match(r.err, /must be a configured provider/);
const config = JSON.parse(readFileSync(f.config, 'utf8'));
config.console = { sso: { publicOrigin: `https://${PUBLIC}`, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'Owner' }] }] } };
writeFileSync(f.config, JSON.stringify(config));
r = await refusedStart(cli, args, env);
assert.equal(r.code, 2, r.err); assert.match(r.err, /client secret slot is missing/);
mkdirSync(join(cfgDir, 'secrets', 'mosaic-stack'), { recursive: true, mode: 0o700 });
const slot = join(cfgDir, 'secrets', 'mosaic-stack', 'a'); writeFileSync(slot, iss.clientSecret, { mode: 0o600 }); chmodSync(slot, 0o600);
const { out, port } = await serve(t, cli, args, env);
assert.match(out, /^Bus: acme$/m, 'the config check ran from the copy');
assert.match(out, /^Queue: read from the --checkout directory$/m);
assert.deepEqual(await get('/healthz'), { status: 200, body: { ok: true } });
const health = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(10_000) });
assert.equal(health.status, 200);
// Under --sso the body adds each provider's state beside ok (serve.mjs /healthz).
const hb = await health.json();
assert.equal(hb.ok, true); assert.deepEqual(Object.keys(hb), ['ok', 'sso']); assert.deepEqual(Object.keys(hb.sso), ['a']);
const get = await signIn(port, op, iss);
const settings = await get('/api/settings');
assert.equal(settings.status, 200);
assert.equal(settings.status, 200, JSON.stringify(settings.body));
assert.equal(settings.body.release, '0.0.0-fixture', 'RELEASE from the copy');
const business = await get('/api/business');
assert.equal(business.status, 200, JSON.stringify(business.body));
@@ -270,10 +370,10 @@ test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE
assert.deepEqual(fields.body, JSON.parse(readFileSync(join(REPO, 'packages', 'tasks', 'fields.json'), 'utf8')));
// Without --checkout the copy's own queue read runs, and the copy holds
// no packages/queue, so it fails closed.
const plain = await serve(t, cli, ['--business', 'acme'], env);
const plain = await serve(t, cli, ['--business', 'acme', '--sso'], env);
assert.doesNotMatch(plain.out, /^Queue:/m);
assert.equal((await plain.get('/healthz')).status, 200);
const none = await plain.get('/api/queue');
assert.equal((await fetch(`http://127.0.0.1:${plain.port}/healthz`, { signal: AbortSignal.timeout(10_000) })).status, 200);
const none = await (await signIn(plain.port, op, iss))('/api/queue');
assert.equal(none.status, 503);
assert.equal(none.body.error, 'queue-refused');
});
@@ -1,3 +1,4 @@
import { ownerAccounts } from './helpers/owner-accounts.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -82,7 +83,7 @@ test('real SSO refuses viewer and collaborator and sign-out prevents the saved w
{ id: 'g-2', user: 'bob', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' },
] };
const config = { publicOrigin: origin, providers: [{ id: 'a', issuer: issuer.issuer, clientId: issuer.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] };
const sso = await createSso(config, { secrets: new Map([['a', issuer.clientSecret]]), fetch: op.fetch, users: consoleAccess({ load: () => access, business: () => 'demo' }), log: () => {} });
const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', issuer.clientSecret]]), fetch: op.fetch, users: consoleAccess({ load: () => access, business: () => 'demo' }), log: () => {} });
t.after(() => sso.close());
const f = await realRemoteFixture(t), original = readFileSync(f.file);
let writes = 0;
+2 -1
View File
@@ -1,3 +1,4 @@
import { ownerAccounts } from './helpers/owner-accounts.mjs';
// #797 / #1839 Task 3: the Console's request intake routes. The first half drives requestHTTP with a
// fake request, response and bus transport. The second half signs in through the fake OP on loopback
// and checks the listener's gates, as decide-sso.test.mjs does for decisions. The bus side is
@@ -253,7 +254,7 @@ async function listener(t, server = {}, access = null) {
const config = { publicOrigin: LISTEN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'user-a', name: 'person-a' }] }] };
const lines = [];
const users = access && { users: consoleAccess({ load: () => access, business: () => 'demo' }) };
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), ...users });
const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), ...users });
t.after(() => sso.close());
const reads = [], writes = [];
const requestRead = async (verb, args, ctx) => { reads.push({ verb, args, ctx }); return { ...STATE, request: args.request }; };
+18
View File
@@ -867,3 +867,21 @@ test('row196 socket flag renders only for supporting targets and refuses mixed t
const both=await run(dir,['render','--tester-socket','--tester-port','7333']);
assert.equal(both.status,4);
});
// M3 follow-up 1: this user-manager path cannot represent the dedicated
// instance uid required by accounts deployments. No systemctl start is used.
test('M3 user-unit path refuses accounts and deployment metadata while checkout development render works', async t => {
for (const kind of ['accounts', 'deployed', 'sso', 'stamp']) {
const dir = tmp(t), configDir = join(dir,'.config/mosaic-dev'), dataRoot = join(dir,'data'); mkdirSync(configDir,{recursive:true}); mkdirSync(dataRoot);
const config = {configVersion:1,environment:'development',dataRoot,execution:{backend:'docker',provider:'zai',model:'test'}};
if(kind==='sso') 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(join(configDir,'config.json'),JSON.stringify(config));
if(kind==='accounts') writeFileSync(join(configDir,'accounts.json'),'{}');
if(kind==='deployed') writeFileSync(join(dataRoot,'.deployed'),'{"instance":"dev"}',{mode:0o444});
const args = ['render','--port','17890'];
if(kind==='stamp') { const copy=fakeCopy(dir); writeFileSync(join(copy,'.stack-build.json'),JSON.stringify({commit:COPY_ID}),{mode:0o444}); args.push('--copy',COPY_ID); }
const r=await run(dir,args); assert.notEqual(r.status,0,kind); assert.match(r.stderr,/system units.*case2-unit|accounts.*user.unit/,kind); assert.equal(r.stdout,'');
}
const dir=tmp(t); const dev=await run(dir,['render','--port','17890']); assert.equal(dev.status,0,dev.stderr);
for(const flag of ['--dev-local-owner','--dev-insecure-bind','--dev-allow-host']) {const r=await run(dir,['render',flag,'127.0.0.1']);assert.notEqual(r.status,0,flag);assert.equal(r.stdout,'');}
});
+20 -8
View File
@@ -1,3 +1,4 @@
import { ownerAccounts } from './helpers/owner-accounts.mjs';
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md section 9): the Console as an
// OIDC relying party (--sso). Every provider is the fake OP in helpers/fake-oidc.mjs on loopback;
// no test reaches a real provider, the live Console or a real config or slot file. The public name
@@ -56,7 +57,7 @@ async function setup(t, { issuers = [{ name: 'a' }], provider = {}, before = nul
const config = { publicOrigin: ORIGIN, providers: iss.map(i => providerFor(i, provider[i.name] ?? {})) };
const lines = [];
const log = line => { lines.push(line); ALL_LINES.push(line); };
const sso = await createSso(config, { secrets: new Map(iss.map(i => [i.name, i.clientSecret])), fetch: op.fetch, log, retryFirst });
const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map(iss.map(i => [i.name, i.clientSecret])), fetch: op.fetch, log, retryFirst });
t.after(() => sso.close());
const sends = [];
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => [{ verb: v }]]));
@@ -297,7 +298,7 @@ test('11: start refusals: HS or none algs, reserved names, HS-only discovery, an
await assert.rejects(startServer({ port: 0, sso, preview: { host: HOST, header: 'remote-user', user: 'jason' } }), /--sso cannot be combined/);
});
test('11: slot files: a symlink, mode 0644, mode 0640 and a file under the repository are refused, and no message holds the value', t => {
test('11: slot files: a symlink, a directory, mode 0644, 0640 or 0604 and a file under the repository are refused, and no message holds the value', t => {
const root = mkdtempSync(join(tmpdir(), 'webui-sso-slot-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const dir = join(root, 'config'), slots = join(dir, 'secrets', 'mosaic-stack');
@@ -309,24 +310,34 @@ test('11: slot files: a symlink, mode 0644, mode 0640 and a file under the repos
assert.equal(readSlot(dir, provider('good'), opts), value);
writeFileSync(join(root, 'target'), value, { mode: 0o600 });
symlinkSync(join(root, 'target'), join(slots, 'link'));
for (const [slot, mode] of [['wide', 0o644], ['group', 0o640]]) { writeFileSync(join(slots, slot), value); chmodSync(join(slots, slot), mode); }
for (const [slot, mode] of [['wide', 0o644], ['group', 0o640], ['other', 0o604]]) { writeFileSync(join(slots, slot), value); chmodSync(join(slots, slot), mode); }
mkdirSync(join(slots, 'dir'), { mode: 0o700 });
// M3 (#1873): a config whose secrets directory is a link into the repository, as stack-release's T57 once pinned.
const linked = join(root, 'linked'), inRepo = join(opts.repo, 'secrets');
mkdirSync(join(inRepo, 'mosaic-stack'), { recursive: true });
mkdirSync(linked);
writeFileSync(join(inRepo, 'mosaic-stack', 'good'), `${value}\n`, { mode: 0o600 });
symlinkSync(inRepo, join(linked, 'secrets'));
writeFileSync(join(slots, 'two'), `${value}\nmore\n`, { mode: 0o600 });
writeFileSync(join(slots, 'empty'), '\n', { mode: 0o600 });
const cases = [
['missing', /slot is missing$/, opts],
['link', /must not be a symbolic link$/, opts],
['dir', /must be a regular file$/, opts],
['wide', /must have no group or other permission bits$/, opts],
['group', /must have no group or other permission bits$/, opts],
['other', /must have no group or other permission bits$/, opts],
['two', /must hold one non-empty line$/, opts],
['empty', /must hold one non-empty line$/, opts],
// The config dir itself under the repository, and under a --checkout.
['good', /must not be inside the repository or a stack copy$/, { ...opts, repo: root }],
['good', /must not be inside the repository or a stack copy$/, { ...opts, checkout: dir }],
['good', /must not be inside the repository or a stack copy$/, { ...opts, copies: dir }],
['good', /must not be inside the repository or a stack copy$/, opts, linked],
];
for (const [slot, why, o] of cases) {
for (const [slot, why, o, cfg = dir] of cases) {
let err = null;
try { readSlot(dir, provider(slot), o); } catch (e) { err = e; }
try { readSlot(cfg, provider(slot), o); } catch (e) { err = e; }
assert.ok(err, `${slot} was read`);
assert.match(err.message, /^console\.sso provider a client secret slot /);
assert.match(err.message, why, slot);
@@ -365,6 +376,7 @@ test('11: the CLI refuses --sso with the old flags and without console.sso, then
assert.equal(wide.status, 2);
assert.match(wide.stderr, /^refused: console\.sso provider a client secret slot must have no group or other permission bits$/m);
chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o600);
writeFileSync(join(root, 'cfg', 'accounts.json'), JSON.stringify({ accountsVersion: 1, revision: 1, users: ownerAccounts({ providers: [providerFor(a)] }).load().users.map(u => ({ ...u, created: '2026-11-10T00:00:00Z' })), memberships: [], invites: [] }), { mode: 0o600 });
// The child trusts the fake OP's certificate through NODE_EXTRA_CA_CERTS; the code under test has no flag for it.
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--sso', '--send'], { env: { ...env, NODE_EXTRA_CA_CERTS: join(root, 'ca.pem') } });
t.after(() => child.kill('SIGKILL'));
@@ -522,11 +534,11 @@ test('16: a provider with RS256 and ES256 takes either, and refuses PS256 with a
refused(env, (await signIn(env, { opts: { key: ps } })).r, 'id-token-invalid');
});
test('17: 33 started logins leave 32 records, and the first one\'s callback finds none', async t => {
test('17: 129 started logins leave 128 records, and the first one\'s callback finds none', async t => {
const env = await setup(t);
const first = await begin(env);
for (let i = 0; i < 32; i++) await begin(env);
assert.equal(env.sso.logins.size, 32);
for (let i = 0; i < 128; i++) await begin(env);
assert.equal(env.sso.logins.size, 128);
refused(env, await callback(env, first, { code: grant(env, first, env.iss[0]).code }), 'login-record-missing');
});
@@ -46,7 +46,7 @@ async function fixture(t, { writer = 'owner', send = true, afterRead = null, bef
// afterRead and beforeWrite let a test act between the route's read and its write.
const requestRead = async (verb, args, ctx) => { calls.push(['read', verb]); const r = await new Client({ path, console: { subject: ctx.subject } }).call(verb, args); await afterRead?.(); return r; };
const systemTypesWrite = async (verb, args) => { await beforeWrite?.(verb); calls.push(['write', verb]); return runtime.broker.request(cap, { verb, args }); };
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason' };
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason', rootOwner: true };
let active = true;
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) };
const organizations = {
@@ -61,7 +61,7 @@ async function fixture(t, { writer = 'owner', send = true, afterRead = null, bef
const project = runtime.broker.request(owner, { verb: 'project.create', args: { name: 'example' } }).id;
const onDisk = () => JSON.parse(readFileSync(file)).plugins['system-type'];
const ownerRequest = (verb, args) => runtime.broker.request(owner, { verb, args });
return { base, file, project, calls, onDisk, ownerRequest, signOut: () => { active = false; } };
return { base, file, project, calls, onDisk, ownerRequest, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } };
}
function call(f, path, method = 'GET', body, headers = {}) {
@@ -147,6 +147,21 @@ test('a sign-out while the read runs dispatches no write on either route', async
assert.deepEqual(f.onDisk(), ['coding']);
});
test('root authority revoked during a read cannot write through a surviving user session', async t => {
for (const projectRoute of [false, true]) {
let revokeOnRead = false;
const f = await fixture(t, { afterRead: () => { if (revokeOnRead) f.revokeOwner(); } });
const [, { revision }] = await call(f, ORG);
revokeOnRead = true;
const path = projectRoute ? `${ORG}/projects/${f.project}` : ORG;
const body = projectRoute ? { systemTypes: ['coding'] } : { revision, enabled: [] };
const [status, result] = await call(f, path, 'POST', body);
assert.deepEqual([status, result.error], [401, 'sign-in-required'], path);
assert.equal(f.calls.filter(c => c[0] === 'write' && c[1] !== 'plugins').length, 0);
assert.deepEqual(f.onDisk(), ['coding']);
}
});
test('the enabled set and its revision come from one read, so a change in between is not undone', async t => {
let raced = false;
const f = await fixture(t, { beforeWrite: async verb => {
@@ -1,3 +1,4 @@
import { ownerAccounts } from './helpers/owner-accounts.mjs';
// R6-N1 (#1807, row 294 N1): the user-session option swap in serve.mjs, driven through HTTP.
// granted-user.test.mjs G4 stops every owner route at the userRoute gate, so it never reaches the
// swap behind it. Here serve.mjs loads with that gate stubbed open (a load hook keyed on one query
@@ -87,7 +88,7 @@ async function setup(t) {
const userOptions = userOptionsFor({ owner: ownerCall, send: true, transport: name => { opened.push(name); return userTransport(name); } });
const lines = [];
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] };
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), users: consoleAccess({ load, business: () => 'demo' }) });
const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), users: consoleAccess({ load, business: () => 'demo' }) });
t.after(() => sso.close());
// Every owner option is a stub that records its own name.
const stub = name => async () => { owner.push(['option', name]); return name === 'send' ? { id: 'msg-0001' } : [{ verb: name }]; };
+9 -2
View File
@@ -203,6 +203,9 @@ if [ ! -e "$CONFIG_DIR" ]; then
fi
out="$("$REPO/scripts/bootstrap.sh" 2>&1)" || refuse "bootstrap: $(printf '%s' "$out" | tail -n 1)"
say "config $CONFIG"
# Case 1 and Case 2 share this installer. Deployment identity is instance-scoped,
# never a switch that can be silently removed to enable the local dev owner.
out="$(cd "$REPO" && node --input-type=module -e 'import { readFileSync } from "node:fs"; import { writeDeployment } from "./packages/cli/src/deployment.mjs"; writeDeployment(JSON.parse(readFileSync(process.argv[1], "utf8")));' "$CONFIG" 2>&1)" || refuse "deployment: $(printf '%s' "$out" | tail -n 1)"
# 3. The stack copy row 195's units run from.
out="$("$STACK_COPY" copy "$COMMIT" --copies "$COPIES" 2>&1)" || refuse "stack-copy: $(printf '%s' "$out" | tail -n 1)"
@@ -250,8 +253,12 @@ if ! out="$("$REPO/scripts/mosaic" business validate "$BUSINESS" 2>&1)"; then
fi
say "business $BUSINESS validates"
# 8. What comes next. Root's steps run from root's own checkout.
say "done. Next: root runs case2-unit.sh --copy $COMMIT --business $BUSINESS (add --proxy when the config has a proxy section; design 10 step 2c), then 2d and 3."
# 8. What comes next. Root's steps run from root's own checkout. The copy is stamped and .deployed
# names this instance (step 2), so its Console starts only with SSO accounts (M3, #1873). The steps
# before root's are the run sheet's (section 7), and nothing here does them: SSO is the operator's
# config, accounts.json comes only from the human verb, and case2-unit.sh refuses until both exist.
say "done. .deployed names this instance and the copy is stamped, so its Console starts only with SSO accounts. Before the units, in order (run sheet section 7): prepare console.sso and its client secret slots in $CONFIG; a human runs \`mosaic accounts init\` as $(id -un); $(id -un) stays a dedicated uid with a non-login shell, the only member of its primary group."
say "Next: root runs case2-unit.sh --copy $COMMIT --business $BUSINESS with --sso in the serve flags (factory: --sso --send; add --proxy when the config has a proxy section; design 10 step 2c), then 2d and 3. It refuses before any write and names each step still missing."
if [ "$PROFILE" = library ]; then
say "Then Jason places the library token in $SLOT (step 4), and <user> runs case2-precheck.sh --business $BUSINESS --library-status (step 4a)."
fi
+112 -13
View File
@@ -5,9 +5,15 @@
# mosaic-proxy.service under --proxy, from the existing templates, which it
# doesn't change.
#
# case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH
# [--profile factory|library] [--proxy] [--pm] [--claude PATH]
# -- SERVE FLAGS
# 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
#
# --check runs every check a render runs, the installed units' included, then
# exits 0 without writing, enabling or starting anything. --check-new does the
# same as a first install would, without the installed units' checks; it is for
# stack-upgrade.sh --next-args, which moves the installed units aside before its
# render (docs/guides/host-run-sheet.md section 7). Neither writes a file.
#
# --org ID is the same option as --business ID (rename design r2), in each of
# two scopes: this script's own flags before --, and the serve flags after it.
@@ -36,6 +42,10 @@
# --proof-header NAME --proof-file PATH
# It is proxy-preview read-only until Case 2 supports --sso (#412); --send
# and --sso are explicitly refused. The proof still protects proxy preview reads.
# That holds for an unstamped copy only. A stamped library copy (M3, #1873;
# Jason's ruling 2026-11-10) signs in through SSO accounts like every
# deployed Console: it takes --port, --business and a bare --sso, refuses
# every proxy-preview and proof flag, and still refuses --send.
# The factory profile takes --port N (1..1023) and --business ID, and an
# optional bare --tester-socket marker. It also takes the paired bare
# --sso --send (#415): the Console signs its owner in from console.sso, which
@@ -43,6 +53,15 @@
# preview and proof flags still refuse. Retired tester TCP refuses. Only
# factory takes --pm and --claude.
#
# A stamped copy (.stack-build.json, from stack-copy.sh) starts only with
# --sso, console.sso, a loadable accounts.json and a .deployed naming the
# default instance (packages/webui/src/cli.mjs, M3 #1873). For a stamped copy,
# in either profile, the render asks the stack user's own copy for those
# facts and refuses before any write, naming every missing step in the run
# sheet's order (docs/guides/host-run-sheet.md section 7). It writes none of
# them and picks no subject: .deployed comes from case2-install.sh, and
# accounts.json from the human verb `mosaic accounts init`.
#
# On a re-install the installed units must equal the render in everything
# but the copy id, and all selected units must name one copy, so a re-pin or rollback
# re-renders them together. A unit that was already running stays on its
@@ -72,7 +91,7 @@ PROXY_UNIT=mosaic-proxy.service
BUS_UNIT=[email protected]
WEBUI_UNIT=mosaic-webui.service
usage() { echo 'usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS' >&2; exit 4; }
usage() { echo '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' >&2; exit 4; }
die() { echo "case2-unit: $*" >&2; exit 1; }
# A value the unit carries as itself: no space, %, $, quote, backslash, & or |.
plain() { case "$1" in *[!A-Za-z0-9/._+@-]*) return 1 ;; esac; }
@@ -103,7 +122,8 @@ serve_refused() {
*) die "serve $2" ;;
esac
}
PROFILE=library USER_NAME='' HOME_DIR='' BUSINESS='' COPY_ID='' NODE='' PROXY=0 PM=0 CLAUDE='' CLAUDE_SET=0
PROFILE=library USER_NAME='' HOME_DIR='' BUSINESS='' COPY_ID='' NODE='' PROXY=0 PM=0 CLAUDE='' CLAUDE_SET=0 CHECK=''
case "${1-}" in --check|--check-new) CHECK="$1"; shift ;; esac
while [ $# -gt 0 ]; do
case "$1" in
--business|--org) BUSINESS="${2-}"; selector_take outer id outer_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;;
@@ -136,12 +156,19 @@ fi
# The user, its uid, primary group and home, from the passwd database.
[[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "--user must be a user name: $USER_NAME"
PW="$("$GETENT" passwd "$USER_NAME")" || die "no user $USER_NAME in the passwd database"
IFS=: read -r _ _ USER_UID USER_GID _ PW_HOME _ <<<"$PW"
IFS=: read -r PW_NAME _ USER_UID USER_GID _ PW_HOME PW_SHELL <<<"$PW"
[[ "$USER_UID" =~ ^[0-9]+$ ]] && [ "$USER_UID" -ne 0 ] || die "--user must not be root: $USER_NAME"
[ "$PW_NAME" = "$USER_NAME" ] || die "passwd name does not match --user"
case "$PW_SHELL" in /usr/sbin/nologin|/sbin/nologin|/bin/false|/usr/bin/false) ;; *) die "--user requires a non-login shell" ;; esac
abs_plain "$HOME_DIR" '--home'
[ "$HOME_DIR" = "$PW_HOME" ] || die "--home $HOME_DIR is not $USER_NAME's home in the passwd database ($PW_HOME)"
GR="$("$GETENT" group "$USER_GID")" || die "no group $USER_GID in the group database"
GROUP="${GR%%:*}"
IFS=: read -r GROUP _ GROUP_GID GROUP_MEMBERS <<<"$GR"
[ "$GROUP_GID" = "$USER_GID" ] || die "primary group id mismatch"
IFS=, read -r -a PRIMARY_MEMBERS <<<"$GROUP_MEMBERS"
for member in "${PRIMARY_MEMBERS[@]}"; do [ -z "$member" ] || [ "$member" = "$USER_NAME" ] || die "primary group must be sole-member"; done
PASSWD_ALL="$("$GETENT" passwd)" || die "cannot enumerate primary group members"
printf '%s\n' "$PASSWD_ALL" | awk -F: -v gid="$USER_GID" -v user="$USER_NAME" '$4 == gid && $1 != user { bad=1 } $4 == gid && $1 == user { seen++ } END { exit bad || seen != 1 }' || die "primary group must be sole-member"
[[ "$GROUP" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "the primary group of $USER_NAME is not a group name: $GROUP"
case "$BUSINESS" in [a-z0-9]*) ;; *) die "--business must be a business id: $BUSINESS" ;; esac
@@ -154,6 +181,9 @@ COPIES="$HOME_DIR/.local/share/mosaic-stack/copies"
COPY="$COPIES/$COPY_ID"
[ -d "$COPY" ] && [ ! -L "$COPY" ] || die "no copy directory $COPY"
[ -f "$COPY/scripts/mosaic" ] || die "no copy $COPY_ID in $COPIES: $COPY/scripts/mosaic is missing"
# M3: presence alone decides, as the Console's buildStamp does (lstat), so a broken link counts.
STAMPED=0
if [ -e "$COPY/.stack-build.json" ] || [ -L "$COPY/.stack-build.json" ]; then STAMPED=1; fi
# Owned by root and writable by no one else, the file and every directory above it.
root_only() {
@@ -382,11 +412,13 @@ serve_flags() {
--tester-socket) [ "$PROFILE" = factory ] || die "the library profile refuses --tester-socket" ;;
--checkout) die "refusing --checkout: Case 2 has no queue panel" ;;
--board) die "refusing --board: Case 2 runs no control board" ;;
--sso) [ "$PROFILE" = factory ] || die "refusing --sso: the Console signs people in through the proxy, with the preview set and the proof pair (Q175 phase 2 is a follow-up)" ;;
--send) [ "$PROFILE" = factory ] || die "the library profile is read-only; refusing --send until Case 2 supports --sso (#412)" ;;
--sso) [ "$PROFILE" = factory ] || [ "$STAMPED" = 1 ] || die "refusing --sso: the Console signs people in through the proxy, with the preview set and the proof pair (Q175 phase 2 is a follow-up)" ;;
--send) [ "$PROFILE" = factory ] || [ "$STAMPED" = 0 ] || die "the library profile is read-only; refusing --send (a stamped library Console signs in with --sso and stays read-only)"
[ "$PROFILE" = factory ] || die "the library profile is read-only; refusing --send until Case 2 supports --sso (#412)" ;;
--port|--business|--org) ;;
--public-host|--auth-header|--allow-user|--proof-header|--proof-file)
[ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy" ;;
[ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy"
[ "$STAMPED" = 0 ] || die "refusing $name: $COPY_ID 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" ;;
*) die "unknown serve flag: $name" ;;
esac
[ -z "${seen[$name]:-}" ] || die "serve flag $name given twice"
@@ -427,7 +459,7 @@ serve_flags() {
i=$((i + 2))
done
local required=(--port --business)
[ "$PROFILE" = factory ] || required+=(--public-host --auth-header --allow-user --proof-header --proof-file)
[ "$PROFILE" = factory ] || [ "$STAMPED" = 1 ] || required+=(--public-host --auth-header --allow-user --proof-header --proof-file)
for name in "${required[@]}"; do
[ -n "${seen[$name]:-}" ] || { [ "$name" = --business ] && [ -n "${seen[--org]:-}" ]; } || die "serve flag $name is required"
done
@@ -436,8 +468,10 @@ serve_flags() {
# proof file is inferred. The Console refuses --sso without console.sso
# at start; this asks the stack user's config first, before any write.
[ "$sso" = 0 ] || [ -n "${seen[--send]:-}" ] || die "refusing --sso without --send in the factory profile"
[ -z "${seen[--send]:-}" ] || [ "$sso" = 1 ] || die "the factory profile refuses --send without --sso"
if [ "$sso" = 1 ]; then
# A stamped copy without --sso is a missing step, which stamped_checks names with the others.
[ -z "${seen[--send]:-}" ] || [ "$sso" = 1 ] || [ "$STAMPED" = 1 ] || die "the factory profile refuses --send without --sso"
# A stamped copy's console.sso is one of stamped_checks' steps, asked in the same query.
if [ "$sso" = 1 ] && [ "$STAMPED" = 0 ]; then
local answer
answer="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$SSO_QUERY" "$COPY/packages/cli/src/config.mjs")" || die "sso config query $(tester_capture_reason "$?")"
[ "$answer" != '{"sso":false}' ] || die "--sso needs console.sso in $USER_NAME's system config"
@@ -452,6 +486,8 @@ serve_flags() {
fi
echo "${flags# }"; return 0
fi
# A stamped library copy has no proxy-preview flag left to check; stamped_checks follows.
if [ "$STAMPED" = 1 ]; then echo "${flags# }"; return 0; fi
# Lead decision 101: the uid header carries the Authentik uid.
if [ "${auth_header,,}" = x-authentik-uid ] && ! [[ "$allow_user" =~ ^[0-9a-f]{64}$ ]]; then
die "with --auth-header $auth_header the allowed user must be an Authentik uid, 64 lower-case hex characters (lead decision 101): $allow_user"
@@ -478,6 +514,61 @@ NODE_DIR="$(dirname -- "$NODE")"
UNIT_PATH="$NODE_DIR"
for d in /usr/local/bin /usr/bin /bin; do [ "$d" = "$NODE_DIR" ] || UNIT_PATH="$UNIT_PATH:$d"; done
FLAGS="$(serve_flags)" || exit 1
SSO_FLAG=0
case " $FLAGS " in *" --sso "*) SSO_FLAG=1 ;; esac
# The config query runs after dropping uid, through the bounded supervisor.
# Root reads only checked facts and stats the two owned directories.
HOST_FACTS="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$TESTER_QUERY" "$COPY/packages/cli/src/config.mjs" "$COPY/packages/webui/src/cli.mjs")" || die "host config query $(tester_capture_reason "$?")"
tester_facts "$HOST_FACTS" || die "host config query has invalid facts"
owned_directory() {
local dir="$1" what="$2" real st
abs_plain "$dir" "$what"
[ -d "$dir" ] && [ ! -L "$dir" ] || die "$what directory must exist without a symlink"
real="$(realpath -e -- "$dir")" || die "cannot resolve $what directory"
[ "$real" = "$dir" ] || die "$what directory contains a symlink"
st="$(stat -c '%u %a' -- "$dir")" || die "cannot stat $what directory"
[ "${st% *}" = "$USER_UID" ] || die "$what directory must be owned by $USER_NAME uid $USER_UID"
(( (8#${st#* } & 8#022) == 0 )) || die "$what directory must not be group or other writable"
}
owned_directory "$HOME_DIR/.config/mosaic-dev" config
owned_directory "$TS_DATA" data-root
# M3 (#1873): a stamped copy's Console starts only with --sso, console.sso, accounts.json that
# loads and a .deployed naming the default instance (packages/webui/src/cli.mjs). The stack user's
# own copy answers, through the same bounded supervisor, with its loadSystem, loadAccounts and
# assertDeployed, so the render asks exactly what the start will. Root reads three checked facts.
# Every missing step is named, in the run sheet's order, before any write. Without console.sso an
# accounts.json is "unchecked": its subjects name providers SSO has not configured yet, so the SSO
# step comes first and the next render checks it. Nothing here writes .deployed or accounts.json,
# or picks a subject.
STAMP_QUERY='import {join} from "node:path";import {homedir} from "node:os";import {pathToFileURL} from "node:url";const u=(f)=>import(pathToFileURL(f).href);const [c,d,a]=await Promise.all(process.argv.slice(1,4).map(u));const s=c.loadSystem();const dir=join(homedir(),".config/mosaic-dev");const fact=(file,check)=>{if(!d.present(file))return "missing";try{check();return "ok"}catch{return "invalid"}};const accounts=!s.console?.sso&&d.present(join(dir,"accounts.json"))?"unchecked":fact(join(dir,"accounts.json"),()=>a.loadAccounts(join(dir,"accounts.json"),{providers:s.console?.sso?.providers??[]}));const deployed=fact(join(s.dataRoot,".deployed"),()=>d.assertDeployed(s,dir));console.log(JSON.stringify({sso:Boolean(s.console&&s.console.sso),accounts,deployed}));'
stamped_checks() {
[ "$STAMPED" = 1 ] || return 0
local answer pattern='^\{"sso":(true|false),"accounts":"(ok|missing|invalid|unchecked)","deployed":"(ok|missing|invalid)"\}$'
local -a steps=()
answer="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$STAMP_QUERY" "$COPY/packages/cli/src/config.mjs" "$COPY/packages/cli/src/deployment.mjs" "$COPY/packages/organization/src/accounts.mjs")" || die "stamped copy query $(tester_capture_reason "$?")"
[[ "$answer" =~ $pattern ]] || die "stamped copy query has invalid facts"
local sso="${BASH_REMATCH[1]}" accounts="${BASH_REMATCH[2]}" deployed="${BASH_REMATCH[3]}"
case "$deployed" in
missing) steps+=("check or create .deployed: $USER_NAME runs case2-install.sh, whose deployment step writes $TS_DATA/.deployed") ;;
invalid) steps+=("check .deployed: $TS_DATA/.deployed is invalid or names another instance; case2-install.sh's deployment step reports why and never replaces it") ;;
esac
[ "$sso" = true ] || steps+=("prepare SSO: console.sso in $USER_NAME's system config $HOME_DIR/.config/mosaic-dev/config.json, with its client secret slots")
case "$accounts" in
missing) steps+=("run \`mosaic accounts init\` as $USER_NAME, a human verb: no $HOME_DIR/.config/mosaic-dev/accounts.json") ;;
invalid) steps+=("fix $HOME_DIR/.config/mosaic-dev/accounts.json: 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") ;;
esac
if [ "$SSO_FLAG" = 0 ]; then
if [ "$PROFILE" = factory ]; then steps+=("add --sso --send to the serve flags, then re-pin")
else steps+=("add --sso to the serve flags, then re-pin"); fi
fi
[ "${#steps[@]}" -gt 0 ] || return 0
local text="refusing the units for $COPY_ID: a stamped copy's Console starts only with SSO accounts; missing steps, in order:" i=1 step
for step in "${steps[@]}"; do text+=$'\n'" $i. $step"; i=$((i + 1)); done
die "$text"
}
stamped_checks
HARDENING="ProtectSystem=strict
ReadWritePaths=$HOME_DIR
@@ -636,6 +727,7 @@ else
|| die "installed $PROXY_UNIT requires --proxy; nothing was changed"
fi
for pair in "${PAIRS[@]}"; do
[ "$CHECK" != --check-new ] || break
IFS=: read -r kind name var <<<"$pair"
path="$UNIT_DIR/$name"
[ ! -L "$path" ] || die "refusing: $path is a symlink"
@@ -653,6 +745,13 @@ read -r -a ids <<<"$installed_ids"
for old in "${ids[@]}"; do
[ "$old" = "${ids[0]}" ] || die "the installed units name different copies, ${ids[0]} and $old"
done
if [ "$CHECK" = --check ]; then
echo "case2-unit: check passed: the units for copy $COPY_ID render, and the installed ones match them; nothing was written"
exit 0
elif [ "$CHECK" = --check-new ]; then
echo "case2-unit: check passed: the units for copy $COPY_ID render as a first install; the installed ones were not compared; nothing was written"
exit 0
fi
# `systemctl start` leaves a running unit on the copy it started from, so
# note which units run before the write (row 195 owns the restart).
+1 -1
View File
@@ -278,7 +278,7 @@ function validateFederation(value) {
fail(2, `${where}.id must match ${FEDERATION_ID_PATTERN}`);
}
if (ids.has(peer.id)) {
fail(2, `${where}.id is already named (by federation.self or an earlier peer)`);
fail(2, `${where}.id ${JSON.stringify(peer.id)} is already named (by federation.self or an earlier peer)`);
}
ids.add(peer.id);
validateFederationOrigin(peer.origin, `${where}.origin`);
+2 -2
View File
@@ -125,7 +125,7 @@ function validateMission(document, file) {
if (!SUPPORTED_TOOLS.includes(tool)) {
fail(2, `unsupported mission tool: (supported: ${SUPPORTED_TOOLS.join(", ")})`);
}
if (seen.has(tool)) fail(2, `duplicate tool in mission capabilities.tools`);
if (seen.has(tool)) fail(2, `duplicate tool in mission capabilities.tools: ${tool}`);
seen.add(tool);
}
capabilities = { tools: [...seen] };
@@ -230,7 +230,7 @@ function validateTask(document, file) {
if (!SUPPORTED_TOOLS.includes(tool)) {
fail(2, `unsupported tool: (supported: ${SUPPORTED_TOOLS.join(", ")})`);
}
if (seen.has(tool)) fail(2, `duplicate tool in capabilities.tools`);
if (seen.has(tool)) fail(2, `duplicate tool in capabilities.tools: ${tool}`);
seen.add(tool);
}
tools = [...seen];
+1
View File
@@ -81,6 +81,7 @@ copy_tree() {
git -C "$1" archive --format=tar "$full" -- "${COPY_PATHS[@]}" | tar -xf - -C "$tmp" || die "git archive failed; no copy written"
[ -f "$tmp/package.json" ] && [ -f "$tmp/package-lock.json" ] || die 'package.json or package-lock.json missing in temp copy; npm ci not run'
(cd "$tmp" && npm ci --no-audit --no-fund) >&2 || die 'npm ci failed; no copy written'
printf '{"commit":"%s"}\n' "$full" > "$tmp/.stack-build.json"
find "$tmp" -type f -exec chmod a-w {} +
find "$tmp" -type d -exec chmod 0555 {} +
if ! mv -T "$tmp" "$target" 2>/dev/null; then
+218 -17
View File
@@ -354,6 +354,25 @@ check_target() {
refuse already-active "$TO is the pointer's copy"
}
# M3 (#1873): an accounts instance runs dedicated system units, never the operator's systemd user
# manager. webui-service.sh install refuses the same four signs, but only after the run stopped the
# units; this refuses before anything stops. The run goes through --units external: root renders
# the units with case2-unit.sh, and activate, confirm, revert and rollback stage and gate them.
# pm-on has no external form, so it passes its own ending ($1). TO names the copy to check.
user_units_allowed() {
local why
why="$(node -e '
const fs=require("fs"),p=require("path"),[root,dir,file]=process.argv.slice(1);
const present=(f)=>{try{fs.lstatSync(f);return true}catch(e){if(e.code==="ENOENT")return false;throw e}};
if(present(p.join(root,".stack-build.json"))){console.log(root+" is a stamped copy");process.exit(1)}
if(present(p.join(dir,"accounts.json"))){console.log(p.join(dir,"accounts.json")+" is present");process.exit(1)}
if(present(file)){let s;try{s=JSON.parse(fs.readFileSync(file,"utf8"))}catch(e){console.log(file+" cannot be read as JSON");process.exit(1)}
if(s.console?.sso){console.log(file+" holds console.sso");process.exit(1)}
if(typeof s.dataRoot==="string"&&present(p.join(s.dataRoot,".deployed"))){console.log(p.join(s.dataRoot,".deployed")+" is present");process.exit(1)}}
' -- "$COPIES/$TO" "$MOSAIC_INSTANCE_CONFIG_DIR" "$MOSAIC_INSTANCE_CONFIG" 2>&1)" ||
refuse accounts-user-unit "${why:-the accounts signs cannot be read}; an accounts instance runs system units, so root renders them with case2-unit.sh${1:- and the $OP runs with --units external}"
}
# --- Plug-in kinds (row 169) ---
# Unlike copy_schema, these import the copy's own code, as a business-check does: its kinds.mjs
@@ -1552,6 +1571,7 @@ do_activate() {
run_external migrate
return
fi
user_units_allowed
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; config_checks; plan; exit 0; fi
precheck
plugin_checks
@@ -1583,6 +1603,7 @@ do_rollback() {
run_external restore
return
fi
user_units_allowed
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; config_checks; plan; exit 0; fi
precheck
plugin_checks
@@ -1935,6 +1956,8 @@ do_pm_on() {
out="$(plugins harness "$P_COPY" 2>&1)" || refuse harness-unset "$P_COPY's loader refuses the business file: $out"
[ "$out" != none ] || refuse harness-unset "the business file sets no harness; Jason sets it with plugins set first"
node -e 'const h=JSON.parse(process.argv[1]);process.exit(Array.isArray(h)&&h.length>0?0:1)' -- "$out" 2>/dev/null || refuse harness-unset "the business file's harness is $out, not a non-empty list"
TO="$P_COPY"
user_units_allowed ", and the PM is a flag in root's bus unit"
instance_busy
drop_in_override
read_units || refuse unit-flags-drift "cannot read $BUS_UNIT or $CONSOLE_UNIT"
@@ -2018,12 +2041,16 @@ r_gone() {
while r_alive "$1"; do [ "$SECONDS" -lt "$deadline" ] || return 1; sleep 1; done
}
# Stops the Console, then the host, with SIGTERM within --timeout; one still there gets SIGKILL
# and a FAIL line. The host has to leave the store's writer.lock behind it.
# Stops the Console, the host, then the rehearsal provider, with SIGTERM within --timeout; one
# still there gets SIGKILL and a FAIL line. The host has to leave the store's writer.lock behind it.
r_stop() {
local name pid
for name in console host; do
if [ "$name" = console ]; then pid="$R_CONSOLE_PID"; R_CONSOLE_PID=""; else pid="$R_HOST_PID"; R_HOST_PID=""; fi
for name in console host provider; do
case "$name" in
console) pid="$R_CONSOLE_PID"; R_CONSOLE_PID="" ;;
host) pid="$R_HOST_PID"; R_HOST_PID="" ;;
provider) pid="$R_PROVIDER_PID"; R_PROVIDER_PID="" ;;
esac
[ -n "$pid" ] || continue
kill -TERM "$pid" 2>/dev/null || true
if r_gone "$pid"; then r_pass "$name-stopped" "pid $pid"
@@ -2045,7 +2072,7 @@ r_end() {
r_stop
if [ -n "$R" ]; then
if [ "$(r_tree_sums "$R_BK")" = "$(cat "$R/backup.before")" ]; then r_pass backup-unchanged; else r_fail backup-unchanged "$R_BK differs from $R/backup.before"; fi
if [ "$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" = "$(cat "$R/live.before")" ]; then r_pass live-config-unchanged; else r_fail live-config-unchanged "$LIVE_CFG or $LIVE_BIZ differs from $R/live.before"; fi
if [ "$(sha256sum "${LIVE_FILES[@]}" 2>&1)" = "$(cat "$R/live.before")" ]; then r_pass live-config-unchanged; else r_fail live-config-unchanged "${LIVE_FILES[*]} differs from $R/live.before"; fi
fi
if [ "$R_RC" = 0 ]; then r_line REHEARSAL_PASS; else r_line REHEARSAL_FAIL; fi
[ -z "$R" ] || echo "rehearsal directory: $R"
@@ -2056,10 +2083,96 @@ r_port_free() {
node -e 'const s=require("node:net").createServer();s.once("error",()=>process.exit(1));s.listen(Number(process.argv[1]),"127.0.0.1",()=>s.close(()=>process.exit(0)));' -- "$1"
}
# The scratch config: the live one with dataRoot set to the scratch data root.
# The scratch config: the live one with dataRoot set to the scratch data root. M3 (#1873): a
# console.sso keeps its provider ids, client ids, algorithms and subjects; the public origin moves to
# rehearsal.invalid, a name that never resolves (RFC 6761). For a stamped copy, whose Console signs
# in, every issuer moves to the rehearsal's own provider (R_PROVIDER_JS, the origin argument). An
# unstamped copy's Console runs without --sso, so its issuers move to rehearsal.invalid too. No
# subject is added; the live config's own are the only ones.
R_CONFIG_JS='
const fs=require("node:fs");const [file,data]=process.argv.slice(1);
const c=JSON.parse(fs.readFileSync(file,"utf8"));c.dataRoot=data;process.stdout.write(JSON.stringify(c,null,2)+"\n");'
const fs=require("node:fs");const [file,data,op]=process.argv.slice(1);
const c=JSON.parse(fs.readFileSync(file,"utf8"));c.dataRoot=data;
const sso=c.console?.sso;
if(sso&&typeof sso==="object"){
sso.publicOrigin="https://rehearsal.invalid";
for(const p of Array.isArray(sso.providers)?sso.providers:[])if(p&&typeof p==="object")p.issuer=`${op||"https://rehearsal.invalid"}/${encodeURIComponent(String(p.id))}${op?"/":""}`;
}
process.stdout.write(JSON.stringify(c,null,2)+"\n");'
# M3 (#1873): the rehearsal's own OpenID provider, for a stamped copy's --sso Console. An https
# server on 127.0.0.1, port 0, with a certificate openssl makes for this run in DIR. Its TLS key is
# read into memory and its file removed before the server listens; cert.pem stays for the scratch
# Console's NODE_EXTRA_CA_CERTS. Per configured provider, at /<id>/, it serves discovery and a JWKS
# holding one public key made in memory for the first of the provider's algorithms it can make.
# Authorization and token answer 403 to any method and query; another method on discovery or the
# JWKS is 405 and any other route 404: it issues no code, no token and no sign-in. Each request is a line in requests.log. It writes origin when listening and exits when
# PARENT (the rehearsal) is gone, on SIGTERM, or on an error.
R_PROVIDER_JS='
const fs=require("node:fs"),path=require("node:path"),https=require("node:https"),crypto=require("node:crypto"),{spawnSync}=require("node:child_process");
const [dir,cfg,parent]=process.argv.slice(1);
const die=(m)=>{console.error(`rehearsal provider: ${m}`);process.exit(1)};
const sso=JSON.parse(fs.readFileSync(cfg,"utf8")).console?.sso;
if(!sso||!Array.isArray(sso.providers)||!sso.providers.length)die("the live config has no console.sso providers");
const GEN={RS:["rsa",{modulusLength:2048}],PS:["rsa",{modulusLength:2048}],ES256:["ec",{namedCurve:"P-256"}],ES384:["ec",{namedCurve:"P-384"}],ES512:["ec",{namedCurve:"P-521"}],Ed25519:["ed25519",{}],EdDSA:["ed25519",{}]};
const keyFor=(alg)=>{
const g=GEN[alg]??GEN[String(alg).slice(0,2)]??(/^ML-DSA-(44|65|87)$/.test(alg)?[alg.toLowerCase(),{}]:null);
if(!g)return null;
try{const {publicKey}=crypto.generateKeyPairSync(...g);return {...publicKey.export({format:"jwk"}),kid:`rehearsal-${crypto.randomBytes(4).toString("hex")}`,use:"sig",alg}}catch{return null}
};
const providers=new Map();
for(const p of sso.providers){
const id=String(p?.id),algs=Array.isArray(p?.algs)?p.algs:[];
let jwk=null;for(const a of algs){jwk=keyFor(a);if(jwk)break}
if(!jwk)die(`provider ${id}: none of ${algs.join(", ")||"no algorithms"} is one this provider can make a key for`);
providers.set(encodeURIComponent(id),jwk);
}
const tls=path.join(dir,"tls-key.pem"),certFile=path.join(dir,"cert.pem");
const r=spawnSync("openssl",["req","-x509","-newkey","ec","-pkeyopt","ec_paramgen_curve:P-256","-nodes","-days","1","-subj","/CN=127.0.0.1","-addext","subjectAltName=IP:127.0.0.1","-keyout",tls,"-out",certFile],{encoding:"utf8"});
let key,cert;
try{if(r.status!==0){const why=String(r.stderr??"").trim().split("\n").pop();throw new Error(`openssl exit ${r.status??r.error?.code}${why?`: ${why}`:""}`)};key=fs.readFileSync(tls);cert=fs.readFileSync(certFile);fs.chmodSync(certFile,0o600)}
catch(e){die(`cannot make the certificate: ${e.message}`)}
finally{fs.rmSync(tls,{force:true})}
const log=(q,status)=>fs.appendFileSync(path.join(dir,"requests.log"),`${q.method} ${q.url} ${status}\n`);
let origin;
const server=https.createServer({key,cert,requestTimeout:5000,headersTimeout:5000},(q,res)=>{
const send=(status,body)=>{log(q,status);res.writeHead(status,{"content-type":"application/json","cache-control":"no-store"});res.end(JSON.stringify(body))};
let at;try{at=new URL(q.url,origin).pathname}catch{at=""}
const m=/^\/([^/]+)\/(\.well-known\/openid-configuration|jwks|authorize|token)$/.exec(at);
const jwk=m?providers.get(m[1]):undefined;
if(!jwk)return send(404,{error:"not found"});
if(m[2]==="authorize"||m[2]==="token")return send(403,{error:"the rehearsal provider issues no code and no token"});
if(q.method!=="GET")return send(405,{error:"GET only"});
const issuer=`${origin}/${m[1]}/`;
if(m[2]==="jwks")return send(200,{keys:[jwk]});
send(200,{issuer,authorization_endpoint:`${issuer}authorize`,token_endpoint:`${issuer}token`,jwks_uri:`${issuer}jwks`,response_types_supported:["code"],subject_types_supported:["public"],id_token_signing_alg_values_supported:[jwk.alg],code_challenge_methods_supported:["S256"]});
});
server.on("error",(e)=>die(e.message));
server.listen(0,"127.0.0.1",()=>{
origin=`https://127.0.0.1:${server.address().port}`;
fs.writeFileSync(path.join(dir,"origin.tmp"),`${origin}\n`,{mode:0o600});fs.renameSync(path.join(dir,"origin.tmp"),path.join(dir,"origin"));
console.log(`rehearsal provider: ${origin}, ${providers.size} provider(s)`);
});
const stop=()=>{server.close();server.closeAllConnections();process.exit(0)};
process.on("SIGTERM",stop);process.on("SIGINT",stop);process.on("SIGHUP",stop);
setInterval(()=>{if(!fs.existsSync(`/proc/${parent}`))stop()},1000);'
# M3 (#1873): one 0600 placeholder per console.sso client secret slot, in the scratch config
# directory where readSlot looks. Never the live slot: the rehearsal reads no secret.
R_SLOTS_JS='
const fs=require("node:fs"),path=require("node:path");const [file]=process.argv.slice(1);
const dir=path.join(path.dirname(file),"secrets","mosaic-stack");
fs.mkdirSync(dir,{recursive:true,mode:0o700});
for(const p of JSON.parse(fs.readFileSync(file,"utf8")).console.sso.providers){
const slot=p?.clientSecret?.slot;
if(typeof slot!=="string"||!/^[a-z0-9-]{1,64}$/.test(slot))throw new Error("a console.sso provider has no valid clientSecret.slot");
try{fs.writeFileSync(path.join(dir,slot),"rehearsal-placeholder-secret\n",{mode:0o600,flag:"wx"})}catch(e){if(e.code!=="EEXIST")throw e}
}'
# M3 (#1873): the scratch .deployed, written by the new copy's own writeDeployment, so it names
# the instance that copy derives from the scratch config directory.
R_DEPLOYED_JS='
const {pathToFileURL}=require("node:url");const [copy,data,dir]=process.argv.slice(1);
import(pathToFileURL(`${copy}/packages/cli/src/deployment.mjs`).href).then((d)=>d.writeDeployment({dataRoot:data},dir));'
# The scratch business file (mos-0962, mos-0963): the live file without its tracker.* variables,
# business and role, and with every credential reference, the roles and tracker.sync, moved to its
@@ -2094,10 +2207,13 @@ fs.writeFileSync(out,JSON.stringify(b,null,2)+"\n",{mode:0o600,flag:"wx"});'
# notify-off the notifier config is {"notifyVersion":1,"binding":null}, 0600 (mos-0962)
# tracker-configured bootConfig sets no trackers (mos-0962)
# credential-ref-live every credential reference is a file under placeholders/ (mos-0963)
# sso-isolated the console.sso origin is rehearsal.invalid, every issuer is the
# rehearsal provider's (OP, a stamped copy) or on rehearsal.invalid, and every
# client secret slot is a scratch placeholder (M3, #1873)
# Exit 0 when all pass, 1 on the first FAIL.
R_GUARD_JS='
const fs=require("node:fs"),path=require("node:path");const {pathToFileURL}=require("node:url");
const [copy,id,live,scratch,ph]=process.argv.slice(1);
const [copy,id,live,scratch,ph,op]=process.argv.slice(1);
const real=(p)=>{try{return fs.realpathSync(p)}catch{return null}};
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,"utf8");return s[s.lastIndexOf(")")+2]!=="Z"}catch{return false}};
const pass=(n,w)=>console.log(`PASS ${n}${w?": "+w:""}`);
@@ -2136,6 +2252,19 @@ const fail=(n,w)=>{console.log(`FAIL ${n}: ${w}`);process.exit(1)};
if(f===null||phr===null||path.dirname(f)!==phr)fail("credential-ref-live",`${what} is ${ref?.file}, not a file in ${ph}`);
}
pass("credential-ref-live",`${refs.length} reference(s), each a placeholder`);
const sso=system.console?.sso;
if(!sso){pass("sso-isolated","no console.sso");return}
const host=(u)=>{try{return new URL(u).hostname}catch{return null}};
if(host(sso.publicOrigin)!=="rehearsal.invalid")fail("sso-isolated",`console.sso.publicOrigin is ${sso.publicOrigin}, not https://rehearsal.invalid`);
const sd=real(path.join(path.dirname(path.resolve(process.env.MOSAIC_CONFIG)),"secrets","mosaic-stack"));
for(const p of sso.providers){
const want=op?`${op}/${encodeURIComponent(String(p.id))}/`:null;
if(op&&p.issuer!==want)fail("sso-isolated",`console.sso provider ${p.id} issuer is ${p.issuer}, not ${want} at the rehearsal provider`);
if(!op&&host(p.issuer)!=="rehearsal.invalid")fail("sso-isolated",`console.sso provider ${p.id} issuer is ${p.issuer}, not on rehearsal.invalid`);
const f=real(path.join(path.dirname(path.resolve(process.env.MOSAIC_CONFIG)),"secrets","mosaic-stack",p.clientSecret.slot));
if(f===null||sd===null||path.dirname(f)!==sd||fs.readFileSync(f,"utf8")!=="rehearsal-placeholder-secret\n")fail("sso-isolated",`console.sso provider ${p.id} has no scratch placeholder secret`);
}
pass("sso-isolated",`${sso.providers.length} provider(s), each ${op?`at the rehearsal provider ${op}`:"at rehearsal.invalid"} with a placeholder secret`);
})().catch((e)=>fail("guard",String(e?.message??e).split("\n")[0]));'
# Opens a data root's store with a copy's Store, as the bus does at start, with no migration
@@ -2203,6 +2332,18 @@ process.stdout.write(why);' -- "$out" "$BUSINESS" "$R_HOST_PID" "$START_MS" 2>&1
R_GATE=healthz
node -e '
fetch(`http://127.0.0.1:${process.argv[1]}/healthz`,{signal:AbortSignal.timeout(5000)}).then((r)=>process.exit(r.status===200?0:1),()=>process.exit(1));' -- "$R_PORT" || return 1
# M3 (#1873): a stamped copy's Console must have found every configured provider at the
# rehearsal provider, not started degraded.
if [ -n "$R_PROVIDER" ]; then
R_GATE=sso-ready
R_WHY="$(node -e '
const ids=JSON.parse(require("node:fs").readFileSync(process.argv[2],"utf8")).console.sso.providers.map((p)=>String(p.id));
fetch(`http://127.0.0.1:${process.argv[1]}/healthz`,{signal:AbortSignal.timeout(5000)}).then((r)=>r.json()).then((h)=>{
const s=h?.sso&&typeof h.sso==="object"?h.sso:{};
process.stdout.write(ids.filter((id)=>s[id]!=="ready").map((id)=>`provider ${id} is ${s[id]??"missing"}, not ready`).join("; "));
},(e)=>process.stdout.write(`healthz unreadable: ${e.message}`));' -- "$R_PORT" "$R/config/config.json" 2>&1)" || R_WHY="${R_WHY:-healthz unreadable}"
[ -z "$R_WHY" ] || return 1
fi
R_GATE=copy-match
tr '\0' ' ' < "/proc/$R_HOST_PID/cmdline" | grep -qF " $copy/packages/cli/src/cli.mjs bus start $BUSINESS --no-launch" || { R_WHY="the host's command line is not $copy's cli.mjs bus start $BUSINESS --no-launch"; return 1; }
tr '\0' ' ' < "/proc/$R_CONSOLE_PID/cmdline" | grep -qF " $copy/packages/webui/src/cli.mjs serve " || { R_WHY="the Console's command line is not $copy's webui cli.mjs serve"; return 1; }
@@ -2215,7 +2356,43 @@ r_gate() {
[ "$SECONDS" -lt "$deadline" ] || { r_fail "$R_GATE" "${R_WHY:-no answer within ${TIMEOUT}s}"; return 1; }
sleep 1
done
r_pass gate "host-up, bus-status, console-up, healthz and copy-match on $TO"
r_pass gate "host-up, bus-status, console-up, healthz${R_PROVIDER:+, sso-ready} and copy-match on $TO"
}
# M3 (#1873): starts R_PROVIDER_JS for a stamped copy, in $R/provider, and waits for its origin.
r_provider() {
local deadline=$((SECONDS + TIMEOUT))
mkdir -m 700 "$R/provider" || r_abort provider-start "cannot make $R/provider"
env -i HOME="$HOME" PATH="$PATH" node -e "$R_PROVIDER_JS" -- "$R/provider" "$LIVE_CFG" "$$" > "$R/provider/provider.log" 2>&1 &
R_PROVIDER_PID=$!
until [ -s "$R/provider/origin" ]; do
r_alive "$R_PROVIDER_PID" || { wait "$R_PROVIDER_PID" 2>/dev/null || true; R_PROVIDER_PID=""; r_abort provider-start "the rehearsal provider exited: $(tail -n 1 "$R/provider/provider.log" 2>/dev/null)"; }
[ "$SECONDS" -lt "$deadline" ] || r_abort provider-start "the rehearsal provider wrote no origin within ${TIMEOUT}s"
sleep 0.2
done
R_PROVIDER="$(cat "$R/provider/origin")"
[[ "$R_PROVIDER" =~ ^https://127\.0\.0\.1:[0-9]{1,5}$ ]] || r_abort provider-start "the rehearsal provider's origin $R_PROVIDER is not https on 127.0.0.1"
[ ! -e "$R/provider/tls-key.pem" ] || r_abort provider-start "the rehearsal provider left its TLS key at $R/provider/tls-key.pem"
r_pass provider-start "pid $R_PROVIDER_PID at $R_PROVIDER: discovery and a JWKS from keys made for this run, no code and no token; its certificate $R/provider/cert.pem is the trust the scratch Console alone gets"
}
# M3 (#1873): the Console fetched each provider's discovery and JWKS from the rehearsal provider and
# asked it for no code and no token.
r_provider_seen() {
local why
why="$(node -e '
const fs=require("node:fs");const [cfg,log]=process.argv.slice(1);
const lines=fs.existsSync(log)?fs.readFileSync(log,"utf8").split("\n").filter(Boolean):[];
const why=[];
for(const p of JSON.parse(fs.readFileSync(cfg,"utf8")).console.sso.providers){
const seg=encodeURIComponent(String(p.id));
for(const what of [".well-known/openid-configuration","jwks"])if(!lines.includes(`GET /${seg}/${what} 200`))why.push(`no GET /${seg}/${what} 200`);
}
for(const l of lines)if(/\/(authorize|token)\b/.test(l))why.push(`a request for ${l}`);
process.stdout.write(why.join("; "));' -- "$R/config/config.json" "$R/provider/requests.log" 2>&1)" || why="${why:-cannot read $R/provider/requests.log}"
if [ -z "$why" ]; then r_pass provider-discovery "the Console fetched each provider's discovery and JWKS from $R_PROVIDER and asked for no code or token"
else r_fail provider-discovery "$why; see $R/provider/requests.log"
fi
}
# No role session: the host wrote no sessions.json and no launches directory or log.
@@ -2258,30 +2435,50 @@ do_rehearse() {
LIVE_CFG="$(realpath -m "${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}")"
LIVE_BIZ="$(dirname "$LIVE_CFG")/businesses/$BUSINESS.json"
[ -f "$LIVE_BIZ" ] || r_abort business-missing "no $LIVE_BIZ"
# M3 (#1873): a stamped copy serves only with --sso, console.sso, a loadable accounts.json and
# a .deployed naming its instance. The rehearsal gives its Console all four in scratch. The
# live instance's console.sso and accounts.json are a human's steps; without them the stamped
# copy cannot start live either, so the rehearsal refuses and names the step.
LIVE_FILES=("$LIVE_CFG" "$LIVE_BIZ") R_STAMPED=""
if [ -e "$COPIES/$TO/.stack-build.json" ] || [ -L "$COPIES/$TO/.stack-build.json" ]; then
R_STAMPED=1
node -e 'process.exit(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")).console?.sso?0:1)' -- "$LIVE_CFG" 2>/dev/null ||
r_abort sso-not-prepared "$TO is a stamped copy, which serves only with --sso, and $LIVE_CFG has no console.sso; prepare SSO for this instance (run sheet) before re-pinning to it"
[ -f "$(dirname "$LIVE_CFG")/accounts.json" ] ||
r_abort accounts-not-initialized "$TO is a stamped copy, which needs an accounts file, and there is no $(dirname "$LIVE_CFG")/accounts.json; a human runs mosaic accounts init (run sheet) before re-pinning to it"
LIVE_FILES+=("$(dirname "$LIVE_CFG")/accounts.json")
fi
local stamp before live
before="$(r_tree_sums "$R_BK")" && live="$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" || r_abort sums "cannot read $R_BK or the live config files"
before="$(r_tree_sums "$R_BK")" && live="$(sha256sum "${LIVE_FILES[@]}" 2>&1)" || r_abort sums "cannot read $R_BK or the live config files"
stamp="$(date -u +%Y%m%dT%H%M%SZ)-${TO:0:12}"
(umask 077; mkdir -p "$DATA/rehearsals" && mkdir "$DATA/rehearsals/$stamp") || r_abort rehearsal-dir "cannot make $DATA/rehearsals/$stamp (one rehearsal per second and copy)"
R="$DATA/rehearsals/$stamp" STEPS="$DATA/rehearsals/$stamp/steps.log"
printf '%s\n' "${R_EARLY[@]}" > "$STEPS" && printf '%s\n' "$before" > "$R/backup.before" && printf '%s\n' "$live" > "$R/live.before" ||
r_abort scratch-build "cannot write the sums to $R"
[ -z "$R_STAMPED" ] || r_provider
(
umask 077
mkdir "$R/config" "$R/config/businesses" "$R/placeholders" "$R/data" "$R/data/bus" "$R/data/notify" "$R/data/notify/$BUSINESS" &&
cp "$R_BK"/bus/* "$R/data/bus/" &&
node -e "$R_CONFIG_JS" -- "$LIVE_CFG" "$R/data" > "$R/config/config.json" &&
node -e "$R_CONFIG_JS" -- "$LIVE_CFG" "$R/data" "$R_PROVIDER" > "$R/config/config.json" &&
node -e "$R_BUSINESS_JS" -- write "$LIVE_BIZ" "$R/config/businesses/$BUSINESS.json" "$R/placeholders" &&
printf '{"notifyVersion":1,"binding":null}\n' > "$R/data/notify/$BUSINESS/notify.json"
printf '{"notifyVersion":1,"binding":null}\n' > "$R/data/notify/$BUSINESS/notify.json" &&
{ ! node -e 'process.exit(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")).console?.sso?0:1)' -- "$R/config/config.json" ||
node -e "$R_SLOTS_JS" -- "$R/config/config.json"; } &&
{ [ -z "$R_STAMPED" ] || {
cp "$(dirname "$LIVE_CFG")/accounts.json" "$R/config/accounts.json" &&
r_env node -e "$R_DEPLOYED_JS" -- "$COPIES/$TO" "$R/data" "$R/config"; }; }
) 2>> "$STEPS" || r_abort scratch-build "cannot build the scratch root under $R; see $STEPS"
local rc=0
diff -u --label "$LIVE_BIZ (as JSON.stringify writes it)" --label "$R/config/businesses/$BUSINESS.json" \
<(node -e "$R_BUSINESS_JS" -- normalize "$LIVE_BIZ") "$R/config/businesses/$BUSINESS.json" > "$R/business.diff" || rc=$?
[ "$rc" -le 1 ] || r_abort scratch-build "cannot write $R/business.diff"
r_pass scratch-root "$R/data from the backup, config and business file under $R/config, business.diff beside them"
[ -z "$R_STAMPED" ] || r_pass deployment "$TO is stamped: $R/data/.deployed from its writeDeployment, the live accounts.json copied to $R/config, the Console starts with --sso"
local out
rc=0; out="$(r_env node -e "$R_GUARD_JS" -- "$COPIES/$TO" "$BUSINESS" "$DATA" "$R/data" "$R/placeholders" 2>&1)" || rc=$?
rc=0; out="$(r_env node -e "$R_GUARD_JS" -- "$COPIES/$TO" "$BUSINESS" "$DATA" "$R/data" "$R/placeholders" "$R_PROVIDER" 2>&1)" || rc=$?
while IFS= read -r i; do r_line "$i"; done <<< "$out"
[ "$rc" = 0 ] || { R_RC=1; r_end; }
@@ -2295,9 +2492,13 @@ do_rehearse() {
R_HOST_PID=$!
local -a tester_rehearsal=()
if grep -q 'MOSAIC_TESTER_SOCKET_V1' "$COPIES/$TO/packages/webui/src/cli.mjs"; then tester_rehearsal=(--tester-socket); fi
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" node "$COPIES/$TO/packages/webui/src/cli.mjs" serve --port "$R_PORT" --business "$BUSINESS" "${tester_rehearsal[@]}" > "$R/console.log" 2>&1 &
[ -z "$R_STAMPED" ] || tester_rehearsal+=(--sso)
# M3 (#1873): the rehearsal provider's certificate is trusted by the scratch Console only.
local -a r_ca=()
[ -z "$R_PROVIDER" ] || r_ca=(NODE_EXTRA_CA_CERTS="$R/provider/cert.pem")
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" "${r_ca[@]}" node "$COPIES/$TO/packages/webui/src/cli.mjs" serve --port "$R_PORT" --business "$BUSINESS" "${tester_rehearsal[@]}" > "$R/console.log" 2>&1 &
R_CONSOLE_PID=$!
if r_gate; then r_no_session; fi
if r_gate; then r_no_session; [ -z "$R_PROVIDER" ] || r_provider_seen; fi
r_end
}
@@ -2335,7 +2536,7 @@ FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
BASE_CONSOLE=() SERVE_FLAGS=() SSO_PLAN="" SSO_STEP="" SSO_DROP="" SSO_NO_TARGET=""
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_PROVIDER_PID="" R_PROVIDER="" R_STARTED="" LIVE_CFG="" LIVE_BIZ="" LIVE_FILES=() R_STAMPED=""
O_ACT="" FLOOR=0
case "$OP" in
adopt) do_adopt ;;
+128 -18
View File
@@ -6,8 +6,9 @@
# agents/darkwing/work/queue-233/BUILD.md.
#
# stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal]
# [--rehearse-port N] [--timeout S]
# stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
# [--rehearse-port N] [--next-args] [--timeout S]
# 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 copy, because the
# stack's user can write the copies. The unit values come from /etc/mosaic-stack/<instance>.args,
@@ -20,6 +21,20 @@
# output but exit codes and the rehearsal's BACKUP_STORE line (sage-1653), which it copies into its
# step log after a match against a fixed pattern and decides nothing on.
#
# Before anything stops, case2-unit.sh --check renders the installed copy from the args file and
# compares the installed units with it, then checks the target's render the same way; either
# refusal is render-refused, with the units left running (#1873 M3).
#
# --next-args changes the serve flags, which a re-render otherwise refuses (row 195's installed-unit
# match): /etc/mosaic-stack/<instance>.args.next, root's with no group or other write, must equal
# the args file before `--` but for --copy's value, and differ from it after `--`. The target
# renders from it through case2-unit.sh --check-new before anything stops. After A3, root moves the
# installed unit files into a new <instance>.replaced.XXXXXX directory beside the args file, and A4
# renders from the next args as a first install. A failure from there moves the replaced files back
# unchanged, restarts them on the old copy and gates it, with no render. After confirm, the next
# args become the args file, and the replaced directory keeps the old unit files and args file for
# a later rollback with --next-args (docs/guides/host-run-sheet.md section 7).
#
# --no-rehearsal is for a stack whose log records no backup. The rehearsal runs either way: exit 3
# from it (no-backup) goes on only with the flag, and exit 0 with the flag refuses
# rehearsal-required (sage-1650, sage-1652).
@@ -47,8 +62,8 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
usage() {
cat >&2 <<'USAGE'
usage: stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal] [--rehearse-port N] [--timeout S]
stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
usage: stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal] [--rehearse-port N] [--next-args] [--timeout S]
stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--next-args] [--timeout S]
USAGE
exit 4
}
@@ -70,7 +85,7 @@ ARGV=("$@")
[ $# -gt 0 ] || usage
OP="$1"; shift
case "$OP" in upgrade|rollback) ;; *) usage ;; esac
INSTANCE="" BUNDLE="" SHA="" MIGRATE=0 NO_REHEARSAL=0 R_PORT=7339 TIMEOUT=60 TO="" RESTORE="" EVENTS=""
INSTANCE="" BUNDLE="" SHA="" MIGRATE=0 NO_REHEARSAL=0 R_PORT=7339 TIMEOUT=60 TO="" RESTORE="" EVENTS="" NEXT=0 ASIDE="" MOVED=() REPLACED_NOTE=""
while [ $# -gt 0 ]; do
case "$1" in
--instance) [ $# -ge 2 ] || usage; INSTANCE="$2"; shift 2 ;;
@@ -79,6 +94,7 @@ while [ $# -gt 0 ]; do
--migrate) [ "$OP" = upgrade ] || usage; MIGRATE=1; shift ;;
--no-rehearsal) [ "$OP" = upgrade ] || usage; NO_REHEARSAL=1; shift ;;
--rehearse-port) [ "$OP" = upgrade ] && [ $# -ge 2 ] && [[ "$2" =~ ^[1-9][0-9]{3,4}$ ]] && [ "$2" -le 65535 ] || usage; R_PORT="$2"; shift 2 ;;
--next-args) NEXT=1; shift ;;
--timeout) [ $# -ge 2 ] && [[ "$2" =~ ^[1-9][0-9]{0,4}$ ]] || usage; TIMEOUT="$2"; shift 2 ;;
--to) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9a-f]{40}$ ]] || usage; TO="$2"; shift 2 ;;
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ $ID_RE ]] || usage; RESTORE="$2"; shift 2 ;;
@@ -125,8 +141,8 @@ logl "start rc=0 argv:$(printf ' %q' "$0" "${ARGV[@]}")"
# The selector scopes: selector.sh defines functions only, from this root-owned checkout, as RENDER is.
. "$REPO/scripts/selector.sh" || refuse checkout-unreadable "cannot read $REPO/scripts/selector.sh"
args_business_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]; }
args_outer_refused() { refuse args-unreadable "$ETC/$INSTANCE.args before --: $2"; }
args_serve_refused() { refuse args-unreadable "$ETC/$INSTANCE.args after --: $2"; }
args_outer_refused() { refuse args-unreadable "$ARGS_FILE before --: $2"; }
args_serve_refused() { refuse args-unreadable "$ARGS_FILE after --: $2"; }
# I2's file: one case2-unit.sh argument per line, owned by root with no group or other write. Sets
# ARGS (the lines), S_USER, S_HOME, BUSINESS, NODE, ARGS_COPY, ARGS_PROFILE and ARGS_PROXY, and the paths derived
@@ -140,7 +156,8 @@ args_serve_refused() { refuse args-unreadable "$ETC/$INSTANCE.args after --: $2"
# says which of them take a value, so a value is never taken for a selector; a token that grammar
# lacks refuses, as the render would.
read_args() {
local f="$ETC/$INSTANCE.args" st i sep copies=0 v profile=library served
ARGS_FILE="${1:-$ETC/$INSTANCE.args}"
local f="$ARGS_FILE" st i sep copies=0 v profile=library served
[ -f "$f" ] && [ ! -L "$f" ] || refuse args-unreadable "no regular file $f"
st="$(stat -c '%u %a' "$f")" || refuse args-unreadable "cannot stat $f"
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || refuse args-unreadable "$f must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })"
@@ -224,17 +241,82 @@ legacy_console() {
done
}
# RENDER_ARGS: the args file's lines with copy $1 in place of --copy's value.
# RENDER_ARGS: the lines of array $2 (default ARGS) with copy $1 in place of --copy's value.
args_with() {
local i past=""
local -n lines_="${2:-ARGS}"
RENDER_ARGS=()
for ((i = 0; i < ${#ARGS[@]}; i++)); do
RENDER_ARGS+=("${ARGS[i]}")
[ "${ARGS[i]}" != -- ] || past=1
if [ -z "$past" ] && [ "${ARGS[i]}" = --copy ]; then RENDER_ARGS+=("$1"); i=$((i + 1)); fi
for ((i = 0; i < ${#lines_[@]}; i++)); do
RENDER_ARGS+=("${lines_[i]}")
[ "${lines_[i]}" != -- ] || past=1
if [ -z "$past" ] && [ "${lines_[i]}" = --copy ]; then RENDER_ARGS+=("$1"); i=$((i + 1)); fi
done
}
# OLD_ARGS: the args file's lines, which the installed units were rendered from. Under --next-args,
# ARGS becomes the next file's lines, which must equal OLD_ARGS before -- but for --copy's value and
# differ after it.
read_next() {
OLD_ARGS=("${ARGS[@]}")
[ "$NEXT" = 1 ] || return 0
local f="$ETC/$INSTANCE.args.next" a b i copy="$ARGS_COPY"
unset SELECTOR_SEEN_outer SELECTOR_VALUE_outer SELECTOR_SEEN_serve SELECTOR_VALUE_serve
read_args "$f"
# read_old checks the installed units against the args file's copy, not the next file's.
ARGS_COPY="$copy"
args_with COPY OLD_ARGS; a=("${RENDER_ARGS[@]}")
args_with COPY; b=("${RENDER_ARGS[@]}")
for ((i = 0; i < ${#a[@]} && i < ${#b[@]}; i++)); do
[ "${a[i]}" = "${b[i]}" ] || refuse next-args-differ "$f must equal $ETC/$INSTANCE.args before -- but for --copy's value; line $((i + 1)) differs"
[ "${a[i]}" != -- ] || break
done
[ "${a[*]}" != "${b[*]}" ] || [ "${#a[@]}" != "${#b[@]}" ] ||
refuse next-args-same "$f has the same serve flags as $ETC/$INSTANCE.args; run without --next-args"
}
# Before anything stops: the installed units against OLD's render from the args file, so a rollback
# render-old works, then copy $1's render, against the installed units or, under --next-args, as a
# first install from the next args.
render_checks() {
args_with "$OLD" OLD_ARGS
step render-old-check "$RENDER" --check "${RENDER_ARGS[@]}" ||
refuse render-refused "case2-unit.sh --check refuses $OLD's units from $ETC/$INSTANCE.args, so a rollback could not render them; its message is above"
args_with "$1"
if [ "$NEXT" = 1 ]; then
step render-check "$RENDER" --check-new "${RENDER_ARGS[@]}" ||
refuse render-refused "case2-unit.sh --check-new refuses $1's units from $ETC/$INSTANCE.args.next; its message is above"
else
step render-check "$RENDER" --check "${RENDER_ARGS[@]}" ||
refuse render-refused "case2-unit.sh --check refuses $1's units from $ETC/$INSTANCE.args; its message is above. A re-pin that changes the serve flags takes --next-args (docs/guides/host-run-sheet.md section 7)"
fi
}
# --next-args, after A3: the installed unit files into a new directory beside the args file, so A4
# renders as a first install. MOVED lists the files moved, for units_back.
units_aside() {
local u rc=0
local -a files=([email protected] "$CON_UNIT")
[ "$ARGS_PROXY" = 0 ] || files+=("$PROXY_UNIT")
ASIDE="$(mktemp -d "$ETC/$INSTANCE.replaced.XXXXXX")" && chmod 0700 "$ASIDE" || rc=1
if [ "$rc" = 0 ]; then
for u in "${files[@]}"; do
mv -T -- "$UNIT_DIR/$u" "$ASIDE/$u" || { rc=1; break; }
MOVED+=("$u")
done
fi
logl "units-aside rc=$rc dir: ${ASIDE:-none} moved: ${MOVED[*]}"
return "$rc"
}
# The moved files back, unchanged, over whatever A4 wrote, then the replaced directory removed.
units_back() {
local u rc=0
for u in "${MOVED[@]}"; do mv -fT -- "$ASIDE/$u" "$UNIT_DIR/$u" || rc=1; done
[ "$rc" != 0 ] || rmdir -- "$ASIDE" || rc=1
logl "units-back rc=$rc dir: $ASIDE moved: ${MOVED[*]}"
return "$rc"
}
# The new args file, written beside the old one before anything stops, moved into place only after
# confirm passes. $1 is the copy.
prepare_args() {
@@ -334,9 +416,25 @@ rollback_to_old() {
stop_wait || { stop_units; finish 2 rollback-failed "$cause failed and the units did not stop for the rollback; $STOPPED_UNITS are stopped"; }
step revert "${AS_USER[@]}" "$SR" revert "$id" "$BUSINESS" --units external --copies "$COPIES_DIR" ||
{ stop_units; finish 2 rollback-failed "$cause failed and revert failed; $STOPPED_UNITS are stopped"; }
args_with "$OLD"
step render-old "$RENDER" "${RENDER_ARGS[@]}" ||
{ stop_units; finish 2 rollback-failed "$cause failed and the render of $OLD failed; $STOPPED_UNITS are stopped"; }
if [ -n "$ASIDE" ]; then
# --next-args: the replaced files back as they were, then the start render-old would do.
units_back ||
{ stop_units; finish 2 rollback-failed "$cause failed and the replaced units in $ASIDE did not all move back; $STOPPED_UNITS are stopped"; }
step daemon-reload "$SYSTEMCTL" daemon-reload ||
{ stop_units; finish 2 rollback-failed "$cause failed and daemon-reload failed after the replaced units moved back; $STOPPED_UNITS are stopped"; }
if [ "$ARGS_PROXY" = 1 ]; then
step start-proxy "$SYSTEMCTL" start "$PROXY_UNIT" ||
{ stop_units; finish 2 rollback-failed "$cause failed and the proxy did not start for $OLD; $STOPPED_UNITS are stopped"; }
fi
step start-bus "$SYSTEMCTL" start "$BUS_UNIT" ||
{ stop_units; finish 2 rollback-failed "$cause failed and the bus did not start for $OLD; $STOPPED_UNITS are stopped"; }
step start-console "$SYSTEMCTL" start "$CON_UNIT" ||
{ stop_units; finish 2 rollback-failed "$cause failed and the Console did not start for $OLD; $STOPPED_UNITS are stopped"; }
else
args_with "$OLD" OLD_ARGS
step render-old "$RENDER" "${RENDER_ARGS[@]}" ||
{ stop_units; finish 2 rollback-failed "$cause failed and the render of $OLD failed; $STOPPED_UNITS are stopped"; }
fi
# confirm --rollback exits 1 when OLD passed its gate and 2 when it failed; anything else is a
# refusal, with OLD's gate never run.
local rc=0
@@ -360,18 +458,27 @@ run_steps() {
*) rollback_to_old "$id" A3 ;;
esac
# A4
if [ "$NEXT" = 1 ]; then units_aside || rollback_to_old "$id" A4; fi
args_with "$id"
step render "$RENDER" "${RENDER_ARGS[@]}" || rollback_to_old "$id" A4
# A5
step confirm "${AS_USER[@]}" "$SR" confirm "$id" "$BUSINESS" --units external --copies "$COPIES_DIR" --timeout "$TIMEOUT" ||
rollback_to_old "$id" A5
if [ "$NEXT" = 1 ]; then
# The old args file joins the replaced units before the next one takes its place.
cp -p -- "$ETC/$INSTANCE.args" "$ASIDE/$INSTANCE.args" && rm -f -- "$ETC/$INSTANCE.args.next" &&
logl "next-args rc=0 replaced: $ASIDE" ||
{ logl "next-args rc=1 replaced: $ASIDE"; finish 5 args-failed "$id is live and confirmed, but the old args file did not reach $ASIDE; $ETC/$INSTANCE.args still names $OLD; copy it there, then put $NEW_ARGS in its place and remove $ETC/$INSTANCE.args.next"; }
fi
if mv -f "$NEW_ARGS" "$ETC/$INSTANCE.args"; then logl "args rc=0 copy: $id"
else logl "args rc=1 copy: $id"; finish 5 args-failed "$id is live and confirmed, but $ETC/$INSTANCE.args still names $OLD; put $NEW_ARGS in its place"
fi
REPLACED_NOTE="${ASIDE:+; the replaced units and args file are in $ASIDE}"
}
do_upgrade() {
read_args
read_next
legacy_console
# Before A1: the bundle into the stack's user's clone, then the copy from it (I1).
step fetch "${AS_USER[@]}" git -C "$CLONE" fetch -q "$BUNDLE" refs/heads/refactor:refs/remotes/origin/refactor ||
@@ -390,9 +497,10 @@ do_upgrade() {
local -a migrate=()
[ "$MIGRATE" = 0 ] || migrate=(--migrate)
plan activate "$SHA" "$BUSINESS" "${migrate[@]}"
render_checks "$SHA"
rehearse
run_steps "$SHA" activate "$SHA" "$BUSINESS" "${migrate[@]}"
finish 0 ok "upgrade ok: $SHA is live for $BUSINESS"
finish 0 ok "upgrade ok: $SHA is live for $BUSINESS$REPLACED_NOTE"
}
# The rehearsal against the log's newest backup, before anything stops. Its output goes to the
@@ -419,6 +527,7 @@ rehearse() {
# check: the target is an older copy than this checkout, and no rehearsal.
do_rollback() {
read_args
read_next
legacy_console
[ -x "$COPIES_DIR/$TO/scripts/mosaic" ] || refuse copy-missing "no $COPIES_DIR/$TO/scripts/mosaic"
hostcheck
@@ -427,8 +536,9 @@ do_rollback() {
[ -z "$RESTORE" ] || restore=(--restore "$RESTORE")
[ -z "$EVENTS" ] || restore+=(--events "$EVENTS")
plan rollback "$BUSINESS" --to "$TO" "${restore[@]}"
render_checks "$TO"
run_steps "$TO" rollback "$BUSINESS" --to "$TO" "${restore[@]}"
finish 0 ok "rollback ok: $TO is live for $BUSINESS"
finish 0 ok "rollback ok: $TO is live for $BUSINESS$REPLACED_NOTE"
}
case "$OP" in
+1 -1
View File
@@ -238,7 +238,7 @@ expect_fed_refusal "federation peer id pattern exits 2 naming the key" "federati
FED_LONG_LABEL="$(printf 'x%.0s' $(seq 81))"
expect_fed_refusal "federation peer label over 80 characters exits 2 naming the key" "federation.peers[0].label" \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"'"$FED_LONG_LABEL"'"}]}'
expect_fed_refusal "federation duplicate peer id exits 2 naming the key" 'federation.peers[1].id is already named' \
expect_fed_refusal "federation duplicate peer id exits 2 naming the key" 'federation.peers[1].id "factory" is already named' \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"F"},{"id":"factory","origin":"https://other.woltje.com","label":"G"}]}'
expect_fed_refusal "federation peer origin with a trailing slash exits 2 naming the key" "federation.peers[0].origin" \
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com/","label":"F"}]}'
+14
View File
@@ -318,6 +318,20 @@ render() {
case "$REPO" in *[!A-Za-z0-9/._+-]*) die "repository path contains a character the unit cannot carry: $REPO" ;; esac
local root
root="$(unit_root)" || exit 1
# Accounts instances use dedicated system services, never a human/seat's
# systemd user manager. The check runs before rendering or installing.
node --input-type=module -e '
import { lstatSync, readFileSync } from "node:fs";
import { join } from "node:path";
const [root, dir, file] = process.argv.slice(1);
const present = p => { try { lstatSync(p); return true; } catch(e) { if(e.code === "ENOENT") return false; throw e; } };
let accounts = present(join(root, ".stack-build.json")) || present(join(dir, "accounts.json"));
if (present(file)) {
const s = JSON.parse(readFileSync(file, "utf8"));
accounts ||= Boolean(s.console?.sso) || present(join(s.dataRoot, ".deployed"));
}
if (accounts) { console.error("webui-service: accounts user-unit path refused; use system units from case2-unit.sh"); process.exit(1); }
' "$root" "$MOSAIC_INSTANCE_CONFIG_DIR" "$MOSAIC_INSTANCE_CONFIG" || exit 1
if [ "$TESTER_SOCKET" = 1 ]; then
grep -q 'MOSAIC_TESTER_SOCKET_V1' "$root/packages/webui/src/cli.mjs" || die "tester socket target lacks support"
fi