Compare commits
19
Commits
org-a3
...
org-base-reds
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e588fb32cb | ||
|
|
e3577e9d31 | ||
|
|
99ce579e3c | ||
|
|
b44b14c7df | ||
|
|
85858938fc | ||
|
|
6415e81ee7 | ||
|
|
15225c5e1c | ||
|
|
cb9580f6fb | ||
|
|
c0e08f81ea | ||
|
|
f7813339b5 | ||
|
|
3e82bc16c3 | ||
|
|
57052c53dd | ||
|
|
930182b54a | ||
|
|
d0cfebe286 | ||
|
|
9acc378555 | ||
|
|
db822c5b9d | ||
|
|
554bc5116d | ||
|
|
6db8fb2dce | ||
|
|
85122dd2bc |
No files matched your search
+84
-6
@@ -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
|
||||
|
||||
@@ -386,10 +386,15 @@ then the bus, then the proxy, re-render all three onto the selected copy, and st
|
||||
then the Console. Recovery after a failed upgrade restores all three to the old copy. A missing or
|
||||
disagreeing proxy unit, or an installed proxy without `--proxy` in the args file, refuses before anything stops.
|
||||
|
||||
For the `library` profile, the serve argv after `--` keeps the proxy-preview trio and required proof pair
|
||||
but omits `--send`. That Console is read-only until Case 2 gains real OIDC in #412; `case2-unit.sh`
|
||||
explicitly refuses `--send`. Proxy preview and proof-protected reads remain available. The `factory`
|
||||
profile and its private tester socket are unchanged.
|
||||
A stamped copy, which is every copy `stack-copy.sh` makes, signs in through SSO accounts in both profiles
|
||||
(M3, #1873; Jason's ruling 2026-11-10). After `--`, the `factory` profile takes `--sso --send` with its
|
||||
private tester socket. The `library` profile takes a bare `--sso`, still without `--send`, so its Console
|
||||
stays read-only. A stamped library copy refuses the proxy-preview trio (`--public-host`, `--auth-header`,
|
||||
`--allow-user`) and the proof pair (`--proof-header`, `--proof-file`): proxy-preview admission does not
|
||||
apply to a stamped copy. Only an unstamped library copy, which no host install makes, keeps the old
|
||||
proxy-preview argv and refuses `--sso`. Before writing anything, `case2-unit.sh` refuses a stamped copy
|
||||
that lacks `--sso` or any prerequisite and names each missing step. The migration below lists them in
|
||||
order.
|
||||
|
||||
A library host installed before #385 has `--send` in two places, because the earlier `case2-unit.sh`
|
||||
required it: a `--send` line after `--` in the args file, and `--send` in the installed Console unit's
|
||||
@@ -418,10 +423,83 @@ grep -n '^ExecStart=' /etc/systemd/system/mosaic-webui.service | grep -c -- ' --
|
||||
lead with the command's output. Removing only the args line does not help: the run then refuses
|
||||
`console-unit-legacy`.
|
||||
|
||||
The Console stays read-only after the upgrade, until #412 (#385).
|
||||
The library Console stays read-only after the upgrade (#385). From M3 it signs in through SSO
|
||||
accounts, as the migration below sets up.
|
||||
|
||||
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` or `console-unit-legacy` on any of these
|
||||
rules, before it stops a unit.
|
||||
### Migrating a host to SSO accounts (M3, #1873)
|
||||
|
||||
A host re-pinned onto an M3 copy migrates first, in this order. The order is the same for the `factory`
|
||||
(Case 1 and .53) and `library` (.54) profiles. Each step is a live step that needs Mos's go. Nothing in the
|
||||
stack picks or invents a subject: the subjects come from `console.sso`, which a human prepares, and
|
||||
`accounts.json` comes only from the human verb `mosaic accounts init`.
|
||||
|
||||
1. Check or create `.deployed` through the installer. The stack's user's `case2-install.sh` (section 6)
|
||||
writes `<dataRoot>/.deployed` naming the instance at its step 2. It never replaces an existing one, and
|
||||
it refuses one that is invalid or names another instance. Check it read-only:
|
||||
`stat -c '%U %a' <dataRoot>/.deployed` prints `mosaic 444`.
|
||||
2. Prepare SSO. A human adds `console.sso` to `/srv/mosaic/.config/mosaic-dev/config.json`, with its
|
||||
providers, their subjects and its client-secret slots, per
|
||||
`docs/plans/2026-11-10_accounts-design.md`. Jason places the client secrets (section 9).
|
||||
3. Run `mosaic accounts init --name <name>` as `mosaic`. This is a human verb, run from a human shell and
|
||||
never by an agent. It creates the one operator account from the configured `console.sso` subjects and
|
||||
refuses if `accounts.json` exists. It also refuses `accounts-unsafe` when any folder above the config
|
||||
directory is not root's or `mosaic`'s, or is group or other writable without the sticky bit (#1919).
|
||||
Check first, read-only: every line printed by
|
||||
`p=/srv/mosaic/.config/mosaic-dev; while [ "$p" != / ]; do stat -c '%U %a %n' "$p"; p=$(dirname "$p"); done; stat -c '%U %a %n' /`
|
||||
names `root` or `mosaic`, and its mode has no group or other write bit unless it starts with 1 (sticky).
|
||||
The config directory itself is `mosaic`'s with no group or other write bit. A group-writable `.config`
|
||||
refuses on purpose; fix the mode, never the check.
|
||||
4. Provision the dedicated uid. `mosaic` must keep section 1's shape: a non-login shell, the only member
|
||||
of its primary group, and owner of its config directory and data root. An accounts instance never runs
|
||||
under a human or development seat uid, and never under the `webui-service.sh` user units.
|
||||
5. Re-pin with `--next-args`. Leave `/etc/mosaic-stack/dev.args` as it is. Write the new flags to
|
||||
`/etc/mosaic-stack/dev.args.next`, root's, mode 0640: the same lines before `--`, and after `--` the
|
||||
serve flags with `--sso` added (`factory`: `--sso --send`). For `library`, also remove the
|
||||
proxy-preview trio (`--public-host`, `--auth-header`, `--allow-user`) and the proof pair
|
||||
(`--proof-header`, `--proof-file`). Then run
|
||||
`stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA --next-args`.
|
||||
|
||||
A plain re-pin refuses `render-refused` here, because an installed unit must equal the render but for
|
||||
the copy id. With `--next-args`, before anything stops, the driver checks the installed copy against
|
||||
`dev.args` and the target as a first install from `dev.args.next`. It refuses `next-args-differ` when
|
||||
the files differ before `--` (other than `--copy`), and `next-args-same` when they do not differ after
|
||||
it. After the stage it moves the installed unit files into a new 0700 directory
|
||||
`/etc/mosaic-stack/dev.replaced.XXXXXX` and renders the target as a first install. A failure from
|
||||
there moves them back unchanged and starts the old copy again. After `confirm` passes,
|
||||
`dev.args.next` becomes `dev.args`, and the replaced directory keeps the old unit files and the old
|
||||
`dev.args`. The result line names the directory. Nothing removes it; root removes it by hand once no
|
||||
rollback needs it.
|
||||
|
||||
To roll back past the migration, put the replaced args back as the next args and roll back with
|
||||
`--next-args`:
|
||||
|
||||
```sh
|
||||
install -m 0640 -o root -g root /etc/mosaic-stack/dev.replaced.XXXXXX/dev.args /etc/mosaic-stack/dev.args.next
|
||||
stack-upgrade.sh rollback --instance dev --to <old copy id> --next-args
|
||||
```
|
||||
|
||||
For `library` this is the only rollback to a copy older than M3: `case2-unit.sh` refuses `--sso` for
|
||||
an unstamped library copy (#416), so the old copy comes back with its old flags, proxy-preview
|
||||
admission included, as before the migration. A `factory` rollback may instead keep `--sso --send`
|
||||
with a plain `rollback`.
|
||||
|
||||
If a step is missing, `case2-unit.sh` refuses before it writes a unit and prints the missing steps,
|
||||
numbered in this order, for example:
|
||||
|
||||
```text
|
||||
case2-unit: refusing the units for <sha>: a stamped copy's Console starts only with SSO accounts; missing steps, in order:
|
||||
1. check or create .deployed: mosaic runs case2-install.sh, whose deployment step writes <dataRoot>/.deployed
|
||||
2. prepare SSO: console.sso in mosaic's system config /srv/mosaic/.config/mosaic-dev/config.json, with its client secret slots
|
||||
3. run `mosaic accounts init` as mosaic, a human verb: no /srv/mosaic/.config/mosaic-dev/accounts.json
|
||||
4. add --sso to the serve flags, then re-pin
|
||||
```
|
||||
|
||||
Without `console.sso`, the render does not check `accounts.json`, because its subjects name providers SSO
|
||||
has not configured yet. The next render after step 2 checks it. A rollback never removes accounts.json,
|
||||
its receipts or `.deployed` (`docs/plans/2026-11-10_accounts-design.md`, section 7).
|
||||
|
||||
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable`, `console-unit-legacy`, `render-refused`,
|
||||
`next-args-differ` or `next-args-same` on any of these rules, before it stops a unit.
|
||||
|
||||
## 8. Enrollment, once per host
|
||||
|
||||
@@ -450,7 +528,7 @@ file with `stat -c '%U %a %n' PATH`, never `cat`. `<business>` is the host's bus
|
||||
| Path | File | Mode | Owner | Profile | Precheck line |
|
||||
|---|---|---|---|---|---|
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | `claude-session.env`, one line `ZAI_API_KEY=...` | 0600 | mosaic | library | `<path> sets ZAI_API_KEY` |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | the Console's proof file, the path the args file's `--proof-file` names: one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | before M3 only, and an unstamped copy only: the Console's proof file, the path the args file's `--proof-file` names. A stamped copy refuses `--proof-file` (section 7). The file is one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | After a unit start, wait for loopback `/healthz` with the bounded retry in section 10 before `case2-precheck.sh --business <business> --library-status`: `library-last-error library-credential-missing` until it's placed, then `library <project> last fetched ...` |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/agents/<business>/<agent>/` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. |
|
||||
|
||||
|
||||
@@ -108,7 +108,10 @@ configured subject (`packages/cli/src/config.mjs:141`), and
|
||||
`packages/webui/src/cli.mjs serve` from that copy. Its flag set is
|
||||
`--port`, `--business`, `--board`, `--sso`, `--send` and the header
|
||||
preview set (`webui-service.sh:98`). It has no dev-owner flag.
|
||||
`scripts/case2-unit.sh` renders Case 2 units the same way.
|
||||
`scripts/case2-unit.sh` renders Case 2 units the same way. At BASE its
|
||||
library profile takes the preview set and refuses `--sso`. Under the
|
||||
owner ruling of 2026-11-10 a stamped library copy signs in through SSO
|
||||
accounts instead (section 6, item 5).
|
||||
|
||||
### Organizations and who may read them
|
||||
|
||||
@@ -1231,7 +1234,13 @@ The executable boundary:
|
||||
this instance, and accounts.json loads. The Case 1 and Case 2 installers
|
||||
write `.deployed`.
|
||||
5. **The unit renderers have no dev flag.** `webui-service.sh` and
|
||||
`case2-unit.sh` accept no dev flag today (`webui-service.sh:98`). M3 adds
|
||||
`case2-unit.sh` accept no dev flag today (`webui-service.sh:98`). For a
|
||||
stamped copy, `case2-unit.sh` renders either profile, factory or library,
|
||||
only with `--sso`, `console.sso`, a loading accounts.json and a valid
|
||||
`.deployed`, and refuses before any write, naming each missing step. A
|
||||
stamped library unit refuses proxy-preview admission and its proof flags:
|
||||
proxy-preview does not apply to a stamped copy. The dev and unstamped
|
||||
paths keep the preview set. M3 adds
|
||||
a test that pins it.
|
||||
|
||||
The instance boundary, which also covers a checkout started by mistake on a
|
||||
@@ -1289,13 +1298,22 @@ business file.
|
||||
|
||||
### Rollback
|
||||
|
||||
Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off.
|
||||
Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off,
|
||||
except the library rollback past the migration below.
|
||||
|
||||
- The supported rollback is a pinned code and config rollback: install the
|
||||
earlier stack copy with the same unit flags, including `--sso`, through
|
||||
`webui-service.sh install --copy <earlier id>`, as the unit script
|
||||
already describes. Leave accounts.json, its receipts and `.deployed` in
|
||||
`stack-upgrade.sh rollback --instance NAME --to <earlier id>` for the
|
||||
dedicated system units. The accounts instance must not use the
|
||||
`webui-service.sh` user-unit path. Leave accounts.json, its receipts and `.deployed` in
|
||||
place. BASE code ignores them, and the next roll-forward needs them.
|
||||
- A library host rolled back to a copy older than M3 is the exception.
|
||||
`case2-unit.sh` refuses `--sso` for an unstamped library copy (#416), so
|
||||
that rollback renders the old copy from the args file the migration
|
||||
replaced, through `stack-upgrade.sh rollback ... --next-args`
|
||||
(`docs/guides/host-run-sheet.md` section 7). The old copy comes back with
|
||||
its old flags, proxy-preview admission included, as before the migration.
|
||||
accounts.json and `.deployed` stay in place.
|
||||
- Before the first accounts org exists, that rollback restores today's
|
||||
behavior completely. `console.sso` owners sign in as before, and FED R6
|
||||
users keep their root grants. Account-only users and pending invites stop
|
||||
@@ -1450,6 +1468,9 @@ Acceptance tests, against a fake OIDC provider on port 0:
|
||||
- `--dev-local-owner` from a checkout refuses with accounts.json present,
|
||||
with `console.sso`, and with `.deployed`.
|
||||
- `webui-service.sh render` and `case2-unit.sh` refuse a dev flag.
|
||||
- For a stamped copy, `case2-unit.sh` renders a library or factory unit
|
||||
only with `--sso` and the prerequisites, names each missing step before
|
||||
any write, and refuses the proxy-preview and proof flags.
|
||||
- `case2-unit.sh` accepts the host run sheet's `mosaic` account fixture only
|
||||
when its shell is non-login, its primary group has no other members, and
|
||||
it owns the config directory and data root. Fixtures for a human login and
|
||||
@@ -1666,6 +1687,8 @@ and draft 3 applies them. No question from draft 2 remains open.
|
||||
| May an invite bind to an email domain? | "agreed" | No domain binding. Section 4 binding, test in M6. |
|
||||
| May members read the board and write tasks? | "Similar to 1. grantable right via RBAC. Default owner R/W, member R" | The `board.read` and `task.write` rights. `member` holds `board.read` only. Section 3, slices M1, M5 and M7. |
|
||||
|
||||
The library Console signs in through SSO accounts like every deployed Console; proxy-preview admission does not apply to a stamped copy.
|
||||
|
||||
### Choices made under the rulings
|
||||
|
||||
These are decided in this draft, not open. Each one is a single reviewed
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
# Credential binding for agent copies
|
||||
|
||||
Date: 2026-11-11 UTC. Lane `org-cred-bind-design`, issue #1873.
|
||||
Status: proposed, for Jason's approval before implementation. Doc only.
|
||||
Baseline: `6415e81ee75a6783b889b67b985f61825f9a074e` (`origin/refactor`).
|
||||
All current-code citations below refer to that commit. References to other
|
||||
designs describe proposed policy, not a claim that it is implemented.
|
||||
|
||||
## 1. Outcome and boundaries
|
||||
|
||||
An owner can approve a credential binding for one root or project agent copy
|
||||
inside one organization. A launch uses only that copy's own secret slot,
|
||||
with verified service permissions bounded by its frozen role and the
|
||||
organization's narrower approval. No agent can bind credentials for itself
|
||||
or another agent. Missing, stale or uncertain authority refuses launch and
|
||||
credential use. Copies in other organizations remain independent.
|
||||
|
||||
This proposes a writer, schema and enforcement changes for later slices. It
|
||||
changes no code, right, role, contract, secret or running instance. Host and
|
||||
model credentials remain on their existing grant and proxy paths. There is
|
||||
no migration of named-agent credentials, no root-wide secret binding, no
|
||||
shared token pool, and no new plugin service in the first build.
|
||||
|
||||
Owner direction requires each non-assistant agent's credentials to stay
|
||||
inside its organization even when its definition comes from root
|
||||
(`docs/plans/2026-11-09_unified-workspace-owner-direction.md:124-131`). A3
|
||||
explicitly deferred binding and its two-org thread-copy test
|
||||
(`/mnt/storage/scratch/mosaic-lead/reports/org-a3-build.md:52,216-217`).
|
||||
|
||||
## 2. Current behavior, traced before the proposal
|
||||
|
||||
| Step | Current behavior and evidence |
|
||||
| --- | --- |
|
||||
| Declare requirements | `checkCredentials` accepts each known service once and validates its scope shape. Gitea uses category levels; Vikunja uses route-group verbs; host has no scopes (`packages/organization/src/role.mjs:100-143`). `admin` and `all` are outside the grantable Gitea categories (`packages/organization/src/vocabulary.mjs:187-191`). |
|
||||
| Declare a named instance | `roles.<instance>.credentials` must reference exactly the role's services, except host grants and the chief's Gitea exception when no library exists. Vikunja also requires a unique bot identity (`packages/organization/src/organization-config.mjs:50-59,124-167`). |
|
||||
| Locate the slot | `parseCredentialRef` accepts exactly one of `store`, `file` or `env`. `{store:true}` derives the file from config directory, business, agent and service; it does not accept another agent's store path. Named agents can still use external file and environment references (`packages/organization/src/credentials.mjs:23-59`). This lane proposes only the store form for copies. |
|
||||
| Resolve launch inputs | `resolveInstance` takes the entry from the validated organization, intersects tools/network/actions with limits, then returns its non-host credential references and tracker metadata (`packages/organization/src/resolve.mjs:34-69,81-95`). These limits do not compute a credential scope intersection. |
|
||||
| Load secrets | Runtime indexes references by `business/role` and sets the store root to the agents secret directory (`packages/bus/src/runtime.mjs:333-345,378-379`). `Credentials` loads those references into its private map (`packages/bus/src/credentials.mjs:130-165`). It derives its store filename from business, agent and service (`packages/bus/src/credentials.mjs:351-355`). File reads reject symlinks, wrong ownership/mode and repository/data-root placement (`packages/bus/src/credentials.mjs:357-415`). |
|
||||
| Use secrets | `Credentials.useFor` derives business and role from the session, rather than a caller-supplied target. Missing tokens refuse `credential-unavailable`; Vikunja expiry refuses `credential-expired` (`packages/bus/src/credentials.mjs:597-624`). The task adapter uses that session-bound path (`packages/tasks/src/adapter.mjs:84-88`). |
|
||||
| Issue and revoke | The broker requires a human, with an explicit tester-verb exception; it checks that the agent exists and prevents delete while ordinary or thread claims remain (`packages/bus/src/broker.mjs:6212-6225`). The credential writer reads only `document.roles[agent].credentials`; issue writes a store reference, revoke drops memory/file before writing or recording the receipt (`packages/bus/src/credential-verbs.mjs:16-51`). |
|
||||
| Record writes | The organization writer takes the shared registry lock, then the organization's history `write.lock`, validates the revision and document, records a prepared change and publishes the file (`packages/organization/src/organization.mjs:143-159,175-220,278-340`; `packages/organization/src/org-registry.mjs:73-101`). Its `pathsOnly` option records changed field paths and presence without values (`organization.mjs:276-278,324-327`). |
|
||||
| Materialize copies | Root and project entries explicitly refuse `credentials`, `tracker`, `identity`, `publish`, `holder` and `hosts` (`packages/organization/src/agent-levels.mjs:18-41,53-59,105-115`). Copy names preserve root names or append project names. Collisions refuse both claimants; org instances shadow copies. Every required non-host service makes a copy `unbound` (`packages/organization/src/agent-copies.mjs:36-53,73-104`). An unbound copy never joins the role map and launch refuses `credential-unbound`, naming missing services (`agent-copies.mjs:223-245,279-287`; `packages/cli/src/launcher.mjs:361-375`). |
|
||||
| Guard launch races | Launch checks against a fresh organization revision, then final `request.admission` compares it again after async preparation (`packages/cli/src/launcher.mjs:357-364,802-813`; `packages/bus/src/runtime.mjs:636-661`). This revision covers organization bytes, not role file bytes. A3 records that limitation explicitly (`/mnt/storage/scratch/mosaic-lead/reports/org-a3-build.md:215`). |
|
||||
|
||||
Important limit: validating a slot reference does not prove the remote
|
||||
token's permissions. For non-host/non-model services, `Credentials.#format`
|
||||
checks token syntax and length, not remote scopes
|
||||
(`packages/bus/src/credentials.mjs:448-451`). Existing Gitea minting has a
|
||||
fixed seat scope list, not a per-copy intersection
|
||||
(`packages/bus/src/gitea-grant.mjs:27-30,262-269`). Vikunja startup has
|
||||
specific negative permission probes, not a complete per-composition scope
|
||||
proof (`packages/tasks/src/startup.mjs:30-55`). The new build must close
|
||||
these gaps for copies; a JSON field that asserts scopes is insufficient.
|
||||
|
||||
## 3. Choice and storage
|
||||
|
||||
Considered three approaches:
|
||||
|
||||
1. Add credentials to root/project definitions. Rejected: a root definition
|
||||
would carry a per-org concern and invite cross-org reuse.
|
||||
2. Write synthetic copies into `roles`. Rejected: this conflicts with org
|
||||
shadowing and turns a boot copy into a persistent named instance.
|
||||
3. Add an org-local binding overlay and reuse the existing per-agent store.
|
||||
Recommended. It keeps copy definitions secret-free, preserves shadowing,
|
||||
and gives bindings the same revision fence as other org settings.
|
||||
|
||||
The authoritative metadata lives in the existing organization file,
|
||||
`<configDir>/businesses/<org>.json`, under a proposed top-level
|
||||
`copyBindings` key. It is not written into `agents.json` or `roles/`.
|
||||
An absent key means no bindings. The writer stores one record per concrete
|
||||
copy instance; the example below is illustrative schema, not current JSON:
|
||||
|
||||
```json
|
||||
"copyBindings": {
|
||||
"writer.site": {
|
||||
"copyId": "<random 128-bit lowercase hex>",
|
||||
"orgIncarnation": "<registry incarnation>",
|
||||
"source": { "level": "project", "name": "writer", "project": "site" },
|
||||
"definition": { "ref": "org:release-writer", "rev": 3, "sha256": "<digest>" },
|
||||
"generation": 1,
|
||||
"state": "bound",
|
||||
"services": {
|
||||
"gitea": {
|
||||
"slot": "self",
|
||||
"scopes": ["read:repository"],
|
||||
"resources": { "projects": ["site"] },
|
||||
"rotateBy": "2026-12-01",
|
||||
"grantId": "<opaque service grant id>",
|
||||
"grantGeneration": 1
|
||||
}
|
||||
},
|
||||
"decision": "<authority decision id>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`slot: "self"` means exactly `(org, concrete instance, service)`. It is a
|
||||
selector for today's derived slot, not an arbitrary string slot name. The
|
||||
record contains no path, environment name, token, token fragment or token
|
||||
hash. Grant ids identify authorization records, never encode token bytes.
|
||||
Secret values stay exclusively in the existing private credential store
|
||||
and trusted service-call memory, never in metadata, evidence, bundles,
|
||||
decision text or errors. The symbolic paths in this design describe source
|
||||
architecture; runtime responses never emit resolved host paths.
|
||||
|
||||
`source` includes the level and original name/project, so dotted-name
|
||||
collisions cannot silently attach an old binding. `copyId` is created on
|
||||
first approval and retired on fire, shadowing, source removal or replacement.
|
||||
`generation` increases for every binding change or revocation and never
|
||||
resets while that copyId exists. An unbound tombstone remains after unbind.
|
||||
A new copyId never adopts an old slot file. Name reuse requires verified
|
||||
old-slot cleanup and newly verified credentials before activation.
|
||||
|
||||
For a root copy, source.project is null and its definition is the frozen
|
||||
root definition identity. For a project copy it names the org definition
|
||||
or template identity. A definition hash includes composition and contract;
|
||||
the applicable instruction/personality revision is pinned too. Import
|
||||
provenance (`from.sha256`) is not the customized definition's current hash.
|
||||
|
||||
Vikunja metadata additionally contains the existing bot identity shape
|
||||
(`tracker: {bot, botId}`) at the copy record level. It must pass the same
|
||||
org-wide uniqueness check as named instances, including other copies.
|
||||
No service binding implicitly grants identity, publication, host enrollment
|
||||
or project access. Existing identity/publish requirements remain separate.
|
||||
|
||||
### Writer and lock order
|
||||
|
||||
Extend `organizationWriter` with dedicated trusted methods
|
||||
`applyCopyBindingDecision` and `revokeCopyBinding`; names are proposed API.
|
||||
Only the authenticated broker/Console owner path invokes these methods.
|
||||
Generic document writes and root/project writers must reject changes to
|
||||
`copyBindings`, even if a submitted document validates. An actor string,
|
||||
decision id or hash supplied by an agent is never authorization.
|
||||
|
||||
Reuse the shared registry lock `<configDir>/org-runtime.lock`, then the
|
||||
organization lock `<configDir>/businesses/.history/<org>/write.lock`.
|
||||
Do not introduce a second competing metadata file or lock. Reuse the
|
||||
writer's revision conflict, prepared/committed receipt and paths-only
|
||||
evidence machinery. Under the locks, establish fresh active org incarnation,
|
||||
owner identity, expected document revision and decision digest. Perform
|
||||
the final checks in the publication critical section, not only in HTTP.
|
||||
|
||||
Do not hold filesystem locks across remote service calls or waits for runs
|
||||
to stop. Before such work, persist a non-active pending operation with a
|
||||
unique id and expected generations. Reacquire the locks, re-read and compare
|
||||
all identities before publishing. A competing write invalidates preparation.
|
||||
Use the same operation id for idempotent recovery; a prepared or uncertain
|
||||
operation cannot produce a usable binding or resurrect the prior token.
|
||||
|
||||
## 4. Authority, decisions and deployed ownership
|
||||
|
||||
Use the existing grantable `org.hire` right as the authority-change entry
|
||||
right. It already exists, defaults to owner yes/member no, and maps
|
||||
`hire.resolve.console` to `org.hire`
|
||||
(`packages/organization/src/org-rights.mjs:6-26,67-69`). Do not add an
|
||||
`org.credentials` right that would imply delegable token-grant authority.
|
||||
|
||||
The right is necessary but not sufficient: any decision containing a
|
||||
credential line requires the current owner of the target org, even if a
|
||||
member holds `org.hire` or `decide`. Map credential resolution to an
|
||||
explicit owner check as well as the right check, rechecked at commit.
|
||||
The owner-only credential read stays owner-only; `board.read` gets at most
|
||||
the existing copy's missing-service status, never a binding record.
|
||||
|
||||
Bind and unbind are **authority-change decisions**, represented by the
|
||||
hire model's `op: "change"`, with a binding-specific payload. The
|
||||
decision names source, copyId (or new-copy proposal), org incarnation,
|
||||
definition revision/hash, before/after service scopes and resources, and
|
||||
expected binding generation. The broker computes credential lines and
|
||||
the whole-decision digest. Approval consumes the decision once. Dropping,
|
||||
narrowing or replacing a line invalidates the old digest and requires
|
||||
confirmation of the new digest; widening always raises a new decision.
|
||||
|
||||
This reuses hire-as-a-decision instead of creating a second approval
|
||||
model. It follows the roles-and-rails design's credential ruling,
|
||||
owner restriction, digest, seven-day expiry, one-step human-originated
|
||||
decision, and post-approval binding steps
|
||||
(`docs/plans/2026-11-10_roles-and-rails-design.md:35,232-310`). The chief
|
||||
may propose a binding only through that decision path, never by naming a
|
||||
slot, supplying a token or calling the writer. Other agents cannot raise
|
||||
this authority-change subtype. No agent resolves it, including the chief,
|
||||
an arbiter, its subject, or a different copy of the same definition.
|
||||
|
||||
This is an authority change under system rails R1, R5, R7 and R8 and the
|
||||
role's credential rail. Default autonomy is **in the loop**. As required by
|
||||
the existing design, credential bindings do not move to near or out of the
|
||||
loop at all without a new owner ruling
|
||||
(`docs/plans/2026-11-10_roles-and-rails-design.md:90-119,416-425,445-463`).
|
||||
Unbind uses the same human decision record, including a one-step
|
||||
owner-originated decision. Emergency **revoke** remains an owner-only
|
||||
immediate verb: it only removes use, must not wait for a chief proposal,
|
||||
and records the authority reduction. Revoke never issues a token or changes
|
||||
an unbound binding to bound.
|
||||
|
||||
A root-level definition change still needs the instance owner. Binding one
|
||||
root copy inside org O needs O's owner and grants nothing in another org.
|
||||
Being an instance operator or root-business owner alone does not make a
|
||||
person O's owner. Accounts design separates these principals explicitly
|
||||
(`docs/plans/2026-11-10_accounts-design.md:314-349`). No account-only session
|
||||
falls back to a human/root transport. All Console mutations retain Origin,
|
||||
session, fresh ownership and confirmation fences.
|
||||
|
||||
The host remains the trust boundary. Host root and the instance uid can
|
||||
already change the instance and its secrets; this proposal does not claim
|
||||
protection from either, or from a sandbox escape to that uid. The deployed
|
||||
instance uid must remain separate from human/development seats
|
||||
(`docs/plans/2026-11-10_accounts-design.md:887-936`).
|
||||
|
||||
## 5. Permission calculation and proof
|
||||
|
||||
For every service V, compute:
|
||||
|
||||
`effective(V) = role-declared(V) intersect org-approved-binding(V)`.
|
||||
|
||||
The binding is the org's per-copy narrowing. Omitted services are unbound,
|
||||
not permission to use another source. Unknown services/scopes refuse; they
|
||||
are never ignored. The requested scopes must already be a subset of the
|
||||
role. Do not silently trim a broader request and report that it succeeded.
|
||||
Resources also intersect the org's current admitted project set and the
|
||||
copy's union. A project copy may access its project only. A root copy may
|
||||
access only the approved projects that still admit it. An empty intersection
|
||||
allows no service operations and leaves a required service unbound.
|
||||
|
||||
Scope comparison is semantic: Gitea read is narrower than write in the same
|
||||
category, categories are independent, and neither admin nor all is allowed.
|
||||
Vikunja compares each route-group/verb pair. Missing categories/groups mean
|
||||
no permission; there is no wildcard. All adapters must apply their service's
|
||||
registered semantics, never compare scope strings lexicographically.
|
||||
|
||||
### At proposal, approval and bind
|
||||
|
||||
1. Resolve the concrete copy from the target org's boot snapshot and current
|
||||
project union. Refuse missing, shadowed, colliding, excluded or fired
|
||||
copies. Never create `roles[copyName]` to make the operation succeed.
|
||||
2. Validate the role through the core composition validator and current
|
||||
enabled registry. Read T1's definition revision/hash, or the frozen root
|
||||
role identity. Verify org active state and incarnation. Compute all
|
||||
credential lines from these inputs, not from chief-supplied summaries.
|
||||
3. Check every requested service/scope/resource against that definition and
|
||||
org. Verify the resolver is the fresh org owner. The owner cannot override
|
||||
a system rail or widen a binding past the approved role; changing the role
|
||||
requires its own approved change first.
|
||||
4. Obtain a dedicated service credential for this exact org/copy/service.
|
||||
Trusted service adapters must establish both token scopes and remote
|
||||
identity resource grants. Prove the allowed projects/repos at the service,
|
||||
not just a local `resources` list. A root-wide or cross-org bot account is
|
||||
unacceptable even if its token is stored in a per-org directory.
|
||||
5. Accept a token only after trusted live introspection verifies its scopes,
|
||||
service account, permitted resources and expiry against this operation,
|
||||
or after a reviewed managed mint path produces and verifies that exact
|
||||
bounded grant. An owner-entered assertion, copied grant id, token format,
|
||||
or a few successful/negative probes are not proof of complete permissions.
|
||||
If the service/version cannot prove the bound, refuse
|
||||
`credential-scope-unverified`. Ship that refusal rather than an unsafe
|
||||
compatibility mode. This is a required service-adapter build item.
|
||||
6. Stage the secret privately in the existing store with the binding inactive.
|
||||
Recheck decision digest, definition hash, org incarnation, document
|
||||
revision and slot generation after remote work. Only then commit the
|
||||
metadata/receipt. A grant record binds the opaque grant id, copyId,
|
||||
incarnation and generation to the issued slot. It stores no secret or
|
||||
token-derived digest. Ambiguous crash recovery keeps use closed and
|
||||
requires a new verified issue if exact provenance cannot be established.
|
||||
|
||||
Gitea's existing fixed `SEAT_SCOPES` must not be reused for a read-only role.
|
||||
Vikunja needs a reviewed verifier for the complete route-group scope set
|
||||
and bot/project access. Until each adapter exists, that service's copy
|
||||
binding refuses. Do not claim the existing named-agent path provides this
|
||||
proof. Extending remote minting beyond its current approved envelope is a
|
||||
separate protected change, identified in slice CB2 below.
|
||||
|
||||
### At launch, restart and every use
|
||||
|
||||
The first build uses the existing restart model for **new references**.
|
||||
Approval may record a binding, but it is not usable until the org bus
|
||||
restarts and verifies it. Surface `restart-required`, not a successful
|
||||
launch. Today reference/bot changes already require restart
|
||||
(`packages/bus/src/runtime.mjs:547-583`). Do not add live reference reload
|
||||
in the first slice. Revocations take effect immediately and never wait
|
||||
for this restart.
|
||||
|
||||
On boot and every launch, re-resolve the effective role and binding,
|
||||
validate source/copyId/incarnation/generation, and obtain fresh trusted
|
||||
service scope/resource proof for the actual slot contents. If verification
|
||||
is unavailable, launch refuses. All required services must be bound and
|
||||
available. Bindings may be approved incrementally, but partial success
|
||||
cannot launch. A stale file alone never counts as a binding.
|
||||
|
||||
Preserve A3's organization revision fence. Add a separate launch authority
|
||||
digest over frozen definition identity, copyId, org incarnation, binding
|
||||
generation and verified grant generation. Do not redefine the writer's
|
||||
document revision. Carry both into final admission and `bindLaunch`, and
|
||||
bind the admitted generations to the session. A pending revocation blocks
|
||||
admission, including the interval between final admission and spawn/bind.
|
||||
If a child was spawned before revocation won, deny its capability and stop
|
||||
it; it must perform no credential call. Do not release a launch fence on an
|
||||
uncertain outcome until that child is confirmed stopped.
|
||||
|
||||
At every credential-bearing operation, check session org/instance, active
|
||||
incarnation, effective scopes/resources, binding/grant generation and slot
|
||||
liveness before any external request. Derive the slot from the session,
|
||||
never from request arguments or thread text. Raw tokens and credential file
|
||||
paths do not enter sandboxes. The trusted service adapter checks each
|
||||
method/resource as well as token permissions. Constrain outbound destinations
|
||||
to the approved service origin and reject redirects carrying authorization
|
||||
to another origin. Operation completion rechecks the generation before
|
||||
publishing its result. Unknown or stale state refuses closed.
|
||||
|
||||
## 6. Lifecycle and running copies
|
||||
|
||||
All transitions below close credential admission first, invalidate affected
|
||||
session generations, then stop **all** affected ordinary and thread runs.
|
||||
Serialize close versus new admission so a concurrent launch cannot escape
|
||||
the stop inventory. Abort cancellable service calls and wait for all held
|
||||
uses to settle before slot deletion or replacement. A completed external
|
||||
write cannot be undone; report that limit, never call local revoke a remote
|
||||
rollback. Existing non-host `Credentials.use` does not provide the host-key
|
||||
abort behavior, so generalizing the use fence is required, not assumed
|
||||
(`packages/bus/src/credentials.mjs:556-569,618-653`).
|
||||
|
||||
| Event | Durable outcome and effect on a running copy | Next launch/use |
|
||||
| --- | --- | --- |
|
||||
| Unbind service | Owner-approved change bumps generation, records unbound tombstone, closes use, stops every run of that copy, revokes/removes its slot. Never retain a reusable token behind an inactive binding. | `credential-unbound`: `credentials.gitea needs an owner-approved binding.` |
|
||||
| Emergency revoke | Owner verb immediately closes use, bumps generation, stops all copy runs and performs local revoke. A managed remote grant is also revoked through its approved adapter. Keep tombstone; reissue needs a fresh decision and verification. | `credential-revoked`: `credentials.gitea was revoked; the owner must approve a new binding.` |
|
||||
| Slot removed or altered outside the writer | A slot liveness/identity check at use and launch refuses even if memory still holds the token. Mark binding unavailable, invalidate session use and stop its runs. Never fall back to cached memory, environment or another slot. Replacement is a newly verified issue. | `credential-unavailable`: `credentials.gitea is unavailable; the owner must verify the slot.` |
|
||||
| Expiry or upstream revocation | Local expiry checks and service authentication failure close the binding, invalidate use and stop runs. Rotation is a new verified slot generation and owner-approved change; no in-place replacement behind active uses. | `credential-expired`: `credentials.vikunja has expired; the owner must approve a replacement.` Upstream revoke uses `credential-revoked`. |
|
||||
| Role/definition changed | Any role identity/hash/revision mismatch closes use and stops affected copies. Do not inherit old credentials even for equal service names. A narrower role requires new bounded service proof; a wider role requires approval first and fresh binding. T1 blocks edits of an in-use definition until the change-decision path exists. | `credential-role-changed`: `credentials.gitea no longer matches the approved definition; the owner must review the binding.` |
|
||||
| Copy fired, source removed, excluded or shadowed | Close use, retire copyId, stop every run, revoke slots and delist the copy. Preserve run/trail evidence. A newly shadowing named instance must not load the old copy's file at the identical slot name. Require cleanup before it can use that name. | `copy-unavailable`: `copy is no longer active in this organization.` |
|
||||
| Copy-name collision | Refuse attaching either binding. If introduced while running, close affected use and stop both claimants. No first-wins fallback. | `copy-refused`: `copy identity is ambiguous; the owner must resolve the definition collision.` |
|
||||
| Organization archived | Close the org's admission, invalidate its running sessions and stop every org run before archive completes. Revoke all copy service slots and mark bindings unbound. Restore preserves definitions but requires fresh binding decisions. Never affect another org's runs or slots. | `org-archived`: `organization is archived.` After restore, `credential-unbound`. |
|
||||
| Organization deleted | Close all admission/use and stop every org run, revoke managed remote grants and local slots before the existing quarantine flow. Preserve evidence/tombstones in deletion evidence. A new org with the same id has a new incarnation and no inherited bindings. | `org-unavailable`: `organization is not active.` Recreated org starts unbound. |
|
||||
| Revoke/stop/cleanup uncertain | Persist a non-usable pending-revocation state and keep the affected copy or org fenced. Do not publish success or restore memory on write failure. Retry with the same operation id. Remote revocation uncertainty remains visible to the owner. | `credential-revoke-failed`: `credentials.gitea revocation is incomplete; the owner must retry cleanup.` |
|
||||
|
||||
These archive/delete effects are proposed extensions. Current archive stops
|
||||
the bus and marks the registry archived; deletion quarantines org runtime,
|
||||
document/history and org secret trees, while its receipt says remote-side
|
||||
revocation is out of scope (`packages/organization/src/org-service.mjs:239-267,332,343-347`).
|
||||
The new build must explicitly test and add copy-run and copy-grant cleanup;
|
||||
it cannot infer it from a stopped bus.
|
||||
|
||||
Refusal codes in this section are proposed public contracts where new.
|
||||
Use `human-required` with `Only the organization owner can approve credential
|
||||
binding.` for an agent/member mutation, `credential-not-yours` with
|
||||
`credentials.gitea must use this copy's own organization slot.` for a wrong
|
||||
slot, `credential-scope-exceeded` with `credentials.gitea.scopes exceeds the
|
||||
approved role.` for excess scopes, and `credential-scope-unverified` with
|
||||
`credentials.gitea permissions could not be verified.` for missing proof.
|
||||
`credential-binding-changed` says `credentials.gitea changed; read and approve
|
||||
again.` for stale generation; `organization-changed` retains the existing
|
||||
document-race code. Validation errors name field positions such as
|
||||
`copyBindings entry 1 services field`, not untrusted submitted keys.
|
||||
|
||||
All new boundaries allow only registered service/field labels and fixed
|
||||
remediation text. Never interpolate a submitted value, resolved path, raw
|
||||
service error, URL, bot name, secret, token hash or token fragment into an
|
||||
error, receipt or trail. New metadata serializers use an allowlist and
|
||||
paths-only receipts. Scan human text with the existing secret scanner and
|
||||
fixed field-size limits; arbitrary unknown keys refuse with a fixed field
|
||||
label. Metadata holds only validated ids, known scope names and bounded
|
||||
dates/integers. The scope is every new copy-binding surface, consistent
|
||||
with the brief's #1916/#1920 requirement, without changing unrelated legacy
|
||||
diagnostics. Neither reviewer nor implementer needs live secrets to test it.
|
||||
|
||||
## 7. Two organizations and identity reuse
|
||||
|
||||
Example acceptance fixture: org A and org B both materialize root `writer`
|
||||
from the same creative template. A's binding selects `(A, writer, gitea)`;
|
||||
B's selects `(B, writer, gitea)`. Each uses a dedicated remote bot/token whose
|
||||
resources exclude the other org. B cannot select A's slot or reuse A's grant
|
||||
record. A path restriction alone would not prevent a token with both orgs'
|
||||
remote grants, so service proof must check those grants too.
|
||||
|
||||
Enforce isolation at all of these independent boundaries:
|
||||
|
||||
1. Console resolves the target org from the authorized route and fresh
|
||||
ownership, not a body-supplied org or an instance-wide owner flag.
|
||||
2. The writer derives its org and slot from that context and binds the
|
||||
metadata to the registry incarnation. A copied record from A has the
|
||||
wrong incarnation in B and refuses before opening any slot.
|
||||
3. Copy source tuple, copyId and definition digest must match B's materialized
|
||||
copy. A root definition carries no binding to propagate.
|
||||
4. Credential loader derives the slot, enforces private regular-file rules,
|
||||
rejects alias/symlink/hardlink substitution for copy slots and verifies
|
||||
remote grant ownership. Reject nlink other than one for these slots.
|
||||
5. Every use derives org/instance from the authenticated session and checks
|
||||
the active binding generation. A thread, room or caller-supplied instance
|
||||
cannot choose the key. Session and grant records cannot cross buses.
|
||||
6. Archive/delete touches only that incarnation's resources. A failed cleanup
|
||||
never makes credentials valid for a later org or copy with the same name.
|
||||
|
||||
Registry incarnation is a dependency of accounts design
|
||||
(`docs/plans/2026-11-10_accounts-design.md:501-514`). The binding build must
|
||||
use that stable value; if the target baseline lacks it, land an explicitly
|
||||
approved incarnation slice first. Do not substitute timestamps, a pathname
|
||||
or an org id alone. The dedicated store layout remains unchanged.
|
||||
|
||||
## 8. T1 dependency and implementation slices
|
||||
|
||||
T1 is in flight. Its brief is
|
||||
`/mnt/storage/scratch/mosaic-lead/uidev/org-release/T1-BRIEF.md`. This design
|
||||
depends on its org-local definitions, `org:` resolver, revision/hash read,
|
||||
composition validation and in-use definition guard, as specified in
|
||||
roles-and-rails sections 4 and 5
|
||||
(`docs/plans/2026-11-10_roles-and-rails-design.md:157-170,186-228`). Do not
|
||||
patch T1's files in this lane. Project copies use those org definitions;
|
||||
root copies keep a frozen root identity. Supporting root-local customized
|
||||
definitions waits for that resolver to exist, rather than inventing another
|
||||
definition format here. A4 supplies the authority-change decision engine;
|
||||
this build extends it, rather than shipping a temporary direct bind verb.
|
||||
|
||||
Every slice below is a later proposal requiring Jason's approval before
|
||||
work. Each ends with focused tests plus affected organization, business,
|
||||
bus, CLI and Console suites. No slice edits `roles/` or `contracts/`.
|
||||
|
||||
| Slice and dependencies | Build and acceptance evidence | Protected changes needing approval |
|
||||
| --- | --- | --- |
|
||||
| CB0: identity foundation, after T1/accounts | Reuse incarnation or add its approved missing support. Define copyId, source/hash pin and binding generation semantics. Pure resolver tests cover identical root copies across two orgs, project dotted-name collisions, org shadowing, removed/recreated org and source-name reuse. No secret read and no launch enabled. | Org schema; registry schema only if incarnation is absent. No new rights or role files. |
|
||||
| CB1: metadata and decisions, after CB0/A4 | Add `copyBindings`, guarded organization writer, owner-only credential subtype under `org.hire`, digest/expiry/single-consumption and paths-only receipts. Tests: owner binds/unbinds; member with org.hire/decide, chief, arbiter, subject agent, tester and reader all fail to resolve/write; agent may not call raw writer; only chief agent can propose; stale revision/digest/generation, expired approval, replay, wrong incarnation and generic write injection have no activation. Pending/partial binding never launches. | Organization and decision payload schema; `VERB_RIGHTS`/owner dispatch changes even though no new grantable right. Approval explicitly covers the reuse of org.hire with the owner restriction. |
|
||||
| CB2: verified service slots, after CB1 | Reuse self-only store and dedicated bot identity; add complete Gitea/Vikunja verification or exact bounded mint adapters. Tests use fake services for narrower/equal/broader scopes, read/write hierarchy, extra route verbs, unrelated repos/projects, reused foreign bot/grant, opaque/unverifiable token, wrong date, symlinks/hardlinks, stale file and token replacement. No request, path or token enters metadata/evidence; lost replies and crash points remain inactive. A live scratch-service acceptance must prove each service/version's permission verifier before that service enables copy launch. | Credential handling/security boundary; grant metadata schema and any remote mint/revoke envelope changes. No automatic reuse of existing fixed Gitea seat scopes. |
|
||||
| CB3: admission and use, after CB2 | Materialize only verified bound copies; new references visibly need restart. Check document revision plus separate authority digest at prepare, final admission and bindLaunch. Session-bound adapter checks every operation and slot liveness. Race tests mutate role hash, org bindings, token contents or admitted projects during async prepare and between admission/spawn/bind/use. Assert no admitted credential call, including an empty project manifest. Concurrent revoke denies new work; no cached-token fallback after unlink. | Launcher/broker credential enforcement boundary; session/launch authority fields and any store schema needed to persist fences. No live reference-reload policy. |
|
||||
| CB4: lifecycle and recovery, after CB3 | Test unbind, emergency revoke, slot removal, expiry, role change, fire, collision, shadowing, archive/restore, delete/recreate. Each closes admissions, invalidates ordinary and every thread run, aborts or drains uses, and denies later calls. Inject failures before/after metadata publication, secret staging, remote revoke, stop acknowledgement and receipt write; restart never resurrects authority. Include named-instance shadowing over a former copy's slot. Other org continues. | Credential/revocation and org lifecycle boundary; durable pending-operation schema. Existing deletion quarantine must not silently claim remote revoke. |
|
||||
| CB5: two-org thread acceptance and operator flow, after CB4 | Finish A3's deferred test with a shipped Gitea-requiring creative thread role in both orgs. Approve independent verified bindings; boot real buses and use fake harness/service, create one thread/session per org, launch both, write/read records, reopen each. Assert distinct session/run/trail/workspace and slot identities, no A record visible in B, no B request uses A's token, and B survives A revoke/archive/delete. Reject raw A binding/grant replay in B. Operator flow shows computed scope lines, confirmation, missing service, restart and remediation states, with sentinel redaction tests on every response/event/log. No screenshot gate for this doc lane. | Console mutation/decision flow; no further right or role changes. Any discovered schema delta returns to approval before build. |
|
||||
|
||||
Existing regressions to retain include organization `credentials.test.mjs`,
|
||||
`role.test.mjs`, `agent-copies.test.mjs`, `instance-credential-transitions.test.mjs`,
|
||||
bus `credential-store.test.mjs`, `credential-verbs.test.mjs`,
|
||||
`agent-copies.test.mjs`, and CLI `thread-launch.test.mjs` and
|
||||
`launcher-final-race-probe.test.mjs`. Add meaningful new focused tests for
|
||||
the guarantees above; merely checking the proposed JSON shape is inadequate.
|
||||
|
||||
Acceptance samples for this document are the seven brief requirements:
|
||||
storage/writer/locks in section 3; right/rail/decision in section 4;
|
||||
role ceiling and verification in section 5; lifecycle/code/text in section 6;
|
||||
two-org enforcement in section 7; tests/slices/protected changes in section 8;
|
||||
and at most three owner questions below. No visual samples apply.
|
||||
|
||||
## 9. Questions for Jason, with recommendations
|
||||
|
||||
1. Approve `org.hire` as the entry right while keeping every credential
|
||||
binding/unbinding approval owner-only? Recommend yes: it matches the
|
||||
existing hire decision model and does not grant members token authority.
|
||||
2. Approve the overlay schema and CB0-CB5 security changes, including complete
|
||||
service scope/resource proof as a prerequisite and restart for new
|
||||
references? Recommend yes. A service without a verifier stays refused;
|
||||
implement Gitea first if its verifier is ready before Vikunja's.
|
||||
3. On archive/restore, discard copy credentials and require fresh bindings?
|
||||
Recommend yes. It adds setup on restore, but avoids keeping dormant
|
||||
usable grants and gives archive one clear revocation meaning.
|
||||
|
||||
These are recommendations, not approvals. The Lead takes this design to
|
||||
Jason. No implementation or rollout is authorized by this document.
|
||||
@@ -9,7 +9,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
|
||||
import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -533,7 +533,7 @@ function checkBusiness(name, b) {
|
||||
if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole))
|
||||
fail('invalid-authority');
|
||||
const all = [...a.withinRole, ...a.crossRole];
|
||||
if (new Set(all).size !== all.length || all.some((v) => !ACTIONS.includes(v) || GATED.has(v)))
|
||||
if (new Set(all).size !== all.length || all.some((v) => (!ACTIONS.includes(v) && !pluginName(v)) || GATED.has(v)))
|
||||
fail('invalid-authority');
|
||||
}
|
||||
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
|
||||
@@ -593,6 +593,7 @@ export class Broker {
|
||||
#builtin;
|
||||
#builtinBinding;
|
||||
#organization;
|
||||
#pluginService;
|
||||
#credentialVerbs;
|
||||
#proxyInit;
|
||||
#proxyPage;
|
||||
@@ -655,7 +656,7 @@ export class Broker {
|
||||
#processing;
|
||||
#taskTracker;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
|
||||
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
|
||||
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
||||
@@ -678,6 +679,7 @@ export class Broker {
|
||||
this.#secretCheck = secretCheck;
|
||||
this.#credentialStatus = credentialStatus;
|
||||
this.#organization = organization;
|
||||
this.#pluginService = pluginService;
|
||||
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
|
||||
this.#credentialVerbs = credentialVerbs;
|
||||
this.#proxyInit = proxyInit;
|
||||
@@ -2151,6 +2153,13 @@ export class Broker {
|
||||
}
|
||||
bindLaunch(record) {
|
||||
const { key, session, old, body } = this.#launchProof(record);
|
||||
const launchRole=this.#business(record.business).roles[record.role];
|
||||
const pluginCapability=[...(launchRole.tools ?? []),...(launchRole.authority?.withinRole ?? []),...(launchRole.authority?.crossRole ?? []),...Object.keys(launchRole.credentials ?? {})].find(pluginName);
|
||||
if(pluginCapability) {
|
||||
if(!this.#pluginService)throw new BusError('plugin-disabled',pluginName(pluginCapability));
|
||||
try { this.#pluginService.check(record.business,record.role); }
|
||||
catch(e) { throw new BusError(e.code === 'plugin-disabled' ? e.code : 'invalid-request', e.code === 'plugin-disabled' ? e.plugin : undefined); }
|
||||
}
|
||||
if (!old) this.#processingWork();
|
||||
if (!old && !this.#harnessOn(record.business, record.harness)) fail('invalid-harness');
|
||||
if (!old && record.thread !== undefined && this.#archived(record.business, this.#threadRow(session, record.thread).project)) fail('project-archived');
|
||||
@@ -4668,7 +4677,14 @@ export class Broker {
|
||||
fail('role-end-refused');
|
||||
}
|
||||
#classification(s, action, target = null) {
|
||||
if (!ACTIONS.includes(action)) fail('unknown-action');
|
||||
if (!ACTIONS.includes(action)) {
|
||||
const name=pluginName(action);
|
||||
const held=this.#business(s.business).roles[s.role]?.authority;
|
||||
if(!name || ![...(held?.withinRole ?? []),...(held?.crossRole ?? [])].includes(action))fail('unknown-action');
|
||||
if(!this.#pluginService)throw new BusError('plugin-disabled',name);
|
||||
let checked;try {checked=this.#pluginService.check(s.business,s.role);}catch{throw new BusError('plugin-disabled',name);}
|
||||
if(!checked.view.registry.actions.includes(action))throw new BusError('plugin-disabled',name);
|
||||
}
|
||||
if (
|
||||
this.#hardWait(s, action, target) ||
|
||||
(action === 'role.launch' && this.#business(s.business).launch?.by !== s.role)
|
||||
@@ -5915,6 +5931,15 @@ export class Broker {
|
||||
}).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-init-failed'), request); });
|
||||
}
|
||||
this.#secretCheck(args);
|
||||
if (request.verb === 'plugin.tool') {
|
||||
if(s.human || s.reader || s.tester || s.user || s.console)fail('agent-required');
|
||||
keys(args,['tool','arguments'],['tool','arguments']);
|
||||
this.#store.transaction(()=>this.#agent(s));
|
||||
if(!this.#pluginService)throw new BusError('plugin-disabled',pluginName(args.tool) ?? undefined);
|
||||
return this.#pluginService.call(s.business,s.role,args.tool,args.arguments).then(result=>{
|
||||
this.#secretCheck(result);return result;
|
||||
}).catch(e=>{throw this.#refused(s,new BusError(['plugin-disabled','invalid-request','not-authorized'].includes(e.code)?e.code:'adapter-failed',e.code==='plugin-disabled'?e.plugin:undefined),{verb:'plugin.tool'});});
|
||||
}
|
||||
if (request.verb === 'processing.halt' || request.verb === 'processing.resume')
|
||||
return this.#requestProcessing(s, request.verb, structuredClone(args));
|
||||
if (request.verb === 'processing.status') {
|
||||
|
||||
@@ -67,7 +67,7 @@ class Failure extends Error {
|
||||
// lineages, each the current, retiring and earlier ids of one agent. A daily key quota follows
|
||||
// the lineage that holds its id, so it outlives rotation and re-issue. Both run only while a
|
||||
// daily key quota is set and a check is switched on. Without keyLineages they refuse.
|
||||
export function createLimits({ store, read, log, makeLoader = createLoader, keyForSubject = () => null,
|
||||
export function createLimits({ store, read, pluginDataRoot = null, log, makeLoader = createLoader, keyForSubject = () => null,
|
||||
keyLineages = () => { throw new Failure('proxy-key-refused'); } }) {
|
||||
if (typeof read !== 'function' || typeof log !== 'function') throw new TypeError('createLimits needs read and log');
|
||||
const loaders = new Map();
|
||||
@@ -103,7 +103,7 @@ export function createLimits({ store, read, log, makeLoader = createLoader, keyF
|
||||
return validLineages(value) ? value : null;
|
||||
}
|
||||
const loaderFor = business => {
|
||||
if (!loaders.has(business)) loaders.set(business, makeLoader({ log }));
|
||||
if (!loaders.has(business)) loaders.set(business, makeLoader({ log, dataRoot:pluginDataRoot }));
|
||||
return loaders.get(business);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createLoader } from '../../plugins/src/loader.mjs';
|
||||
const validId = s => typeof s === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(s);
|
||||
const validModel = s => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(s);
|
||||
const disabled = () => ({ status: 'disabled', observedAt: null, models: [], notices: [] });
|
||||
export function createModelCatalogue({ client, settings, now = Date.now, makeLoader = createLoader, proxyLock = null }) {
|
||||
export function createModelCatalogue({ client, settings, pluginDataRoot = null, now = Date.now, makeLoader = createLoader, proxyLock = null }) {
|
||||
const cache = new Map(), flights = new Map(); let generation = 0, writing = 0;
|
||||
function invalidate() { generation++; cache.clear(); }
|
||||
function begin() { writing++; invalidate(); }
|
||||
@@ -19,7 +19,7 @@ export function createModelCatalogue({ client, settings, now = Date.now, makeLoa
|
||||
try {
|
||||
if (off(id)) return disabled();
|
||||
const names = [...settings(id)['model-provider']];
|
||||
const loader = makeLoader({ log: () => {} });
|
||||
const loader = makeLoader({ log: () => {}, dataRoot:pluginDataRoot });
|
||||
const result = await loader.load({ 'model-provider': names });
|
||||
if (result.refused.length || loader.snapshot('model-provider').length !== names.length) throw Error();
|
||||
const c = client(); if (!c) throw Error();
|
||||
|
||||
@@ -19,6 +19,7 @@ export function busOrganization(business, resolved) {
|
||||
out.roles[role].network = r.limits.network;
|
||||
out.roles[role].authority = structuredClone(r.limits.authority);
|
||||
// REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs.
|
||||
out.roles[role].tools = [...(r.limits.tools ?? [])];
|
||||
out.roles[role].web = (r.limits.tools ?? []).includes('web');
|
||||
// Row 210 (#1707, design D5): resolveInstance leaves host.<id> keys out of the session's
|
||||
// credentials. The bus child uses them, so they come back here from the business entry.
|
||||
|
||||
@@ -8,8 +8,8 @@ const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
|
||||
const ID = /^[a-f0-9]{12}$/;
|
||||
const plain = n => n <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(n) : String(n);
|
||||
// makeLoader is the plug-in loader's factory; a test wraps it to see what a module is handed.
|
||||
export function createProxyUsage({ store, read, client, records, log, now = Date.now, makeLoader = createLoader, enableDrain = false }) {
|
||||
const loader = makeLoader({ log });
|
||||
export function createProxyUsage({ store, read, pluginDataRoot = null, client, records, log, now = Date.now, makeLoader = createLoader, enableDrain = false }) {
|
||||
const loader = makeLoader({ log, dataRoot:pluginDataRoot });
|
||||
let due = 0, previous = '', pending = null, closed = false;
|
||||
const states = new Map();
|
||||
// #778: per enabled business, the batches taken off the proxy queue and then not written, since
|
||||
|
||||
@@ -8,6 +8,8 @@ const group = (codes, rail, nextStep) => {
|
||||
entries.push([code, guidance]);
|
||||
}
|
||||
};
|
||||
group("plugin-disabled", "R3/R6/R10: installed, pinned and enabled plugin capabilities", "Ask the instance owner to verify the installed plugin and the organization owner to enable it. A replacement of an already loaded package needs a host restart; then relaunch the agent.");
|
||||
group("plugin-restart-required", "R3/R10: one imported package pin per host process", "Ask the instance owner to restart the host after replacing an already loaded package, then retry through the supported path.");
|
||||
group(
|
||||
"access-refused access-revoked share-exists share-not-covered share-not-found tester-dev-only tester-off tester-refused tester-required tester-target-not-running thread-not-allowed thread-not-owned",
|
||||
"R7: caller access and scoped session boundary",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { pluginService } from '../../plugins/src/tools.mjs';
|
||||
import { readPluginLock } from '../../plugins/src/pinned.mjs';
|
||||
import { createRequirementsHost } from '../../tasks/src/builtin.mjs';
|
||||
import { createRequirementCatalog } from './requirement-catalog.mjs';
|
||||
import { readPrdMaterial } from '../../project/src/prd.mjs';
|
||||
@@ -71,10 +73,10 @@ export const ACCESS_SWEEP_MS = 30_000;
|
||||
// web.fetch still works. A plug-in that refuses to load is logged and leaves search off; it never
|
||||
// stops the broker. Under harness-builtin the harness serves web itself, so the broker gets no
|
||||
// service and refuses the web verbs.
|
||||
export async function webServices(businesses, { log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), deps } = {}) {
|
||||
export async function webServices(businesses, { log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), deps, dataRoot = null } = {}) {
|
||||
const services = new Map();
|
||||
for (const [name, b] of Object.entries(businesses)) {
|
||||
const loader = createLoader({ log });
|
||||
const loader = createLoader({ log, dataRoot });
|
||||
await loader.load(b.plugins ?? {});
|
||||
const [source] = loader.modules('search-source');
|
||||
// "harness-builtin" is served by the harness itself, not the broker: no broker web verbs.
|
||||
@@ -90,7 +92,7 @@ export async function webServices(businesses, { log = (e) => process.stderr.writ
|
||||
// checks a write runs, which are synchronous. A module that doesn't load, or answers anything but a
|
||||
// list of names, answers null, which refuses the write. The tree is pinned, so a module changes only
|
||||
// with a re-pin, and a re-pin restarts the bus.
|
||||
export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`) } = {}) {
|
||||
export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), dataRoot = null } = {}) {
|
||||
const levels = new Map();
|
||||
let names = [];
|
||||
try {
|
||||
@@ -98,8 +100,12 @@ export async function harnessLevels({ log = (e) => process.stderr.write(`plugins
|
||||
} catch (e) {
|
||||
log({ event: 'harness-levels-unread', message: e.message });
|
||||
}
|
||||
if (dataRoot !== null) {
|
||||
try { names = [...new Set([...names,...Object.keys(readPluginLock(dataRoot).plugins).filter(name=>hasModule('harness',name,dataRoot))])]; }
|
||||
catch { log({event:'harness-levels-unread',message:'installed harness discovery refused'}); }
|
||||
}
|
||||
for (const name of names) {
|
||||
const loader = createLoader({ log });
|
||||
const loader = createLoader({ log, dataRoot });
|
||||
if ((await loader.load({ harness: [name] })).refused.length) continue;
|
||||
try {
|
||||
const list = loader.modules('harness')[0].levels();
|
||||
@@ -291,7 +297,7 @@ export async function startBroker({
|
||||
if (copies !== null && (typeof copies !== 'object' || Array.isArray(copies) || typeof copies.org !== 'string'
|
||||
|| !Object.hasOwn(businesses ?? {}, copies.org) || copies.copies === null || typeof copies.copies !== 'object' || Array.isArray(copies.copies)))
|
||||
throw new BusError('invalid-host-request');
|
||||
const loadOrg = id => loadWithCopies(id, { dir: organization.dir, rolesDir: organization.rolesDir, snapshot: copies?.org === id ? copies : null });
|
||||
const loadOrg = id => loadWithCopies(id, { dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot, snapshot: copies?.org === id ? copies : null });
|
||||
if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) ||
|
||||
Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512)))
|
||||
throw new BusError('invalid-host-request');
|
||||
@@ -406,20 +412,20 @@ export async function startBroker({
|
||||
|
||||
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
|
||||
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
|
||||
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
|
||||
const levels=organization?await harnessLevels():()=>null;
|
||||
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot ?? dataRoot,exists:(kind,name)=>hasModule(kind,name,instanceRoot ?? dataRoot)}):null;
|
||||
const levels=organization?await harnessLevels({dataRoot:instanceRoot ?? dataRoot}):()=>null;
|
||||
// Row 333 R4 (#1831): the writer's key lineage lookup, ids only, from this bus's own credential
|
||||
// records. A daily key quota moved within one agent's lineage keeps its since and its count.
|
||||
const keyLineage=(business,keyId)=>modelKeyLineage(credentials.status(),business,keyId);
|
||||
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot),levels,keyLineage}):null;
|
||||
modelCatalogues = modelCatalogue ?? (settings ? createModelCatalogue({ client: () => modelProxyClient, settings: id => settings.current(id).plugins ?? {}, proxyLock }) : null);
|
||||
modelCatalogues = modelCatalogue ?? (settings ? createModelCatalogue({ pluginDataRoot:instanceRoot ?? dataRoot, client: () => modelProxyClient, settings: id => settings.current(id).plugins ?? {}, proxyLock }) : null);
|
||||
// Resolve legacy table entries with the enabled modules' declarations. A
|
||||
// missing or ambiguous mapping never becomes an available route.
|
||||
async function catalogueFor(id, options = {}) {
|
||||
const snapshot = settings.writer.read(id);
|
||||
const current = settings.current(id);
|
||||
const names = current.plugins?.['model-provider'] ?? [];
|
||||
const loader = makeProviderLoader({log:()=>{}});
|
||||
const loader = makeProviderLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}});
|
||||
const loaded = await loader.load({'model-provider': names});
|
||||
const catalogue = await modelCatalogues.read(id, options);
|
||||
if (loaded.refused.length || settings.writer.read(id).revision !== snapshot.revision) return {...catalogue, status:'unavailable', legacy:{}};
|
||||
@@ -479,16 +485,16 @@ export async function startBroker({
|
||||
return { revoked: services, outsideStore: how === 'fire' ? outside : [] };
|
||||
}
|
||||
// Row 165 (#1662): the human adds and changes role instances through instances and instances.set.
|
||||
const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) }) : null;
|
||||
const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) }) : null;
|
||||
const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system,levels,revoke,
|
||||
...(proxyOrganization ? { writer: proxyOrganization.writer, revoke: proxyOrganization.revoke(revoke) } : {})}):null;
|
||||
const usageLog = entry => process.stderr.write(`usage: ${JSON.stringify(entry)}\n`);
|
||||
const keyForRun = (business, run) => modelRuns.get(business + ':' + run)?.keyId ?? null;
|
||||
// Row 333 R3 (#1831): the usage view marks key quotas that match no hired agent; limits answers.
|
||||
let orphanedKeys = () => null;
|
||||
const usage = await createUsage({ store, dataRoot, businesses, log: usageLog, keyForRun,
|
||||
const usage = await createUsage({ store, dataRoot, pluginDataRoot:instanceRoot ?? dataRoot, businesses, log: usageLog, keyForRun,
|
||||
proxyView: business => proxyUsage?.view(business) ?? null, keyOrphans: business => orphanedKeys(business) });
|
||||
proxyUsage = createProxyUsage({ store, log: usageLog, client: async () => {
|
||||
proxyUsage = createProxyUsage({ store, pluginDataRoot:instanceRoot ?? dataRoot, log: usageLog, client: async () => {
|
||||
if (!modelKeys) return null;
|
||||
await modelKeys.verifyIdentity();
|
||||
return modelProxyClient;
|
||||
@@ -498,7 +504,7 @@ export async function startBroker({
|
||||
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the limit checks read the organization file at
|
||||
// each ask, so a limit, a reset or a switched-on check takes effect without a restart.
|
||||
const limitLog = entry => process.stderr.write(`limits: ${JSON.stringify(entry)}\n`);
|
||||
const limits = createLimits({ store, read: id => settings ? settings.current(id) : businesses[id], log: limitLog,
|
||||
const limits = createLimits({ store, pluginDataRoot:instanceRoot ?? dataRoot, read: id => settings ? settings.current(id) : businesses[id], log: limitLog,
|
||||
keyForSubject: (business, subject) => modelUsageKey(credentials.status(), modelRuns, business, subject),
|
||||
keyLineages: business => modelKeyLineages(credentials.status(), business) });
|
||||
orphanedKeys = business => limits.orphanedKeys(business);
|
||||
@@ -591,7 +597,7 @@ export async function startBroker({
|
||||
// receipt-backed addition under the organization lock: the file must still be this business plus that entry,
|
||||
// with the credentials, bots, libraries, roles and prior mappings the bus booted with. Anything else leaves
|
||||
// the product pending. Both readiness records must exist before even the broker's reload begins.
|
||||
const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) : null;
|
||||
const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) : null;
|
||||
const known = new Map(Object.entries(businesses).map(([id, b]) => [id, canonicalJson(b.projects ?? {})]));
|
||||
function activateManagedProject({ business: id, project, receipt, organization_revision }) {
|
||||
if (!writer || !booted.has(id) || !Object.hasOwn(businesses, id)) throw new BusError('unknown-business');
|
||||
@@ -692,6 +698,7 @@ export async function startBroker({
|
||||
trackerOf.set(id, named ?? ((trackers ? tracked : tasks !== null) ? 'vikunja' : 'store'));
|
||||
}
|
||||
broker = new Broker({
|
||||
pluginService: pluginService({dataRoot:instanceRoot ?? dataRoot,read:id=>organization ? loadOrg(id) : businesses[id]}),
|
||||
taskTracker: (id) => trackerOf.get(id),
|
||||
workspaceRead: state => requestWorkspace({ dataRoot, state, events: store.all("SELECT kind,actor_role,subject,body FROM events WHERE business=? AND kind IN ('task.created','task.assigned','session.launched') ORDER BY seq LIMIT 10001", state.business) }),
|
||||
requirementsHost,
|
||||
@@ -760,7 +767,7 @@ export async function startBroker({
|
||||
// configured reads as unreadable, so requireCitation is true), and thread.messages' `last`.
|
||||
guard: (context, body, options) => libraryModule.guardReply({ ...context, dataRoot }, body, options),
|
||||
replyLimits: id => libraryModule.resolveReplyLimits(organization
|
||||
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }).readRaw(id) : { readable: false }),
|
||||
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }).readRaw(id) : { readable: false }),
|
||||
last: (context, paths) => libraryModule.lastCommits({ ...context, dataRoot }, paths),
|
||||
GuardError: libraryModule.GuardError,
|
||||
},
|
||||
@@ -791,7 +798,7 @@ export async function startBroker({
|
||||
known.set(id, canonicalJson(next.projects));
|
||||
return getProjectRemote(writer, id, args.project);
|
||||
}
|
||||
if (verb === 'system-types.installed') return installedSystemTypes();
|
||||
if (verb === 'system-types.installed') return installedSystemTypes({dataRoot:instanceRoot ?? dataRoot});
|
||||
if (verb === 'system-types.current') return settings.current(id).plugins?.['system-type'] ?? [];
|
||||
if(verb==='limits')return withTokens(id,settings.read(id));
|
||||
if(verb==='plugins')return {...plugins.read(id), available:{harness:levels.names ?? []}};
|
||||
@@ -832,7 +839,10 @@ export async function startBroker({
|
||||
});
|
||||
result=settings.setRoutes(id,args,actor);
|
||||
}
|
||||
else if(verb==='plugins.set'){result=plugins.set(id,args,actor);modelCatalogues?.invalidate();}
|
||||
else if(verb==='plugins.set') {
|
||||
if(actor==='tester' && Object.entries(args.plugins ?? {}).some(([kind,value])=>(Array.isArray(value)?value:[value]).some(name=>name!==null && !hasModule(kind,name)))) throw new BusError('human-required');
|
||||
result=plugins.set(id,args,actor);modelCatalogues?.invalidate();
|
||||
}
|
||||
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
|
||||
else if(verb==='veto.set')result=instances.veto(id,args,actor);
|
||||
// Row 298: the human's grant scope, through the instance writer.
|
||||
@@ -868,7 +878,7 @@ export async function startBroker({
|
||||
return settings;
|
||||
} : null,
|
||||
credentialVerbs: organization
|
||||
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) })
|
||||
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) })
|
||||
: null,
|
||||
// Row 65: a new launch binds only on a harness its business switches on, read from the file
|
||||
// as it stands now; an unreadable file switches none on.
|
||||
@@ -941,7 +951,7 @@ export async function startBroker({
|
||||
let factory = null;
|
||||
if (name === 'vikunja' && tasks) factory = tasks;
|
||||
else if (name !== 'vikunja' || [...Object.values(businesses)].some((b) => b?.plugins?.['task-tracker'] === 'vikunja')) {
|
||||
const loader = createLoader({ log: pluginLog });
|
||||
const loader = createLoader({ dataRoot:instanceRoot ?? dataRoot, log: pluginLog });
|
||||
const { refused } = await loader.load({ 'task-tracker': name });
|
||||
const [module] = loader.modules('task-tracker');
|
||||
if (!refused.length && module) factory = module.adapter({ trackers: trackers ?? {} });
|
||||
@@ -963,7 +973,7 @@ export async function startBroker({
|
||||
return one.handle(cap, verb, args);
|
||||
};
|
||||
}
|
||||
const web = await webServices(businesses);
|
||||
const web = await webServices(businesses,{dataRoot:instanceRoot ?? dataRoot});
|
||||
// Row 210 (#1707, design D7 step 6): clear what a killed bus child left, before anyone can call.
|
||||
if (runRoot) sweepRunDir(runRoot);
|
||||
// Row 234 (r6 X3): end the enrollments a killed bus child left, before anyone can call.
|
||||
@@ -1216,13 +1226,13 @@ export async function startBroker({
|
||||
if (vars[prefix + 'model'] !== modelKey || vars[prefix + 'harness'] !== harness) return refused('model-provider-unavailable');
|
||||
const entry = current.launch?.models?.[modelKey];
|
||||
if (!entry || canonicalJson(entry) !== canonicalJson(expected ?? null) || entry.provider !== provider || entry.model !== model) return refused('model-provider-unavailable');
|
||||
const harnessLoader = createLoader({log:()=>{}});
|
||||
const harnessLoader = createLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}});
|
||||
if ((await harnessLoader.load({harness:[harness]})).refused.length) return refused('model-provider-unavailable');
|
||||
const countsAccount = harnessLoader.modules('harness')[0].countsAccount();
|
||||
if (typeof countsAccount !== 'boolean' || account !== (countsAccount ? current.launch?.account?.name ?? null : null)) return refused('model-provider-unavailable');
|
||||
const names = current.plugins?.['model-provider'];
|
||||
if (!Array.isArray(names) || !names.length || (entry.module && !names.includes(entry.module))) return refused('model-provider-unavailable');
|
||||
const loader = makeProviderLoader({log:()=>{}}), loaded = await loader.load({'model-provider':entry.module ? [entry.module] : names});
|
||||
const loader = makeProviderLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}}), loaded = await loader.load({'model-provider':entry.module ? [entry.module] : names});
|
||||
if (loaded.refused.length) return refused('model-provider-unavailable');
|
||||
const selected = selectModelProvider(loader.snapshot('model-provider'), entry);
|
||||
if (!selected) return refused('model-provider-unavailable');
|
||||
|
||||
@@ -53,10 +53,10 @@ export function turnRecords(store, business) {
|
||||
};
|
||||
});
|
||||
}
|
||||
export async function createUsage({ store, businesses, log, keyForRun = () => null, proxyView = () => null, keyOrphans = () => null }) {
|
||||
export async function createUsage({ store, businesses, pluginDataRoot = null, log, keyForRun = () => null, proxyView = () => null, keyOrphans = () => null }) {
|
||||
const loaders = new Map();
|
||||
for (const [id, b] of Object.entries(businesses)) {
|
||||
const loader = createLoader({ log });
|
||||
const loader = createLoader({ log, dataRoot:pluginDataRoot });
|
||||
await loader.load(b.plugins ?? {});
|
||||
loaders.set(id, loader);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
|
||||
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
|
||||
// Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
|
||||
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
|
||||
"import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/proxy-page-vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -8,6 +8,7 @@
|
||||
// host (src/host.mjs) the systemd unit mosaic-bus@<business> runs; with
|
||||
// --pm it also launches the business's PM (src/launcher.mjs).
|
||||
|
||||
import { assertBuildStart, assertDevInstance } from './deployment.mjs';
|
||||
import { readFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
@@ -17,6 +18,7 @@ import { interviewCommand, INTERVIEW_USAGE, northStarCommand, NORTH_STAR_USAGE }
|
||||
import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TALK_USAGE, THREAD_USAGE, THREADS_USAGE } from "./thread.mjs";
|
||||
import { sessionCommand, SESSION_USAGE } from "./session.mjs";
|
||||
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
|
||||
import { pluginInstallCommand } from "./plugin-install.mjs";
|
||||
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
|
||||
import { proxyCommand } from "./model-proxy.mjs";
|
||||
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
|
||||
@@ -219,7 +221,7 @@ function callFor(parsed, io, deps) {
|
||||
// Only the converted project/thread surface uses a channel. Management stays on callFor.
|
||||
function channelCallFor(parsed, io, deps) {
|
||||
const call = callFor(parsed, io, deps);
|
||||
const channel = createChannelCaller({ name: "cli", call,
|
||||
const channel = createChannelCaller({ name: "cli", call, dataRoot:(deps.system ?? loadSystem({env:io.env})).dataRoot,
|
||||
log: entry => io.stderr.write(`mosaic: channel module refused (${entry.code})\n`),
|
||||
});
|
||||
return Object.assign(async (verb, args = {}) => {
|
||||
@@ -614,8 +616,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") });
|
||||
@@ -756,6 +760,10 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === 'accounts') return accountsCommand(rest, io, { system: deps.system ?? loadSystem({ env: io.env }), configDir: deps.accountsConfigDir });
|
||||
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
|
||||
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
|
||||
if (verb === "plugins" && ["install","list","remove"].includes(rest[0])) {
|
||||
refuseInsideAgent(io.env);
|
||||
return pluginInstallCommand(rest,io,{system:deps.system ?? loadSystem({env:io.env}),dir:deps.accountsConfigDir});
|
||||
}
|
||||
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 199 (#1697): issue and rotate read the token on stdin.
|
||||
if (verb === "proxy") return proxyCommand(rest, io, { system: () => deps.system ?? loadSystem({ env: io.env }), callFor: p => callFor(p, io, deps), prompt: deps.promptProxyKey });
|
||||
|
||||
@@ -153,10 +153,10 @@ export const consoleOwners = (sso) =>
|
||||
// snapshot is plain data that the broker and the launcher re-apply to every later read of the
|
||||
// organization file, so a root file change takes effect at the next boot only. A root file that
|
||||
// refuses hides the root level (design section 6): no root copy, and the organization still boots.
|
||||
export function copiesAtBoot({ businessId, env = process.env, warn = () => {} }) {
|
||||
export function copiesAtBoot({ businessId, env = process.env, warn = () => {}, dataRoot = null }) {
|
||||
const dir = configDir(env), roles = rolesDir(env);
|
||||
loadBusiness(businessId, { dir, rolesDir: roles });
|
||||
const { document } = organizationWriter({ dir, rolesDir: roles }).read(businessId);
|
||||
loadBusiness(businessId, { dir, rolesDir: roles, dataRoot });
|
||||
const { document } = organizationWriter({ dir, rolesDir: roles, dataRoot }).read(businessId);
|
||||
let root = null;
|
||||
try { root = loadRootAgents(dir, { rolesDir: roles }); }
|
||||
catch (error) {
|
||||
@@ -164,13 +164,13 @@ export function copiesAtBoot({ businessId, env = process.env, warn = () => {} })
|
||||
warn(`root agent file refused, so ${businessId} runs no root copies until it is fixed and the bus restarts: ${error.message}`);
|
||||
}
|
||||
const snapshot = materializeCopies({ orgId: businessId, doc: document, root, rolesDir: roles });
|
||||
return { snapshot, business: withCopies(document, businessFilePath(businessId, dir), { rolesDir: roles, snapshot }) };
|
||||
return { snapshot, business: withCopies(document, businessFilePath(businessId, dir), { rolesDir: roles, snapshot, dataRoot }) };
|
||||
}
|
||||
|
||||
export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env = process.env, warn = () => {} }) {
|
||||
return business(() => {
|
||||
const vars = systemVars(system);
|
||||
const { snapshot, business: b } = copiesAtBoot({ businessId, env, warn });
|
||||
const { snapshot, business: b } = copiesAtBoot({ businessId, env, warn, dataRoot:system.dataRoot });
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: vars, business: b, instance });
|
||||
const config = {
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -261,7 +261,7 @@ export function createLauncher({
|
||||
}
|
||||
// Row 64 (#1557): where a role's skills are read from; scripts/skill.sh activate fills it.
|
||||
const skillsDir = join(system.instanceRoot ?? dataRoot, "skills-enabled");
|
||||
const loadOrg = () => loadWithCopies(host.business, { dir: configDir(env), rolesDir: rolesDir(env), snapshot: copies?.org === host.business ? copies : null });
|
||||
const loadOrg = () => loadWithCopies(host.business, { dir: configDir(env), rolesDir: rolesDir(env), dataRoot:system.instanceRoot ?? dataRoot, snapshot: copies?.org === host.business ? copies : null });
|
||||
let b = loadOrg();
|
||||
const vars = systemVars(system);
|
||||
|
||||
@@ -358,9 +358,9 @@ export function createLauncher({
|
||||
const admissionGeneration = admission().generation;
|
||||
let businessRevision;
|
||||
try {
|
||||
const snapshot = organizationWriter({dir:configDir(env),rolesDir:rolesDir(env)}).read(host.business);
|
||||
const snapshot = organizationWriter({dir:configDir(env),rolesDir:rolesDir(env),dataRoot:system.instanceRoot ?? dataRoot}).read(host.business);
|
||||
businessRevision = snapshot.revision;
|
||||
b = withCopies(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env), snapshot: copies?.org === host.business ? copies : null});
|
||||
b = withCopies(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env), dataRoot:system.instanceRoot ?? dataRoot, snapshot: copies?.org === host.business ? copies : null});
|
||||
} catch(e){throw new Refusal("organization-refused",e.message);}
|
||||
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
|
||||
// #1873 A3: an unbound or refused copy never launches. The message names the copy, its project
|
||||
@@ -382,16 +382,16 @@ export function createLauncher({
|
||||
throw new Refusal("legacy-run-live", `a run of ${instance} with no session is still live`);
|
||||
let resolved;
|
||||
try {
|
||||
resolved = resolveInstance({ system: vars, business: b, instance });
|
||||
resolved = resolveInstance({ system: vars, business: b, instance, enforcePlugins:true, pluginRoot:system.instanceRoot ?? dataRoot });
|
||||
} catch (e) {
|
||||
throw new Refusal("unsupported-harness", e.message);
|
||||
throw new Refusal(e.code === "plugin-disabled" ? "plugin-disabled" : "unsupported-harness", e.code === "plugin-disabled" ? e.plugin : e.message);
|
||||
}
|
||||
// Resolve the business's current provider modules with a fresh loader; a refused
|
||||
// load never inherits a previous business's authority.
|
||||
const providerNames = b.plugins?.['model-provider'] ?? [];
|
||||
const providerModules = new Map();
|
||||
if (providerNames.length) {
|
||||
const loader = makeLoader({ log: entry => record({ ...entry, instance }) });
|
||||
const loader = makeLoader({ dataRoot:system.instanceRoot ?? dataRoot, log: entry => record({ ...entry, instance }) });
|
||||
const loaded = await loader.load({ 'model-provider': providerNames });
|
||||
if (loaded.refused.length) throw new Refusal('model-provider-unavailable', 'model provider module refused');
|
||||
for (const { name, module } of loader.snapshot('model-provider')) providerModules.set(name, module);
|
||||
@@ -427,7 +427,7 @@ export function createLauncher({
|
||||
}
|
||||
// One harness through a fresh loader, so a refusal never leaves an earlier load's module in use.
|
||||
const harnessOf = async (m) => {
|
||||
const loader = makeLoader({ log: (entry) => record({ ...entry, instance }) });
|
||||
const loader = makeLoader({ dataRoot:system.instanceRoot ?? dataRoot, log: (entry) => record({ ...entry, instance }) });
|
||||
const { refused } = await loader.load({ harness: [m.harness] });
|
||||
if (refused.length) throw new Refusal("harness-refused", `harness ${m.harness}: ${refused.map((r) => `${r.code}: ${r.message}`).join("; ")}`);
|
||||
const module = loader.modules("harness")[0];
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Instance-level shell verbs. Intentionally separate from pluginsCommand's bus transport.
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { assertAccountsOwner } from '../../organization/src/accounts.mjs';
|
||||
import { withRegistry } from '../../organization/src/org-registry.mjs';
|
||||
import { readJsonFile } from '../../organization/src/util.mjs';
|
||||
import { installPlugin, removePlugin } from '../../plugins/src/install.mjs';
|
||||
import { readPluginLock } from '../../plugins/src/pinned.mjs';
|
||||
|
||||
export async function pluginInstallCommand(args,io,{system,dir=configDir(io.env)}) {
|
||||
refuseInsideAgent(io.env);
|
||||
try {
|
||||
assertAccountsOwner(dir);
|
||||
const [verb,value,...rest]=args;
|
||||
if(!['install','list','remove'].includes(verb))throw new CliError('mosaic plugins install SOURCE --sha256 HASH [--commit FULL_SHA] | list | remove NAME',4);
|
||||
const opts={};
|
||||
if(verb==='install') {
|
||||
for(let i=0;i<rest.length;i+=2){if(!['--sha256','--commit'].includes(rest[i]) || !rest[i+1] || Object.hasOwn(opts,rest[i]))throw new CliError('invalid plugin install options',4);opts[rest[i]]=rest[i+1];}
|
||||
if(!value || !opts['--sha256'])throw new CliError('install requires SOURCE and --sha256 HASH',4);
|
||||
}else if(rest.length || (verb==='list'?value!==undefined:!value))throw new CliError('invalid plugin management arguments',4);
|
||||
let result;
|
||||
if(verb==='list') result=readPluginLock(system.dataRoot);
|
||||
else if(verb==='install')result=installPlugin({dataRoot:system.dataRoot,source:value,commit:opts['--commit'],expectedHash:opts['--sha256'],print:line=>io.stdout.write(line+'\n')});
|
||||
else result=await withRegistry({configDir:dir,instance:system.instance ?? dir.split('/').pop().replace(/^mosaic-/, '')},()=>{
|
||||
// All organization files, including archived organizations, must be readable. The registry
|
||||
// lock prevents a simultaneous create from disappearing from this check.
|
||||
let files=[];try{files=readdirSync(join(dir,'businesses'));}catch(e){if(e.code!=='ENOENT')throw e;}
|
||||
return removePlugin({dataRoot:system.dataRoot,name:value,enabled:name=>files.filter(n=>n.endsWith('.json')).some(n=>{
|
||||
const doc=readJsonFile(join(dir,'businesses',n),'organization file');
|
||||
return Object.values(doc.plugins ?? {}).flat().includes(name);
|
||||
})});
|
||||
},{write:false});
|
||||
io.stdout.write(JSON.stringify(result,null,2)+'\n');
|
||||
}catch(e){if(e instanceof CliError)throw e;throw new CliError(`plugin management refused: ${/^[a-z-]+$/.test(e.code ?? '')?e.code:'plugin-refused'}`,3);}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { readFileSync } from "node:fs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
import { orgSelector } from "./org-selector.mjs";
|
||||
|
||||
export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] [--tester] | set --file PATCH [--business ID] [--tester]";
|
||||
export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] [--tester] | set --file PATCH [--business ID] [--tester] | install SOURCE --sha256 HASH [--commit FULL_SHA] | list | remove NAME";
|
||||
|
||||
function parse(args) {
|
||||
const out = { positional: [], flags: new Set(), values: {} };
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -5,6 +5,13 @@ const LEGACY = '# row276-legacy-begin';
|
||||
// Stable even when a mutant removes the outer timeout from this command.
|
||||
const LAUNCH = ' LC_ALL=C ';
|
||||
const WAIT = ' wait "$child" 2>/dev/null || rc=$?\n';
|
||||
// org-base-reds: the root pair above also spans supervisor start-up and clean_session, a scan of
|
||||
// every host /proc entry whose cost grows with the host's process count. The timer pair brackets
|
||||
// only the inner timer: from just before the supervisor starts its sleep to the start of finish().
|
||||
const SUPERVISOR = '# row253-supervisor-begin\n';
|
||||
const SUPERVISOR_END = '# row253-supervisor-end\n';
|
||||
const TIMER = '"$sleep_bin" "$seconds" & timer=$!\n';
|
||||
const FINISH = 'finish() {\n local rc="$?"\n';
|
||||
|
||||
export const P10_OBSERVERS = Object.freeze({
|
||||
begin: ` # row299-observer-begin-start
|
||||
@@ -20,6 +27,26 @@ export const P10_OBSERVERS = Object.freeze({
|
||||
printf 'end %s\\n' "$row299_up" >> "$ROW299_TIMING_FILE"; } 2>/dev/null || :
|
||||
fi
|
||||
# row299-observer-end-end
|
||||
`,
|
||||
// Supervisor-side pair. Only the P10 peer's arguments carry the peer-credential option name.
|
||||
// The pattern is split so this block never adds that literal to the supervisor text: the fake
|
||||
// sudo stalls any command whose arguments contain it, and the supervisor text is an argument of
|
||||
// every capture. finish() has no arguments of its own, so its stamp follows exactly one begin
|
||||
// and is written at most once.
|
||||
timerBegin: `# row299-timer-begin-start
|
||||
if [ -n "\${ROW299_TIMER_FILE-}" ] && [[ "$*" == *SO_PEER*CRED* ]]; then
|
||||
{ read -r row299_up row299_spare < /proc/uptime &&
|
||||
printf 'begin %s\\n' "$row299_up" >> "$ROW299_TIMER_FILE"; } 2>/dev/null || :
|
||||
fi
|
||||
# row299-timer-begin-end
|
||||
`,
|
||||
timerEnd: ` # row299-timer-end-start
|
||||
if [ -n "\${ROW299_TIMER_FILE-}" ]; then
|
||||
{ read -r row299_up row299_spare < /proc/uptime &&
|
||||
[[ "$(< "$ROW299_TIMER_FILE")" == begin* && "$(< "$ROW299_TIMER_FILE")" != *end* ]] &&
|
||||
printf 'end %s\\n' "$row299_up" >> "$ROW299_TIMER_FILE"; } 2>/dev/null || :
|
||||
fi
|
||||
# row299-timer-end-end
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -40,7 +67,15 @@ export function instrumentP10Probe(source) {
|
||||
uniqueIndex(capture, WAIT);
|
||||
capture = capture.replace(LAUNCH, P10_OBSERVERS.begin + LAUNCH);
|
||||
capture = capture.replace(WAIT, WAIT + P10_OBSERVERS.end);
|
||||
return source.slice(0, start) + capture + source.slice(end);
|
||||
source = source.slice(0, start) + capture + source.slice(end);
|
||||
const from = uniqueIndex(source, SUPERVISOR), to = uniqueIndex(source, SUPERVISOR_END);
|
||||
if (to <= from) throw new Error('P10 observer supervisor scope is reversed');
|
||||
let supervisor = source.slice(from, to);
|
||||
uniqueIndex(supervisor, TIMER);
|
||||
uniqueIndex(supervisor, FINISH);
|
||||
supervisor = supervisor.replace(TIMER, P10_OBSERVERS.timerBegin + TIMER);
|
||||
supervisor = supervisor.replace(FINISH, FINISH + P10_OBSERVERS.timerEnd);
|
||||
return source.slice(0, from) + supervisor + source.slice(to);
|
||||
}
|
||||
|
||||
export function removeP10Observers(source) {
|
||||
|
||||
@@ -378,6 +378,9 @@ test('row299 P10 timing copy preserves shipped bytes and isolates observer varia
|
||||
assert.deepEqual([...block.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)=/g)],[],'no other assignments in observer blocks');
|
||||
assert.doesNotMatch(block,/\$ROOT|\$\{ROOT/,'fixture root cannot supply the clock');
|
||||
}
|
||||
// The fake sudo keys its faults on these argument markers; observers must not add or remove any.
|
||||
for(const marker of['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
|
||||
assert.equal(copy.split(marker).length,source.split(marker).length,`observers keep the ${marker} count`);
|
||||
const changedTimer=source.replace('tester_capture 2 4 128','tester_capture 1 4 128');
|
||||
assert.equal(removeP10Observers(instrumentP10Probe(changedTimer)),changedTimer,'copy keeps current timer mutants');
|
||||
});
|
||||
@@ -402,6 +405,34 @@ capture`;
|
||||
assert.match(read(clock),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
|
||||
});
|
||||
|
||||
test('row299 P10 timer observers keep supervisor status and record one P10 pair',t=>{
|
||||
const dir=tmp(t,'p10-timer-'),clock=join(dir,'clock'),trapFile=join(dir,'trap');
|
||||
put(clock,'',0o600);
|
||||
// Same shape as TESTER_SUPERVISOR: positional arguments are the setsid command, finish() has none.
|
||||
const program=`set +m
|
||||
finish() {
|
||||
local rc="$?"
|
||||
${P10_OBSERVERS.timerEnd} printf trap > "$ROW299_TRAP_FILE"
|
||||
exit "$rc"
|
||||
}
|
||||
trap finish EXIT
|
||||
${P10_OBSERVERS.timerBegin}printf stdout-without-newline
|
||||
printf stderr-without-newline >&2
|
||||
exit 81`;
|
||||
const supervise=(...args)=>spawnSync('/bin/bash',['-c',program,'--',...args],{encoding:'utf8',env:{...process.env,ROW299_TIMER_FILE:clock,ROW299_TRAP_FILE:trapFile}});
|
||||
let r=supervise('node','-e','query');
|
||||
assert.equal(r.status,81);assert.equal(read(clock),'','a supervisor without the P10 peer records nothing');
|
||||
r=supervise('python','-c','getsockopt(SO_PEERCRED)');
|
||||
assert.equal(r.status,81);
|
||||
assert.equal(r.stdout,'stdout-without-newline');
|
||||
assert.equal(r.stderr,'stderr-without-newline');
|
||||
assert.equal(read(trapFile),'trap');
|
||||
assert.match(read(clock),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
|
||||
const pair=read(clock);
|
||||
r=supervise('node','-e','later');
|
||||
assert.equal(r.status,81);assert.equal(read(clock),pair,'a later supervisor cannot add a second end');
|
||||
});
|
||||
|
||||
test('row299 P10 observer records are complete and ordered',()=>{
|
||||
assert.equal(readProbeSeconds('begin 100.01\nend 102.03\n'),2.02);
|
||||
const invalid={
|
||||
@@ -433,23 +464,33 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
|
||||
if(fault==='inner')put(join(h.hc,'peer-hang'),'');
|
||||
if(fault==='outer'){put(join(h.hc,'stall-probe'),'');put(join(h.hc,'stall-output'),'200 997 997\n');}
|
||||
if(fault==='overflow'){put(join(h.hc,'peer-output'),'200 997 997\n'+'X'.repeat(256));put(join(h.hc,'peer-status'),'0');}
|
||||
let observedScript=SCRIPT,timingFile;
|
||||
let observedScript=SCRIPT,timingFile,timerFile;
|
||||
if(fault==='inner'||fault==='outer'){
|
||||
observedScript=join(h.dir,'case1-p10-observed.sh');timingFile=join(h.hc,'probe-uptime');
|
||||
put(timingFile,'',0o600);put(observedScript,instrumentP10Probe(read(SCRIPT)),0o700);
|
||||
// The outer fault stalls before the supervisor starts, so only the inner fault has a timer pair.
|
||||
if(fault==='inner'){timerFile=join(h.hc,'timer-uptime');put(timerFile,'',0o600);}
|
||||
put(join(h.dir,'selector.sh'),read(join(REPO,'scripts','selector.sh')),0o644);
|
||||
assert.equal(removeP10Observers(read(observedScript)),read(SCRIPT),'observer reversal equals a fresh shipped script read');
|
||||
}
|
||||
const stop=fixtureGuard(t,files,7000,timingFile);
|
||||
const start=performance.now(),r=run(h,timingFile?{ROW299_TIMING_FILE:timingFile}:{},undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
|
||||
const observe={...(timingFile?{ROW299_TIMING_FILE:timingFile}:{}),...(timerFile?{ROW299_TIMER_FILE:timerFile}:{})};
|
||||
const start=performance.now(),r=run(h,observe,undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
|
||||
assert.equal(r.status,0,r.out+r.err);
|
||||
const reason=fault==='inner'?'inner-timeout':fault==='outer'?'outer-timeout':'output-limit';
|
||||
assert.match(r.out,new RegExp('P10 tester-socket-squat unnamed boundary=failed reason=probe-'+reason+' '));
|
||||
assert.doesNotMatch(r.out,/boundary=observed/);
|
||||
let probeSeconds=seconds;
|
||||
let probeSeconds=seconds,timerSeconds;
|
||||
if(timingFile)assert.doesNotThrow(()=>{probeSeconds=readProbeSeconds(read(timingFile));},'P10 observer pair is required');
|
||||
if(fault==='inner'||fault==='outer')t.diagnostic(JSON.stringify({fault,mode,baselineSeconds,faultSeconds:seconds,probeSeconds}));
|
||||
if(fault==='inner')assert.ok(probeSeconds>=1.25&&probeSeconds<2.75,`${mode}: direct probe ${probeSeconds}s; baseline ${baselineSeconds}s is diagnostic only`);
|
||||
if(timerFile)assert.doesNotThrow(()=>{timerSeconds=readProbeSeconds(read(timerFile));},'P10 timer observer pair is required');
|
||||
if(fault==='inner'||fault==='outer')t.diagnostic(JSON.stringify({fault,mode,baselineSeconds,faultSeconds:seconds,probeSeconds,timerSeconds}));
|
||||
// org-base-reds: the band applies to the inner timer itself. The root pair adds supervisor start-up and
|
||||
// clean_session's whole-host /proc scan, which grows with the host's process count, so it must contain
|
||||
// the timer pair but has no band of its own; probe-inner-timeout above already places it before the outer bound.
|
||||
if(fault==='inner'){
|
||||
assert.ok(timerSeconds>=1.25&&timerSeconds<2.75,`${mode}: inner timer ${timerSeconds}s (root probe ${probeSeconds}s); baseline ${baselineSeconds}s is diagnostic only`);
|
||||
assert.ok(probeSeconds>=timerSeconds,`${mode}: root probe ${probeSeconds}s must contain the inner timer ${timerSeconds}s`);
|
||||
}
|
||||
if(fault==='outer'){
|
||||
assert.ok(probeSeconds>=3.25&&probeSeconds<4.75,`${mode}: direct probe ${probeSeconds}s; baseline ${baselineSeconds}s is diagnostic only`);
|
||||
if(mode==='hop')assert.ok(recordedLivePids(files[0]).length>0,'stalled supervisor is R3 residual');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -38,10 +38,12 @@ const IMPORT=new RegExp([
|
||||
`${DYNAMIC.source}\\s*['"](\\.{1,2}\\/[^'"]+)['"]\\s*\\)`,
|
||||
].join('|'),'g');
|
||||
const DYNAMIC_LITERAL=/^import\s*\(\s*(['"])[^'"\n]*\1\s*\)/;
|
||||
// The plug-in loader's one computed import, allowed only in that file and only in this exact form. It loads
|
||||
// The plug-in loader's computed imports, allowed only in that file and these exact forms. Core loads
|
||||
// packages/plugins/<kind>/<name>/index.mjs after checking that path, and a real copy ships all of
|
||||
// packages/plugins, so a fixture that never loads a plug-in needs nothing more from it.
|
||||
const LOADER_FILE='packages/plugins/src/loader.mjs',LOADER_IMPORT=/^import\(pathToFileURL\(expected\)\.href\)/;
|
||||
// PL1 adds the checked instance package entry with its verified hash as a cache key. Installed
|
||||
// bytes belong to the instance, not to a release fixture's import closure.
|
||||
const LOADER_FILE='packages/plugins/src/loader.mjs',LOADER_IMPORT=/^import\(pathToFileURL\(expected\)\.href\)|^import\(pathToFileURL\(entry\)\.href \+ '\?pin=' \+ checked\.manifest\.sha256\)/;
|
||||
const REGEX_AFTER='(,=:[!&|?{};+-*%<>~^';
|
||||
const inert=(literals,index)=>literals.some(([from,to])=>index>=from&&index<to);
|
||||
const sources=(dir)=>readdirSync(dir,{withFileTypes:true}).flatMap((e)=>e.isDirectory()?sources(join(dir,e.name)):e.name.endsWith('.mjs')?[join(dir,e.name)]:[]);
|
||||
|
||||
@@ -43,6 +43,14 @@ test('the loader exception holds only for the loader file and its exact computed
|
||||
assert.throws(()=>organizationImportedFiles(tree({[`${ORG}a.mjs`]:"import '../../plugins/src/loader.mjs';\n",'repo/packages/plugins/src/loader.mjs':"await import(x);\n"})),/not a plain string literal/);
|
||||
});
|
||||
|
||||
test('PL1 pinned import exception holds only for the checked loader entry and exact hash cache key',()=>{
|
||||
const computed="await import(pathToFileURL(entry).href + '?pin=' + checked.manifest.sha256);\n";
|
||||
const at=(file,text=computed)=>tree({[`${ORG}a.mjs`]:`import '../../plugins/src/${file}';\n`,[`repo/packages/plugins/src/${file}`]:text});
|
||||
assert.deepEqual(organizationImportedFiles(at('loader.mjs')),['packages/plugins/src/loader.mjs']);
|
||||
assert.throws(()=>organizationImportedFiles(at('other.mjs')),/not a plain string literal/);
|
||||
for(const changed of [computed.replace('entry','arbitrary'),computed.replace('?pin=','?unchecked='),computed.replace('checked.manifest.sha256','input')])assert.throws(()=>organizationImportedFiles(at('loader.mjs',changed)),/not a plain string literal/);
|
||||
});
|
||||
|
||||
test('every static form and a literal dynamic import is found, and the imports of what it finds in turn', ()=>{
|
||||
const repo=withOrg([
|
||||
"import x from '../../other/src/from.mjs';",
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;}}
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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),[]);
|
||||
});
|
||||
@@ -41,7 +41,7 @@ export function webPlan(resolved, harness, builtinWeb) {
|
||||
}
|
||||
return { source, builtin: granted && source === "harness-builtin" };
|
||||
}
|
||||
export const builtinTools = (tools) => tools.filter((t) => t !== "web");
|
||||
export const builtinTools = (tools) => tools.filter((t) => t !== "web" && !t.includes("."));
|
||||
export const SOURCES = Object.freeze({
|
||||
piExtension: fileURLToPath(new URL("./pi-extension.mjs", import.meta.url)),
|
||||
claudeGate: fileURLToPath(new URL("./claude-gate.mjs", import.meta.url)),
|
||||
|
||||
@@ -363,11 +363,12 @@ export function toolServer({ path, tools, client, local }) {
|
||||
if (!tool) return answer({ ok: false, error: "unknown-tool" });
|
||||
const args = request.args && typeof request.args === "object" && !Array.isArray(request.args) ? request.args : {};
|
||||
try {
|
||||
const result = Object.hasOwn(local, tool.verb) ? await local[tool.verb](args, { signal: controller.signal }) : await client.call(tool.verb, args);
|
||||
const result = Object.hasOwn(local, tool.verb) ? await local[tool.verb](args, { signal: controller.signal }) : await client.call(tool.verb, tool.pluginTool ? {tool:tool.pluginTool,arguments:args} : args);
|
||||
answer({ ok: true, result });
|
||||
} catch (e) {
|
||||
answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed",
|
||||
...refusalGuidance(e.code),
|
||||
...(e.code === "plugin-disabled" && /^[a-z][a-z0-9-]{0,31}$/.test(e.reason ?? "") ? {plugin:e.reason} : {}),
|
||||
...(typeof e.reason === "string" && request.tool === "advance_request" && tool.verb === "request.advance" && e.code === "request-evidence-required"
|
||||
? { reason: e.reason.slice(0, 300) } : tool.verb === "record.write" && e.code === "project-record-invalid" && typeof e.reason === "string"
|
||||
? { reason: e.reason.slice(0, 300) } : {}) });
|
||||
|
||||
@@ -96,7 +96,9 @@ const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESU
|
||||
// "working.land" go to the host's git socket.
|
||||
export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true, land = false, library = false }) {
|
||||
const held = new Set([...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole]);
|
||||
const tools = [];
|
||||
const tools = (resolved.pluginTools ?? []).filter(t=>resolved.limits.tools.includes(t.name)).map(t=>({
|
||||
name:t.name, verb:"plugin.tool", pluginTool:t.name, action:null, description:t.description, parameters:t.arguments,
|
||||
}));
|
||||
const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters });
|
||||
if (held.has("message.send")) {
|
||||
add("send_message", "message.send", "message.send", "Send a message to another role instance, or to \"human\". Your final answer to a message is sent back to its sender for you; use this for anything else. A reply to an answer gets no automatic answer; use class REQUEST to ask a follow-up.", obj({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -188,11 +188,11 @@ function projectCopyRefusal(copy, document, levels) {
|
||||
// dropped: the file's own instance wins. Returns a frozen business whose `copies` key is the
|
||||
// snapshot's view, with each copy's state as it is in this document; a business with no copies and
|
||||
// no project agents is plain validateBusinessDocument.
|
||||
export function withCopies(document, file, { rolesDir, snapshot }) {
|
||||
export function withCopies(document, file, { rolesDir, snapshot, dataRoot = null }) {
|
||||
const copies = snapshot?.copies ?? {};
|
||||
if (Object.keys(copies).length === 0 && !hasProjectAgents(document)) return validateBusinessDocument(document, file, { rolesDir });
|
||||
if (Object.keys(copies).length === 0 && !hasProjectAgents(document)) return validateBusinessDocument(document, file, { rolesDir, dataRoot });
|
||||
// The document's own refusals come first, whole.
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
validateBusinessDocument(document, file, { rolesDir, dataRoot });
|
||||
const orgId = snapshot?.org ?? document.id;
|
||||
const levels = resolveAgentLevels({ orgId, doc: document, root: { agents: snapshot?.rootAgents ?? {} }, rolesDir });
|
||||
const unions = {};
|
||||
@@ -234,25 +234,25 @@ export function withCopies(document, file, { rolesDir, snapshot }) {
|
||||
if (refused) { view[instance] = { ...view[instance], state: "refused", reason: refused }; continue; }
|
||||
const entry = entryOf(copy, copy.project === null && narrows[copy.name] ? narrowVars("root", copy.vars, narrows[copy.name]) : copy.vars);
|
||||
try {
|
||||
validateBusinessDocument({ ...narrowed, roles: { ...roles, [instance]: entry } }, file, { rolesDir });
|
||||
validateBusinessDocument({ ...narrowed, roles: { ...roles, [instance]: entry } }, file, { rolesDir, dataRoot });
|
||||
join[instance] = entry;
|
||||
} catch (error) {
|
||||
if (error?.name !== "BusinessError") throw error;
|
||||
view[instance] = { ...view[instance], state: "refused", reason: reasonOf(error, file, orgId, document, copy) };
|
||||
}
|
||||
}
|
||||
const business = validateBusinessDocument({ ...narrowed, roles: { ...roles, ...join } }, file, { rolesDir });
|
||||
const business = validateBusinessDocument({ ...narrowed, roles: { ...roles, ...join } }, file, { rolesDir, dataRoot });
|
||||
return deepFreeze({ ...business, ...(Object.keys(copies).length ? { copies: view } : {}), unions });
|
||||
}
|
||||
|
||||
// The organization with its copies, as a bus or launcher loads it. The plain load keeps
|
||||
// loadBusiness's file checks; when there are copies or project agents, the document is read again
|
||||
// through the organization's own writer and validated with them, and only that second read is used.
|
||||
export function loadWithCopies(id, { dir, rolesDir, snapshot }) {
|
||||
const plain = loadBusiness(id, { dir, rolesDir });
|
||||
const { document } = organizationWriter({ dir, rolesDir }).read(id);
|
||||
export function loadWithCopies(id, { dir, rolesDir, snapshot, dataRoot = null }) {
|
||||
const plain = loadBusiness(id, { dir, rolesDir, dataRoot });
|
||||
const { document } = organizationWriter({ dir, rolesDir, dataRoot }).read(id);
|
||||
if ((!snapshot || Object.keys(snapshot.copies ?? {}).length === 0) && !hasProjectAgents(document)) return plain;
|
||||
return withCopies(document, businessFilePath(id, dir), { rolesDir, snapshot });
|
||||
return withCopies(document, businessFilePath(id, dir), { rolesDir, snapshot, dataRoot });
|
||||
}
|
||||
|
||||
// Why `instance` can't work in code project `project` of this business, as { code, message }, or
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -34,7 +34,7 @@ const AUTHORITY_KEYS = Object.freeze(["withinRole", "crossRole"]);
|
||||
const MESSAGING_KEYS = Object.freeze(["sendTo", "receiveFrom"]);
|
||||
const PERSONALITY_KEYS = Object.freeze(["summary", "traits", "voice"]);
|
||||
const CREDENTIAL_KEYS = Object.freeze(["service", "scopes"]);
|
||||
const PLUGIN_KEYS = Object.freeze(["name", "tools", "actions", "services"]);
|
||||
const PLUGIN_KEYS = Object.freeze(["name", "tools", "actions", "services", "roleTemplates"]);
|
||||
const PLUGIN_SERVICE_KEYS = Object.freeze(["name", "scopes"]);
|
||||
|
||||
// Step 6's length bounds, in characters or entries. The design gives none, so P1 sets them. The
|
||||
@@ -137,7 +137,14 @@ function checkPlugin(plugin, i, core) {
|
||||
const names = [...tools, ...actions, ...services.map((s) => s.name.slice(name.length + 1))];
|
||||
const twice = names.find((n, j) => names.indexOf(n) !== j);
|
||||
if (twice !== undefined) refuse(`${where} declares ${JSON.stringify(full(twice))} twice`);
|
||||
return { name, tools: tools.map(full), actions: actions.map(full), services };
|
||||
if (plugin.roleTemplates !== undefined && !Array.isArray(plugin.roleTemplates)) refuse("invalid plugin role templates");
|
||||
const roleTemplates=(plugin.roleTemplates ?? []).map(t=>{
|
||||
if(!isPlain(t) || Object.keys(t).sort().join(",")!=="file,name" || typeof t.name!=="string" || !DECLARED_NAME.test(t.name) || coreName(t.name)
|
||||
|| typeof t.file!=="string" || !/^[a-zA-Z0-9._/-]+$/.test(t.file) || t.file.split("/").some(p=>!p || p==="." || p==="..")) refuse("invalid plugin role template");
|
||||
return {name:full(t.name),file:t.file};
|
||||
});
|
||||
if(new Set(roleTemplates.map(t=>t.name)).size!==roleTemplates.length)refuse("duplicate plugin role template");
|
||||
return { name, tools: tools.map(full), actions: actions.map(full), services, roleTemplates };
|
||||
}
|
||||
|
||||
// The registry checkComposition reads: the core set plus each plugin's declared set. The plugin
|
||||
@@ -162,6 +169,7 @@ export function buildRegistry({ plugins = [] } = {}) {
|
||||
routed: [...ROUTED, ...pluginActions],
|
||||
services,
|
||||
plugins: declared,
|
||||
roleTemplates: declared.flatMap(p=>p.roleTemplates),
|
||||
});
|
||||
BUILT.add(registry);
|
||||
return registry;
|
||||
|
||||
@@ -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,6 +8,8 @@ import { refuse } from "./errors.mjs";
|
||||
import { credentialKind, isHostKind } from "./vocabulary.mjs";
|
||||
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
|
||||
|
||||
import { pluginName } from "../../plugins/src/organization.mjs";
|
||||
|
||||
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
export const DATE_KEY = Object.freeze({ gitea: "rotateBy", vikunja: "expires" });
|
||||
const WARN_DAYS = 7;
|
||||
@@ -21,10 +23,10 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// can't use the form. A `file` reference into the agents' store must be the
|
||||
// agent's own path (`credential-not-yours`).
|
||||
export function parseCredentialRef(ref, service, where, { store = null } = {}) {
|
||||
if (!credentialKind(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
|
||||
if (!credentialKind(service) && !pluginName(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
|
||||
requireObject(ref, where);
|
||||
// Row 210 (#1707, design D1): an ssh key doesn't expire, so host.<id> takes rotateBy as gitea does.
|
||||
const dateKey = isHostKind(service) ? "rotateBy" : DATE_KEY[service];
|
||||
const dateKey = isHostKind(service) ? "rotateBy" : (DATE_KEY[service] ?? "rotateBy");
|
||||
rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where);
|
||||
const hasFile = ref.file !== undefined;
|
||||
const hasEnv = ref.env !== undefined;
|
||||
|
||||
@@ -150,7 +150,7 @@ function createdCredentials(old, instance, definition, args) {
|
||||
// services) revokes the instance's credentials for a fire or a delete and throws
|
||||
// credential-revoke-failed when it can't. Null in a writer with no bus (the CLI's), which revokes
|
||||
// nothing. Row 239 (#1745): writer is the organization writer, injected only by tests.
|
||||
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir }) }) {
|
||||
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir, dataRoot:system?.dataRoot ?? null }) }) {
|
||||
|
||||
// The definitions in rolesDir and whether add and set accept each one.
|
||||
function definitions() {
|
||||
|
||||
@@ -168,18 +168,18 @@ const ROUTE_FIELDS = Object.freeze(["harness", "model", "thinking"]);
|
||||
// Without it, a write that adds a daily key quota refuses proxy-key-refused; no other write asks.
|
||||
export function launchSettings({ dir, rolesDir, system, sessions = () => [], now = () => new Date().toISOString(), levels = () => null,
|
||||
keyLineage = () => { throw new OrganizationError("proxy-key-refused"); } }) {
|
||||
const writer = organizationWriter({ dir, rolesDir });
|
||||
const writer = organizationWriter({ dir, rolesDir, dataRoot:system?.dataRoot ?? null });
|
||||
|
||||
// The validated business as the file stands now (row 125: the limit checks read it at each ask).
|
||||
function current(id) {
|
||||
return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir });
|
||||
return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
|
||||
}
|
||||
|
||||
// The file before a write, validated, for checkRoutes; null when it no longer validates, so every
|
||||
// route is checked.
|
||||
function before(id, document) {
|
||||
try {
|
||||
return validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
|
||||
return validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
|
||||
} catch (e) {
|
||||
if (e.name === "BusinessError") return null;
|
||||
throw e;
|
||||
@@ -188,7 +188,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
|
||||
function view(id) {
|
||||
const { document, revision } = writer.read(id);
|
||||
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
|
||||
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
|
||||
if (!business.launch) throw new OrganizationError("launch-not-configured");
|
||||
const instances = Object.fromEntries(Object.keys(business.roles).map(instance => {
|
||||
const vars = resolveInstance({ business, system, instance }).vars;
|
||||
@@ -305,7 +305,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
if (next.roles[instance]?.definition !== entry.definition) throw new OrganizationError("organization-definition-refused");
|
||||
}
|
||||
// Check every instance's route before publishing the complete document.
|
||||
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
|
||||
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
|
||||
checkRoutes({ proposed, old: before(id, old.document), levels, catalogue });
|
||||
const receipt = writer.write(id, next, { revision: args.revision, actor });
|
||||
return { ...view(id), transaction: receipt.transaction };
|
||||
@@ -317,7 +317,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
// doesn't set reads null; its launch refuses no-route.
|
||||
function routes(id) {
|
||||
const { document, revision } = writer.read(id);
|
||||
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
|
||||
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
|
||||
if (!business.launch) throw new OrganizationError("launch-not-configured");
|
||||
const harnesses = Array.isArray(business.plugins?.harness) ? [...business.plugins.harness] : [];
|
||||
const named = new Set(harnesses);
|
||||
@@ -397,7 +397,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
next.roles[instance].vars = vars;
|
||||
}
|
||||
if (canonicalJson(next) === canonicalJson(old.document)) throw new OrganizationError("organization-no-change");
|
||||
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
|
||||
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
|
||||
checkRoutes({ proposed, old: before(id, old.document), levels, catalogue });
|
||||
const receipt = writer.write(id, next, { revision: args.revision, actor });
|
||||
return { ...routes(id), transaction: receipt.transaction };
|
||||
|
||||
@@ -228,3 +228,6 @@ export async function orgLock(ctx, id, { clear = false } = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PL1: the organization primitive set, derived from enabled, verified manifests only.
|
||||
export { organizationPlugins as buildOrganizationRegistry } from "../../plugins/src/organization.mjs";
|
||||
@@ -13,6 +13,7 @@ import { loadRole } from "./role.mjs";
|
||||
import { END_PROTECTED } from "./vocabulary.mjs";
|
||||
import { checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
|
||||
import { hasModule } from "../../plugins/src/loader.mjs";
|
||||
import { readPluginLock } from "../../plugins/src/pinned.mjs";
|
||||
import { checkMapping } from "../../library/src/mapping.mjs";
|
||||
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
|
||||
import { checkAppearance } from "./appearance.mjs";
|
||||
@@ -238,7 +239,7 @@ function checkRetired(retired, roles, file) {
|
||||
|
||||
// Validate a parsed business document read from `file`. `rolesDir` is where
|
||||
// role definitions live. Returns a frozen business with its definitions.
|
||||
export function validateOrganizationDocument(document, file, { rolesDir }) {
|
||||
export function validateOrganizationDocument(document, file, { rolesDir, dataRoot = null }) {
|
||||
requireObject(document, "business file");
|
||||
rejectUnknownKeys(document, TOP_KEYS, "business file");
|
||||
if (document.businessVersion !== 1) refuse(`business file "businessVersion" must be 1 (${file})`);
|
||||
@@ -333,8 +334,13 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
|
||||
// here; whether each module loads is the loader's check, at load time.
|
||||
const plugins = document.plugins === undefined ? {}
|
||||
: structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) }));
|
||||
let installedNames = new Set();
|
||||
if (dataRoot !== null && plugins["system-type"]?.some(id=>!hasModule("system-type",id))) {
|
||||
try { installedNames = new Set(Object.keys(readPluginLock(dataRoot).plugins)); }
|
||||
catch { installedNames = null; } // the loader reports an unreadable lock; core still loads
|
||||
}
|
||||
for (const id of plugins["system-type"] ?? []) {
|
||||
if (!hasModule("system-type", id)) refuse(`${file} plugins.system-type names unknown module ${id}`);
|
||||
if (!hasModule("system-type", id) && installedNames !== null && !installedNames.has(id)) refuse(`${file} plugins.system-type names unknown module ${id}`);
|
||||
}
|
||||
const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage);
|
||||
const appearance = checkAppearance(document.appearance, `${file} appearance`);
|
||||
@@ -349,14 +355,14 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
|
||||
// Load businesses/<id>.json from the config directory. The file holds
|
||||
// authority, so it must belong to this user and not be writable by group
|
||||
// or other.
|
||||
export function loadOrganization(id, { dir = configDir(), rolesDir }) {
|
||||
export function loadOrganization(id, { dir = configDir(), rolesDir, dataRoot = null }) {
|
||||
if (!rolesDir) throw new Error("loadBusiness needs rolesDir");
|
||||
const file = organizationFilePath(id, dir);
|
||||
const document = readJsonFile(file, "business file", (stat) => {
|
||||
if (stat.uid !== process.getuid()) refuse(`business file must belong to uid ${process.getuid()}: ${file}`);
|
||||
if ((stat.mode & 0o022) !== 0) refuse(`business file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
|
||||
});
|
||||
return validateOrganizationDocument(document, file, { rolesDir });
|
||||
return validateOrganizationDocument(document, file, { rolesDir, dataRoot });
|
||||
}
|
||||
|
||||
// The old names (rename design r2, module map): the same functions, not copies.
|
||||
|
||||
@@ -140,7 +140,7 @@ export function setReplyLimits(writer, id, args, actor) {
|
||||
return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor });
|
||||
}
|
||||
|
||||
export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = () => {} }) {
|
||||
const evidence = join(dir, "businesses", ".history");
|
||||
const logFile = id => join(evidence, id, "changes.jsonl");
|
||||
// Keep the writer's established busy refusal when the shared registry lock
|
||||
@@ -155,7 +155,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
const file = businessFilePath(id, dir);
|
||||
const bytes = regular(file);
|
||||
const document = parseJsonFileText(bytes, file, "organization file");
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
validateBusinessDocument(document, file, { rolesDir, dataRoot });
|
||||
return { document, revision: sha256(bytes) };
|
||||
}
|
||||
|
||||
@@ -297,7 +297,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
modelQuotaFence(old, document, actor);
|
||||
usageFence(old, document, actor);
|
||||
systemTypesFence(old, document, actor);
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
validateBusinessDocument(document, file, { rolesDir, dataRoot });
|
||||
const bytes = JSON.stringify(document, null, 2) + "\n";
|
||||
// Check and log exactly what lands on disk, for every setter: a value JSON
|
||||
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
|
||||
@@ -307,7 +307,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
modelQuotaFence(old, written, actor);
|
||||
usageFence(old, written, actor);
|
||||
systemTypesFence(old, written, actor);
|
||||
validateBusinessDocument(written, file, { rolesDir });
|
||||
validateBusinessDocument(written, file, { rolesDir, dataRoot });
|
||||
if (beforeWrite) {
|
||||
beforeWrite();
|
||||
revoked = true;
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
// Writes go through the organization writer, which logs old and new names
|
||||
// like a limit change. A write is a patch per kind (see set()): kinds it doesn't name are kept. A refused write throws a code and logs nothing.
|
||||
import { organizationWriter, OrganizationError } from "./organization.mjs";
|
||||
import { verifiedPlugin } from "../../plugins/src/pinned.mjs";
|
||||
import { hasModule } from "../../plugins/src/loader.mjs";
|
||||
import { isPlainObject } from "./util.mjs";
|
||||
import { KINDS, PluginError, checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
|
||||
|
||||
// `exists(kind, name)`, when given, refuses a name no module answers to (the host passes the
|
||||
// loader's hasModule). Without it the setter checks only the shape, as before.
|
||||
export function pluginSettings({ dir, rolesDir, exists = null }) {
|
||||
const writer = organizationWriter({ dir, rolesDir });
|
||||
export function pluginSettings({ dir, rolesDir, exists = null, dataRoot = null }) {
|
||||
const writer = organizationWriter({ dir, rolesDir, dataRoot });
|
||||
|
||||
function read(id) {
|
||||
const { document, revision } = writer.read(id);
|
||||
@@ -51,6 +53,10 @@ export function pluginSettings({ dir, rolesDir, exists = null }) {
|
||||
if (exists) {
|
||||
for (const [kind, setting] of Object.entries(sets)) {
|
||||
for (const name of Array.isArray(setting) ? setting : [setting]) {
|
||||
if (dataRoot !== null && !hasModule(kind,name)) {
|
||||
try { verifiedPlugin(dataRoot,name,kind); }
|
||||
catch(e) { throw new OrganizationError(e.code === "missing-lock" ? "unknown-plugin" : "invalid-plugins", "installed plugin verification refused"); }
|
||||
}
|
||||
if (!exists(kind, name)) throw new OrganizationError("unknown-plugin", `no ${kind} module named ${name} in this tree`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// the record a launcher or the broker uses. Plain values: the most specific
|
||||
// layer wins. Limits: every layer narrows, nothing widens.
|
||||
|
||||
import { organizationPlugins, assertPluginCapabilities } from "../../plugins/src/organization.mjs";
|
||||
import { ownRails } from "./rails.mjs";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { ACTIONS, NETWORKS, isHostKind } from "./vocabulary.mjs";
|
||||
@@ -31,11 +32,18 @@ function narrowerNetwork(a, b) {
|
||||
// business loadBusiness(...) output
|
||||
// project loadProject(...) output, or null
|
||||
// instance a role instance the business file declares
|
||||
export function resolveInstance({ system, business, project = null, instance }) {
|
||||
export function resolveInstance({ system, business, project = null, instance, enforcePlugins = false, pluginRoot = system?.dataRoot ?? null }) {
|
||||
const entry = Object.hasOwn(business.roles, instance) ? business.roles[instance] : null;
|
||||
if (!entry) refuse(`business ${business.id} declares no role instance ${JSON.stringify(instance)}`);
|
||||
const definition = business.definitions[entry.definition];
|
||||
|
||||
const pluginView = organizationPlugins(pluginRoot,business.plugins);
|
||||
let disabledPlugin = null;
|
||||
try { assertPluginCapabilities(definition,pluginView); } catch(e) {
|
||||
if(enforcePlugins)throw e;
|
||||
disabledPlugin=e.plugin;
|
||||
}
|
||||
const pluginTools = pluginView.manifests.flatMap(m=>m.tools).filter(t=>definition.tools.includes(t.name));
|
||||
const layers = [
|
||||
{ layer: "system", source: "system", vars: system },
|
||||
{ layer: "business", source: `business:${business.id}`, vars: business.vars },
|
||||
@@ -79,6 +87,9 @@ export function resolveInstance({ system, business, project = null, instance })
|
||||
}
|
||||
|
||||
const resolved = {
|
||||
...(pluginTools.length ? {pluginTools} : {}),
|
||||
...(pluginView.registry.plugins.length ? {pluginActions:pluginView.registry.plugins.flatMap(p=>p.actions)} : {}),
|
||||
...(disabledPlugin ? {disabledPlugin} : {}),
|
||||
business: business.id,
|
||||
project: projectId,
|
||||
instance,
|
||||
@@ -112,7 +123,7 @@ export function resolveInstance({ system, business, project = null, instance })
|
||||
// Classify one action for a resolved instance: "within", "cross" or
|
||||
// "gated". An action outside the vocabulary refuses.
|
||||
export function classify(resolved, action) {
|
||||
if (!ACTIONS.includes(action)) refuse(`unknown action: ${JSON.stringify(action)}`);
|
||||
if (!ACTIONS.includes(action) && !resolved.pluginActions?.includes(action)) refuse(`unknown action: ${JSON.stringify(action)}`);
|
||||
if (resolved.limits.authority.withinRole.includes(action)) return "within";
|
||||
if (resolved.limits.authority.crossRole.includes(action)) return "cross";
|
||||
return "gated";
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
} from "./util.mjs";
|
||||
|
||||
import { pluginName } from "../../plugins/src/organization.mjs";
|
||||
|
||||
const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/;
|
||||
const V1_KEYS = ["roleVersion", "name", "tools", "network"];
|
||||
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
|
||||
@@ -24,7 +26,7 @@ const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
|
||||
function checkTools(tools) {
|
||||
if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names');
|
||||
return requireDistinctList(tools, "role tools", (tool) => {
|
||||
if (!TOOLS.includes(tool)) refuse(`unsupported tool (supported: ${TOOLS.join(", ")})`);
|
||||
if (!TOOLS.includes(tool) && !pluginName(tool)) refuse(`unsupported tool (supported: ${TOOLS.join(", ")})`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,12 +41,12 @@ function checkAuthority(authority) {
|
||||
const lists = {};
|
||||
for (const key of ["withinRole", "crossRole"]) {
|
||||
lists[key] = requireDistinctList(authority[key], `role authority.${key}`, (action) => {
|
||||
if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}; expected an action from the role vocabulary`);
|
||||
if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists an action which is always gated`);
|
||||
if (!ACTIONS.includes(action) && !pluginName(action)) refuse(`unknown action in authority.${key}; expected an action from the role vocabulary`);
|
||||
if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists ${action}, which is always gated`);
|
||||
});
|
||||
}
|
||||
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 +89,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 +108,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;
|
||||
}
|
||||
|
||||
@@ -132,13 +134,15 @@ function checkCredentials(credentials) {
|
||||
// Row 210 (#1707, design D1): "host" takes no scopes; the business file names the hosts.
|
||||
const host = entry.service === HOST_SERVICE;
|
||||
rejectUnknownKeys(entry, host ? ["service"] : ["service", "scopes"], `role credentials[${i}]`);
|
||||
if (!host && !SERVICES.includes(entry.service)) {
|
||||
if (!host && !SERVICES.includes(entry.service) && !pluginName(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);
|
||||
const scopes = pluginName(entry.service) ? requireDistinctList(entry.scopes, "plugin credential scopes", s => {
|
||||
if (typeof s !== "string" || !/^[a-z][a-z0-9_.:-]{0,63}$/.test(s)) refuse("invalid plugin credential scope");
|
||||
}, {nonEmpty:true}) : entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
|
||||
return { service: entry.service, scopes };
|
||||
});
|
||||
}
|
||||
@@ -167,12 +171,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")) {
|
||||
|
||||
@@ -374,3 +374,12 @@ export function grantInScope(scope, body) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// PL1: a capability's plugin namespace, without loading files or code. Kept pure because the
|
||||
// runner's bus client imports the broker error type inside the existing sandbox binds.
|
||||
export function pluginName(capability) {
|
||||
const core = [...ACTIONS,...TOOLS,...SERVICES,HOST_SERVICE];
|
||||
if(typeof capability!=="string" || core.includes(capability) || !/^[a-z][a-z0-9-]*\.[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/.test(capability))return null;
|
||||
const name=capability.split(".")[0];
|
||||
return [...core,...NETWORKS].some(n=>n.split(".")[0]===name) ? null : name;
|
||||
}
|
||||
@@ -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/);
|
||||
|
||||
@@ -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;
|
||||
}; }
|
||||
@@ -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);
|
||||
});
|
||||
@@ -505,3 +505,92 @@ A harness declares `outputPolicy({ proxy })` as `adapter`, `non-newline-stdout`,
|
||||
from module directories, and malformed modules refuse discovery. An organization
|
||||
selects modules with `plugins.system-type`; an omitted list enables none. The
|
||||
initial five modules declare no toolsets yet.
|
||||
|
||||
### Instance-installed packages (PL1, #1873)
|
||||
|
||||
The instance owner may install trusted plugin code from the host shell:
|
||||
|
||||
```
|
||||
mosaic plugins install ./package --sha256 <64 lowercase hex>
|
||||
mosaic plugins install ./package.zip --sha256 <64 lowercase hex>
|
||||
mosaic plugins install https://example.invalid/plugin.git --commit <40 lowercase hex> --sha256 <64 lowercase hex>
|
||||
mosaic plugins list
|
||||
mosaic plugins remove <name>
|
||||
```
|
||||
|
||||
These commands read the selected system config's `dataRoot`, use
|
||||
`<dataRoot>/plugins/<name>`, and publish `<dataRoot>/plugins.lock.json`.
|
||||
They have no bus, Console, organization, tester, or agent installation path.
|
||||
Removal holds the organization registry lock and refuses if any organization
|
||||
file still enables the package. Disable it through the existing owner-only
|
||||
`plugins.set` before removal. The installer prints the names of all host tools
|
||||
before its first instance write. Plugin code runs as the instance uid; installing
|
||||
it is a trust decision, and a hash pin does not make code safe.
|
||||
|
||||
Every package has this strict `plugin.json` shape (all fields required):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "example",
|
||||
"version": "1.0.0",
|
||||
"kinds": ["usage-source"],
|
||||
"interfaceVersions": {"usage-source": 1},
|
||||
"sha256": "<64 lowercase hex>",
|
||||
"source": {"path": "./source", "version": "1.0.0"},
|
||||
"signature": null,
|
||||
"tools": [{
|
||||
"name": "example.lookup",
|
||||
"description": "Look up a local fixture",
|
||||
"arguments": {"type": "object", "properties": {}, "required": [], "additionalProperties": false},
|
||||
"runs": "sandbox",
|
||||
"entry": "tools/lookup.mjs"
|
||||
}],
|
||||
"actions": ["example.publish"],
|
||||
"services": [{"name": "example.account", "scopes": ["read"]}],
|
||||
"roleTemplates": [{"name": "example.writer", "file": "templates/writer.json"}]
|
||||
}
|
||||
```
|
||||
|
||||
`source` takes exactly one of `path`/`url` and one of `version`/`commit`.
|
||||
Git installs record their actual full commit and URL in the lock. `signature`
|
||||
is reserved and must be null. Kinds and interface versions remain core policy;
|
||||
each kind has `<kind>/index.mjs` with the usual exports. Every declaration has
|
||||
the plugin namespace, and no declaration may reuse a core name or hard wait.
|
||||
Templates are recorded as data; installation imports no role and hires nobody.
|
||||
|
||||
`pluginHash(directory)` in `src/pinned.mjs` computes the expected digest. Sort
|
||||
relative regular-file paths lexically and hash the UTF-8 frame
|
||||
`<path byte length>:<path>:<file byte length>:` followed by each file's bytes.
|
||||
For `plugin.json`, replace only the top-level `sha256` value with 64 zeroes.
|
||||
All other bytes, including whitespace and descriptions, are pinned. Symlinks,
|
||||
hard links, special files, unsafe archive paths and duplicate archive entries
|
||||
refuse. Files become 0444, directories 0555. Empty directories carry no code
|
||||
and are omitted from the digest.
|
||||
|
||||
A declared tool exports `execute(arguments)` from its `entry` module. Argument
|
||||
schemas use the bounded, reference-free subset checked in `src/manifest.mjs`;
|
||||
unknown keywords refuse. Tool presence follows the resolved role, and the
|
||||
broker rechecks the current organization setting and hash on every call.
|
||||
Host tools run in the host process. Sandbox tools run in a fresh bubblewrap
|
||||
namespace with the pinned package and Node runtime, no home, config, workspace
|
||||
or network. They receive JSON on stdin and return JSON on stdout.
|
||||
|
||||
The loader checks the complete package against the lock before every installed
|
||||
module import. A refusal logs static codes and the validated package name;
|
||||
core modules continue. A bad pin evicts previously loaded installed modules.
|
||||
Once a package path has been imported, the process latches its verified pin before
|
||||
the first import attempt (including failed attempts). Removing and reinstalling a
|
||||
different pin at that path refuses with `plugin-restart-required` until the host
|
||||
restarts. The latch is shared by loaders and host tools, so cached helper modules
|
||||
cannot run under a replacement pin. A fresh sandbox tool process has no old cache.
|
||||
|
||||
Executable dependencies must be bundled in the package and imported by relative
|
||||
path (or an in-package file URL). A shared resolver guard refuses bare third-party
|
||||
lookups, executable data URLs and paths outside that package; Node built-ins remain
|
||||
available. This applies to static imports, dynamic imports and `createRequire`,
|
||||
including sandbox tools. It constrains module resolution, not R10 host authority:
|
||||
trusted host code can still use filesystem and other host APIs.
|
||||
|
||||
A role referencing a missing or disabled plugin refuses at launch with
|
||||
`plugin-disabled` and the package name. Disabling never fires or rewrites an
|
||||
agent; its next plugin tool call refuses with that same code.
|
||||
@@ -8,10 +8,10 @@ const report = entry => process.stderr.write(`channel: ${JSON.stringify({
|
||||
event: entry.event, kind: entry.kind, name: entry.name, code: entry.code, kept: entry.kept,
|
||||
})}\n`);
|
||||
|
||||
export function createChannelCaller({ name, call, log = report }) {
|
||||
export function createChannelCaller({ name, call, log = report, dataRoot = null }) {
|
||||
if (typeof name !== "string" || !NAME.test(name)) throw new PluginError("bad-name", "invalid channel name");
|
||||
if (typeof call !== "function") throw new TypeError("channel needs a core caller");
|
||||
const loader = createLoader({ log });
|
||||
const loader = createLoader({ log, dataRoot });
|
||||
return async (verb, args = {}) => {
|
||||
const settings = await call("plugins", {});
|
||||
if (!record(settings) || !record(settings.plugins)) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// PL1: executable dependencies must be bundled inside the pinned package.
|
||||
// This constrains module resolution, not the host authority granted by R10.
|
||||
// Kept standalone so a sandbox tool can use this same guard in its fresh process.
|
||||
import { isBuiltin, registerHooks } from 'node:module';
|
||||
import { isAbsolute, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const roots = new Set();
|
||||
let registered = false;
|
||||
const inside = (root, path) => {
|
||||
const rel = relative(root, path);
|
||||
return rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel);
|
||||
};
|
||||
const refused = () => { throw new Error('plugin import refused'); };
|
||||
export function guardPluginImports(directory) {
|
||||
roots.add(resolve(directory));
|
||||
if (registered) return;
|
||||
registerHooks({resolve(specifier, context, nextResolve) {
|
||||
let parent;
|
||||
try { if (context.parentURL?.startsWith('file:')) parent = fileURLToPath(context.parentURL); }
|
||||
catch { return refused(); }
|
||||
const root = parent && [...roots].find(dir => inside(dir, parent));
|
||||
if (!root || isBuiltin(specifier)) return nextResolve(specifier, context);
|
||||
// Refuse bare lookups before Node searches unpinned ancestor node_modules.
|
||||
if (!specifier.startsWith('./') && !specifier.startsWith('../') && !specifier.startsWith('/') && !specifier.startsWith('file:')) return refused();
|
||||
try {
|
||||
const candidate = new URL(specifier, context.parentURL);
|
||||
if (candidate.protocol !== 'file:' || !inside(root, fileURLToPath(candidate))) return refused();
|
||||
const result = nextResolve(specifier, context);
|
||||
if (!result.url.startsWith('file:') || !inside(root, fileURLToPath(result.url))) return refused();
|
||||
return result;
|
||||
} catch { return refused(); }
|
||||
}});
|
||||
registered = true;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Instance-uid, host-only management. No broker verb or organization writer calls this module.
|
||||
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { bad, HASH } from './manifest.mjs';
|
||||
import { KINDS } from './kinds.mjs';
|
||||
import { hashFiles, pluginFiles, pluginPaths, readPlugin, readPluginLock, safeDirectory } from './pinned.mjs';
|
||||
const run = (cmd,args,options={}) => {
|
||||
const r=spawnSync(cmd,args,{encoding:'utf8',maxBuffer:40*1024*1024,timeout:120000,...options});
|
||||
if(r.status!==0 || r.error) bad('source-refused'); return r.stdout;
|
||||
};
|
||||
// Validate the complete archive before creating a file. Never extract links, devices,
|
||||
// duplicate names, absolute paths, dot components, or an archive's permission bits.
|
||||
const EXTRACT = `import sys,zipfile,tarfile,os,stat,io
|
||||
kind,src,dst=sys.argv[1:]
|
||||
archive=zipfile.ZipFile(src) if kind=='zip' else tarfile.open(src)
|
||||
rows=[]; seen=set(); total=0
|
||||
for e in archive.infolist() if kind=='zip' else archive.getmembers():
|
||||
n=e.filename if kind=='zip' else e.name
|
||||
directory=e.is_dir() if kind=='zip' else e.isdir()
|
||||
n=n.rstrip('/') if directory else n
|
||||
if not n or n.startswith('/') or any(p in ('','..','.') for p in n.split('/')) or '\\\\' in n: raise ValueError('path')
|
||||
if n in seen: raise ValueError('duplicate')
|
||||
seen.add(n)
|
||||
mode=e.external_attr>>16 if kind=='zip' else e.mode
|
||||
if kind=='zip':
|
||||
if stat.S_IFMT(mode) not in (0,stat.S_IFREG,stat.S_IFDIR): raise ValueError('type')
|
||||
elif not (e.isfile() or e.isdir()): raise ValueError('type')
|
||||
size=e.file_size if kind=='zip' else e.size
|
||||
total+=size
|
||||
if total>33554432 or len(seen)>2048: raise ValueError('size')
|
||||
rows.append((e,n,directory))
|
||||
for e,n,directory in rows:
|
||||
p=os.path.join(dst,n)
|
||||
if directory: os.makedirs(p,exist_ok=True)
|
||||
else:
|
||||
os.makedirs(os.path.dirname(p),exist_ok=True)
|
||||
with open(p,'xb') as out: out.write(archive.read(e) if kind=='zip' else archive.extractfile(e).read())
|
||||
`;
|
||||
function sourceFiles(source,commit,scratch) {
|
||||
if(commit!==undefined) {
|
||||
if(!/^[a-f0-9]{40}$/.test(commit) || typeof source!=='string' || source.startsWith('-') || /[\x00-\x1f]/.test(source)) bad('commit-required');
|
||||
const repo=join(scratch,'repo'),archive=join(scratch,'source.tar'),out=join(scratch,'out'); mkdirSync(out);
|
||||
run('git',['-c','core.hooksPath=/dev/null','clone','--no-checkout','--no-local','--',source,repo]);
|
||||
const actual=run('git',['-C',repo,'rev-parse','--verify',`${commit}^{commit}`]).trim(); if(actual!==commit) bad('source-refused');
|
||||
run('git',['-C',repo,'archive','--format=tar',`--output=${archive}`,commit]);
|
||||
run('python3',['-c',EXTRACT,'tar',archive,out]); return pluginFiles(out);
|
||||
}
|
||||
if(typeof source!=='string' || /^(https?:|ssh:|git:|git@)/.test(source)) bad('commit-required');
|
||||
const st=lstatSync(source); if(st.isSymbolicLink()) bad('unsafe-path');
|
||||
if(st.isDirectory()) return pluginFiles(source);
|
||||
if(!st.isFile() || !source.endsWith('.zip')) bad('source-refused');
|
||||
const out=join(scratch,'out');mkdirSync(out);run('python3',['-c',EXTRACT,'zip',source,out]); return pluginFiles(out);
|
||||
}
|
||||
function writableRemove(dir) {
|
||||
if(!existsSync(dir))return;
|
||||
for(const [p] of pluginFiles(dir)) chmodSync(join(dir,p),0o600);
|
||||
const dirs=new Set(['']);for(const [p] of pluginFiles(dir)) {let parts=p.split('/');parts.pop();while(parts.length){dirs.add(parts.join('/'));parts.pop();}}
|
||||
for(const p of dirs)chmodSync(join(dir,p),0o700);
|
||||
rmSync(dir,{recursive:true});
|
||||
}
|
||||
function withInstallLock(dataRoot,fn) {
|
||||
safeDirectory(dataRoot);const st=lstatSync(dataRoot);if(st.uid!==process.getuid() || st.mode&0o022)bad("plugin-root");const paths=pluginPaths(dataRoot);let fd;
|
||||
try {fd=openSync(paths.mutex,'wx',0o600);}catch{bad('plugin-locked');}
|
||||
try{return fn(paths);}finally{closeSync(fd);unlinkSync(paths.mutex);}
|
||||
}
|
||||
function writeLock(lock,doc) {
|
||||
const temp=`${lock}.new`; try {writeFileSync(temp,JSON.stringify(doc,null,2)+'\n',{flag:'wx',mode:0o600});renameSync(temp,lock);}finally{if(existsSync(temp))unlinkSync(temp);}
|
||||
}
|
||||
export function installPlugin({dataRoot,source,commit,expectedHash,print=()=>{}}) {
|
||||
if(!HASH.test(expectedHash))bad('expected-hash-required');
|
||||
const scratch=mkdtempSync(join(tmpdir(),'pl1-'));
|
||||
try {
|
||||
const files=sourceFiles(source,commit,scratch),candidate=join(scratch,'candidate');mkdirSync(candidate);
|
||||
for(const [p,bytes]of files){mkdirSync(join(candidate,p,'..'),{recursive:true});writeFileSync(join(candidate,p),bytes,{flag:'wx',mode:0o600});}
|
||||
const {manifest}=readPlugin(candidate);
|
||||
if(hashFiles(files)!==expectedHash || manifest.sha256!==expectedHash)bad('hash-mismatch',manifest.name);
|
||||
for(const kind of Object.keys(KINDS)) if (existsSync(new URL(`../${kind}/${manifest.name}/index.mjs`,import.meta.url))) bad('core-name');
|
||||
// This is before even the instance lock is created. Only validated names are printed.
|
||||
for(const tool of manifest.tools.filter(t=>t.runs==='host'))print(`host tool: ${tool.name}`);
|
||||
return withInstallLock(dataRoot,({root,lock})=>{
|
||||
const doc=readPluginLock(dataRoot);if(Object.hasOwn(doc.plugins,manifest.name))bad('already-installed',manifest.name);
|
||||
if(!existsSync(root))mkdirSync(root,{mode:0o700});safeDirectory(root);
|
||||
const dest=join(root,manifest.name);if(existsSync(dest))bad('already-installed',manifest.name);
|
||||
const stage=mkdtempSync(join(root,'.install-'));
|
||||
try {
|
||||
for(const[p,bytes]of files){mkdirSync(join(stage,p,'..'),{recursive:true,mode:0o755});writeFileSync(join(stage,p),bytes,{flag:'wx',mode:0o444});}
|
||||
const dirs=new Set(['']);for(const[p]of files){let parts=p.split('/');parts.pop();while(parts.length){dirs.add(parts.join('/'));parts.pop();}}
|
||||
for(const p of dirs)chmodSync(join(stage,p),0o555);
|
||||
readPlugin(stage);renameSync(stage,dest);
|
||||
doc.plugins[manifest.name]={version:manifest.version,source:commit === undefined ? manifest.source : {url:source,commit},sha256:expectedHash};
|
||||
try{writeLock(lock,doc);}catch(e){writableRemove(dest);throw e;}
|
||||
return {name:manifest.name,...doc.plugins[manifest.name]};
|
||||
}finally{if(existsSync(stage))writableRemove(stage);}
|
||||
});
|
||||
}catch(e){if(e?.name==='PluginError')throw e;bad('install-refused');}finally{rmSync(scratch,{recursive:true,force:true});}
|
||||
}
|
||||
export function removePlugin({dataRoot,name,enabled}) {
|
||||
if(typeof enabled!=='function')bad('organization-state-required');
|
||||
return withInstallLock(dataRoot,({root,lock})=>{
|
||||
const doc=readPluginLock(dataRoot);if(!Object.hasOwn(doc.plugins,name))bad('missing-lock');
|
||||
if(enabled(name))bad('plugin-enabled',name);
|
||||
// Remove the lock first: an interrupted removal can never leave unpinned code loadable.
|
||||
delete doc.plugins[name];writeLock(lock,doc);writableRemove(join(root,name));return {removed:name};
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
// The plug-in loader (REQ-PLUG-1). It switches on the modules the
|
||||
// organization file's `plugins` object names, one key per kind, and loads
|
||||
// only packages/plugins/<kind>/<name>/index.mjs from the tree this file runs
|
||||
// from. It takes no path from config and loads nothing outside that tree
|
||||
// (lead decision 143). A loaded module runs with the host's authority.
|
||||
// core packages/plugins/<kind>/<name>/index.mjs from its own tree, plus
|
||||
// hash-pinned installed modules from <instance dataRoot>/plugins only. PL1
|
||||
// (#1873), owner-authorized amendment of lead decision 143. No arbitrary
|
||||
// config path is accepted. A loaded module runs with the host's authority.
|
||||
//
|
||||
// A refusal keeps the kind's last good modules and is logged. A kind refused
|
||||
// A core refusal keeps the kind's last good modules and is logged. A refused
|
||||
// installed plugin is omitted; valid core modules in its kind still load. A kind refused
|
||||
// on its first load has nothing switched on. `loaded()` reports what each
|
||||
// kind actually has switched on.
|
||||
import { lstatSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { KINDS, NAME, PluginError, namesFor } from "./kinds.mjs";
|
||||
import { verifiedPlugin, readPluginLock, claimPluginImport } from "./pinned.mjs";
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
@@ -38,7 +41,8 @@ function component(path, directory) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModule(spec, name, barrier) {
|
||||
async function loadModule(spec, name, barrier, dataRoot = null) {
|
||||
if (dataRoot !== null && !hasModule(spec.kind, name)) return loadPinnedModule(spec, name, barrier, dataRoot);
|
||||
const kindDir = join(ROOT, spec.kind);
|
||||
const nameDir = join(kindDir, name);
|
||||
const index = join(nameDir, "index.mjs");
|
||||
@@ -74,7 +78,10 @@ async function loadModule(spec, name, barrier) {
|
||||
// True when packages/plugins/<kind>/<name>/index.mjs exists in this tree as a real file, with no
|
||||
// symlink on the way down. The setters use it to refuse a name no module answers to before it is
|
||||
// written; the loader's own checks still run at load.
|
||||
export function hasModule(kind, name) {
|
||||
export function hasModule(kind, name, dataRoot = null) {
|
||||
if (dataRoot !== null && !hasModule(kind, name)) {
|
||||
try { verifiedPlugin(dataRoot, name, kind); return true; } catch { return false; }
|
||||
}
|
||||
try {
|
||||
if (!Object.hasOwn(KINDS, kind) || !NAME.test(name)) return false;
|
||||
for (const dir of [ROOT, join(ROOT, kind), join(ROOT, kind, name)]) component(dir, true);
|
||||
@@ -113,7 +120,7 @@ export function discoverModules(kind) {
|
||||
// (loadModule, unchanged). One refused module is reported and never hides the others, and nothing
|
||||
// here changes what createLoader has switched on. Node caches an import by path, so a module
|
||||
// whose file changed is read again only after a restart; a removed directory is gone at once.
|
||||
export async function loadInstalled(kind, { barrier = () => {} } = {}) {
|
||||
export async function loadInstalled(kind, { barrier = () => {}, dataRoot = null } = {}) {
|
||||
if (!Object.hasOwn(KINDS, kind)) refuse("unknown-kind", `unknown kind ${JSON.stringify(kind)}`);
|
||||
const spec = KINDS[kind];
|
||||
const modules = [], refused = [];
|
||||
@@ -129,9 +136,14 @@ export async function loadInstalled(kind, { barrier = () => {} } = {}) {
|
||||
return { modules, refused };
|
||||
}
|
||||
for (const name of found.invalid) report(name, new PluginError("bad-name", `${kind}/${name} doesn't match ${NAME}`, name));
|
||||
if (dataRoot !== null) {
|
||||
try { for (const name of Object.keys(readPluginLock(dataRoot).plugins)) {
|
||||
try { if (verifiedPlugin(dataRoot, name).manifest.kinds.includes(kind) && !found.names.includes(name)) found.names.push(name); } catch(e) { report(name,e); }
|
||||
} } catch(e) { report(null,e); }
|
||||
}
|
||||
for (const name of found.names) {
|
||||
try {
|
||||
modules.push(await loadModule(spec, name, barrier));
|
||||
modules.push(await loadModule(spec, name, barrier, dataRoot));
|
||||
} catch (e) {
|
||||
report(name, e);
|
||||
}
|
||||
@@ -139,7 +151,7 @@ export async function loadInstalled(kind, { barrier = () => {} } = {}) {
|
||||
return { modules, refused };
|
||||
}
|
||||
|
||||
export function createLoader({ log, barrier = () => {} }) {
|
||||
export function createLoader({ log, barrier = () => {}, dataRoot = null }) {
|
||||
if (typeof log !== "function") throw new TypeError("createLoader needs a log callback");
|
||||
const current = new Map(Object.keys(KINDS).map(kind => [kind, Object.freeze([])]));
|
||||
let queue = Promise.resolve();
|
||||
@@ -151,6 +163,14 @@ export function createLoader({ log, barrier = () => {} }) {
|
||||
event: "plugin-refused", kind, name: e.plugin ?? name, code: e.code, message: e.message,
|
||||
kept: kind === null ? loaded() : current.has(kind) ? current.get(kind).map(m => m.name) : [],
|
||||
};
|
||||
if (dataRoot !== null) {
|
||||
const affected=kind===null ? [...current.keys()] : current.has(kind) ? [kind] : [];
|
||||
for(const checkedKind of affected)current.set(checkedKind, Object.freeze(current.get(checkedKind).filter(m => {
|
||||
if (!m.installed) return true;
|
||||
try { verifiedPlugin(dataRoot,m.name,checkedKind); return true; } catch { return false; }
|
||||
})));
|
||||
entry.kept = kind===null ? loaded() : current.has(kind) ? current.get(kind).map(m=>m.name) : [];
|
||||
}
|
||||
log(entry);
|
||||
report.push(entry);
|
||||
}
|
||||
@@ -180,9 +200,10 @@ export function createLoader({ log, barrier = () => {} }) {
|
||||
let ok = true;
|
||||
for (const name of names) {
|
||||
try {
|
||||
next.push(await loadModule(spec, name, barrier));
|
||||
next.push(await loadModule(spec, name, barrier, dataRoot));
|
||||
} catch (e) {
|
||||
refused(kind, name, e, report);
|
||||
if (dataRoot !== null && !hasModule(kind,name)) continue;
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
@@ -219,3 +240,19 @@ export function createLoader({ log, barrier = () => {} }) {
|
||||
|
||||
// Shared pure plug-in setting classification; no check implementation is loaded.
|
||||
export { isDailyModelQuota } from "./model-quota.mjs";
|
||||
|
||||
// Verify before every import, including cache hits. Recheck after the test barrier and import.
|
||||
async function loadPinnedModule(spec, name, barrier, dataRoot) {
|
||||
let checked = verifiedPlugin(dataRoot,name,spec.kind);
|
||||
barrier("checked", {kind:spec.kind,name});
|
||||
checked = verifiedPlugin(dataRoot,name,spec.kind);
|
||||
const entry = join(checked.dir,spec.kind,"index.mjs");
|
||||
claimPluginImport(checked);
|
||||
let module;
|
||||
try { module=await import(pathToFileURL(entry).href + '?pin=' + checked.manifest.sha256); }
|
||||
catch { refuse("load-failed","installed plugin load refused",name); }
|
||||
verifiedPlugin(dataRoot,name,spec.kind);
|
||||
for(const key of spec.exports) if(typeof module[key]!=="function") refuse("missing-export","installed plugin export refused",name);
|
||||
if(module.interfaceVersion!==spec.interfaceVersion) refuse("interface-version","installed plugin interface refused",name);
|
||||
return Object.freeze({name,module,installed:true});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// PL1 (#1873): strict, non-executing manifest validation. All errors are static;
|
||||
// neither a malformed field nor plugin free text is ever included in a refusal.
|
||||
import { KINDS, NAME, PluginError } from './kinds.mjs';
|
||||
import { buildRegistry } from '../../organization/src/composition.mjs';
|
||||
|
||||
export const HASH = /^[a-f0-9]{64}$/;
|
||||
const object = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
export const bad = (code = 'invalid-manifest', name = null) => { throw new PluginError(code, `plugin refused: ${code}`, name); };
|
||||
const keys = (v, allowed, required = allowed) => {
|
||||
if (!object(v) || Object.keys(v).some(k => !allowed.includes(k)) || required.some(k => !Object.hasOwn(v, k))) bad();
|
||||
};
|
||||
const text = (v, max = 2048) => { if (typeof v !== 'string' || !v.length || v.length > max || /[\x00-\x1f]/.test(v)) bad(); };
|
||||
export function pluginPath(v) {
|
||||
if (typeof v !== 'string' || v.length > 200 || !/^[a-zA-Z0-9._/-]+$/.test(v) || v.split('/').some(p => !p || p === '.' || p === '..') || v.startsWith('/')) bad('unsafe-path');
|
||||
return v;
|
||||
}
|
||||
// Deliberately bounded JSON Schema subset. No remote references, executable validators,
|
||||
// or unknown keywords. The same schema is enforced before dispatch, not just shown to a model.
|
||||
export function checkSchema(s, depth = 0) {
|
||||
keys(s, ['type', 'description', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems'], ['type']);
|
||||
if (depth > 12 || !['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'].includes(s.type)) bad();
|
||||
if (s.description !== undefined) text(s.description);
|
||||
if (s.enum !== undefined && (!Array.isArray(s.enum) || !s.enum.length || s.enum.some(v => v !== null && !['string','number','boolean'].includes(typeof v)))) bad();
|
||||
for (const k of ['minLength','maxLength','minItems','maxItems']) if (s[k] !== undefined && (!Number.isSafeInteger(s[k]) || s[k] < 0)) bad();
|
||||
for (const k of ['minimum','maximum']) if (s[k] !== undefined && !Number.isFinite(s[k])) bad();
|
||||
if (s.type === 'object') {
|
||||
if (!object(s.properties) || s.additionalProperties !== false || !Array.isArray(s.required) || new Set(s.required).size !== s.required.length || s.required.some(k => typeof k !== 'string' || !Object.hasOwn(s.properties,k))) bad();
|
||||
for (const [k,v] of Object.entries(s.properties)) { if (!/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(k)) bad(); checkSchema(v, depth+1); }
|
||||
} else if (s.properties !== undefined || s.required !== undefined || s.additionalProperties !== undefined) bad();
|
||||
if (s.type === 'array') checkSchema(s.items, depth+1);
|
||||
else if (s.items !== undefined) bad();
|
||||
return s;
|
||||
}
|
||||
export function validArguments(v, s) {
|
||||
if (s.enum && !s.enum.some(x => x === v)) return false;
|
||||
if (s.type === 'object') return object(v) && Object.keys(v).every(k => Object.hasOwn(s.properties,k) && validArguments(v[k],s.properties[k])) && s.required.every(k => Object.hasOwn(v,k));
|
||||
if (s.type === 'array') return Array.isArray(v) && v.length >= (s.minItems ?? 0) && v.length <= (s.maxItems ?? 10000) && v.every(x => validArguments(x,s.items));
|
||||
if (s.type === 'null') return v === null;
|
||||
if (s.type === 'integer' ? !Number.isSafeInteger(v) : typeof v !== s.type) return false;
|
||||
if (typeof v === 'string') return v.length >= (s.minLength ?? 0) && v.length <= (s.maxLength ?? 1000000);
|
||||
if (typeof v === 'number') return Number.isFinite(v) && v >= (s.minimum ?? -Infinity) && v <= (s.maximum ?? Infinity);
|
||||
return true;
|
||||
}
|
||||
export function declaredSet(m) {
|
||||
const bare = n => n.slice(m.name.length + 1);
|
||||
return { name:m.name, roleTemplates:m.roleTemplates.map(t=>({name:bare(t.name),file:t.file})), tools:m.tools.map(t => bare(t.name)), actions:m.actions.map(bare), services:m.services.map(s => ({name:bare(s.name),scopes:s.scopes})) };
|
||||
}
|
||||
export function checkManifest(m) {
|
||||
keys(m, ['name','version','kinds','interfaceVersions','sha256','source','signature','tools','actions','services','roleTemplates']);
|
||||
if (typeof m.name !== 'string' || !NAME.test(m.name) || m.name.length > 32) bad();
|
||||
if (typeof m.version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(m.version) || !HASH.test(m.sha256)) bad();
|
||||
if (!Array.isArray(m.kinds) || !m.kinds.length || new Set(m.kinds).size !== m.kinds.length || m.kinds.some(k => !Object.hasOwn(KINDS,k))) bad('unknown-kind');
|
||||
keys(m.interfaceVersions,m.kinds);
|
||||
for (const k of m.kinds) if (m.interfaceVersions[k] !== KINDS[k].interfaceVersion) bad('interface-version');
|
||||
keys(m.source,['url','path','commit','version'],[]);
|
||||
if (Object.hasOwn(m.source,'url') === Object.hasOwn(m.source,'path') || Object.hasOwn(m.source,'commit') === Object.hasOwn(m.source,'version')) bad();
|
||||
for (const v of Object.values(m.source)) text(v);
|
||||
if (m.source.commit !== undefined && !/^[a-f0-9]{40}$/.test(m.source.commit)) bad();
|
||||
if (m.signature !== null) bad(); // reserved, never claims verification
|
||||
for (const k of ['tools','actions','services','roleTemplates']) if (!Array.isArray(m[k]) || m[k].length > 128) bad();
|
||||
const full = n => { if (typeof n !== 'string' || !n.startsWith(`${m.name}.`) || !/^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$/.test(n) || n.length > 64) bad(); };
|
||||
for (const t of m.tools) {
|
||||
keys(t,['name','description','arguments','runs','entry']); full(t.name); text(t.description); checkSchema(t.arguments);
|
||||
if (t.arguments.type !== 'object' || !['sandbox','host'].includes(t.runs)) bad(); pluginPath(t.entry);
|
||||
if (!t.entry.endsWith('.mjs')) bad();
|
||||
}
|
||||
for (const a of m.actions) full(a);
|
||||
for (const s of m.services) { keys(s,['name','scopes']); full(s.name); }
|
||||
const seen = new Set([...m.tools.map(t=>t.name),...m.actions,...m.services.map(s=>s.name)]);
|
||||
for (const r of m.roleTemplates) { keys(r,['name','file']); full(r.name); pluginPath(r.file); if (seen.has(r.name)) bad(); seen.add(r.name); }
|
||||
try { buildRegistry({plugins:[declaredSet(m)]}); } catch { bad(); }
|
||||
return structuredClone(m);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Non-executing organization view. Disabled/missing capabilities carry only a validated name.
|
||||
import { buildRegistry } from '../../organization/src/composition.mjs';
|
||||
import { declaredSet, bad } from './manifest.mjs';
|
||||
import { verifiedPlugin } from './pinned.mjs';
|
||||
import { hasModule } from './loader.mjs';
|
||||
export {pluginName} from "../../organization/src/vocabulary.mjs";
|
||||
import {pluginName} from "../../organization/src/vocabulary.mjs";
|
||||
export function organizationPlugins(dataRoot,settings = {}) {
|
||||
const names=new Set(Object.entries(settings).flatMap(([kind,v])=>(Array.isArray(v)?v:[v]).filter(name=>!hasModule(kind,name))));
|
||||
const manifests=[],refused=[];
|
||||
if(dataRoot!==null) for(const name of names){try {const m=verifiedPlugin(dataRoot,name).manifest;
|
||||
if(m.kinds.every(k=>(Array.isArray(settings[k])?settings[k]:[settings[k]]).includes(name)))manifests.push(m);
|
||||
}catch {refused.push(name);}}
|
||||
const registry=buildRegistry({plugins:manifests.map(declaredSet)});
|
||||
return {registry,manifests,refused,roleTemplates:manifests.flatMap(m=>m.roleTemplates)};
|
||||
}
|
||||
export function assertPluginCapabilities(role,view) {
|
||||
const check = (cap,registered) => {
|
||||
const name=pluginName(cap);if(!name)return;
|
||||
if(!view.manifests.some(m=>m.name===name))bad('plugin-disabled',name);
|
||||
if(!registered)bad('invalid-request');
|
||||
};
|
||||
for(const tool of role.tools ?? [])check(tool,view.registry.tools.includes(tool));
|
||||
for(const action of [...(role.authority?.withinRole ?? []),...(role.authority?.crossRole ?? [])])check(action,view.registry.actions.includes(action));
|
||||
const credentials=Array.isArray(role.credentials) ? role.credentials : Object.keys(role.credentials ?? {}).map(service=>({service}));
|
||||
for(const credential of credentials) {
|
||||
const {service,scopes}=credential;check(service,Object.hasOwn(view.registry.services,service));
|
||||
if(pluginName(service) && scopes!==undefined && (!Array.isArray(scopes) || scopes.some(s=>!view.registry.services[service].scopes.includes(s))))bad('invalid-request');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// PL1: only these derived instance paths may supply installed plugin bytes.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { constants, openSync, closeSync, fstatSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { isAbsolute, join, resolve } from 'node:path';
|
||||
import { NAME } from './kinds.mjs';
|
||||
import { bad, checkManifest, HASH } from './manifest.mjs';
|
||||
import { guardPluginImports } from './imports.mjs';
|
||||
// Node caches transitive modules by canonical path. Never attribute old cached
|
||||
// code to a replacement pin, even after a failed import or a new loader instance.
|
||||
const importedPins = new Map();
|
||||
function checkImportedPin(dir, hash, name) {
|
||||
if (importedPins.has(dir) && importedPins.get(dir) !== hash) bad('plugin-restart-required',name);
|
||||
}
|
||||
export function claimPluginImport({dir,manifest}) {
|
||||
checkImportedPin(dir,manifest.sha256,manifest.name);
|
||||
guardPluginImports(dir);
|
||||
importedPins.set(dir,manifest.sha256); // before the first import attempt
|
||||
}
|
||||
export function pluginPaths(dataRoot) {
|
||||
if (typeof dataRoot !== 'string' || !isAbsolute(dataRoot) || resolve(dataRoot) !== dataRoot) bad('plugin-root');
|
||||
return {root:join(dataRoot,'plugins'),lock:join(dataRoot,'plugins.lock.json'),mutex:join(dataRoot,'plugins.install.lock')};
|
||||
}
|
||||
export function safeDirectory(path) {
|
||||
let current = '/';
|
||||
for (const part of resolve(path).split('/').filter(Boolean)) {
|
||||
current = join(current,part); const st = lstatSync(current);
|
||||
if (st.isSymbolicLink() || !st.isDirectory()) bad('unsafe-path');
|
||||
}
|
||||
}
|
||||
function readRegular(path,expected=null) {
|
||||
let fd;try {
|
||||
fd=openSync(path,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
|
||||
const st=fstatSync(fd);
|
||||
if(!st.isFile() || st.nlink!==1 || (expected && (st.ino!==expected.ino || st.dev!==expected.dev)) || st.size>32*1024*1024)bad('unsafe-path');
|
||||
return readFileSync(fd);
|
||||
}finally{if(fd!==undefined)closeSync(fd);}
|
||||
}
|
||||
export function pluginFiles(dir) {
|
||||
safeDirectory(dir);
|
||||
const files = []; let size = 0;
|
||||
function walk(rel = '') {
|
||||
for (const n of readdirSync(join(dir,rel)).sort()) {
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(n)) bad('unsafe-path');
|
||||
const path = rel ? `${rel}/${n}` : n, st = lstatSync(join(dir,path));
|
||||
if (st.uid!==process.getuid() || st.mode&0o022 || st.isSymbolicLink() || (!st.isDirectory() && !st.isFile()) || st.nlink > (st.isDirectory() ? Infinity : 1)) bad('unsafe-path');
|
||||
if (st.isDirectory()) walk(path);
|
||||
else { size += st.size; if (size > 32*1024*1024 || files.length > 2048) bad('plugin-too-large'); files.push([path,readRegular(join(dir,path),st)]); }
|
||||
}
|
||||
}
|
||||
walk(); return files.sort(([a],[b]) => a < b ? -1 : a > b ? 1 : 0);
|
||||
}
|
||||
// Hash every byte, replacing only the top-level sha256 string with 64 zeroes to
|
||||
// break the manifest's self-reference. Formatting and all other metadata stay pinned.
|
||||
function manifestBytes(bytes) {
|
||||
let text,value;try{text=new TextDecoder('utf-8',{fatal:true,ignoreBOM:true}).decode(bytes);value=JSON.parse(text);}catch{bad();}
|
||||
if(!value || typeof value!=="object" || !HASH.test(value.sha256))bad();
|
||||
let depth=0,found=0,result=text;
|
||||
for(let i=0;i<text.length;i++) {
|
||||
if(text[i]==='{' || text[i]==='['){depth++;continue;}
|
||||
if(text[i]==='}' || text[i]===']'){depth--;continue;}
|
||||
if(text[i]!== '\"')continue;
|
||||
const start=i++;for(;i<text.length;i++){if(text[i]==='\\'){i++;continue;}if(text[i]==='\"')break;}
|
||||
if(depth!==1 || JSON.parse(text.slice(start,i+1))!=="sha256")continue;
|
||||
const rest=/^\s*:\s*"([a-f0-9]{64})"/.exec(text.slice(i+1));
|
||||
if(!rest)continue;
|
||||
const offset=i+1+rest[0].lastIndexOf(rest[1]);result=result.slice(0,offset)+'0'.repeat(64)+result.slice(offset+64);found++;
|
||||
}
|
||||
if(found!==1)bad();return Buffer.from(result);
|
||||
}
|
||||
export function hashFiles(files) {
|
||||
const h=createHash('sha256');
|
||||
for (let [path,bytes] of files) {
|
||||
if(path==='plugin.json')bytes=manifestBytes(bytes);
|
||||
h.update(`${Buffer.byteLength(path)}:${path}:${bytes.length}:`).update(bytes);
|
||||
}
|
||||
return h.digest('hex');
|
||||
}
|
||||
export const pluginHash = dir => hashFiles(pluginFiles(dir));
|
||||
export function readPlugin(dir) {
|
||||
const files=pluginFiles(dir); let manifest;
|
||||
try { manifest=checkManifest(JSON.parse(files.find(([p])=>p==='plugin.json')?.[1])); } catch { bad('invalid-manifest'); }
|
||||
if (hashFiles(files)!==manifest.sha256) bad('hash-mismatch',manifest.name);
|
||||
const names=new Set(files.map(([p])=>p));
|
||||
for (const p of [...manifest.kinds.map(k=>`${k}/index.mjs`),...manifest.tools.map(t=>t.entry),...manifest.roleTemplates.map(t=>t.file)]) if (!names.has(p)) bad('missing-file',manifest.name);
|
||||
return {manifest,files};
|
||||
}
|
||||
export function readPluginLock(dataRoot) {
|
||||
const {lock}=pluginPaths(dataRoot); safeDirectory(dataRoot);
|
||||
const rootStat=lstatSync(dataRoot);if(rootStat.uid!==process.getuid() || rootStat.mode&0o022)bad('plugin-root');
|
||||
let doc;
|
||||
try { const st=lstatSync(lock); if (st.isSymbolicLink() || !st.isFile() || st.uid!==process.getuid() || st.mode&0o022) bad('invalid-lock'); doc=JSON.parse(readRegular(lock,st)); }
|
||||
catch (e) { if(e.code==='ENOENT') return {version:1,plugins:{}}; bad('invalid-lock'); }
|
||||
if (!doc || doc.version!==1 || Object.keys(doc).some(k=>!['version','plugins'].includes(k)) || !doc.plugins || Array.isArray(doc.plugins) || typeof doc.plugins!=='object') bad('invalid-lock');
|
||||
for(const [n,p] of Object.entries(doc.plugins)) if(n.length>32 || !NAME.test(n) || !p || Object.keys(p).sort().join(',')!=='sha256,source,version' || !HASH.test(p.sha256) || typeof p.version!=='string') bad('invalid-lock');
|
||||
return doc;
|
||||
}
|
||||
export function verifiedPlugin(dataRoot,name,kind=null) {
|
||||
if(typeof name!=='string' || name.length>32 || !NAME.test(name)) bad('bad-name');
|
||||
try {
|
||||
const pin=readPluginLock(dataRoot).plugins[name]; if(!pin) bad('missing-lock',name);
|
||||
const {root}=pluginPaths(dataRoot); const dir=join(root,name); const result=readPlugin(dir);
|
||||
if(result.manifest.name!==name || result.manifest.sha256!==pin.sha256 || result.manifest.version!==pin.version) bad('hash-mismatch',name);
|
||||
if(kind && !result.manifest.kinds.includes(kind)) bad('unknown-kind',name);
|
||||
checkImportedPin(dir,result.manifest.sha256,name);
|
||||
return {...result,dir};
|
||||
} catch(e) { bad(['missing-lock','hash-mismatch','unknown-kind','unsafe-path','invalid-lock','plugin-restart-required'].includes(e.code)?e.code:'plugin-unavailable',name); }
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { loadInstalled } from './loader.mjs';
|
||||
import { NAME } from './kinds.mjs';
|
||||
|
||||
// Discovery is fail-closed: malformed or unloadable installed modules cannot be advertised.
|
||||
export async function installedSystemTypes() {
|
||||
const { modules, refused } = await loadInstalled('system-type');
|
||||
export async function installedSystemTypes({dataRoot = null} = {}) {
|
||||
const { modules, refused } = await loadInstalled('system-type',{dataRoot});
|
||||
if (refused.length) throw Object.assign(new Error('system type module refused'), { code: 'system-type-invalid' });
|
||||
return modules.map(({ name, module }) => {
|
||||
const value = module.systemType();
|
||||
|
||||
@@ -111,8 +111,8 @@ export function checkTheme(value, name = null) {
|
||||
// Every installed theme module that loads and checks, as [{ id, displayName, tokens }] sorted by
|
||||
// id, and what was refused. Each refusal goes to `log` as the loader's plugin-refused entry.
|
||||
// Nothing names the themes: loadInstalled lists packages/plugins/theme/.
|
||||
export async function installedThemes({ log = () => {}, barrier } = {}) {
|
||||
const { modules, refused } = await loadInstalled("theme", { barrier });
|
||||
export async function installedThemes({ log = () => {}, barrier, dataRoot = null } = {}) {
|
||||
const { modules, refused } = await loadInstalled("theme", { barrier, dataRoot });
|
||||
const themes = [], problems = [...refused];
|
||||
for (const { name, module } of modules) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Trusted host dispatch for declared tools. No plugin code is imported until the
|
||||
// current organization setting and the complete installed package pass verification.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, realpathSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
import { organizationPlugins, assertPluginCapabilities, pluginName } from './organization.mjs';
|
||||
import { verifiedPlugin, claimPluginImport } from './pinned.mjs';
|
||||
import { bad, validArguments } from './manifest.mjs';
|
||||
const SCRIPT = `let input=''; for await(const b of process.stdin)input+=b;
|
||||
const {guardPluginImports}=await import('file:///plugin-guard.mjs');guardPluginImports('/plugin');
|
||||
const {entry,args}=JSON.parse(input); const m=await import(entry);
|
||||
process.stdout.write(JSON.stringify(await m.execute(args)));`;
|
||||
function sandboxTool(dir,entry,args) {
|
||||
// A fresh mount/network/user namespace with only runtime libraries and the pinned package.
|
||||
// No home, config, workspace, sockets or inherited environment is exposed.
|
||||
const binds=['/usr','/lib','/lib64','/bin'].filter(existsSync).flatMap(p=>['--ro-bind',realpathSync(p),p]);
|
||||
return new Promise((resolve,reject)=>{
|
||||
const child=spawn('bwrap',['--die-with-parent','--unshare-all','--new-session','--proc','/proc','--dev','/dev','--tmpfs','/tmp',...binds,'--ro-bind',realpathSync(process.execPath),'/node','--ro-bind',fileURLToPath(new URL('./imports.mjs',import.meta.url)),'/plugin-guard.mjs','--ro-bind',dir,'/plugin','--chdir','/plugin','--clearenv','/node','--input-type=module','-e',SCRIPT],{env:{PATH:'/usr/bin:/bin'},stdio:['pipe','pipe','pipe']});
|
||||
let out='',size=0;const timer=setTimeout(()=>child.kill('SIGKILL'),30000);
|
||||
child.stdout.on('data',b=>{size+=b.length;if(size>1024*1024)child.kill('SIGKILL');else out+=b;});
|
||||
child.stderr.resume();child.on('error',()=>{clearTimeout(timer);reject(new Error('sandbox refused'));});
|
||||
child.stdin.on('error',()=>{});child.stdin.end(JSON.stringify({entry:`file:///plugin/${entry}`,args}));
|
||||
child.on('close',code=>{clearTimeout(timer);try{if(code!==0 || size>1024*1024)throw Error();resolve(JSON.parse(out));}catch{reject(new Error('sandbox refused'));}});
|
||||
});
|
||||
}
|
||||
export function pluginService({dataRoot,read}) {
|
||||
function check(business,instance) {
|
||||
const b=read(business);const entry=b.roles?.[instance];let role=b.definitions?.[entry?.definition] ?? entry;
|
||||
if(!role)bad('plugin-disabled');
|
||||
role=structuredClone(role);
|
||||
for(const vars of [b.vars,entry?.vars]) {
|
||||
if(Array.isArray(vars?.['limits.tools']))role.tools=(role.tools ?? []).filter(t=>vars['limits.tools'].includes(t));
|
||||
if(Array.isArray(vars?.['limits.authority']) && role.authority)for(const key of ['withinRole','crossRole'])role.authority[key]=(role.authority[key] ?? []).filter(a=>vars['limits.authority'].includes(a));
|
||||
}
|
||||
const view=organizationPlugins(dataRoot,b.plugins);assertPluginCapabilities(role,view);return {role,view};
|
||||
}
|
||||
async function call(business,instance,name,args) {
|
||||
const prefix=pluginName(name);if(!prefix)bad('invalid-request');
|
||||
const {role,view}=check(business,instance);
|
||||
if(!role.tools?.includes(name))bad('not-authorized');
|
||||
const tool=view.manifests.flatMap(m=>m.tools).find(t=>t.name===name);if(!tool)bad('plugin-disabled',prefix);
|
||||
if(!validArguments(args,tool.arguments))bad('invalid-request');
|
||||
const checked=verifiedPlugin(dataRoot,prefix),{dir,manifest}=checked;
|
||||
if(tool.runs==='sandbox') {
|
||||
try {return await sandboxTool(dir,tool.entry,structuredClone(args));}
|
||||
catch {bad('plugin-tool-failed',prefix);}
|
||||
}
|
||||
claimPluginImport(checked);
|
||||
let module;
|
||||
try {module=await import(pathToFileURL(join(dir,tool.entry)).href+`?pin=${manifest.sha256}`);}
|
||||
catch {bad('plugin-tool-failed',prefix);}
|
||||
// A concurrent disable while import was in flight must refuse before execute.
|
||||
check(business,instance);verifiedPlugin(dataRoot,prefix);
|
||||
if(typeof module.execute!=='function')bad('missing-export');
|
||||
// Plugin code cannot impersonate a trusted refusal by throwing a matching error name.
|
||||
try {return await module.execute(structuredClone(args));}
|
||||
catch {bad('plugin-tool-failed',prefix);}
|
||||
}
|
||||
return {check,call};
|
||||
}
|
||||
@@ -27,6 +27,9 @@ export function tree(t) {
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const pkg = join(root, "packages", "plugins");
|
||||
cpSync(SRC, join(pkg, "src"), { recursive: true });
|
||||
const org = join(root,"packages","organization","src");
|
||||
mkdirSync(org,{recursive:true});
|
||||
for (const file of ["composition.mjs","vocabulary.mjs","errors.mjs","util.mjs"]) cpSync(join(SRC,"..","..","organization","src",file),join(org,file));
|
||||
const write = (kind, name, body) => {
|
||||
const dir = join(pkg, kind, name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
@@ -105,6 +105,7 @@ test("REQ-PLUG-1 a symlinked packages/plugins refuses (run with --preserve-symli
|
||||
t.after(() => rmSync(other, { recursive: true, force: true }));
|
||||
mkdirSync(join(other, "packages"));
|
||||
symlinkSync(s.pkg, join(other, "packages", "plugins"));
|
||||
symlinkSync(join(s.root,"packages","organization"), join(other,"packages","organization"));
|
||||
const url = new URL(`file://${join(other, "packages", "plugins", "src", "loader.mjs")}`).href;
|
||||
const script = `const { createLoader } = await import(${JSON.stringify(url)});
|
||||
const logged = []; const loader = createLoader({ log: e => logged.push(e) });
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { checkManifest } from '../src/manifest.mjs';
|
||||
import { pluginHash, readPluginLock, verifiedPlugin } from '../src/pinned.mjs';
|
||||
import { installPlugin, removePlugin } from '../src/install.mjs';
|
||||
import { createLoader } from '../src/loader.mjs';
|
||||
import { organizationPlugins, assertPluginCapabilities } from '../src/organization.mjs';
|
||||
import { pluginService } from '../src/tools.mjs';
|
||||
import { typedTools } from '../../harness/src/tools.mjs';
|
||||
import { resolveInstance } from '../../organization/src/resolve.mjs';
|
||||
import { pluginInstallCommand } from '../../cli/src/plugin-install.mjs';
|
||||
import { pluginSettings } from '../../organization/src/plugin-settings.mjs';
|
||||
import { hasModule } from '../src/loader.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
const SECRET='sk-test-NOT-A-SECRET-1234';
|
||||
function fixture(t) {
|
||||
const root=mkdtempSync(join(tmpdir(),'pl1-')),src=join(root,'src'),dataRoot=join(root,'data');
|
||||
mkdirSync(src);mkdirSync(dataRoot);mkdirSync(join(src,'usage-source'));mkdirSync(join(src,'tools'));
|
||||
t.after(()=>{const thaw=p=>{const st=lstatSync(p);if(st.isSymbolicLink())return;if(st.isDirectory()){chmodSync(p,0o700);for(const n of readdirSync(p))thaw(join(p,n));}};thaw(root);rmSync(root,{recursive:true,force:true});});
|
||||
writeFileSync(join(src,'usage-source','index.mjs'),'export const interfaceVersion=1; export const readTurn=()=>"fixture";');
|
||||
writeFileSync(join(src,'tools','echo.mjs'),`export const execute=(args)=>({answer:args.text}); // ${SECRET}`);
|
||||
const manifest={name:'acmeplug',version:'1.0.0',kinds:['usage-source'],interfaceVersions:{'usage-source':1},sha256:'0'.repeat(64),source:{path:'fixture',version:'1.0.0'},signature:null,tools:[{name:'acmeplug.echo',description:SECRET,arguments:{type:'object',properties:{text:{type:'string'}},required:['text'],additionalProperties:false},runs:'host',entry:'tools/echo.mjs'}],actions:['acmeplug.publish'],services:[{name:'acmeplug.account',scopes:['read']}],roleTemplates:[{name:'acmeplug.writer',file:'writer.json'}]};
|
||||
writeFileSync(join(src,'writer.json'),'{}');
|
||||
const seal=()=>{writeFileSync(join(src,'plugin.json'),JSON.stringify(manifest));manifest.sha256=pluginHash(src);writeFileSync(join(src,'plugin.json'),JSON.stringify(manifest));return manifest.sha256;};
|
||||
const hash=seal();const install=(source=src,extra={})=>installPlugin({dataRoot,source,expectedHash:hash,...extra});
|
||||
return {root,src,dataRoot,manifest,seal,hash,install};
|
||||
}
|
||||
const refused=code=>e=>e.code===code && !e.message.includes(SECRET);
|
||||
test('PL1 strict fields, kinds, core names, prefixes, hard waits and traversal refuse without echo',t=>{
|
||||
const f=fixture(t);
|
||||
for(const mutate of [m=>m.extra=SECRET,m=>m.tools[0].extra=SECRET,m=>m.kinds=['unknown'],m=>m.tools[0].name='read',m=>m.tools[0].name='acmeplug.read',m=>m.actions=['acmeplug.deploy'],m=>m.actions=['acmeplug.git.push.remote'],m=>m.roleTemplates[0].file='../file',m=>m.tools[0].arguments.$ref=SECRET,m=>m.name='git']){
|
||||
const m=structuredClone(f.manifest);mutate(m);assert.throws(()=>checkManifest(m),e=>!e.message.includes(SECRET));
|
||||
}
|
||||
});
|
||||
test('PL1 directory install is pinned and read-only; host tools printed before any instance write',t=>{
|
||||
const f=fixture(t),printed=[];const entry=f.install(f.src,{print:line=>{assert.deepEqual(readPluginLock(f.dataRoot).plugins,{});assert.equal(existsSync(join(f.dataRoot,'plugins')),false);printed.push(line);}});
|
||||
assert.equal(entry.sha256,f.hash);assert.deepEqual(printed,['host tool: acmeplug.echo']);
|
||||
for(const p of ['plugins/acmeplug','plugins/acmeplug/tools'])assert.equal(lstatSync(join(f.dataRoot,p)).mode&0o777,0o555);
|
||||
assert.equal(lstatSync(join(f.dataRoot,'plugins/acmeplug/plugin.json')).mode&0o777,0o444);
|
||||
assert.equal(verifiedPlugin(f.dataRoot,'acmeplug').manifest.sha256,f.hash);
|
||||
});
|
||||
test('PL1 mismatch and symlink sources refuse before instance writes',t=>{
|
||||
const f=fixture(t);assert.throws(()=>f.install(f.src,{expectedHash:'f'.repeat(64)}),refused('hash-mismatch'));
|
||||
symlinkSync('/etc/passwd',join(f.src,'escape'));assert.throws(()=>f.install(),refused('unsafe-path'));
|
||||
assert.equal(existsSync(join(f.dataRoot,'plugins')),false);
|
||||
});
|
||||
test('PL1 zip traversal, symlinks, and git URL without a full commit refuse',t=>{
|
||||
const f=fixture(t);const zip=join(f.root,'bad.zip');
|
||||
for(const script of ["z.writestr('../escape','bad')","e=zipfile.ZipInfo('link');e.create_system=3;e.external_attr=(0o120777<<16);z.writestr(e,'/etc/passwd')"]){
|
||||
assert.equal(spawnSync('python3',['-c',`import zipfile\nz=zipfile.ZipFile(${JSON.stringify(zip)},'w')\n${script}\nz.close()`]).status,0);
|
||||
assert.throws(()=>f.install(zip),refused('source-refused'));
|
||||
}
|
||||
assert.throws(()=>f.install('https://example.invalid/repo.git'),refused('commit-required'));
|
||||
assert.throws(()=>f.install(f.src,{commit:'abc'}),refused('commit-required'));
|
||||
});
|
||||
test('PL1 directory, generated zip and local bare git at full commit land the same hash',t=>{
|
||||
const f=fixture(t);const zip=join(f.root,'source.zip');
|
||||
assert.equal(spawnSync('python3',['-c',`import zipfile,os\nz=zipfile.ZipFile(${JSON.stringify(zip)},'w')\nfor root,dirs,files in os.walk(${JSON.stringify(f.src)}):\n for n in files:z.write(os.path.join(root,n),os.path.relpath(os.path.join(root,n),${JSON.stringify(f.src)}))\nz.close()`]).status,0);
|
||||
const git=(args)=>{const r=spawnSync('git',args,{encoding:'utf8'});assert.equal(r.status,0,r.stderr);return r.stdout.trim();};
|
||||
git(['init',f.src]);git(['-C',f.src,'add','.']);git(['-C',f.src,'-c','user.name=Fixture','-c','[email protected]','commit','-m','fixture']);
|
||||
const commit=git(['-C',f.src,'rev-parse','HEAD']),bare=join(f.root,'bare.git');git(['clone','--bare',f.src,bare]);
|
||||
// The local directory used for install intentionally has no .git administrative files.
|
||||
rmSync(join(f.src,'.git'),{recursive:true});
|
||||
for(const [source,opts]of [[f.src,{}],[zip,{}],[bare,{commit}]]){
|
||||
const d=mkdtempSync(join(f.root,'instance-'));const r=installPlugin({dataRoot:d,source,expectedHash:f.hash,...opts});assert.equal(r.sha256,f.hash);assert.equal(readPluginLock(d).plugins.acmeplug.sha256,f.hash);
|
||||
}
|
||||
});
|
||||
test('PL1 changed file/missing lock refuses next load and evicts installed code, core keeps running',async t=>{
|
||||
const f=fixture(t);f.install();const logs=[],loader=createLoader({dataRoot:f.dataRoot,log:e=>logs.push(e)});
|
||||
const settings={'usage-source':['transcript','acmeplug'],harness:['pi']};assert.equal((await loader.load(settings)).refused.length,0);
|
||||
const file=join(f.dataRoot,'plugins/acmeplug/tools/echo.mjs');chmodSync(file,0o600);writeFileSync(file,SECRET);
|
||||
const result=await loader.load(settings);assert.equal(result.refused[0].code,'hash-mismatch');assert.deepEqual(loader.loaded()['usage-source'],['transcript']);assert.deepEqual(loader.loaded().harness,['pi']);
|
||||
assert.ok(!JSON.stringify(logs).includes(SECRET));
|
||||
rmSync(join(f.dataRoot,'plugins.lock.json'));assert.equal((await loader.load(settings)).refused[0].code,'missing-lock');
|
||||
});
|
||||
test('PL1 registry includes only enabled verified declarations, services and templates',t=>{
|
||||
const f=fixture(t);f.install();const view=organizationPlugins(f.dataRoot,{'usage-source':['acmeplug']});
|
||||
assert.ok(view.registry.tools.includes('acmeplug.echo'));assert.ok(view.registry.routed.includes('acmeplug.publish'));assert.deepEqual(view.registry.services['acmeplug.account'].scopes,['read']);assert.equal(view.roleTemplates[0].name,'acmeplug.writer');
|
||||
for(const settings of [{},{'usage-source':['missing']}])assert.throws(()=>assertPluginCapabilities({tools:['acmeplug.echo']},organizationPlugins(f.dataRoot,settings)),e=>e.code==='plugin-disabled' && e.plugin==='acmeplug' && !e.message.includes(SECRET));
|
||||
});
|
||||
test('PL1 disabled or missing tools/actions refuse at launch with name only, enabled tool is bundled',t=>{
|
||||
const f=fixture(t);f.install();const definition={name:'writer',contractPath:'/contract',tools:['acmeplug.echo'],network:'none',authority:{withinRole:['acmeplug.publish'],crossRole:[]},credentials:[],messaging:null,skills:[]};
|
||||
const business={id:'acme',roles:{writer:{definition:'writer',vars:{},credentials:{}}},definitions:{writer:definition},vars:{},arbiters:{}};
|
||||
const args={system:{dataRoot:f.dataRoot},business,instance:'writer',enforcePlugins:true};
|
||||
assert.throws(()=>resolveInstance(args),e=>e.code==='plugin-disabled' && e.plugin==='acmeplug');
|
||||
business.plugins={'usage-source':['acmeplug']};const r=resolveInstance(args);assert.equal(typedTools({resolved:r}).find(t=>t.name==='acmeplug.echo').pluginTool,'acmeplug.echo');
|
||||
business.plugins={};assert.throws(()=>resolveInstance(args),refused('plugin-disabled'));
|
||||
});
|
||||
test('PL1 running tool calls recheck enabled setting, pin, arguments and held tool',async t=>{
|
||||
const f=fixture(t);f.install();const role={tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}};let enabled=true;
|
||||
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:enabled?{'usage-source':['acmeplug']}:{},roles:{writer:role}})});
|
||||
assert.deepEqual(await service.call('acme','writer','acmeplug.echo',{text:'hello'}),{answer:'hello'});
|
||||
await assert.rejects(service.call('acme','writer','acmeplug.echo',{unexpected:SECRET}),refused('invalid-request'));
|
||||
enabled=false;await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:SECRET}),e=>e.code==='plugin-disabled' && e.plugin==='acmeplug' && !e.message.includes(SECRET));
|
||||
});
|
||||
test('PL1 remove refuses enabled organizations; host CLI rejects organization/tester options and agents',async t=>{
|
||||
const f=fixture(t);f.install();assert.throws(()=>removePlugin({dataRoot:f.dataRoot,name:'acmeplug',enabled:()=>true}),refused('plugin-enabled'));
|
||||
const dir=join(f.root,'config');mkdirSync(dir,{mode:0o700});let out='';const io={env:{},stdout:{write:v=>out+=v}};
|
||||
for(const args of [['install',f.src,'--sha256',f.hash,'--org','acme'],['install',f.src,'--sha256',f.hash,'--tester']])await assert.rejects(pluginInstallCommand(args,io,{system:{dataRoot:f.dataRoot},dir}));
|
||||
await assert.rejects(pluginInstallCommand(['list'],{...io,env:{MOSAIC_RUN_ID:'fixture'}},{system:{dataRoot:f.dataRoot},dir}));
|
||||
writeJson(join(dir,'businesses','acme.json'),{plugins:{'usage-source':['acmeplug']}});
|
||||
await assert.rejects(pluginInstallCommand(['remove','acmeplug'],io,{system:{dataRoot:f.dataRoot},dir}),e=>e.message.includes('plugin-enabled'));
|
||||
});
|
||||
test('PL1 enabling a tampered installed plugin refuses before organization write',t=>{
|
||||
const f=fixture(t);f.install();const doc=businessDoc(f.root),rolesDir=rolesCopy(f.root);writeJson(join(f.root,'businesses','acme.json'),doc);
|
||||
const settings=pluginSettings({dir:f.root,rolesDir,dataRoot:f.dataRoot,exists:(k,n)=>hasModule(k,n,f.dataRoot)});
|
||||
const before=settings.read('acme');const file=join(f.dataRoot,'plugins/acmeplug/tools/echo.mjs');chmodSync(file,0o600);writeFileSync(file,SECRET);
|
||||
assert.throws(()=>settings.set('acme',{revision:before.revision,plugins:{'usage-source':['acmeplug']}},'jason'),refused('invalid-plugins'));
|
||||
assert.deepEqual(settings.read('acme'),before);
|
||||
});
|
||||
|
||||
test('PL1 sandbox tools execute with no home or network namespace access',async t=>{
|
||||
const f=fixture(t);f.manifest.tools[0].runs='sandbox';
|
||||
writeFileSync(join(f.src,'tools/echo.mjs'),`import {existsSync} from 'node:fs';export const execute=()=>({home:existsSync('/home'),config:existsSync('/mnt/storage')});`);
|
||||
const hash=f.seal();f.install(f.src,{expectedHash:hash});
|
||||
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})});
|
||||
assert.deepEqual(await service.call('acme','writer','acmeplug.echo',{text:'hello'}),{home:false,config:false});
|
||||
});
|
||||
|
||||
test('PL1 broker launch and running dispatch enforce enable state; organization install and member/agent enable refuse',async t=>{
|
||||
const {Store}=await import('../../bus/src/store.mjs');const {Broker}=await import('../../bus/src/broker.mjs');
|
||||
const f=fixture(t);f.install();const authority={withinRole:[],crossRole:[]};
|
||||
const business={id:'acme',human:'owner',plugins:{'usage-source':['acmeplug']},arbiters:{technical:'chief',delivery:'chief'},roles:{chief:{authority},writer:{tools:['acmeplug.echo'],authority}}};
|
||||
const service=pluginService({dataRoot:f.dataRoot,read:()=>business});const store=new Store(f.dataRoot);t.after(()=>store.close());
|
||||
const b=new Broker({store,businesses:{acme:business},pluginService:service});
|
||||
const cap=b.bindLaunch({business:'acme',role:'writer',run:'writer-1',harness:'pi'});b.request(cap,{verb:'role.claim'});
|
||||
assert.deepEqual(await b.request(cap,{verb:'plugin.tool',args:{tool:'acmeplug.echo',arguments:{text:'working'}}}),{answer:'working'});
|
||||
const member=b.bindReader({business:'acme'});
|
||||
for(const c of [cap,member])assert.throws(()=>b.request(c,{verb:'plugins.set',args:{revision:'a'.repeat(64),plugins:{'usage-source':['acmeplug']}}}),/human-required/);
|
||||
assert.throws(()=>b.request(cap,{verb:'plugins.install',args:{source:SECRET}}),e=>!e.message.includes(SECRET));
|
||||
business.plugins={};
|
||||
await assert.rejects(b.request(cap,{verb:'plugin.tool',args:{tool:'acmeplug.echo',arguments:{text:SECRET}}}),e=>e.code==='plugin-disabled' && e.reason==='acmeplug' && !e.message.includes(SECRET));
|
||||
assert.throws(()=>b.bindLaunch({business:'acme',role:'writer',run:'writer-2',harness:'pi'}),e=>e.code==='plugin-disabled' && e.reason==='acmeplug');
|
||||
assert.equal(b.bindLaunch({business:'acme',role:'chief',run:'chief-1',harness:'pi'}).length,64,'core can still launch');
|
||||
});
|
||||
|
||||
test('PL1 registry preserves capability categories and credential scope ceilings',t=>{
|
||||
const f=fixture(t);f.install();const view=organizationPlugins(f.dataRoot,{'usage-source':['acmeplug']});
|
||||
for(const role of [{tools:['acmeplug.publish']},{authority:{withinRole:['acmeplug.echo']}},{credentials:[{service:'acmeplug.account',scopes:['admin']}]}])assert.throws(()=>assertPluginCapabilities(role,view),refused('invalid-request'));
|
||||
});
|
||||
|
||||
test('PL1 manifest whitespace is pinned; remove succeeds after the owner disables it',t=>{
|
||||
const f=fixture(t);f.install();const file=join(f.dataRoot,'plugins/acmeplug/plugin.json');chmodSync(file,0o600);writeFileSync(file,readFileSync(file,'utf8')+'\n');
|
||||
assert.throws(()=>verifiedPlugin(f.dataRoot,'acmeplug'),refused('hash-mismatch'));
|
||||
removePlugin({dataRoot:f.dataRoot,name:'acmeplug',enabled:()=>false});
|
||||
assert.deepEqual(readPluginLock(f.dataRoot).plugins,{});assert.equal(existsSync(join(f.dataRoot,'plugins/acmeplug')),false);
|
||||
});
|
||||
|
||||
test('PL1 CLI host install/list/remove never calls a bus transport',async t=>{
|
||||
const {main}=await import('../../cli/src/cli.mjs');const f=fixture(t),dir=join(f.root,'config');mkdirSync(dir,{mode:0o700});
|
||||
let output='';const io={env:{},stdout:{write:v=>output+=v},stderr:{write:()=>{}}};const deps={system:{dataRoot:f.dataRoot},accountsConfigDir:dir,transport:()=>{throw Error('bus must not be used');}};
|
||||
await main(['plugins','install',f.src,'--sha256',f.hash],io,deps);assert.match(output,/host tool: acmeplug.echo/);assert.ok(!output.includes(SECRET));
|
||||
output='';await main(['plugins','list'],io,deps);assert.equal(JSON.parse(output).plugins.acmeplug.sha256,f.hash);
|
||||
output='';await main(['plugins','remove','acmeplug'],io,deps);assert.deepEqual(JSON.parse(output),{removed:'acmeplug'});
|
||||
});
|
||||
|
||||
test('PL1 a running plugin tool obeys a newly narrowed tool ceiling',async t=>{
|
||||
const f=fixture(t);f.install();const b={plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]},vars:{}}}};
|
||||
const service=pluginService({dataRoot:f.dataRoot,read:()=>b});assert.deepEqual(await service.call('acme','writer','acmeplug.echo',{text:'before'}),{answer:'before'});
|
||||
b.roles.writer.vars['limits.tools']=['read'];await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:'after'}),refused('not-authorized'));
|
||||
});
|
||||
|
||||
test('PL1 an installed package cannot reuse a core module name from another kind',t=>{
|
||||
const f=fixture(t);f.manifest.name='pi';for(const tool of f.manifest.tools)tool.name=tool.name.replace('acmeplug.','pi.');f.manifest.actions=['pi.publish'];f.manifest.services[0].name='pi.account';f.manifest.roleTemplates[0].name='pi.writer';
|
||||
const hash=f.seal();assert.throws(()=>f.install(f.src,{expectedHash:hash}),refused('core-name'));
|
||||
assert.equal(existsSync(join(f.dataRoot,'plugins')),false);
|
||||
});
|
||||
|
||||
test('PL1 malformed settings cannot retain an installed module whose pin is now bad',async t=>{
|
||||
const f=fixture(t);f.install();const loader=createLoader({dataRoot:f.dataRoot,log:()=>{}});await loader.load({'usage-source':['transcript','acmeplug'],harness:['pi']});
|
||||
const file=join(f.dataRoot,'plugins/acmeplug/tools/echo.mjs');chmodSync(file,0o600);writeFileSync(file,SECRET);
|
||||
await loader.load(null);assert.deepEqual(loader.loaded()['usage-source'],['transcript']);assert.deepEqual(loader.loaded().harness,['pi']);
|
||||
});
|
||||
|
||||
test('PL1 runtime discovers installed harness levels from the instance root',async t=>{
|
||||
const {harnessLevels}=await import('../../bus/src/runtime.mjs');
|
||||
const {KINDS}=await import('../src/kinds.mjs');
|
||||
const f=fixture(t);f.manifest.kinds=['harness'];f.manifest.interfaceVersions={harness:1};
|
||||
mkdirSync(join(f.src,'harness'));
|
||||
writeFileSync(join(f.src,'harness/index.mjs'),KINDS.harness.exports.map(name=>`export function ${name}(){return ${name==='levels'?"['high']":'null'};}`).join('\n')+'\nexport const interfaceVersion=1;');
|
||||
const hash=f.seal();f.install(f.src,{expectedHash:hash});
|
||||
const levels=await harnessLevels({dataRoot:f.dataRoot,log:()=>{}});
|
||||
assert.deepEqual(levels('acmeplug'),['high']);assert.ok(levels.names.includes('pi'));
|
||||
});
|
||||
|
||||
test('PL1 invalid UTF-8 manifest bytes refuse instead of normalizing outside the pin',t=>{
|
||||
const f=fixture(t),path=join(f.src,'plugin.json'),bytes=readFileSync(path);
|
||||
bytes[bytes.indexOf(SECRET)]=0xff;writeFileSync(path,bytes);
|
||||
assert.throws(()=>pluginHash(f.src),refused('invalid-manifest'));
|
||||
});
|
||||
|
||||
test('PL1 tool exceptions cannot impersonate trusted refusals or echo payload bytes',async t=>{
|
||||
const f=fixture(t);
|
||||
writeFileSync(join(f.src,'tools/echo.mjs'),`export function execute(){throw Object.assign(new Error(${JSON.stringify(SECRET)}),{name:'PluginError',code:'plugin-disabled',plugin:${JSON.stringify(SECRET)}});}`);
|
||||
const hash=f.seal();f.install(f.src,{expectedHash:hash});
|
||||
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})});
|
||||
await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:'hello'}),e=>e.code==='plugin-tool-failed' && e.plugin==='acmeplug' && !e.message.includes(SECRET));
|
||||
});
|
||||
|
||||
test('PL1 A3 organization copy readers preserve the derived installed-plugin root',async t=>{
|
||||
const {withCopies,loadWithCopies}=await import('../../organization/src/agent-copies.mjs');
|
||||
const f=fixture(t);f.manifest.kinds=['system-type'];f.manifest.interfaceVersions={'system-type':1};
|
||||
mkdirSync(join(f.src,'system-type'));writeFileSync(join(f.src,'system-type/index.mjs'),'export const interfaceVersion=1;export function systemType(){return {}};');
|
||||
const hash=f.seal();f.install(f.src,{expectedHash:hash});
|
||||
const doc=businessDoc(f.root),rolesDir=rolesCopy(f.root);doc.plugins={...doc.plugins,'system-type':['acmeplug']};writeJson(join(f.root,'businesses/acme.json'),doc);
|
||||
const options={rolesDir,dataRoot:f.dataRoot,snapshot:null};
|
||||
assert.deepEqual(withCopies(doc,join(f.root,'businesses/acme.json'),options).plugins['system-type'],['acmeplug']);
|
||||
assert.deepEqual(loadWithCopies('acme',{...options,dir:f.root}).plugins['system-type'],['acmeplug']);
|
||||
const installed=join(f.dataRoot,'plugins/acmeplug/system-type/index.mjs');chmodSync(installed,0o600);writeFileSync(installed,SECRET);
|
||||
// Config checks names only; the loader owns bad-pin refusal and preserves core.
|
||||
assert.deepEqual(withCopies(doc,join(f.root,'businesses/acme.json'),options).plugins['system-type'],['acmeplug']);
|
||||
doc.plugins['system-type']=['defualt'];
|
||||
assert.throws(()=>withCopies(doc,join(f.root,'businesses/acme.json'),options),/unknown module/);
|
||||
});
|
||||
|
||||
test('PL1 replacement pins refuse across loader and host tools until a fresh process',async t=>{
|
||||
for(const first of ['loader','tool']) {
|
||||
const f=fixture(t);
|
||||
writeFileSync(join(f.src,'helper.mjs'),'export const value=1;');
|
||||
writeFileSync(join(f.src,'usage-source/index.mjs'),"import {value} from '../helper.mjs';export const interfaceVersion=1;export const readTurn=()=>value;");
|
||||
writeFileSync(join(f.src,'tools/echo.mjs'),"import {value} from '../helper.mjs';export const execute=()=>value;");
|
||||
const pin=f.seal();f.install(f.src,{expectedHash:pin});
|
||||
const settings={'usage-source':['transcript','acmeplug'],harness:['pi']};
|
||||
const loader=createLoader({dataRoot:f.dataRoot,log:()=>{}});
|
||||
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:settings,roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})});
|
||||
if(first==='loader'){await loader.load(settings);assert.equal(loader.modules('usage-source')[1].readTurn(),1);}
|
||||
else assert.equal(await service.call('acme','writer','acmeplug.echo',{text:'before'}),1);
|
||||
removePlugin({dataRoot:f.dataRoot,name:'acmeplug',enabled:()=>false});
|
||||
writeFileSync(join(f.src,'helper.mjs'),'export const value=2;');f.manifest.version='2.0.0';
|
||||
const next=f.seal();assert.notEqual(next,pin);f.install(f.src,{expectedHash:next});
|
||||
// Verification, new loaders, and the other import path share one process latch.
|
||||
assert.throws(()=>verifiedPlugin(f.dataRoot,'acmeplug'),refused('plugin-restart-required'));
|
||||
await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:'after'}),refused('plugin-disabled'));
|
||||
const freshLoader=createLoader({dataRoot:f.dataRoot,log:()=>{}}),result=await freshLoader.load(settings);
|
||||
assert.equal(result.refused[0].code,'plugin-restart-required');
|
||||
assert.deepEqual(result.loaded['usage-source'],['transcript']);assert.deepEqual(result.loaded.harness,['pi']);
|
||||
await loader.load(null);assert.deepEqual(loader.loaded()['usage-source'],first==='loader'?['transcript']:[]);
|
||||
const script=`import {createLoader} from ${JSON.stringify(import.meta.resolve('../src/loader.mjs'))};
|
||||
const loader=createLoader({dataRoot:process.argv[1],log:()=>{}});const r=await loader.load({'usage-source':['acmeplug']});
|
||||
console.log(JSON.stringify({refused:r.refused,value:loader.modules('usage-source')[0].readTurn()}));`;
|
||||
const child=spawnSync(process.execPath,['--input-type=module','-e',script,f.dataRoot],{encoding:'utf8'});
|
||||
assert.equal(child.status,0,child.stderr);assert.deepEqual(JSON.parse(child.stdout),{refused:[],value:2});
|
||||
}
|
||||
});
|
||||
|
||||
test('PL1 even a failed import claims its package pin before dependencies enter the cache',async t=>{
|
||||
const f=fixture(t);writeFileSync(join(f.src,'helper.mjs'),'export const value=1;');
|
||||
writeFileSync(join(f.src,'usage-source/index.mjs'),"import '../helper.mjs';throw Error('fixture');");
|
||||
let pin=f.seal();f.install(f.src,{expectedHash:pin});
|
||||
const loader=createLoader({dataRoot:f.dataRoot,log:()=>{}});
|
||||
assert.equal((await loader.load({'usage-source':['acmeplug']})).refused[0].code,'load-failed');
|
||||
removePlugin({dataRoot:f.dataRoot,name:'acmeplug',enabled:()=>false});
|
||||
writeFileSync(join(f.src,'usage-source/index.mjs'),'export const interfaceVersion=1;export const readTurn=()=>2;');
|
||||
pin=f.seal();f.install(f.src,{expectedHash:pin});
|
||||
assert.equal((await loader.load({'usage-source':['transcript','acmeplug']})).refused[0].code,'plugin-restart-required');
|
||||
assert.deepEqual(loader.loaded()['usage-source'],['transcript']);
|
||||
});
|
||||
|
||||
test('PL1 plugin imports refuse unpinned bare, escaping, dynamic and require dependencies',async t=>{
|
||||
for(const spec of ['extdep','../../../outside.mjs']) {
|
||||
const f=fixture(t),marker=join(f.dataRoot,'executed');
|
||||
const outside=`import {writeFileSync} from 'node:fs';writeFileSync(${JSON.stringify(marker)},'ran');export const value=1;`;
|
||||
mkdirSync(join(f.dataRoot,'node_modules/extdep'),{recursive:true});
|
||||
writeFileSync(join(f.dataRoot,'node_modules/extdep/package.json'),JSON.stringify({type:'module',main:'index.mjs'}));
|
||||
writeFileSync(join(f.dataRoot,'node_modules/extdep/index.mjs'),outside);writeFileSync(join(f.dataRoot,'outside.mjs'),outside);
|
||||
writeFileSync(join(f.src,'usage-source/index.mjs'),`import ${JSON.stringify(spec)};export const interfaceVersion=1;export const readTurn=()=>1;`);
|
||||
const pin=f.seal();f.install(f.src,{expectedHash:pin});
|
||||
const result=await createLoader({dataRoot:f.dataRoot,log:()=>{}}).load({'usage-source':['transcript','acmeplug'],harness:['pi']});
|
||||
assert.equal(result.refused[0].code,'load-failed');assert.deepEqual(result.loaded['usage-source'],['transcript']);assert.deepEqual(result.loaded.harness,['pi']);assert.equal(existsSync(marker),false);
|
||||
}
|
||||
for(const body of ["return (await import('../../../outside.mjs')).value;","return require('../../../outside.cjs');"]) {
|
||||
const f=fixture(t),marker=join(f.dataRoot,'executed');
|
||||
writeFileSync(join(f.dataRoot,'outside.mjs'),`import {writeFileSync} from 'node:fs';writeFileSync(${JSON.stringify(marker)},'ran');export const value=1;`);
|
||||
writeFileSync(join(f.dataRoot,'outside.cjs'),`require('node:fs').writeFileSync(${JSON.stringify(marker)},'ran');module.exports=1;`);
|
||||
writeFileSync(join(f.src,'tools/echo.mjs'),`import {createRequire} from 'node:module';const require=createRequire(import.meta.url);export async function execute(){${body}}`);
|
||||
const pin=f.seal();f.install(f.src,{expectedHash:pin});
|
||||
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})});
|
||||
await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:SECRET}),refused('plugin-tool-failed'));assert.equal(existsSync(marker),false);
|
||||
}
|
||||
});
|
||||
|
||||
test('PL1 sandbox tools also refuse executable data URLs outside the pinned package',async t=>{
|
||||
const f=fixture(t);f.manifest.tools[0].runs='sandbox';
|
||||
writeFileSync(join(f.src,'tools/echo.mjs'),"export async function execute(){return (await import('data:text/javascript,export default 42')).default;}");
|
||||
const pin=f.seal();f.install(f.src,{expectedHash:pin});
|
||||
const service=pluginService({dataRoot:f.dataRoot,read:()=>({plugins:{'usage-source':['acmeplug']},roles:{writer:{tools:['acmeplug.echo'],authority:{withinRole:[],crossRole:[]}}}})});
|
||||
await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:SECRET}),refused('plugin-tool-failed'));
|
||||
});
|
||||
|
||||
test('PL1 lock package names use the manifest length bound',t=>{
|
||||
const f=fixture(t);writeFileSync(join(f.dataRoot,'plugins.lock.json'),JSON.stringify({version:1,plugins:{['a'.repeat(33)]:{version:'1.0.0',source:f.manifest.source,sha256:f.hash}}}),{mode:0o600});
|
||||
assert.throws(()=>readPluginLock(f.dataRoot),refused('invalid-lock'));
|
||||
assert.throws(()=>verifiedPlugin(f.dataRoot,'a'.repeat(33)),refused('bad-name'));
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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$/);
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: () => {} });
|
||||
}
|
||||
@@ -294,7 +294,7 @@
|
||||
d = document.createElement('dialog');
|
||||
d.id = 'tb-dialog'; d.className = 'tb-dialog'; d.setAttribute('aria-labelledby', 'tb-dialog-h');
|
||||
document.body.append(d);
|
||||
d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
|
||||
d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; d.removeAttribute('aria-describedby'); opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
|
||||
}
|
||||
return d;
|
||||
}
|
||||
@@ -322,7 +322,7 @@
|
||||
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Create task</button></div>
|
||||
</form>`;
|
||||
}
|
||||
function editForm(t) {
|
||||
function editForm(t, refusal = null) {
|
||||
const o = orgOf(t.org), w = taskWritable(t);
|
||||
const why = t.tracker === 'vikunja' ? 'This task is in Vikunja. The board reads it but does not change it; change it in Vikunja.'
|
||||
: !canWrite ? 'This Console takes no task changes.' : o.state !== 'ok' ? 'This organization\'s bus did not answer.' : !w ? 'The bus did not give this task a version to check a change against.' : '';
|
||||
@@ -331,6 +331,7 @@
|
||||
return `<div class="tb-form" data-tb-form="view"><h2 id="tb-dialog-h">${title(t)}</h2>${facts}
|
||||
<dl class="tb-dl"><dt>State</dt><dd>${esc(STATE_LABEL[t.state] || 'Not read')}</dd><dt>Assignees</dt><dd>${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'}</dd><dt>Priority</dt><dd>${prio(t) || 'none'}</dd><dt>Due</dt><dd>${due(t) || 'none'}</dd></dl>
|
||||
<p class="tb-field-note">${esc(why)}</p>
|
||||
${refusal ? `<p id="tb-dialog-status" class="tb-form-status" role="status" data-tb-form-status>${esc(refusal)}</p>` : ''}
|
||||
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Close</button></div></div>`;
|
||||
}
|
||||
return `<form method="dialog" class="tb-form" data-tb-form="edit" data-tb-key="${esc(key(t))}">
|
||||
@@ -345,6 +346,7 @@
|
||||
function openDialog(html, back, task = null) {
|
||||
const d = dialogEl();
|
||||
d.innerHTML = html; d.dataset.return = back || ''; opened = task ? structuredClone(task) : null;
|
||||
if (d.querySelector('#tb-dialog-status')) d.setAttribute('aria-describedby', 'tb-dialog-status'); else d.removeAttribute('aria-describedby');
|
||||
if (!d.open) d.showModal();
|
||||
d.querySelector('input:not([type=checkbox]),select,button')?.focus();
|
||||
}
|
||||
@@ -371,6 +373,25 @@
|
||||
return (Array.isArray(b?.tasks) && b.tasks.find(x => x.org === t.org && x.ref === t.ref)) || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
// A conflict replaces the stale form only once the current task has been read.
|
||||
async function reopenConflict(form, t, message) {
|
||||
formStatus(form, 'Reading the current task…');
|
||||
// A read a poll overtook leaves the board's old read in place, so only a read that landed counts.
|
||||
const fresh = await reload().catch(() => false);
|
||||
const now = (fresh && find(key(t))) || await current(t);
|
||||
// Closed (Escape, Cancel) while the task was read: the dialog stays closed. close() clears `open`
|
||||
// at once but queues the event that empties the dialog, so `open` is the check that holds.
|
||||
const d = dialogEl();
|
||||
if (!d.open || !d.contains(form)) { paint(); return; }
|
||||
if (now) { openDialog(editForm(now, message), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, message); }
|
||||
else {
|
||||
// The current task could not be read: the form still holds the old one, so it takes no save.
|
||||
opened = null;
|
||||
formStatus(form, 'The task changed after the board read it. Nothing was written, and the current task could not be read; close this form and open the task again.');
|
||||
form.querySelectorAll('[data-tb-cancel]').forEach(b => { b.disabled = false; });
|
||||
}
|
||||
paint();
|
||||
}
|
||||
async function submit(form) {
|
||||
const kind = form.dataset.tbForm, btns = form.querySelectorAll('button');
|
||||
// A form whose Save is disabled is sending, or waiting on the current task: it takes no second save.
|
||||
@@ -390,28 +411,7 @@
|
||||
}
|
||||
btns.forEach(b => { b.disabled = true; }); formStatus(form, 'Sending…');
|
||||
const r = await write(body);
|
||||
// The task changed under the form: the form reopens on the current task and keeps the refusal.
|
||||
// Until the current task is read the old form stays disabled and does not show the refusal, so
|
||||
// the refusal never stands beside the old task and no save goes out against its digest.
|
||||
if (!r.ok && r.error === 'task-conflict' && t) {
|
||||
formStatus(form, 'Reading the current task…');
|
||||
// A read a poll overtook leaves the board's old read in place, so only a read that landed counts.
|
||||
const fresh = await reload().catch(() => false);
|
||||
const now = (fresh && find(key(t))) || await current(t);
|
||||
// Closed (Escape, Cancel) while the task was read: the dialog stays closed. close() clears `open`
|
||||
// at once but queues the event that empties the dialog, so `open` is the check that holds.
|
||||
const d = dialogEl();
|
||||
if (!d.open || !d.contains(form)) { paint(); return; }
|
||||
if (now) { openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, r.message); }
|
||||
else {
|
||||
// The current task could not be read: the form still holds the old one, so it takes no save.
|
||||
opened = null;
|
||||
formStatus(form, 'The task changed after the board read it. Nothing was written, and the current task could not be read; close this form and open the task again.');
|
||||
form.querySelectorAll('[data-tb-cancel]').forEach(b => { b.disabled = false; });
|
||||
}
|
||||
paint();
|
||||
return;
|
||||
}
|
||||
if (!r.ok && r.error === 'task-conflict' && t) { await reopenConflict(form, t, r.message); return; }
|
||||
btns.forEach(b => { b.disabled = false; });
|
||||
if (!r.ok) {
|
||||
formStatus(form, r.message);
|
||||
@@ -425,6 +425,7 @@
|
||||
paint();
|
||||
}
|
||||
async function archive(form) {
|
||||
if (form.querySelector('[data-tb-archive]')?.disabled) return;
|
||||
const t = opened && key(opened) === form.dataset.tbKey ? opened : null;
|
||||
if (!t) return;
|
||||
if (!form.querySelector('[data-tb-archive]').hasAttribute('data-armed')) {
|
||||
@@ -432,7 +433,11 @@
|
||||
form.querySelector('[data-tb-archive]').textContent = 'Archive: press again to confirm';
|
||||
return;
|
||||
}
|
||||
const btns = form.querySelectorAll('button');
|
||||
btns.forEach(b => { b.disabled = true; }); formStatus(form, 'Sending…');
|
||||
const r = await write({ op: 'archive', org: t.org, task_ref: t.ref, expect: t.digest });
|
||||
if (!r.ok && r.error === 'task-conflict') { await reopenConflict(form, t, r.message); return; }
|
||||
btns.forEach(b => { b.disabled = false; });
|
||||
if (!r.ok) { formStatus(form, r.message); if (r.unknown) { try { await reload(); } catch {} paint(); } return; }
|
||||
notice = { text: `${shortId(t.ref)} archived in ${orgOf(t.org).label}.` };
|
||||
dialogEl().close();
|
||||
|
||||
@@ -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
@@ -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 }],
|
||||
|
||||
@@ -28,6 +28,32 @@ const state = b => b.evaluate('JSON.parse(JSON.stringify(window.MosaicAppearance
|
||||
const stored = (b, key) => b.evaluate(`localStorage.getItem(${JSON.stringify(key)})`);
|
||||
const inline = b => b.evaluate('document.documentElement.getAttribute("style")');
|
||||
|
||||
test('an organization browser override draws system and explicit light from the light colours, including after reload', { timeout: 90000 }, async t => {
|
||||
const { b, f } = await open(t, { saved: { theme: 'rosewood', mode: 'dark' } });
|
||||
const rgb = hex => `rgb(${hex.slice(1).match(/../g).map(n => parseInt(n, 16)).join(', ')})`;
|
||||
const theme = THEMES.find(t => t.id === 'rosewood');
|
||||
for (const scheme of ['light', 'dark']) {
|
||||
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: scheme }] });
|
||||
for (const mode of ['system', 'light']) {
|
||||
await b.evaluate(`window.MosaicAppearance.set({ mode: ${JSON.stringify(mode)} })`);
|
||||
// Read the actual scoped storage on a new document, not only the in-memory choice.
|
||||
await b.navigate(`${f.base}/?appearance=${scheme}-${mode}`);
|
||||
await b.evaluate('window.MosaicAppearance.ready.then(() => true)');
|
||||
const expected = mode === 'system' ? scheme : mode;
|
||||
const drawn = await b.evaluate(`({
|
||||
palette: document.documentElement.dataset.palette,
|
||||
mode: document.documentElement.getAttribute('data-mode'),
|
||||
canvas: getComputedStyle(document.body).backgroundColor,
|
||||
source: window.MosaicAppearance.state().modeSource,
|
||||
override: JSON.parse(localStorage.getItem('mosaic-console-appearance:acme')),
|
||||
})`);
|
||||
assert.deepEqual(drawn, { palette: 'rosewood', mode: mode === 'system' ? null : mode,
|
||||
canvas: rgb(theme.tokens[expected]['--canvas']), source: 'browser', override: { mode } }, `${scheme}/${mode}`);
|
||||
t.diagnostic(JSON.stringify({ scheme, requested: mode, ...drawn }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the built-in default is drawn by tokens.css alone: Harbor, dark, no inline colour, no warning, no cue, nothing stored', { timeout: 90000 }, async t => {
|
||||
const { b } = await open(t, { org: null });
|
||||
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }; };
|
||||
|
||||
@@ -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,'');}
|
||||
});
|
||||
@@ -53,6 +53,31 @@ const title = key => `(document.querySelector("#pane-${key} [data-pane-ttl]")?.t
|
||||
const query = '(() => Object.fromEntries(new URLSearchParams(location.hash.split("?")[1] || "")))()';
|
||||
const pick = (b, key, value) => b.evaluate(`(() => { const s = document.querySelector("#pane-${key}-pick"); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error("no option " + ${JSON.stringify(value)}); s.dispatchEvent(new Event("change", { bubbles: true })); })()`);
|
||||
|
||||
test('side by side stays dark by default under light media, and applying system or light really draws light', async t => {
|
||||
const { b, wait, base } = await open(t);
|
||||
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`);
|
||||
await b.evaluate('window.MosaicAppearance.ready.then(() => true)');
|
||||
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] });
|
||||
const drawn = () => b.evaluate(`({ mode: document.documentElement.getAttribute('data-mode'),
|
||||
palette: document.documentElement.dataset.palette, canvas: getComputedStyle(document.body).backgroundColor,
|
||||
source: window.MosaicAppearance.state().modeSource })`);
|
||||
assert.deepEqual(await drawn(), { mode: 'dark', palette: 'rosewood', canvas: 'rgb(26, 20, 23)', source: 'default' });
|
||||
for (const mode of ['system', 'light']) {
|
||||
// U5's corrected shot recipe seeds the per-org key, then loads a fresh document.
|
||||
const before = await drawn();
|
||||
await b.evaluate(`localStorage.setItem('mosaic-console-appearance:ddigital', JSON.stringify({ mode: ${JSON.stringify(mode)} }))`);
|
||||
assert.deepEqual(await drawn(), before, 'writing storage alone does not apply an override to this document');
|
||||
await b.navigate('about:blank');
|
||||
await b.navigate(`${base}?appearance=${mode}#/orgs/ddigital/side-by-side`);
|
||||
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`);
|
||||
await b.evaluate('window.MosaicAppearance.ready.then(() => true)');
|
||||
const result = await drawn();
|
||||
assert.deepEqual(result, { mode: mode === 'system' ? null : 'light', palette: 'rosewood',
|
||||
canvas: 'rgb(246, 243, 245)', source: 'browser' });
|
||||
t.diagnostic(JSON.stringify({ scheme: 'light', requested: mode, ...result }));
|
||||
}
|
||||
});
|
||||
|
||||
test('the Side by side nav item opens two panes with different organizations, and the URL restores both after a reload', async t => {
|
||||
const state = { flip: false, seen: [] };
|
||||
const { b, base, wait } = await open(t, { hash: '#/orgs/ddigital/organization', state });
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user