Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0cfebe286 | ||
|
|
9acc378555 | ||
|
|
db822c5b9d | ||
|
|
554bc5116d | ||
|
|
6db8fb2dce | ||
|
|
85122dd2bc | ||
|
|
82dcba2d45 | ||
|
|
29f7c3af85 | ||
|
|
17a48217f6 | ||
|
|
9d00f9ad54 |
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,77 @@ grep -n '^ExecStart=' /etc/systemd/system/mosaic-webui.service | grep -c -- ' --
|
||||
lead with the command's output. Removing only the args line does not help: the run then refuses
|
||||
`console-unit-legacy`.
|
||||
|
||||
The Console stays read-only after the upgrade, until #412 (#385).
|
||||
The library Console stays read-only after the upgrade (#385). From M3 it signs in through SSO
|
||||
accounts, as the migration below sets up.
|
||||
|
||||
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` or `console-unit-legacy` on any of these
|
||||
rules, before it stops a unit.
|
||||
### Migrating a host to SSO accounts (M3, #1873)
|
||||
|
||||
A host re-pinned onto an M3 copy migrates first, in this order. The order is the same for the `factory`
|
||||
(Case 1 and .53) and `library` (.54) profiles. Each step is a live step that needs Mos's go. Nothing in the
|
||||
stack picks or invents a subject: the subjects come from `console.sso`, which a human prepares, and
|
||||
`accounts.json` comes only from the human verb `mosaic accounts init`.
|
||||
|
||||
1. Check or create `.deployed` through the installer. The stack's user's `case2-install.sh` (section 6)
|
||||
writes `<dataRoot>/.deployed` naming the instance at its step 2. It never replaces an existing one, and
|
||||
it refuses one that is invalid or names another instance. Check it read-only:
|
||||
`stat -c '%U %a' <dataRoot>/.deployed` prints `mosaic 444`.
|
||||
2. Prepare SSO. A human adds `console.sso` to `/srv/mosaic/.config/mosaic-dev/config.json`, with its
|
||||
providers, their subjects and its client-secret slots, per
|
||||
`docs/plans/2026-11-10_accounts-design.md`. Jason places the client secrets (section 9).
|
||||
3. Run `mosaic accounts init --name <name>` as `mosaic`. This is a human verb, run from a human shell and
|
||||
never by an agent. It creates the one operator account from the configured `console.sso` subjects and
|
||||
refuses if `accounts.json` exists.
|
||||
4. Provision the dedicated uid. `mosaic` must keep section 1's shape: a non-login shell, the only member
|
||||
of its primary group, and owner of its config directory and data root. An accounts instance never runs
|
||||
under a human or development seat uid, and never under the `webui-service.sh` user units.
|
||||
5. Re-pin with `--next-args`. Leave `/etc/mosaic-stack/dev.args` as it is. Write the new flags to
|
||||
`/etc/mosaic-stack/dev.args.next`, root's, mode 0640: the same lines before `--`, and after `--` the
|
||||
serve flags with `--sso` added (`factory`: `--sso --send`). For `library`, also remove the
|
||||
proxy-preview trio (`--public-host`, `--auth-header`, `--allow-user`) and the proof pair
|
||||
(`--proof-header`, `--proof-file`). Then run
|
||||
`stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA --next-args`.
|
||||
|
||||
A plain re-pin refuses `render-refused` here, because an installed unit must equal the render but for
|
||||
the copy id. With `--next-args`, before anything stops, the driver checks the installed copy against
|
||||
`dev.args` and the target as a first install from `dev.args.next`. It refuses `next-args-differ` when
|
||||
the files differ before `--` (other than `--copy`), and `next-args-same` when they do not differ after
|
||||
it. After the stage it moves the installed unit files into a new 0700 directory
|
||||
`/etc/mosaic-stack/dev.replaced.XXXXXX` and renders the target as a first install. A failure from
|
||||
there moves them back unchanged and starts the old copy again. After `confirm` passes,
|
||||
`dev.args.next` becomes `dev.args`, and the replaced directory keeps the old unit files and the old
|
||||
`dev.args`. The result line names the directory. Nothing removes it; root removes it by hand once no
|
||||
rollback needs it.
|
||||
|
||||
To roll back past the migration, put the replaced args back as the next args and roll back with
|
||||
`--next-args`:
|
||||
|
||||
```sh
|
||||
install -m 0640 -o root -g root /etc/mosaic-stack/dev.replaced.XXXXXX/dev.args /etc/mosaic-stack/dev.args.next
|
||||
stack-upgrade.sh rollback --instance dev --to <old copy id> --next-args
|
||||
```
|
||||
|
||||
For `library` this is the only rollback to a copy older than M3: `case2-unit.sh` refuses `--sso` for
|
||||
an unstamped library copy (#416), so the old copy comes back with its old flags, proxy-preview
|
||||
admission included, as before the migration. A `factory` rollback may instead keep `--sso --send`
|
||||
with a plain `rollback`.
|
||||
|
||||
If a step is missing, `case2-unit.sh` refuses before it writes a unit and prints the missing steps,
|
||||
numbered in this order, for example:
|
||||
|
||||
```text
|
||||
case2-unit: refusing the units for <sha>: a stamped copy's Console starts only with SSO accounts; missing steps, in order:
|
||||
1. check or create .deployed: mosaic runs case2-install.sh, whose deployment step writes <dataRoot>/.deployed
|
||||
2. prepare SSO: console.sso in mosaic's system config /srv/mosaic/.config/mosaic-dev/config.json, with its client secret slots
|
||||
3. run `mosaic accounts init` as mosaic, a human verb: no /srv/mosaic/.config/mosaic-dev/accounts.json
|
||||
4. add --sso to the serve flags, then re-pin
|
||||
```
|
||||
|
||||
Without `console.sso`, the render does not check `accounts.json`, because its subjects name providers SSO
|
||||
has not configured yet. The next render after step 2 checks it. A rollback never removes accounts.json,
|
||||
its receipts or `.deployed` (`docs/plans/2026-11-10_accounts-design.md`, section 7).
|
||||
|
||||
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable`, `console-unit-legacy`, `render-refused`,
|
||||
`next-args-differ` or `next-args-same` on any of these rules, before it stops a unit.
|
||||
|
||||
## 8. Enrollment, once per host
|
||||
|
||||
@@ -450,7 +522,7 @@ file with `stat -c '%U %a %n' PATH`, never `cat`. `<business>` is the host's bus
|
||||
| Path | File | Mode | Owner | Profile | Precheck line |
|
||||
|---|---|---|---|---|---|
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | `claude-session.env`, one line `ZAI_API_KEY=...` | 0600 | mosaic | library | `<path> sets ZAI_API_KEY` |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | the Console's proof file, the path the args file's `--proof-file` names: one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | before M3 only, and an unstamped copy only: the Console's proof file, the path the args file's `--proof-file` names. A stamped copy refuses `--proof-file` (section 7). The file is one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | After a unit start, wait for loopback `/healthz` with the bounded retry in section 10 before `case2-precheck.sh --business <business> --library-status`: `library-last-error library-credential-missing` until it's placed, then `library <project> last fetched ...` |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/agents/<business>/<agent>/` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1220,6 +1220,11 @@ export class Broker {
|
||||
this.#businesses[name] = next;
|
||||
this.#scheduleRequests();
|
||||
}
|
||||
// #1873 A3: host-only, for the launch boundary's admission: the code that refuses `instance` in code
|
||||
// project `repo` under the business's current document (the one the last reload swapped in), or null.
|
||||
projectBarred(business, instance, repo) {
|
||||
return this.#projectRefused(this.#business(business), instance, repo)?.code ?? null;
|
||||
}
|
||||
// Host-only current role resolver shared by the broker and built-in task tracker.
|
||||
taskRoles(business) {
|
||||
const roles = this.#business(business).roles;
|
||||
@@ -1534,11 +1539,40 @@ export class Broker {
|
||||
this.#processingWork();
|
||||
const b = this.#business(s.business);
|
||||
if ((b.retired ?? []).includes(instance)) fail('instance-retired');
|
||||
this.#copyRefused(b, instance);
|
||||
if (!Object.hasOwn(b.roles, instance)) fail('unknown-role');
|
||||
if (this.#threaded(s.business, instance)) fail(thread);
|
||||
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked') fail('launch-revoked');
|
||||
if (this.#firing(s.business, instance)) fail('instance-firing');
|
||||
}
|
||||
// #1873 A3: a root or project copy the boot left unbound or refused isn't in `roles`. Its refusal
|
||||
// names the agent and the missing services or the reason, never a value (organization
|
||||
// agent-copies.mjs copyRefusal; inlined because this module also loads in the sandbox client).
|
||||
#copyRefused(b, instance) {
|
||||
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
|
||||
if (!copy || copy.state === 'instance') return;
|
||||
if (copy.state === 'unbound') throw new BusError('credential-unbound', `${instance} has no binding in ${b.id} for ${copy.missing.join(', ')}`);
|
||||
throw new BusError(copy.project === null ? 'copy-refused' : 'project-refused', `${instance} can't launch in ${b.id}: ${copy.reason}`);
|
||||
}
|
||||
// #1873 A3: why `instance` can't work in code project `repo`, as a BusError, or null when it can
|
||||
// (organization agent-copies.mjs projectRefusal, inlined for the same reason). A project copy works
|
||||
// only in its own project; in a project whose union refuses no agent works; an instance the
|
||||
// project excludes doesn't work there. The reason names the instance, the project and why.
|
||||
#projectRefused(b, instance, repo) {
|
||||
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
|
||||
if (copy && copy.project !== null && copy.project !== repo)
|
||||
return new BusError('project-agent-elsewhere', `${instance} works only in project ${copy.project}`);
|
||||
const union = repo === null || !b.unions || !Object.hasOwn(b.unions, repo) ? null : b.unions[repo];
|
||||
if (!union) return null;
|
||||
if (!union.ok) return new BusError('project-refused', `${instance} can't work in project ${repo}: project ${repo} refuses its agents: ${union.reason}`);
|
||||
if (!union.agents.includes(instance)) return new BusError('project-refused', `${instance} can't work in project ${repo}: the project excludes it`);
|
||||
return null;
|
||||
}
|
||||
// #1873 A3: the union refusal of code project `repo`, as a BusError naming the project and why, or null.
|
||||
#unionRefused(b, repo) {
|
||||
const union = repo === null || !b.unions || !Object.hasOwn(b.unions, repo) ? null : b.unions[repo];
|
||||
return union && !union.ok ? new BusError('project-refused', `project ${repo} refuses its agents: ${union.reason}`) : null;
|
||||
}
|
||||
// W-N1 (webby-0014): a fire of I stopped its runs, and no fire of I has finished since.
|
||||
#firing(business, instance) {
|
||||
return !!this.#store.get(
|
||||
@@ -1852,7 +1886,15 @@ export class Broker {
|
||||
this.#revokeLost('access-expiry', true, doc);
|
||||
rows = this.#threadRows('t.business=?', business);
|
||||
}
|
||||
return rows.map(({ owner, project, ...r }) => ({ ...r, ...(this.#archived(business, project) ? { archived: true } : {}), ...(lost(r, owner, project) ? { revoked: true } : {}) }));
|
||||
// #1873 A3: a thread whose instance no longer works in its project's code project (the project's
|
||||
// union refuses, or it excludes the instance) says so, so the launcher refuses its launch.
|
||||
const b = this.#business(business);
|
||||
const refusedIn = (instance, project) => {
|
||||
if (!b.unions && !b.copies) return null;
|
||||
const e = this.#projectRefused(b, instance, this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', project, business)?.repo ?? null);
|
||||
return e ? { projectRefused: { code: e.code, reason: e.reason } } : null;
|
||||
};
|
||||
return rows.map(({ owner, project, ...r }) => ({ ...r, ...(this.#archived(business, project) ? { archived: true } : {}), ...(lost(r, owner, project) ? { revoked: true } : {}), ...refusedIn(r.instance, project) }));
|
||||
}
|
||||
// Row 121 round 2 (#1615, Dewey's F3 in 28591): the launcher's idle stop. The tick decides
|
||||
// from a threadsState snapshot, and a message the run took after it would be delivered to a
|
||||
@@ -6110,6 +6152,12 @@ export class Broker {
|
||||
entry.running = this.#running(s.business, name);
|
||||
entry.alive = this.#aliveAfterFire(s.business, name);
|
||||
}
|
||||
// #1873 A3: the copies this bus booted with, and each one's running runs. The file's own
|
||||
// instances above carry no copy; a copy carries its state, missing service names or reason.
|
||||
if (request.verb === "instances" && result?.instances && typeof result.instances === "object" && this.#business(s.business).copies)
|
||||
result.copies = Object.fromEntries(Object.entries(this.#business(s.business).copies).map(([name, c]) => [name, {
|
||||
...structuredClone(c), running: c.state === 'instance' ? this.#running(s.business, name) : [],
|
||||
}]));
|
||||
return result;
|
||||
}
|
||||
return this.#store.transaction(() => {
|
||||
@@ -8033,7 +8081,17 @@ export class Broker {
|
||||
// #797 MVP3: an archived project takes no new room threads and no new requests; reading it
|
||||
// (room.read) and every existing record stay open.
|
||||
if ((opening || sending) && this.#archived(s.business, project)) fail('project-archived');
|
||||
const members = Object.keys(b.roles).filter((instance) => this.#threaded(s.business, instance)).sort();
|
||||
// #1873 A3: the room's members are the thread instances that work in its code project. A project
|
||||
// whose union refuses takes no new room thread or request, naming the project and why; its
|
||||
// existing threads stay readable.
|
||||
const repo = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', project, s.business)?.repo ?? null;
|
||||
const unionRefused = this.#unionRefused(b, repo);
|
||||
if ((opening || sending) && unionRefused) throw unionRefused;
|
||||
const members = Object.keys(b.roles).filter((instance) => {
|
||||
if (!this.#threaded(s.business, instance)) return false;
|
||||
const refused = this.#projectRefused(b, instance, repo);
|
||||
return !refused || (unionRefused !== null && refused.code === 'project-refused');
|
||||
}).sort();
|
||||
if (!members.length) fail('thread-instance-missing');
|
||||
const owner = this.#owner(s);
|
||||
// Preflight all collisions/missing threads before open creates or send writes anything.
|
||||
@@ -8121,7 +8179,13 @@ export class Broker {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['project', 'name', 'instance'], ['project', 'name']);
|
||||
if (!name(a.name)) fail('invalid-request');
|
||||
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
// #1873 A3: a root or project copy takes a thread only when the caller names it, so leaving the
|
||||
// instance out still chooses among the organization file's own thread instances.
|
||||
const copies = b.copies ?? {};
|
||||
// #1873 A3: the code project the chat project maps to decides which agents work in it.
|
||||
const repo = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', identifier(a.project), s.business)?.repo ?? null;
|
||||
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r) &&
|
||||
(a.instance !== undefined || (!Object.hasOwn(copies, r) && !this.#projectRefused(b, r, repo))));
|
||||
const instance = a.instance === undefined
|
||||
? (instances.length === 1 ? instances[0] : null)
|
||||
: identifier(a.instance);
|
||||
@@ -8135,9 +8199,18 @@ export class Broker {
|
||||
fail('project-not-found');
|
||||
// #797 MVP3: an archived project refuses new threads (and the room's requests, above).
|
||||
if (this.#archived(s.business, identifier(a.project))) fail('project-archived');
|
||||
// #1873 A3: a named copy the boot left unbound or refused says why, and a project whose union
|
||||
// refuses takes no thread, naming the project and why.
|
||||
if (a.instance !== undefined && instance) this.#copyRefused(b, instance);
|
||||
const unionRefused = this.#unionRefused(b, repo);
|
||||
if (unionRefused) throw unionRefused;
|
||||
// The human selects an instance; omission is compatible only with one candidate.
|
||||
if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
|
||||
if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
|
||||
// #1873 A3: a project agent works only in its own project, and an agent the project excludes
|
||||
// doesn't work in it.
|
||||
const elsewhere = this.#projectRefused(b, instance, repo);
|
||||
if (elsewhere) throw elsewhere;
|
||||
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
|
||||
const tid = randomUUID();
|
||||
this.#store.run(
|
||||
|
||||
@@ -88,7 +88,7 @@ function launchOp(m) {
|
||||
if (m.op === 'request.release') return runtime.requestRelease(m.cap, m.record);
|
||||
if (m.op === 'request.manifest') return runtime.requestManifest(m.record);
|
||||
if (m.op === 'request.chief') return runtime.requestChief(m.business);
|
||||
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, thread: m.thread, session: m.session });
|
||||
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session });
|
||||
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
|
||||
if (m.op === 'modelAdmission') return runtime.modelAdmission(m);
|
||||
if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
|
||||
|
||||
@@ -35,7 +35,7 @@ import { instanceSettings } from '../../organization/src/instance-settings.mjs';
|
||||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
|
||||
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
||||
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { loadRole } from '../../organization/src/role.mjs';
|
||||
import { resolveInstance } from '../../organization/src/resolve.mjs';
|
||||
import { canonicalJson } from '../../organization/src/util.mjs';
|
||||
@@ -284,7 +284,14 @@ export async function startBroker({
|
||||
builtinBinding = null,
|
||||
modelCatalogue = null, // in-process fixture seam; boot JSON cannot supply a service
|
||||
makeProviderLoader = createLoader, // in-process fixture seam; boot JSON cannot supply a function
|
||||
// #1873 A3: the boot's root and project agent copies (cli config.mjs copiesAtBoot), plain data. Every
|
||||
// reload of the organization file below re-applies this snapshot, never the root file.
|
||||
copies = null,
|
||||
}) {
|
||||
if (copies !== null && (typeof copies !== 'object' || Array.isArray(copies) || typeof copies.org !== 'string'
|
||||
|| !Object.hasOwn(businesses ?? {}, copies.org) || copies.copies === null || typeof copies.copies !== 'object' || Array.isArray(copies.copies)))
|
||||
throw new BusError('invalid-host-request');
|
||||
const loadOrg = id => loadWithCopies(id, { dir: organization.dir, rolesDir: organization.rolesDir, snapshot: copies?.org === id ? copies : null });
|
||||
if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) ||
|
||||
Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512)))
|
||||
throw new BusError('invalid-host-request');
|
||||
@@ -564,7 +571,7 @@ export async function startBroker({
|
||||
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
|
||||
function reload(id) {
|
||||
try {
|
||||
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const b = loadOrg(id);
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
|
||||
const next = withoutPending(id, busOrganization(b, resolved));
|
||||
@@ -595,7 +602,7 @@ export async function startBroker({
|
||||
if (!found || found.after !== organization_revision) refuse('bootstrap-receipt-mismatch');
|
||||
// Only this bootstrap's own post-add revision may be admitted: any later organization write means the confirmed world moved (#800).
|
||||
if (revision !== found.after) refuse('bootstrap-revision-stale');
|
||||
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const b = loadOrg(id);
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
|
||||
const full = busOrganization(b, resolved);
|
||||
@@ -622,7 +629,11 @@ export async function startBroker({
|
||||
// product the broker holds is ready only while it has a complete marker and a committed activate receipt: the
|
||||
// answer is read from both on every call, so a marker removed after the activation closes it again, and the
|
||||
// broker's reload now follows both readiness records (#802).
|
||||
function admission({ business: id, project, projects, thread, session }) {
|
||||
// With `instance` (#1873 A3), the launch boundary also asks whether that instance may work in each
|
||||
// project as the business stands now: a project that excludes it, or whose union refuses, is not ready.
|
||||
// `instance` comes with `revision`, the organization file revision the launch resolved its bundle
|
||||
// from; a file that has changed since (a narrow, a project define's vars, an exclude) is not ready.
|
||||
function admission({ business: id, project, projects, instance, revision: launched, thread, session }) {
|
||||
if (projects !== undefined) {
|
||||
const no = code => ({ ready: false, revision: null, code });
|
||||
if (typeof id !== 'string' || !Object.hasOwn(businesses, id) || !Array.isArray(projects) || projects.some(p => typeof p !== 'string')) return no('project-unknown');
|
||||
@@ -631,9 +642,21 @@ export async function startBroker({
|
||||
if (!row) return no('session-not-found');
|
||||
if (row.archived) return no('project-archived');
|
||||
}
|
||||
if (instance !== undefined && (typeof instance !== 'string' || typeof launched !== 'string')) return no('project-unknown');
|
||||
if (instance !== undefined) {
|
||||
let now;
|
||||
try { now = writer?.read(id).revision; } catch { return no('organization-unreadable'); }
|
||||
if (now === undefined) return no('organization-unreadable');
|
||||
if (now !== launched) return no('organization-changed');
|
||||
}
|
||||
for (const p of projects) {
|
||||
const result = admission({ business: id, project: p });
|
||||
if (!result.ready) return result;
|
||||
if (instance !== undefined) {
|
||||
// The broker holds the document the last writer reload swapped in; `businesses` is the boot one.
|
||||
const barred = broker.projectBarred(id, instance, p);
|
||||
if (barred) return no(barred);
|
||||
}
|
||||
}
|
||||
return { ready: true, revision: null, code: null };
|
||||
}
|
||||
@@ -753,7 +776,7 @@ export async function startBroker({
|
||||
// plugins.set reloads the business's limit checks in the background.
|
||||
organization:settings?(proxyOrganization?.wrap ?? (operation => operation))((id,verb,args,actor,context)=>{
|
||||
if (verb === 'project.remote.get.console' || verb === 'project.remote.set.console') {
|
||||
const current = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const current = loadOrg(id);
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(current.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: current, instance });
|
||||
const live = withoutPending(id, busOrganization(current, resolved));
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// #1873 Phase A slice A3: the broker half of root and project agent copies. The business carries the
|
||||
// boot's `copies` view (organization agent-copies.mjs withCopies): a bound copy is also in `roles`,
|
||||
// an unbound or refused one is not. The cli half, with real runners, is cli agent-copies.test.mjs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { BusError, Broker } from '../src/broker.mjs';
|
||||
|
||||
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
|
||||
const copy = (name, definition, project, state, extra = {}) => ({ name, level: project === null ? 'root' : 'project', project, definition, state, ...extra });
|
||||
const demo = () => ({
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' },
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, companion: { messaging: shipped('companion').messaging } },
|
||||
projects: { stack: { root: '/nonexistent/stack' }, web: { root: '/nonexistent/web' }, docs: { root: '/nonexistent/docs' } },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', ...none },
|
||||
pm: { definition: 'pm', ...none },
|
||||
assistant: { definition: 'assistant', ...none },
|
||||
gate: { definition: 'merge-gate', ...none },
|
||||
'companion.stack': { definition: 'companion', ...none },
|
||||
buddy: { definition: 'companion', ...none },
|
||||
},
|
||||
copies: {
|
||||
gate: copy('gate', 'merge-gate', null, 'instance'),
|
||||
buddy: copy('buddy', 'companion', null, 'instance'),
|
||||
'companion.stack': copy('companion', 'companion', 'stack', 'instance'),
|
||||
researcher: copy('researcher', 'research', null, 'unbound', { missing: ['gitea'] }),
|
||||
wide: copy('wide', 'merge-gate', null, 'refused', { reason: 'model <value> is not in launch.models' }),
|
||||
'lint.web': copy('lint', 'security-reviewer', 'web', 'refused', { reason: 'project web refuses its agents: ghost is excluded, but nothing defines it' }),
|
||||
},
|
||||
// Each code project's union as withCopies computes it: web excludes buddy, docs refuses.
|
||||
unions: {
|
||||
stack: { ok: true, agents: ['assistant', 'buddy', 'chief', 'companion.stack', 'gate', 'pm'] },
|
||||
web: { ok: true, agents: ['assistant', 'chief', 'gate', 'pm'] },
|
||||
docs: { ok: false, reason: 'projects.docs.agents.ghost: names no root or organization agent to exclude' },
|
||||
},
|
||||
});
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-copies-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const b = new Broker({ store, businesses: { demo: demo() } });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (verb, args = {}) => b.request(human, { verb, args });
|
||||
const refusal = (fn) => {
|
||||
try { fn(); } catch (e) { assert.ok(e instanceof BusError, String(e)); return e; }
|
||||
assert.fail('no refusal');
|
||||
};
|
||||
const trail = () => store.all('SELECT kind, body FROM events').map((e) => `${e.kind} ${e.body}`).join('\n');
|
||||
return { b, store, call, refusal, trail };
|
||||
}
|
||||
|
||||
test('A3 bus: a bound copy takes a launch request; an unbound or refused one refuses at the verb by name, with the code alone in the trail', (t) => {
|
||||
const f = setup(t);
|
||||
assert.match(f.call('instance.launch', { instance: 'gate' }).request, /\S/);
|
||||
const unbound = f.refusal(() => f.call('instance.launch', { instance: 'researcher' }));
|
||||
assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']);
|
||||
const refused = f.refusal(() => f.call('instance.launch', { instance: 'wide' }));
|
||||
assert.equal(refused.code, 'copy-refused');
|
||||
assert.match(refused.reason, /^wide can't launch in demo: /);
|
||||
const project = f.refusal(() => f.call('instance.launch', { instance: 'lint.web' }));
|
||||
assert.equal(project.code, 'project-refused');
|
||||
assert.match(project.reason, /^lint\.web can't launch in demo: project web refuses its agents: /);
|
||||
// Regenerate takes the same check.
|
||||
assert.equal(f.refusal(() => f.call('instance.regenerate', { instance: 'researcher' })).code, 'credential-unbound');
|
||||
// A name that is no copy and no instance is still unknown.
|
||||
assert.equal(f.refusal(() => f.call('instance.launch', { instance: 'nobody' })).code, 'unknown-role');
|
||||
// The trail holds each refusal's code, never its reason.
|
||||
const trail = f.trail();
|
||||
assert.match(trail, /credential-unbound/);
|
||||
for (const text of ['has no binding', "can't launch", 'refuses its agents', SECRET]) assert.equal(trail.includes(text), false, text);
|
||||
});
|
||||
|
||||
test('A3 bus: a project agent takes a thread only in its own project, and only when named', (t) => {
|
||||
const f = setup(t);
|
||||
const stack = f.call('project.create', { name: 'stack work', repo: 'stack' }).id;
|
||||
const web = f.call('project.create', { name: 'web work', repo: 'web' }).id;
|
||||
const loose = f.call('project.create', { name: 'loose' }).id;
|
||||
// Unnamed, the organization file's own thread instance is still the one choice.
|
||||
const plain = f.call('thread.create', { project: stack, name: 'plain' }).id;
|
||||
assert.equal(f.store.get('SELECT instance FROM threads WHERE id=?', plain).instance, 'assistant');
|
||||
const own = f.call('thread.create', { project: stack, name: 'own', instance: 'companion.stack' });
|
||||
assert.match(own.id, /\S/);
|
||||
for (const project of [web, loose])
|
||||
assert.equal(f.refusal(() => f.call('thread.create', { project, name: 'elsewhere', instance: 'companion.stack' })).code, 'project-agent-elsewhere');
|
||||
// A named copy the boot refused says why, before the thread instance check.
|
||||
const unbound = f.refusal(() => f.call('thread.create', { project: stack, name: 'r', instance: 'researcher' }));
|
||||
assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']);
|
||||
});
|
||||
|
||||
test('A3 bus: a room holds only the thread instances that work in its project; an excluded agent takes no thread there; a refused union takes none at all, naming the project', (t) => {
|
||||
const f = setup(t);
|
||||
const project = (repo) => f.call('project.create', { name: `${repo ?? 'loose'} work`, ...(repo ? { repo } : {}) }).id;
|
||||
const [stack, web, docs, loose] = [project('stack'), project('web'), project('docs'), project(null)];
|
||||
const members = (id) => f.call('room.open', { project: id }).threads.map((x) => x.instance).sort();
|
||||
// Another project's agent never joins, and the project's own exclusion holds.
|
||||
assert.deepEqual(members(stack), ['assistant', 'buddy', 'companion.stack']);
|
||||
assert.deepEqual(members(web), ['assistant']);
|
||||
assert.deepEqual(members(loose), ['assistant', 'buddy']);
|
||||
assert.deepEqual(f.call('room.send', { project: web, body: 'hello' }).to, ['assistant']);
|
||||
const excluded = f.refusal(() => f.call('thread.create', { project: web, name: 'b', instance: 'buddy' }));
|
||||
assert.deepEqual([excluded.code, excluded.reason], ['project-refused', "buddy can't work in project web: the project excludes it"]);
|
||||
// docs's union refuses: no thread, named or not, and no room, each naming docs and why.
|
||||
for (const fn of [
|
||||
() => f.call('thread.create', { project: docs, name: 'plain' }),
|
||||
() => f.call('thread.create', { project: docs, name: 'a', instance: 'assistant' }),
|
||||
() => f.call('room.open', { project: docs }),
|
||||
]) {
|
||||
const e = f.refusal(fn);
|
||||
assert.equal(e.code, 'project-refused');
|
||||
assert.match(e.reason, /^project docs refuses its agents: .*ghost/);
|
||||
}
|
||||
assert.equal(f.trail().includes('refuses its agents'), false, 'the trail holds the code only');
|
||||
});
|
||||
@@ -37,7 +37,9 @@ const baselines = {
|
||||
// #1839: artifact Console replies alone may use 2 MiB; the proof and other limits are unchanged.
|
||||
// #1873 U1: humanCall, busCaller, userCall and userCaller take an optional signal and absolute
|
||||
// deadline; userCall shares transport(). Nonces, argv and limits are unchanged.
|
||||
"packages/webui/src/bus.mjs": "3ef8064dd7911f9eee4475b52c74a1d551583a065474b1bcee968cd45f722747",
|
||||
// #1873 A3: STATUS maps credential-unbound, copy-refused, project-refused and project-agent-elsewhere
|
||||
// to 409; no proof or transport change.
|
||||
"packages/webui/src/bus.mjs": "d8f3e0eb5299fdb192e98a1238ca693aae7c36af9a21de72d5f5d39610589377",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/agent-copies.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/agent-copies.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";
|
||||
@@ -614,8 +615,10 @@ async function bus(rest, io, deps) {
|
||||
const parsed = parse(args, { flags: ["--pm", "--no-launch", "--dev-local-owner", "--detach", "--internal-host"] });
|
||||
if (parsed.positional.length !== 1) throw usage("bus start takes one organization");
|
||||
if (parsed.flags.has("--no-launch") && parsed.flags.has("--pm")) throw usage("bus start takes --pm or --no-launch, not both");
|
||||
assertBuildStart({ devOwner: parsed.flags.has("--dev-local-owner") });
|
||||
if (parsed.flags.has("--dev-local-owner")) devOwnerInstance(io.env);
|
||||
const ctx = organizationContext(io, deps);
|
||||
if (parsed.flags.has("--dev-local-owner")) assertDevInstance(ctx.system, io.env);
|
||||
const businessId = parsed.positional[0];
|
||||
if (!parsed.flags.has("--internal-host")) {
|
||||
const receipt = await busStart(ctx, businessId, { detach: parsed.flags.has("--detach"), devLocalOwner: parsed.flags.has("--dev-local-owner"), pm: parsed.flags.has("--pm"), noLaunch: parsed.flags.has("--no-launch") });
|
||||
@@ -658,7 +661,7 @@ async function bus(rest, io, deps) {
|
||||
// tracker gets (the bus runtime makes the same choice, runtime.mjs trackerOf).
|
||||
const taskModule = boot.businesses?.[businessId]?.plugins?.["task-tracker"];
|
||||
const tracker = Boolean(boot.trackers?.[businessId]) || taskModule === undefined || taskModule === "store";
|
||||
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
|
||||
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, copies: boot.copies ?? null, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
|
||||
await launcher.listen();
|
||||
if (instance) io.stdout.write(`DEV LOCAL OWNER MODE: console owner local-dev accepted (named instance ${instance})\n`);
|
||||
if (instance && parsed.flags.has("--pm")) io.stdout.write('DEV LOCAL OWNER MODE: launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only\n');
|
||||
|
||||
@@ -10,6 +10,10 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { managedState } from "../../organization/src/project-bootstrap.mjs";
|
||||
import { BusinessError, accessFilePath, configDir, loadAccess, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../organization/src/index.mjs";
|
||||
import { loadRootAgents } from "../../organization/src/agent-levels.mjs";
|
||||
import { materializeCopies, withCopies } from "../../organization/src/agent-copies.mjs";
|
||||
import { organizationWriter } from "../../organization/src/organization.mjs";
|
||||
import { businessFilePath } from "../../organization/src/organization-config.mjs";
|
||||
import { busOrganization } from "../../bus/src/organization-context.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
@@ -144,10 +148,29 @@ export const consoleOwners = (sso) =>
|
||||
// The `{op: 'boot'}` config for packages/bus/src/process.mjs, for one
|
||||
// business. The host holds a reader capability for the notifier and binds
|
||||
// launches later through bindLaunch, so `launches` starts empty.
|
||||
// #1873 A3: the organization's root and project agent copies, read once here. The root file is read
|
||||
// through loadRootAgents and the organization file through the organization's own writer; the
|
||||
// snapshot is plain data that the broker and the launcher re-apply to every later read of the
|
||||
// organization file, so a root file change takes effect at the next boot only. A root file that
|
||||
// refuses hides the root level (design section 6): no root copy, and the organization still boots.
|
||||
export function copiesAtBoot({ businessId, env = process.env, warn = () => {} }) {
|
||||
const dir = configDir(env), roles = rolesDir(env);
|
||||
loadBusiness(businessId, { dir, rolesDir: roles });
|
||||
const { document } = organizationWriter({ dir, rolesDir: roles }).read(businessId);
|
||||
let root = null;
|
||||
try { root = loadRootAgents(dir, { rolesDir: roles }); }
|
||||
catch (error) {
|
||||
if (!(error instanceof BusinessError)) throw error;
|
||||
warn(`root agent file refused, so ${businessId} runs no root copies until it is fixed and the bus restarts: ${error.message}`);
|
||||
}
|
||||
const snapshot = materializeCopies({ orgId: businessId, doc: document, root, rolesDir: roles });
|
||||
return { snapshot, business: withCopies(document, businessFilePath(businessId, dir), { rolesDir: roles, snapshot }) };
|
||||
}
|
||||
|
||||
export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env = process.env, warn = () => {} }) {
|
||||
return business(() => {
|
||||
const vars = systemVars(system);
|
||||
const b = loadBusiness(businessId, { dir: configDir(env), rolesDir: rolesDir(env) });
|
||||
const { snapshot, business: b } = copiesAtBoot({ businessId, env, warn });
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: vars, business: b, instance });
|
||||
const config = {
|
||||
@@ -158,6 +181,8 @@ export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env
|
||||
organization: {dir:configDir(env),rolesDir:rolesDir(env),system:vars},
|
||||
launches: [],
|
||||
readers: [b.id],
|
||||
// Present only when the organization has a copy, so a boot with none is the boot it was.
|
||||
...(Object.keys(snapshot.copies).length ? { copies: snapshot } : {}),
|
||||
};
|
||||
const tracker = trackerFor(vars, b, warn, orgRoot);
|
||||
if (tracker) {
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -143,9 +143,10 @@ import { createHash } from 'node:crypto';
|
||||
import { watchModelOutput } from './model-stall.mjs';
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../organization/src/index.mjs";
|
||||
import { configDir, resolveInstance, systemVars } from "../../organization/src/index.mjs";
|
||||
import { organizationWriter } from "../../organization/src/organization.mjs";
|
||||
import { businessFilePath, validateBusinessDocument } from "../../organization/src/organization-config.mjs";
|
||||
import { businessFilePath } from "../../organization/src/organization-config.mjs";
|
||||
import { copyOf, copyRefusal, loadWithCopies, projectRefusal, withCopies } from "../../organization/src/agent-copies.mjs";
|
||||
import { selectModelProvider } from "../../plugins/src/model-provider-selection.mjs";
|
||||
import { THREAD_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
|
||||
import { limitHit } from "../../organization/src/launch.mjs";
|
||||
@@ -244,6 +245,9 @@ export function createLauncher({
|
||||
git = {},
|
||||
stopOptions = {},
|
||||
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
|
||||
// #1873 A3: the root and project agent copies the bus booted with (config.mjs copiesAtBoot), or
|
||||
// null. Every read of the organization file here re-applies this snapshot, never the root file.
|
||||
copies = null,
|
||||
}) {
|
||||
if (testProxy !== null && system.proxy)
|
||||
throw Object.assign(new Error("testProxy skips P2 admission, so it is refused with system.proxy set"), { code: "test-proxy-refused" });
|
||||
@@ -257,7 +261,8 @@ export function createLauncher({
|
||||
}
|
||||
// Row 64 (#1557): where a role's skills are read from; scripts/skill.sh activate fills it.
|
||||
const skillsDir = join(system.instanceRoot ?? dataRoot, "skills-enabled");
|
||||
let b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
|
||||
const loadOrg = () => loadWithCopies(host.business, { dir: configDir(env), rolesDir: rolesDir(env), snapshot: copies?.org === host.business ? copies : null });
|
||||
let b = loadOrg();
|
||||
const vars = systemVars(system);
|
||||
|
||||
// Sessions an earlier host left in sessions.json: it died without ending
|
||||
@@ -355,10 +360,17 @@ export function createLauncher({
|
||||
try {
|
||||
const snapshot = organizationWriter({dir:configDir(env),rolesDir:rolesDir(env)}).read(host.business);
|
||||
businessRevision = snapshot.revision;
|
||||
b = validateBusinessDocument(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env)});
|
||||
b = withCopies(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env), snapshot: copies?.org === host.business ? copies : null});
|
||||
} catch(e){throw new Refusal("organization-refused",e.message);}
|
||||
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
|
||||
// #1873 A3: an unbound or refused copy never launches. The message names the copy, its project
|
||||
// and the missing service names or the refusal, never a value.
|
||||
const copyRefused = copyRefusal(b, instance);
|
||||
if (copyRefused) throw new Refusal(copyRefused.code, copyRefused.message);
|
||||
// #1873 A3: a bound copy isn't on the chief's list. It launches when the human asks, or for a
|
||||
// thread, which only a human or a granted user makes, naming the copy.
|
||||
const boundCopy = copyOf(b, instance)?.state === "instance" && Object.hasOwn(b.roles, instance);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)) || (thread && boundCopy))) throw new Refusal("instance-not-listed");
|
||||
const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
|
||||
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
|
||||
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
|
||||
@@ -539,12 +551,12 @@ export function createLauncher({
|
||||
await pickRoute(() => standIn);
|
||||
await gate(model, harness, null);
|
||||
admission(admissionGeneration);
|
||||
return { resolved, model, account: null, harness, route, routeReason, admissionGeneration, resolver };
|
||||
return { resolved, model, account: null, harness, route, routeReason, admissionGeneration, resolver, revision: businessRevision };
|
||||
}
|
||||
const sessionProxy = await pickRoute(proxyFor);
|
||||
const account = await gate(model, harness, sessionProxy);
|
||||
admission(admissionGeneration);
|
||||
return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy, resolver };
|
||||
return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy, resolver, revision: businessRevision };
|
||||
}
|
||||
|
||||
// Reread host state on every new-run path, including opens/clears queued before archive.
|
||||
@@ -554,6 +566,8 @@ export function createLauncher({
|
||||
const row = rows.find(r => r.thread === thread && r.session === session);
|
||||
if (!row) throw new Refusal("session-not-found");
|
||||
if (row.archived) throw new Refusal("project-archived");
|
||||
// #1873 A3: the thread's project no longer takes its instance: the union refuses, or excludes it.
|
||||
if (row.projectRefused) throw new Refusal(row.projectRefused.code, row.projectRefused.reason);
|
||||
}
|
||||
|
||||
// Bundle, spawn, bind, register, hand over the capability.
|
||||
@@ -616,13 +630,24 @@ export function createLauncher({
|
||||
// (a mapping published before the seed and records finished) is left out and the launch goes on without
|
||||
// it. Ordinary mappings also recheck archive state through the same host admission.
|
||||
const codeProjects = {};
|
||||
// #1873 A3: an instance gets only the code projects it works in: a project agent its own one, and
|
||||
// no instance a project that excludes it or whose union refuses. A project agent refuses when its
|
||||
// own project isn't admitted. A checkout an earlier launch left in the workspace for a project
|
||||
// the instance may not work in now is hidden from the session, never deleted (sandbox.mjs).
|
||||
const own = copyOf(b, instance)?.project ?? null;
|
||||
const declared = new Set();
|
||||
for (const [project, entry] of Object.entries(b.projects)) {
|
||||
if (entry.kind === 'library') continue;
|
||||
declared.add(project);
|
||||
if (own !== null && project !== own) continue;
|
||||
const unavailable = projectRefusal(b, instance, project);
|
||||
if (unavailable) { record({ event: "checkout-skipped", instance, project, reason: unavailable.code }); continue; }
|
||||
let answer = null;
|
||||
try { answer = await host.op({ op: 'request.admission', business: b.id, project }); } catch (e) { answer = { ready: false, code: e.code ?? 'admission-unavailable' }; }
|
||||
if (answer?.ready !== true) { record({ event: "checkout-skipped", instance, project, reason: answer?.code ?? 'project-pending' }); continue; }
|
||||
codeProjects[project] = entry;
|
||||
}
|
||||
if (own !== null && !Object.hasOwn(codeProjects, own)) throw new Refusal("project-refused", `${instance} works only in project ${own}, which isn't ready`);
|
||||
await threadAdmission(thread, session);
|
||||
let checkouts;
|
||||
try {
|
||||
@@ -646,9 +671,30 @@ export function createLauncher({
|
||||
catch (error) { throw new Refusal(error.code ?? 'proxy-down', error.code ?? 'proxy-down'); }
|
||||
if (!admittedProxy || typeof admittedProxy.key !== 'string' || !admittedProxy.key || admittedProxy.baseUrl !== `http://127.0.0.1:${system.proxy.port}`) throw new Refusal('proxy-down', 'proxy-down');
|
||||
}
|
||||
let harness, route, routeReason, sessionProxy, resolver;
|
||||
({resolved,model,account,harness,route,routeReason,sessionProxy,resolver}=await check(instance,{pm:instance===b.launch.by,thread,session,human,proxyContext:admittedProxy,proxyReady:true}));
|
||||
let harness, route, routeReason, sessionProxy, resolver, revision;
|
||||
({resolved,model,account,harness,route,routeReason,sessionProxy,resolver,revision}=await check(instance,{pm:instance===b.launch.by,thread,session,human,proxyContext:admittedProxy,proxyReady:true}));
|
||||
admission(admissionGeneration);
|
||||
// #1873 A3: check() read the organization again. A project that excluded or refused this
|
||||
// instance while its checkouts were prepared refuses the launch; the next one leaves it out.
|
||||
for (const project of Object.keys(codeProjects)) {
|
||||
const unavailable = projectRefusal(b, instance, project);
|
||||
if (unavailable) throw new Refusal(unavailable.code, unavailable.message);
|
||||
}
|
||||
// #1873 A3: every declared code project this launch leaves out, in the document it started from
|
||||
// or the one check() read, is hidden if an earlier launch left its checkout in the workspace,
|
||||
// whatever kept it out (not admitted, excluded, refused, another project's agent). Only a real
|
||||
// directory can be covered: a tmpfs mounted at a symlink would land on its target, so anything
|
||||
// else at that path refuses the launch, and nothing on the host is changed or removed.
|
||||
for (const [project, entry] of Object.entries(b.projects)) if (entry.kind !== 'library') declared.add(project);
|
||||
const hidden = [];
|
||||
for (const project of declared) {
|
||||
if (Object.hasOwn(codeProjects, project)) continue;
|
||||
const path = join(workspace, project);
|
||||
let stat;
|
||||
try { stat = lstatSync(path); } catch (e) { if (e.code === "ENOENT") continue; throw new Refusal("checkout-unsafe", `${instance}'s workspace path for project ${project} can't be read`); }
|
||||
if (!stat.isDirectory()) throw new Refusal("checkout-unsafe", `${instance}'s workspace path for project ${project} isn't a directory, so it can't be hidden; a person removes it before ${instance} launches again`);
|
||||
hidden.push(path);
|
||||
}
|
||||
const { run, runDir } = newRun(dataRoot, b.id);
|
||||
let modelKeyId;
|
||||
if (admittedProxy) {
|
||||
@@ -744,6 +790,7 @@ export function createLauncher({
|
||||
gitSocket: git,
|
||||
threadStore: store,
|
||||
readOnly,
|
||||
hidden,
|
||||
};
|
||||
admission(admissionGeneration);
|
||||
await threadAdmission(thread, session);
|
||||
@@ -754,7 +801,9 @@ export function createLauncher({
|
||||
}
|
||||
// One synchronous broker snapshot covers the thread and the entire manifest after every
|
||||
// asynchronous preparation read. Admission is the launch boundary; bindLaunch remains a fence.
|
||||
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), thread, session });
|
||||
// #1873 A3: `revision` is the organization file check() resolved the bundle from; a file that
|
||||
// changed since (a narrow, a define's vars, an exclude) refuses, and the next launch resolves anew.
|
||||
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), instance, revision, thread, session });
|
||||
if (finalAdmission?.ready !== true) throw new Refusal(finalAdmission?.code ?? "project-pending");
|
||||
const managed = registerRuntime(dataRoot, { kind: 'session', business: b.id, run });
|
||||
let child;
|
||||
@@ -889,6 +938,8 @@ export function createLauncher({
|
||||
for (const i of pending) {
|
||||
const session = registry.running(b.id, i.target, null);
|
||||
if (!session || !i.project || stopping.has(session.run)) continue;
|
||||
// #1873 A3: a manifest never gains a project its instance doesn't work in.
|
||||
if (projectRefusal(b, i.target, i.project)) continue;
|
||||
let projects = [];
|
||||
try { projects = JSON.parse(readFileSync(join(session.runDir, 'bundle/manifest.json'), 'utf8')).projects ?? []; } catch {}
|
||||
if (!projects.includes(i.project)) {
|
||||
@@ -1348,7 +1399,7 @@ export function createLauncher({
|
||||
if (typeof instance === 'string') await host.op({ op: 'request.launchResult', record: { business: b.id, instance, code: out.ok ? null : out.error, run: out.ok ? out.result.run : null } });
|
||||
return out;
|
||||
};
|
||||
try { b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) }); }
|
||||
try { b = loadOrg(); }
|
||||
catch { return reply({ ok: false, error: 'organization-refused' }); }
|
||||
// Product enums and clones are frozen in a session's manifest. A legitimate chief
|
||||
// launch may request a drain, never interrupt the turn or start a privileged worker.
|
||||
|
||||
@@ -69,3 +69,19 @@ 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,/config directory must belong to uid/);
|
||||
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());
|
||||
});
|
||||
@@ -0,0 +1,595 @@
|
||||
import { requestHostOps } from './request-host-ops.mjs';
|
||||
// #1873 Phase A slice A3: root and project agent copies through the real bus and launcher. The
|
||||
// cli's boot step reads the root file once and hands the snapshot to the broker and the launcher;
|
||||
// a bound copy launches as an ordinary instance of the organization, an unbound or refused one
|
||||
// refuses at the verb, and a project agent gets only its own project. Real runners in the bwrap
|
||||
// sandbox on the fake adapter, with the in-process host lifecycle.test.mjs uses.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { businessDoc, launchDoc, tokenFile, writeJson } from "../../organization/tests/helpers.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { launchLogFile } from "../../seat/src/session.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
|
||||
|
||||
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
const VARS = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
|
||||
async function until(fn, ms = 15000) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
throw new Error("timed out waiting");
|
||||
}
|
||||
|
||||
function gitRepo(dir) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const git = (...args) => {
|
||||
const r = spawnSync("git", ["-c", "maintenance.auto=false", "-c", "gc.auto=0", "-c", "user.name=test", "-c", "[email protected]", "-C", dir, ...args], { env: gitEnv(), encoding: "utf8" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
};
|
||||
git("init", "-q", "-b", "main");
|
||||
writeFileSync(join(dir, "file.txt"), "one\n");
|
||||
git("add", "file.txt");
|
||||
git("commit", "-q", "-m", "one");
|
||||
}
|
||||
|
||||
// acme: two code projects, stack and web. stack defines the project agent checker. The root file
|
||||
// holds gate (merge-gate, no credential: bound), researcher (research, needs gitea: unbound in every
|
||||
// organization) and sec (security-reviewer, excluded from acme). The coder's gitea token file is
|
||||
// named for, and holds, the test secret, so it is bound elsewhere in the organization.
|
||||
function prepare(t, edit = (doc) => doc) {
|
||||
const root = tmp(t, "mosaic-copy-");
|
||||
const f = fixture(root, "acme", (doc) => {
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
doc.roles.coder.vars.model = "claude-sonnet-5-5";
|
||||
const secret = tokenFile(join(root, "secrets"), SECRET);
|
||||
writeFileSync(secret, SECRET);
|
||||
doc.roles.coder.credentials.gitea = { ...doc.roles.coder.credentials.gitea, file: secret };
|
||||
doc.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
|
||||
doc.projects.stack.agents = { checker: { definition: "merge-gate", vars: VARS } };
|
||||
doc.projects.web = { root: join(root, "web") };
|
||||
return edit(doc);
|
||||
});
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
gitRepo(f.doc.projects.stack.root);
|
||||
gitRepo(f.doc.projects.web.root);
|
||||
writeJson(join(root, "config", "agents.json"), { agentsVersion: 1, agents: {
|
||||
gate: { definition: "merge-gate", vars: VARS },
|
||||
researcher: { definition: "research", vars: VARS },
|
||||
sec: { definition: "security-reviewer", vars: VARS, excludeOrgs: ["acme"] },
|
||||
} }, 0o600);
|
||||
const adapter = join(root, "adapter.sh");
|
||||
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
|
||||
return { root, f, adapter };
|
||||
}
|
||||
|
||||
async function start(t, { f, adapter }, { businessId = "acme", orgRoot } = {}) {
|
||||
const system = loadSystem({ env: f.env });
|
||||
const { trackers, ...boot } = bootConfig({ system, businessId, env: f.env, ...(orgRoot ? { orgRoot } : {}) });
|
||||
const runtime = await startBroker(boot);
|
||||
const dataRoot = boot.dataRoot;
|
||||
mkdirSync(join(dataRoot, "bus-host"), { recursive: true, mode: 0o700 });
|
||||
const ops = {
|
||||
...requestHostOps(runtime),
|
||||
identity: (m) => runtime.identity(m.cap),
|
||||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||
endLaunch: (m) => runtime.endLaunch(m.record),
|
||||
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
||||
threadsState: (m) => runtime.threadsState(m.business),
|
||||
threadIdle: (m) => runtime.threadIdle(m.record),
|
||||
mayLaunch: (m) => runtime.mayLaunch(m),
|
||||
landHeld: (m) => runtime.landHeld(m.record),
|
||||
landHolders: (m) => runtime.landHolders(m.business),
|
||||
landRelease: (m) => runtime.landRelease(m.record),
|
||||
lifecycleRequests: (m) => runtime.lifecycleRequests(m.business),
|
||||
lifecycleTake: (m) => runtime.lifecycleTake(m.record),
|
||||
lifecycleDelete: (m) => runtime.lifecycleDelete(m.record),
|
||||
lifecycleDone: (m) => runtime.lifecycleDone(m.record),
|
||||
};
|
||||
const refusing = (what, fn) => async (m) => {
|
||||
try {
|
||||
return fn(m);
|
||||
} catch (e) {
|
||||
const code = e instanceof BusError ? e.code : `${what}-refused`;
|
||||
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
|
||||
}
|
||||
};
|
||||
const hooks = [];
|
||||
let closing = null;
|
||||
const host = {
|
||||
business: businessId,
|
||||
path: runtime.path,
|
||||
dataRoot,
|
||||
op: refusing("launch-op", (m) => {
|
||||
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
|
||||
return ops[m.op](m);
|
||||
}),
|
||||
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
|
||||
beforeClose: (fn) => void hooks.push(fn),
|
||||
};
|
||||
const logs = [];
|
||||
const launcher = createLauncher({
|
||||
host,
|
||||
system: orgRoot ? { ...system, dataRoot } : system,
|
||||
env: f.env,
|
||||
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
|
||||
sessionDefaults: { pollInterval: 100 },
|
||||
harnessVersion: "0.85.1",
|
||||
readOnly: [FAKE],
|
||||
threadTickMs: 3600000,
|
||||
stopTimeoutMs: 1000,
|
||||
log: (l) => logs.push(l),
|
||||
copies: boot.copies ?? null,
|
||||
});
|
||||
const close = () =>
|
||||
(closing ??= (async () => {
|
||||
for (const fn of hooks) await fn(0);
|
||||
await runtime.close();
|
||||
})());
|
||||
t.after(close);
|
||||
await launcher.listen();
|
||||
const human = runtime.broker.bindHuman({ business: businessId, human: f.doc.human, via: "cli", outsideAgent: true });
|
||||
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
|
||||
const carry = async (request) => {
|
||||
await launcher.tick();
|
||||
return call("instance.request", { request });
|
||||
};
|
||||
const running = (instance) => launcher.registry.sessions.filter((s) => s.instance === instance);
|
||||
return { boot, dataRoot, runtime, launcher, host, call, carry, running, logs, close };
|
||||
}
|
||||
|
||||
// What the bus's trail holds: every event body, as text.
|
||||
function trail(dataRoot) {
|
||||
const db = new DatabaseSync(join(dataRoot, "bus", "bus.sqlite"), { readOnly: true });
|
||||
try {
|
||||
return db.prepare("SELECT kind, body FROM events").all().map((e) => `${e.kind} ${e.body}`).join("\n");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
const refusedAt = (s, instance) => {
|
||||
try {
|
||||
s.call("instance.launch", { instance });
|
||||
} catch (e) {
|
||||
assert.ok(e instanceof BusError, String(e));
|
||||
return e;
|
||||
}
|
||||
assert.fail(`${instance} launched`);
|
||||
};
|
||||
|
||||
test("A3: a bound root copy launches as an instance of the organization; the excluded one and the root assistant aren't there", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
assert.deepEqual(Object.keys(s.boot.copies.copies).sort(), ["checker.stack", "gate", "researcher"]);
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("gate");
|
||||
assert.equal(run.run, done.done.run);
|
||||
// The grid read names it running from the bus's own claims.
|
||||
await until(() => readFileSync(join(s.dataRoot, "launches", "acme", run.run, "runner.log"), "utf8").includes("claimed by run"));
|
||||
const grid = s.call("instances");
|
||||
assert.equal(grid.copies.gate.state, "instance");
|
||||
assert.deepEqual(grid.copies.gate.running, [run.run]);
|
||||
// sec is excluded from acme: no copy, no instance, and a launch names it unknown.
|
||||
assert.equal(Object.hasOwn(grid.copies, "sec"), false);
|
||||
assert.equal(refusedAt(s, "sec").code, "unknown-role");
|
||||
});
|
||||
|
||||
test("A3: an unbound copy refuses at the verb by name and binding; no value reaches the refusal, the trail or the grid", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const e = refusedAt(s, "researcher");
|
||||
assert.equal(e.code, "credential-unbound");
|
||||
assert.match(e.reason, /^researcher has no binding in acme for gitea$/);
|
||||
const grid = JSON.stringify(s.call("instances"));
|
||||
assert.match(grid, /"researcher":\{[^}]*"state":"unbound"/);
|
||||
for (const text of [e.message, e.reason, grid, trail(s.dataRoot), s.logs.join("\n")]) {
|
||||
assert.equal(text.includes(SECRET), false);
|
||||
assert.equal(text.includes(p.root), false, "no host path");
|
||||
}
|
||||
// Nothing was asked of the launcher.
|
||||
await s.launcher.tick();
|
||||
assert.equal(s.running("researcher").length, 0);
|
||||
});
|
||||
|
||||
test("A3: a project agent launches with only its own project's mapping; a project whose union refuses refuses every agent in it", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "checker.stack" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("checker.stack");
|
||||
const manifest = JSON.parse(readFileSync(join(run.runDir, "bundle", "manifest.json"), "utf8"));
|
||||
assert.deepEqual(manifest.projects, ["stack"]);
|
||||
// An org instance of the same role takes both code projects.
|
||||
const coder = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
|
||||
assert.equal(coder.done.refusal, null, JSON.stringify(coder));
|
||||
const [c] = s.running("coder");
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(c.runDir, "bundle", "manifest.json"), "utf8")).projects.sort(), ["stack", "web"]);
|
||||
|
||||
// web excludes an agent nothing defines: its union refuses, so its agents refuse, naming web.
|
||||
const q = prepare(t, (doc) => {
|
||||
doc.projects.web.agents = { checker: { definition: "merge-gate", vars: VARS }, lint: { definition: "security-reviewer", vars: VARS }, ghost: { exclude: true } };
|
||||
return doc;
|
||||
});
|
||||
const r = await start(t, q);
|
||||
for (const name of ["checker.web", "lint.web"]) {
|
||||
const e = refusedAt(r, name);
|
||||
assert.equal(e.code, "project-refused");
|
||||
assert.match(e.reason, new RegExp(`^${name.replace(".", "\\.")} can't launch in acme: project web refuses its agents: .*ghost`));
|
||||
}
|
||||
// The other project's agent is untouched.
|
||||
assert.equal((await r.carry(r.call("instance.launch", { instance: "checker.stack" }).request)).done.refusal, null);
|
||||
// An inherited root copy and an organization instance still launch, without web's mount.
|
||||
for (const instance of ["gate", "coder"]) {
|
||||
const out = await r.carry(r.call("instance.launch", { instance }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const [inherited] = r.running(instance);
|
||||
assert.deepEqual(projectsOf(inherited), ["stack"], instance);
|
||||
}
|
||||
});
|
||||
|
||||
const projectsOf = (run) => JSON.parse(readFileSync(join(run.runDir, "bundle", "manifest.json"), "utf8")).projects.sort();
|
||||
const bundleOf = (run, file) => JSON.parse(readFileSync(join(run.runDir, "bundle", file), "utf8"));
|
||||
|
||||
test("A3: a project's exclude takes its mount from an inherited agent, and its narrow reaches the agent's real bundle", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t, (doc) => {
|
||||
doc.projects.stack.agents.gate = { vars: { "limits.tools": ["read"] } };
|
||||
doc.projects.web.agents = { gate: { exclude: true } };
|
||||
return doc;
|
||||
});
|
||||
const s = await start(t, p);
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack"]);
|
||||
const tools = bundleOf(run, "policy.json").tools;
|
||||
assert.ok(tools.includes("read"), JSON.stringify(tools));
|
||||
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
|
||||
// The launch record names the skipped project and the code, never a reason text.
|
||||
const skipped = readFileSync(launchLogFile(s.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l)).filter((e) => e.event === "checkout-skipped");
|
||||
assert.deepEqual(skipped.map(({ instance, project, reason }) => ({ instance, project, reason })), [{ instance: "gate", project: "web", reason: "project-refused" }]);
|
||||
});
|
||||
|
||||
// Writes the organization file the way a human edit does, outside any writer verb.
|
||||
const editOrg = (p, change) => {
|
||||
const file = join(p.root, "config", "businesses", "acme.json");
|
||||
const doc = JSON.parse(readFileSync(file, "utf8"));
|
||||
change(doc);
|
||||
writeFileSync(file, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
|
||||
};
|
||||
// A writer verb reloads the broker from the file. Keeping coder twice changes nothing and refuses,
|
||||
// so a repeat reload drops the keep instead.
|
||||
const reload = (s) => {
|
||||
try { return s.call("veto.set", { revision: s.call("instances").revision, op: "keep", name: "coder" }); }
|
||||
catch (e) { if (e.code !== "organization-no-change") throw e; }
|
||||
return s.call("veto.set", { revision: s.call("instances").revision, op: "unkeep", name: "coder" });
|
||||
};
|
||||
// What the run's first turn said, from inside the real sandbox, about the web checkout.
|
||||
async function webSeen(s, run) {
|
||||
await until(() => existsSync(join(run.runDir, "runner.log")) && readFileSync(join(run.runDir, "runner.log"), "utf8").includes("claimed by run"));
|
||||
s.call("message.send", { to: run.instance, class: "REQUEST", body: "fixture" });
|
||||
const said = join(run.runDir, "turns", "0001.stderr");
|
||||
await until(() => existsSync(said) && /WEB-(READABLE|ABSENT)/.test(readFileSync(said, "utf8")));
|
||||
return readFileSync(said, "utf8").match(/WEB-(READABLE|ABSENT)/)[1];
|
||||
}
|
||||
|
||||
test("A3: a checkout an earlier launch made is out of sight in the sandbox once its project excludes the agent, and stays on the host", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
// The adapter runs inside the sandbox and says only whether the web checkout's file is readable.
|
||||
writeFileSync(p.adapter, `if test -r "$MOSAIC_WORKSPACE/web/file.txt"; then echo WEB-READABLE >&2; else echo WEB-ABSENT >&2; fi\nexec '${process.execPath}' '${FAKE}'\n`);
|
||||
const s = await start(t, p);
|
||||
let out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
let [run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack", "web"]);
|
||||
assert.equal(await webSeen(s, run), "READABLE");
|
||||
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
||||
await until(() => s.running("gate").length === 0);
|
||||
for (const exclude of [{ gate: { exclude: true } }, { ghost: { exclude: true } }]) {
|
||||
// The project excludes gate, then refuses its whole union.
|
||||
editOrg(p, (d) => { d.projects.web.agents = exclude; });
|
||||
reload(s);
|
||||
out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
[run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack"]);
|
||||
assert.equal(await webSeen(s, run), "ABSENT");
|
||||
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
||||
await until(() => s.running("gate").length === 0);
|
||||
}
|
||||
// The earlier work is still on the host, untouched.
|
||||
assert.equal(readFileSync(join(s.dataRoot, "workspaces", "acme", "gate", "web", "file.txt"), "utf8"), "one\n");
|
||||
});
|
||||
|
||||
test("A3: a project that excludes the agent while its launch is prepared refuses the launch; the bus's last admission asks too", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const op = s.host.op;
|
||||
let changed = false;
|
||||
s.host.op = async (m) => {
|
||||
const out = await op(m);
|
||||
if (!changed && m.op === "request.admission" && m.project === "web") {
|
||||
changed = true;
|
||||
editOrg(p, (d) => { d.projects.web.agents = { gate: { exclude: true } }; });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(changed, true);
|
||||
assert.equal(out.done.refusal, "project-refused", JSON.stringify(out));
|
||||
assert.equal(s.running("gate").length, 0);
|
||||
// Once the bus has the change, its launch-boundary admission refuses the instance there too.
|
||||
reload(s);
|
||||
const revision = s.call("instances").revision;
|
||||
assert.deepEqual(s.runtime.admission({ business: "acme", projects: ["stack", "web"], instance: "gate", revision }).code, "project-refused");
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate", revision }).ready, true);
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack", "web"] }).ready, true);
|
||||
// The launch boundary takes an instance only with the revision its bundle came from.
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate" }).code, "project-unknown");
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate", revision: "0".repeat(64) }).code, "organization-changed");
|
||||
});
|
||||
|
||||
// Launches gate with both projects, reads web from inside the sandbox, and ends the run, so the
|
||||
// workspace keeps a web checkout an earlier launch made.
|
||||
async function launchBoth(s) {
|
||||
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const [run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack", "web"]);
|
||||
assert.equal(await webSeen(s, run), "READABLE");
|
||||
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
||||
await until(() => s.running("gate").length === 0);
|
||||
}
|
||||
const readsWeb = (p) => writeFileSync(p.adapter, `if test -r "$MOSAIC_WORKSPACE/web/file.txt"; then echo WEB-READABLE >&2; else echo WEB-ABSENT >&2; fi\nexec '${process.execPath}' '${FAKE}'\n`);
|
||||
|
||||
test("A3: a checkout of a project the bus doesn't admit now is out of sight too, not only an excluded one", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
readsWeb(p);
|
||||
const s = await start(t, p);
|
||||
await launchBoth(s);
|
||||
// web is declared and gate is in its union, but the bus doesn't admit the project for this launch.
|
||||
const op = s.host.op;
|
||||
s.host.op = async (m) => (m.op === "request.admission" && m.project === "web" ? { ready: false, revision: null, code: "project-pending" } : op(m));
|
||||
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const [run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack"]);
|
||||
assert.equal(await webSeen(s, run), "ABSENT");
|
||||
assert.equal(readFileSync(join(s.dataRoot, "workspaces", "acme", "gate", "web", "file.txt"), "utf8"), "one\n");
|
||||
});
|
||||
|
||||
test("A3: a symlink or a file where a hidden checkout would be refuses the launch, and nothing on the host is touched", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
readsWeb(p);
|
||||
const s = await start(t, p);
|
||||
await launchBoth(s);
|
||||
const ws = join(s.dataRoot, "workspaces", "acme", "gate");
|
||||
// The run's own workspace moves the checkout aside and leaves a link at web, then web excludes gate.
|
||||
renameSync(join(ws, "web"), join(ws, "kept"));
|
||||
symlinkSync(join(ws, "kept"), join(ws, "web"));
|
||||
editOrg(p, (d) => { d.projects.web.agents = { gate: { exclude: true } }; });
|
||||
reload(s);
|
||||
let out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, "checkout-unsafe", JSON.stringify(out));
|
||||
assert.equal(s.running("gate").length, 0);
|
||||
assert.equal(lstatSync(join(ws, "web")).isSymbolicLink(), true);
|
||||
assert.equal(readFileSync(join(ws, "kept", "file.txt"), "utf8"), "one\n");
|
||||
// A plain file there refuses the same way.
|
||||
renameSync(join(ws, "web"), join(ws, "link"));
|
||||
writeFileSync(join(ws, "web"), "not a checkout\n");
|
||||
out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, "checkout-unsafe", JSON.stringify(out));
|
||||
assert.equal(readFileSync(join(ws, "web"), "utf8"), "not a checkout\n");
|
||||
});
|
||||
|
||||
test("A3: a narrow that lands after the launcher's recheck and before the bus's last admission refuses the launch; the next one takes it", { skip, timeout: 90000 }, async (t) => {
|
||||
for (const [instance, narrow, after] of [
|
||||
// An inherited agent's narrow in its project.
|
||||
["gate", (d) => { d.projects.stack.agents.gate = { vars: { "limits.tools": ["read"] } }; }, "launches"],
|
||||
// A project agent's define: its vars change after boot.
|
||||
["checker.stack", (d) => { d.projects.stack.agents.checker.vars["limits.tools"] = ["read"]; }, "project-refused"],
|
||||
]) {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const op = s.host.op;
|
||||
let landed = false;
|
||||
// The narrow lands after every launcher read, just before the bus's last admission answers.
|
||||
s.host.op = async (m) => {
|
||||
if (!landed && m.op === "request.admission" && m.projects !== undefined) { landed = true; editOrg(p, narrow); reload(s); }
|
||||
return op(m);
|
||||
};
|
||||
let out = await s.carry(s.call("instance.launch", { instance }).request);
|
||||
assert.equal(landed, true, instance);
|
||||
assert.equal(out.done.refusal, "organization-changed", `${instance} ${JSON.stringify(out)}`);
|
||||
assert.equal(s.running(instance).length, 0, instance);
|
||||
// The next launch resolves from the narrowed file.
|
||||
if (after === "launches") {
|
||||
out = await s.carry(s.call("instance.launch", { instance }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const tools = bundleOf(s.running(instance)[0], "policy.json").tools;
|
||||
assert.ok(tools.includes("read"), JSON.stringify(tools));
|
||||
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
|
||||
} else {
|
||||
assert.equal(refusedAt(s, instance).code, after, instance);
|
||||
}
|
||||
await s.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("A3: a project define whose vars change after boot refuses until the next start rather than launch with the old ones", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
let s = await start(t, p);
|
||||
editOrg(p, (d) => { d.projects.stack.agents.checker.vars["limits.tools"] = ["read"]; });
|
||||
reload(s);
|
||||
const copy = s.call("instances").copies["checker.stack"];
|
||||
assert.equal(copy.state, "refused");
|
||||
assert.match(copy.reason, /^project stack changed checker's vars since the bus started/);
|
||||
assert.equal(refusedAt(s, "checker.stack").code, "project-refused");
|
||||
await s.close();
|
||||
s = await start(t, p);
|
||||
const out = await s.carry(s.call("instance.launch", { instance: "checker.stack" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const tools = bundleOf(s.running("checker.stack")[0], "policy.json").tools;
|
||||
assert.ok(tools.includes("read"), JSON.stringify(tools));
|
||||
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
|
||||
});
|
||||
|
||||
test("A3: a collision a project brings in after boot refuses the copy it collides with, naming both claimants", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const rootFile = join(p.root, "config", "agents.json");
|
||||
const root = JSON.parse(readFileSync(rootFile, "utf8"));
|
||||
root.agents["new.stack"] = { definition: "security-reviewer", vars: VARS };
|
||||
writeFileSync(rootFile, JSON.stringify(root));
|
||||
const s = await start(t, p);
|
||||
assert.equal(s.call("instances").copies["new.stack"].state, "instance");
|
||||
editOrg(p, (d) => { d.projects.stack.agents.new = { definition: "merge-gate", vars: VARS }; });
|
||||
reload(s);
|
||||
const copy = s.call("instances").copies["new.stack"];
|
||||
assert.equal(copy.state, "refused");
|
||||
assert.deepEqual(copy.claims, [{ name: "new.stack", project: null }, { name: "new", project: "stack" }]);
|
||||
assert.equal(copy.reason, "the instance name new.stack is claimed by root agent new.stack and project stack's new");
|
||||
assert.equal(refusedAt(s, "new.stack").code, "copy-refused");
|
||||
// Taking the project's define back out frees the root copy again.
|
||||
editOrg(p, (d) => { delete d.projects.stack.agents.new; });
|
||||
reload(s);
|
||||
assert.equal(s.call("instances").copies["new.stack"].state, "instance");
|
||||
});
|
||||
|
||||
test("A3: an organization instance written over a root copy after boot launches as itself, with its own role", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
assert.equal(s.call("instances").copies.gate.state, "instance");
|
||||
const file = join(p.root, "config", "businesses", "acme.json");
|
||||
const doc = JSON.parse(readFileSync(file, "utf8"));
|
||||
doc.roles.gate = { definition: "security-reviewer", vars: VARS };
|
||||
writeFileSync(file, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
|
||||
// A writer verb reloads the broker from the file, as any human edit would.
|
||||
const { revision } = s.call("instances");
|
||||
s.call("veto.set", { revision, op: "keep", name: "coder" });
|
||||
assert.equal(Object.hasOwn(s.call("instances").copies ?? {}, "gate"), false);
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("gate");
|
||||
assert.equal(bundleOf(run, "manifest.json").definition, "security-reviewer");
|
||||
});
|
||||
|
||||
test("A3: two organizations each launch their own gate copy; runs, sessions and trails stay under each one's data root", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const beta = businessDoc(p.root, "beta");
|
||||
for (const r of Object.values(beta.roles)) r.vars = { ...VARS, ...r.vars, model: r.vars?.model ?? "claude-sonnet-5-5" };
|
||||
beta.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
beta.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
|
||||
beta.plugins = { ...beta.plugins, harness: ["pi", "claude-code"] };
|
||||
writeJson(join(p.root, "config", "businesses", "beta.json"), beta);
|
||||
const a = await start(t, p);
|
||||
mkdirSync(join(p.root, "ob"), { mode: 0o700 });
|
||||
const b = await start(t, p, { businessId: "beta", orgRoot: join(p.root, "ob") });
|
||||
const runs = {};
|
||||
for (const [id, s] of [["acme", a], ["beta", b]]) {
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("gate");
|
||||
runs[id] = run;
|
||||
assert.ok(run.runDir.startsWith(join(s.dataRoot, "launches", id) + "/"), run.runDir);
|
||||
await until(() => existsSync(join(run.runDir, "session")));
|
||||
await until(() => readFileSync(join(run.runDir, "runner.log"), "utf8").includes("claimed by run"));
|
||||
assert.deepEqual(s.call("instances").copies.gate.running, [run.run]);
|
||||
}
|
||||
assert.notEqual(runs.acme.run, runs.beta.run);
|
||||
// Neither bus, trail or data root holds the other's run.
|
||||
for (const [mine, other, s] of [["acme", "beta", a], ["beta", "acme", b]]) {
|
||||
assert.equal(trail(s.dataRoot).includes(runs[other].run), false, mine);
|
||||
assert.equal(existsSync(join(s.dataRoot, "launches", other)), false, mine);
|
||||
assert.equal(readdirSync(s.dataRoot, { recursive: true }).join("\n").includes(runs[other].run), false, mine);
|
||||
}
|
||||
assert.equal(b.running("gate").some((r) => r.run === runs.acme.run), false);
|
||||
});
|
||||
|
||||
test("A3: a root file change while the bus runs reaches no relaunch or reload; the next boot takes it", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const rootFile = join(p.root, "config", "agents.json");
|
||||
writeJson(rootFile, { agentsVersion: 1, agents: { fresh: { definition: "merge-gate", vars: VARS } } }, 0o600);
|
||||
// A launch, a relaunch and a reload all keep the boot's copy set.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
await until(() => s.running("gate").length === 1 && readFileSync(join(s.running("gate")[0].runDir, "runner.log"), "utf8").includes("claimed by run"));
|
||||
const { revision } = s.call("instances");
|
||||
s.call("veto.set", { revision, op: i ? "unkeep" : "keep", name: "coder" });
|
||||
assert.deepEqual(Object.keys(s.call("instances").copies).sort(), ["checker.stack", "gate", "researcher"]);
|
||||
assert.equal(refusedAt(s, "fresh").code, "unknown-role");
|
||||
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
||||
await until(() => s.running("gate").length === 0);
|
||||
}
|
||||
await s.close();
|
||||
// A restart reads the file again.
|
||||
const next = await start(t, p);
|
||||
assert.deepEqual(Object.keys(next.boot.copies.copies).sort(), ["checker.stack", "fresh"]);
|
||||
assert.equal(refusedAt(next, "gate").code, "unknown-role");
|
||||
assert.equal((await next.carry(next.call("instance.launch", { instance: "fresh" }).request)).done.refusal, null);
|
||||
});
|
||||
|
||||
test("A3: the chief can't launch a copy; only the human or a thread names one", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const chief = await s.host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
await new Client({ path: s.runtime.path, cap: chief.cap }).call("role.claim");
|
||||
for (const instance of ["gate", "checker.stack"]) {
|
||||
const out = await s.launcher.fromSession(chief.cap, instance);
|
||||
assert.deepEqual(out, { ok: false, error: "instance-not-listed" }, instance);
|
||||
}
|
||||
const unbound = await s.launcher.fromSession(chief.cap, "researcher");
|
||||
assert.deepEqual(unbound, { ok: false, error: "credential-unbound" });
|
||||
assert.equal(s.launcher.registry.sessions.length, 0);
|
||||
});
|
||||
|
||||
test("A3: a second organization boots its own copies from the same root file and shares no record, request or session with the first", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const beta = businessDoc(p.root, "beta");
|
||||
for (const r of Object.values(beta.roles)) r.vars = { ...VARS, ...r.vars, model: r.vars?.model ?? "claude-sonnet-5-5" };
|
||||
beta.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
beta.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
|
||||
beta.plugins = { ...beta.plugins, harness: ["pi", "claude-code"] };
|
||||
writeJson(join(p.root, "config", "businesses", "beta.json"), beta);
|
||||
const a = await start(t, p);
|
||||
mkdirSync(join(p.root, "ob"), { mode: 0o700 });
|
||||
const b = await start(t, p, { businessId: "beta", orgRoot: join(p.root, "ob") });
|
||||
assert.notEqual(a.dataRoot, b.dataRoot);
|
||||
assert.equal(a.boot.copies.org, "acme");
|
||||
assert.equal(b.boot.copies.org, "beta");
|
||||
// beta isn't excluded from sec, and has no project agent of acme's.
|
||||
assert.deepEqual(Object.keys(b.boot.copies.copies).sort(), ["gate", "researcher", "sec"]);
|
||||
const { request } = a.call("instance.launch", { instance: "gate" });
|
||||
const done = await a.carry(request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = a.running("gate");
|
||||
// beta's bus holds none of it: no request, no claim, no run, no session.
|
||||
assert.throws(() => b.call("instance.request", { request }), BusError);
|
||||
assert.deepEqual(b.call("instances").copies.gate.running, []);
|
||||
assert.equal(trail(b.dataRoot).includes(run.run), false);
|
||||
assert.equal(existsSync(join(b.dataRoot, "launches", "acme")), false);
|
||||
const sessions = (root) => existsSync(join(root, "sessions")) ? readdirSync(join(root, "sessions"), { recursive: true }).join("\n") : "";
|
||||
assert.equal(sessions(b.dataRoot).includes(run.run), false);
|
||||
assert.equal(b.running("gate").length, 0);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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),[]);
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
// Root and project agent copies (#1873 Phase A, slice A3; design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 4 and 9 item 2).
|
||||
//
|
||||
// When an organization's bus boots it reads the root file once, through loadRootAgents, and its
|
||||
// own organization file through its own writer. Each root agent the organization does not exclude,
|
||||
// and each agent a project defines, becomes a copy. The snapshot this module returns is plain data
|
||||
// and is fixed until the next boot: nothing here, or anything that holds the snapshot, reads the
|
||||
// root file again.
|
||||
//
|
||||
// A copy runs as an ordinary role instance of the organization: withCopies adds it to the
|
||||
// organization document's `roles` and validates the whole document, so every copy goes through the
|
||||
// same checkRoles an org instance takes, with the organization's credential map. A root or project
|
||||
// definition carries no credential (agent-levels.mjs refuses one), and A3 has no way to bind one,
|
||||
// so a copy whose role needs a credential service or a tracker bot is unbound: it is not added, and
|
||||
// a launch refuses it by name, naming the missing services and never a value.
|
||||
//
|
||||
// Nothing in a copy's view carries a var value, a credential reference, a path or file bytes.
|
||||
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { HOST_SERVICE } from "./vocabulary.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { resolveAgentLevels } from "./agent-levels.mjs";
|
||||
import { mergeVars } from "./vars.mjs";
|
||||
import { businessFilePath, loadBusiness, validateBusinessDocument } from "./organization-config.mjs";
|
||||
import { organizationWriter } from "./organization.mjs";
|
||||
import { deepFreeze } from "./util.mjs";
|
||||
|
||||
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
// How a refusal names one claimant of an instance name.
|
||||
const claimLabel = ({ name, project }) => (project === null ? `root agent ${name}` : `project ${project}'s ${name}`);
|
||||
const collision = (key, claimants) => `the instance name ${key} is claimed by ${claimants.map(claimLabel).join(" and ")}`;
|
||||
// The root assistant is shown, not copied (A6); agent-levels.mjs names it the same way.
|
||||
const ASSISTANT = "assistant";
|
||||
export const COPY_STATES = Object.freeze(["instance", "unbound", "refused"]);
|
||||
|
||||
// The bus instance name of a copy. A root copy keeps its name. A project agent is
|
||||
// `<name>.<project>`, so two projects may define the same name and a project may define a name an
|
||||
// organization or root agent already has, each as its own instance. Null when it isn't a valid id.
|
||||
export function copyInstanceName(name, project = null) {
|
||||
const instance = project === null ? name : `${name}.${project}`;
|
||||
return ID.test(instance) ? instance : null;
|
||||
}
|
||||
|
||||
// The credential services a copy of `definition` would need bound in the organization, and whether
|
||||
// it needs a tracker bot. The host key comes from a grant record at bus start, never from the file,
|
||||
// so it is not one (organization-config.mjs, checkRoles).
|
||||
function needs(definition, rolesDir, cache) {
|
||||
if (!cache.has(definition)) {
|
||||
const role = loadRole(rolesDir, definition);
|
||||
const services = role.credentials.map((c) => c.service).filter((s) => s !== HOST_SERVICE);
|
||||
cache.set(definition, [...new Set(services)].sort());
|
||||
}
|
||||
return cache.get(definition);
|
||||
}
|
||||
|
||||
// The copies of one organization, from its raw document (as its own writer read it) and the root
|
||||
// agents loadRootAgents returned. `root` is null when the root file refused: the root level is then
|
||||
// hidden everywhere (design section 6), so no root copy is made, and project agents still are.
|
||||
// Returns a frozen plain-data snapshot { org, root, rootAgents, copies: { instance: copy } } where
|
||||
// each copy is { name, level, project, definition, vars, state, missing?, reason? }. `rootAgents`
|
||||
// holds the root agents this organization takes (not excluded, not the root assistant), so a later
|
||||
// read of the organization file recomputes each project's union without reading the root file.
|
||||
export function materializeCopies({ orgId, doc, root, rolesDir }) {
|
||||
const rootState = root === null ? "refused" : root.state;
|
||||
const levels = resolveAgentLevels({ orgId, doc, root: root ?? { agents: {} }, rolesDir });
|
||||
const rootAgents = {};
|
||||
for (const [name, entry] of Object.entries(root?.agents ?? {})) {
|
||||
if (entry.definition === ASSISTANT || entry.excludeOrgs.includes(orgId)) continue;
|
||||
rootAgents[name] = { definition: entry.definition, vars: entry.vars ?? {}, excludeOrgs: [] };
|
||||
}
|
||||
const cache = new Map();
|
||||
const copies = {};
|
||||
const add = (agent, project, refusal = null) => {
|
||||
const instance = copyInstanceName(agent.name, project);
|
||||
// An over-long project agent name has no instance; it refuses under its own name.
|
||||
const key = instance ?? `${agent.name}.${project}`;
|
||||
const base = { name: agent.name, level: project === null ? "root" : "project", project, definition: agent.definition, vars: agent.vars };
|
||||
// Two agents whose instance names come out the same (root checker.stack and project stack's
|
||||
// checker, or two dotted names) refuse both, naming each; neither overwrites the other. The
|
||||
// entry lists every claimant, so a reader finds each agent's copy by its name and project.
|
||||
if (Object.hasOwn(copies, key)) {
|
||||
const claimants = [...(copies[key].claims ?? [{ name: copies[key].name, project: copies[key].project }]), { name: agent.name, project }];
|
||||
copies[key] = { ...copies[key], state: "refused", reason: collision(key, claimants), claims: claimants };
|
||||
delete copies[key].missing;
|
||||
return;
|
||||
}
|
||||
// The organization file's own instance of that name wins: no copy is made (design section 3).
|
||||
if (instance !== null && Object.hasOwn(doc?.roles ?? {}, instance)) return;
|
||||
if (refusal) { copies[key] = { ...base, state: "refused", reason: refusal }; return; }
|
||||
if (instance === null) { copies[key] = { ...base, state: "refused", reason: `project ${project}: ${agent.name}.${project} is longer than an instance name may be` }; return; }
|
||||
const missing = needs(agent.definition, rolesDir, cache);
|
||||
copies[key] = missing.length ? { ...base, state: "unbound", missing } : { ...base, state: "instance" };
|
||||
};
|
||||
// Root copies: the base union's root-level agents. It already leaves out the root assistant (A6),
|
||||
// the ones this organization is excluded from and the ones an org instance shadows by name.
|
||||
for (const agent of levels.base.agents) if (agent.level === "root") add(agent, null);
|
||||
// Project agents: each project's own define entries. When the project's union refuses, every
|
||||
// agent it defines refuses too, naming the project and the reason.
|
||||
for (const [project, entries] of Object.entries(levels.projects)) {
|
||||
const union = levels.unions[project];
|
||||
for (const [name, entry] of Object.entries(entries)) {
|
||||
if (entry.form !== "define") continue;
|
||||
const agent = { name, definition: entry.definition, vars: entry.vars };
|
||||
add(agent, project, union.ok ? null : `project ${project} refuses its agents: ${union.reason}`);
|
||||
}
|
||||
}
|
||||
return deepFreeze({ org: orgId, root: rootState, rootAgents, copies });
|
||||
}
|
||||
|
||||
// The view of a snapshot that leaves the boot: names, levels, definitions, states, the missing
|
||||
// service names and refusal reasons. No vars.
|
||||
export function copiesView(copies) {
|
||||
return Object.fromEntries(Object.entries(copies ?? {}).map(([instance, c]) => [instance, {
|
||||
name: c.name, level: c.level, project: c.project, definition: c.definition, state: c.state,
|
||||
...(c.missing ? { missing: [...c.missing] } : {}), ...(c.reason ? { reason: c.reason } : {}),
|
||||
...(c.claims ? { claims: c.claims.map(({ name, project }) => ({ name, project })) } : {}),
|
||||
}]));
|
||||
}
|
||||
|
||||
// Every string the document or the copy holds as a var or a credential reference, longest first.
|
||||
function valuesOf(document, copy) {
|
||||
const out = new Set();
|
||||
const walk = (x) => {
|
||||
if (typeof x === "string") { if (x.length >= 3) out.add(x); }
|
||||
else if (x && typeof x === "object") for (const v of Object.values(x)) walk(v);
|
||||
};
|
||||
walk(copy.vars);
|
||||
walk(document.vars);
|
||||
for (const role of Object.values(document.roles ?? {})) { walk(role?.vars); walk(role?.credentials); }
|
||||
for (const project of Object.values(document.projects ?? {})) walk(project?.vars);
|
||||
return [...out].sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
// A validation refusal for a copy, said without the host path, a quoted "got" value, or any string
|
||||
// the document or the copy holds as a var or a credential reference.
|
||||
function reasonOf(error, file, orgId, document, copy) {
|
||||
let message = error.message.replaceAll(file, `${orgId}.json`).replace(/; got [^)]*\)/g, ")").replace(/ \(got [\s\S]*$/, "");
|
||||
for (const value of valuesOf(document, copy)) message = message.replaceAll(value, "<value>");
|
||||
return message.slice(0, 400);
|
||||
}
|
||||
|
||||
const entryOf = (copy, vars = copy.vars) => ({ definition: copy.definition, ...(Object.keys(vars ?? {}).length ? { vars: structuredClone(vars) } : {}) });
|
||||
|
||||
// Whether any code project of the raw document has its own agent entries.
|
||||
const hasProjectAgents = (document) => Object.values(document?.projects ?? {}).some((p) => p?.kind !== "library" && p?.agents !== undefined);
|
||||
|
||||
// `vars` narrowed by each project entry in `narrows`, the way projectUnion narrows one: mergeVars
|
||||
// applies the intersect rule, and only the keys either side set are kept.
|
||||
function narrowVars(level, vars, narrows) {
|
||||
return narrows.reduce((current, entry) => {
|
||||
const merged = mergeVars([{ source: level, vars: current }, { source: "project", vars: entry }]).vars;
|
||||
const keys = new Set([...Object.keys(current), ...Object.keys(entry)]);
|
||||
return Object.fromEntries(Object.entries(merged).filter(([key]) => keys.has(key)));
|
||||
}, vars ?? {});
|
||||
}
|
||||
|
||||
// The instance name each union agent runs as: a project's own agent is its copy, every other agent
|
||||
// is the instance of its own name.
|
||||
const unionInstance = (agent, project) => (agent.level === "project" ? copyInstanceName(agent.name, project) ?? `${agent.name}.${project}` : agent.name);
|
||||
|
||||
// Why a project copy can't join this document, or null when it can: its project is still a declared
|
||||
// code project, the project still defines it with the definition and vars the bus started with, and
|
||||
// the project's union, recomputed from this document, still accepts it. A copy is made at boot, so
|
||||
// a changed definition or vars waits for the next start; until then the copy refuses rather than
|
||||
// launch with settings the project no longer gives it.
|
||||
function projectCopyRefusal(copy, document, levels) {
|
||||
const p = document.projects?.[copy.project];
|
||||
if (!p || p.kind === "library") return `project ${copy.project} is no longer declared`;
|
||||
const union = levels.unions[copy.project];
|
||||
if (!union.ok) return `project ${copy.project} refuses its agents: ${union.reason}`;
|
||||
const entry = levels.projects[copy.project]?.[copy.name];
|
||||
if (entry?.form !== "define" || entry.definition !== copy.definition) return `project ${copy.project} no longer defines ${copy.name} as ${copy.definition}`;
|
||||
if (!isDeepStrictEqual(entry.vars ?? {}, copy.vars ?? {})) return `project ${copy.project} changed ${copy.name}'s vars since the bus started; the change takes effect at the next start`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// The organization document with the snapshot's bound copies added to `roles`, validated whole by
|
||||
// validateBusinessDocument, the loader every org instance goes through. Each project's union is
|
||||
// recomputed from this document and the snapshot's root agents (never the root file), so:
|
||||
// - the business carries `unions`: for each code project with agents, { ok: true, agents: [the
|
||||
// instance names available there] } or { ok: false, reason };
|
||||
// - an instance a project narrows is narrowed in `roles` itself, so its tools, network and actions
|
||||
// are the intersection over every project that narrows it (a narrow in one project narrows the
|
||||
// instance in every launch: fail closed, since one launch mounts every project it may work in);
|
||||
// - a project copy whose project stops defining it, or whose union now refuses, is refused.
|
||||
// A copy the document refuses (a model var the organization doesn't list, say) is left out and
|
||||
// reported refused; the rest still join. A copy whose name the organization file now declares is
|
||||
// dropped: the file's own instance wins. Returns a frozen business whose `copies` key is the
|
||||
// snapshot's view, with each copy's state as it is in this document; a business with no copies and
|
||||
// no project agents is plain validateBusinessDocument.
|
||||
export function withCopies(document, file, { rolesDir, snapshot }) {
|
||||
const copies = snapshot?.copies ?? {};
|
||||
if (Object.keys(copies).length === 0 && !hasProjectAgents(document)) return validateBusinessDocument(document, file, { rolesDir });
|
||||
// The document's own refusals come first, whole.
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
const orgId = snapshot?.org ?? document.id;
|
||||
const levels = resolveAgentLevels({ orgId, doc: document, root: { agents: snapshot?.rootAgents ?? {} }, rolesDir });
|
||||
const unions = {};
|
||||
const narrows = {};
|
||||
for (const [project, union] of Object.entries(levels.unions)) {
|
||||
if (!union.ok) { unions[project] = { ok: false, reason: union.reason }; continue; }
|
||||
unions[project] = { ok: true, agents: union.agents.map((a) => unionInstance(a, project)).sort() };
|
||||
for (const agent of union.agents) {
|
||||
if (agent.level !== "project" && agent.narrowed.length) (narrows[agent.name] ??= []).push(levels.projects[project][agent.name].vars);
|
||||
}
|
||||
}
|
||||
const roles = { ...document.roles };
|
||||
for (const [name, list] of Object.entries(narrows)) {
|
||||
if (Object.hasOwn(roles, name)) roles[name] = { ...roles[name], vars: narrowVars("org", roles[name].vars, list) };
|
||||
}
|
||||
const narrowed = { ...document, roles };
|
||||
const view = copiesView(copies);
|
||||
// Who claims each copy's instance name now: the boot's root copies and the projects' define
|
||||
// entries as this document has them. A project that comes to define an agent whose name collides
|
||||
// with a copy refuses that copy, naming every claimant, as the boot does.
|
||||
const claimants = {};
|
||||
const claim = (key, who) => { if (!(claimants[key] ??= []).some((c) => c.name === who.name && c.project === who.project)) claimants[key].push(who); };
|
||||
for (const [instance, copy] of Object.entries(copies)) for (const c of copy.claims ?? [{ name: copy.name, project: copy.project }]) if (c.project === null) claim(instance, c);
|
||||
for (const [project, entries] of Object.entries(levels.projects)) {
|
||||
for (const [name, entry] of Object.entries(entries)) if (entry.form === "define") claim(copyInstanceName(name, project) ?? `${name}.${project}`, { name, project });
|
||||
}
|
||||
const join = {};
|
||||
for (const [instance, copy] of Object.entries(copies)) {
|
||||
// The organization file's own instance of this name wins; the copy is gone, not refused.
|
||||
if (Object.hasOwn(roles, instance)) { delete view[instance]; continue; }
|
||||
if ((claimants[instance]?.length ?? 0) > 1) {
|
||||
const all = claimants[instance].map(({ name, project }) => ({ name, project }));
|
||||
const { missing: _, ...rest } = view[instance];
|
||||
view[instance] = { ...rest, state: "refused", reason: collision(instance, all), claims: all };
|
||||
continue;
|
||||
}
|
||||
if (copy.state !== "instance") continue;
|
||||
const refused = copy.project === null ? null : projectCopyRefusal(copy, document, levels);
|
||||
if (refused) { view[instance] = { ...view[instance], state: "refused", reason: refused }; continue; }
|
||||
const entry = entryOf(copy, copy.project === null && narrows[copy.name] ? narrowVars("root", copy.vars, narrows[copy.name]) : copy.vars);
|
||||
try {
|
||||
validateBusinessDocument({ ...narrowed, roles: { ...roles, [instance]: entry } }, file, { rolesDir });
|
||||
join[instance] = entry;
|
||||
} catch (error) {
|
||||
if (error?.name !== "BusinessError") throw error;
|
||||
view[instance] = { ...view[instance], state: "refused", reason: reasonOf(error, file, orgId, document, copy) };
|
||||
}
|
||||
}
|
||||
const business = validateBusinessDocument({ ...narrowed, roles: { ...roles, ...join } }, file, { rolesDir });
|
||||
return deepFreeze({ ...business, ...(Object.keys(copies).length ? { copies: view } : {}), unions });
|
||||
}
|
||||
|
||||
// The organization with its copies, as a bus or launcher loads it. The plain load keeps
|
||||
// loadBusiness's file checks; when there are copies or project agents, the document is read again
|
||||
// through the organization's own writer and validated with them, and only that second read is used.
|
||||
export function loadWithCopies(id, { dir, rolesDir, snapshot }) {
|
||||
const plain = loadBusiness(id, { dir, rolesDir });
|
||||
const { document } = organizationWriter({ dir, rolesDir }).read(id);
|
||||
if ((!snapshot || Object.keys(snapshot.copies ?? {}).length === 0) && !hasProjectAgents(document)) return plain;
|
||||
return withCopies(document, businessFilePath(id, dir), { rolesDir, snapshot });
|
||||
}
|
||||
|
||||
// Why `instance` can't work in code project `project` of this business, as { code, message }, or
|
||||
// null when it can. A project copy works only in its own project; an instance a project excludes,
|
||||
// or any instance in a project whose union refuses, doesn't work there. The message names the
|
||||
// instance, the project and the reason, never a value.
|
||||
export function projectRefusal(business, instance, project) {
|
||||
const copy = copyOf(business, instance);
|
||||
if (copy?.project != null && copy.project !== project) {
|
||||
return { code: "project-agent-elsewhere", message: `${instance} works only in project ${copy.project}` };
|
||||
}
|
||||
const union = project == null ? undefined : business?.unions?.[project];
|
||||
if (!union) return null;
|
||||
if (!union.ok) return { code: "project-refused", message: `${instance} can't work in project ${project}: project ${project} refuses its agents: ${union.reason}` };
|
||||
if (!union.agents.includes(instance)) return { code: "project-refused", message: `${instance} can't work in project ${project}: the project excludes it` };
|
||||
return null;
|
||||
}
|
||||
|
||||
// The copy a launch names, or null for an org instance. `business` is one withCopies returned.
|
||||
export function copyOf(business, instance) {
|
||||
return business?.copies && Object.hasOwn(business.copies, instance) ? business.copies[instance] : null;
|
||||
}
|
||||
|
||||
// Why a copy can't launch, as { code, message }, or null when it can. The message names the agent,
|
||||
// its project and the missing services or the refusal, and never a value.
|
||||
export function copyRefusal(business, instance) {
|
||||
const copy = copyOf(business, instance);
|
||||
if (!copy || copy.state === "instance") return null;
|
||||
if (copy.state === "unbound") {
|
||||
return { code: "credential-unbound", message: `${instance} has no binding in ${business.id} for ${copy.missing.join(", ")}` };
|
||||
}
|
||||
return { code: copy.project === null ? "copy-refused" : "project-refused", message: `${instance} can't launch in ${business.id}: ${copy.reason}` };
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// #1873 Phase A slice A3: root and project agent copies (design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 4 and 9 item 2).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { checkRootAgents, loadRootAgents } from "../src/agent-levels.mjs";
|
||||
import {
|
||||
copiesView, copyInstanceName, copyRefusal, loadWithCopies, materializeCopies, projectRefusal, withCopies,
|
||||
} from "../src/agent-copies.mjs";
|
||||
import { businessFilePath, validateBusinessDocument } from "../src/organization-config.mjs";
|
||||
import { resolveInstance } from "../src/resolve.mjs";
|
||||
import { REPO, REPO_ROLES, businessDoc, systemFor, tmp, tokenFile, writeJson } from "./helpers.mjs";
|
||||
|
||||
const rolesDir = REPO_ROLES;
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
const root = (agents) => ({ state: "ok", ...checkRootAgents({ agentsVersion: 1, agents }, { rolesDir }) });
|
||||
|
||||
// An organization `id` under `dir`, written where loadBusiness and the writer read it.
|
||||
function org(dir, id, edit = (d) => d) {
|
||||
const doc = edit(businessDoc(dir, id));
|
||||
writeJson(businessFilePath(id, dir), doc);
|
||||
return doc;
|
||||
}
|
||||
const boot = (dir, id, doc, r) => {
|
||||
const snapshot = materializeCopies({ orgId: id, doc, root: r, rolesDir });
|
||||
return { snapshot, business: withCopies(doc, businessFilePath(id, dir), { rolesDir, snapshot }) };
|
||||
};
|
||||
|
||||
test("A3: two root agents, one excluded: the other becomes an instance in each organization, each its own", () => {
|
||||
const dir = tmp();
|
||||
const r = root({ gate: { definition: "merge-gate" }, sec: { definition: "security-reviewer", excludeOrgs: ["acme"] }, assistant: { definition: "assistant" } });
|
||||
const acme = boot(dir, "acme", org(dir, "acme"), r);
|
||||
assert.deepEqual(Object.keys(acme.snapshot.copies), ["gate"]);
|
||||
assert.equal(acme.business.roles.gate.definition, "merge-gate");
|
||||
assert.equal(Object.hasOwn(acme.business.roles, "sec"), false, "the excluded root agent is no instance");
|
||||
// The root assistant stays shown, not launched (A6): never a copy.
|
||||
assert.equal(Object.hasOwn(acme.business.roles, "assistant"), false);
|
||||
assert.equal(acme.business.copies.gate.state, "instance");
|
||||
// A second organization makes its own copies from the same root agents.
|
||||
const beta = boot(dir, "beta", org(dir, "beta"), r);
|
||||
assert.deepEqual(Object.keys(beta.snapshot.copies).sort(), ["gate", "sec"]);
|
||||
assert.equal(beta.snapshot.org, "beta");
|
||||
assert.notEqual(beta.snapshot.copies.gate, acme.snapshot.copies.gate);
|
||||
assert.ok(Object.isFrozen(acme.snapshot.copies.gate), "the boot snapshot can't be changed in place");
|
||||
// Each copy carries only its own organization's entry: the credential map is the organization's
|
||||
// own, and a copy holds no credential.
|
||||
assert.deepEqual(acme.business.roles.gate.credentials, {});
|
||||
assert.deepEqual(beta.business.roles.gate.credentials, {});
|
||||
});
|
||||
|
||||
test("A3: an unbound copy refuses by name and binding, and no value reaches the refusal or the view", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
// The secret is bound elsewhere in this organization: the coder's gitea token file is named it
|
||||
// and holds it.
|
||||
const file = tokenFile(join(dir, "secrets"), SECRET);
|
||||
writeFileSync(file, SECRET);
|
||||
d.roles.coder.credentials.gitea = { file, rotateBy: "2099-01-01" };
|
||||
return d;
|
||||
});
|
||||
const r = root({ researcher: { definition: "research" }, reviewer2: { definition: "reviewer" } });
|
||||
const { snapshot, business } = boot(dir, "acme", doc, r);
|
||||
assert.equal(snapshot.copies.researcher.state, "unbound");
|
||||
assert.deepEqual(snapshot.copies.researcher.missing, ["gitea"]);
|
||||
assert.deepEqual(snapshot.copies.reviewer2.missing, ["gitea", "vikunja"]);
|
||||
assert.equal(Object.hasOwn(business.roles, "researcher"), false, "an unbound copy is no instance");
|
||||
const refusal = copyRefusal(business, "researcher");
|
||||
assert.equal(refusal.code, "credential-unbound");
|
||||
assert.match(refusal.message, /researcher/);
|
||||
assert.match(refusal.message, /gitea/);
|
||||
assert.equal(copyRefusal(business, "coder"), null, "an org instance is no copy");
|
||||
for (const text of [refusal.message, copyRefusal(business, "reviewer2").message, JSON.stringify(business.copies), JSON.stringify(copiesView(snapshot.copies))]) {
|
||||
assert.equal(text.includes(SECRET), false);
|
||||
assert.equal(text.includes(dir), false, "no host path");
|
||||
}
|
||||
});
|
||||
|
||||
test("A3: a copy whose var the organization refuses is refused alone, and its value stays out of the reason", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme");
|
||||
const r = root({ gate: { definition: "merge-gate" }, wide: { definition: "merge-gate", vars: { model: SECRET } } });
|
||||
const { business } = boot(dir, "acme", doc, r);
|
||||
assert.equal(business.roles.gate.definition, "merge-gate", "the other copy still joins");
|
||||
assert.equal(Object.hasOwn(business.roles, "wide"), false);
|
||||
assert.equal(business.copies.wide.state, "refused");
|
||||
const refusal = copyRefusal(business, "wide");
|
||||
assert.equal(refusal.code, "copy-refused");
|
||||
assert.match(refusal.message, /^wide can't launch in acme: /);
|
||||
assert.equal(refusal.message.includes(SECRET), false);
|
||||
assert.equal(JSON.stringify(business.copies).includes(SECRET), false);
|
||||
});
|
||||
|
||||
test("A3: a copy's tools and actions equal an org instance's of the same role; a copy that skips checkRoles fails this", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme", (d) => ({ ...d, roles: { ...d.roles, "gate-org": { definition: "merge-gate", vars: { model: "glm-5.3-flash" } } } }));
|
||||
const r = root({ gate: { definition: "merge-gate", vars: { model: "glm-5.3-flash" } }, wide: { definition: "merge-gate", vars: { model: "not-a-listed-model" } } });
|
||||
const snapshot = materializeCopies({ orgId: "acme", doc, root: r, rolesDir });
|
||||
const file = businessFilePath("acme", dir);
|
||||
const system = systemFor(dir);
|
||||
const same = (business) => {
|
||||
assert.deepEqual(business.roles.gate, business.roles["gate-org"]);
|
||||
const copy = resolveInstance({ system, business, instance: "gate" });
|
||||
const own = resolveInstance({ system, business, instance: "gate-org" });
|
||||
assert.deepEqual(copy.limits.tools, own.limits.tools);
|
||||
assert.deepEqual(copy.limits.authority, own.limits.authority);
|
||||
assert.deepEqual(copy.credentials, own.credentials);
|
||||
// A copy the organization's own checks refuse never joins.
|
||||
assert.equal(Object.hasOwn(business.roles, "wide"), false);
|
||||
};
|
||||
same(withCopies(doc, file, { rolesDir, snapshot }));
|
||||
// The counterfactual: the copies joined as their raw entries, with no checkRoles.
|
||||
const plain = validateBusinessDocument(doc, file, { rolesDir });
|
||||
const skipped = { ...plain, roles: { ...plain.roles, gate: { definition: "merge-gate", vars: { model: "glm-5.3-flash" } }, wide: { definition: "merge-gate", vars: { model: "not-a-listed-model" } } } };
|
||||
assert.throws(() => same(skipped), assert.AssertionError);
|
||||
});
|
||||
|
||||
test("A3: a root file change after boot changes nothing until the next boot", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme");
|
||||
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { gate: { definition: "merge-gate" } } });
|
||||
const snapshot = materializeCopies({ orgId: "acme", doc, root: loadRootAgents(dir, { rolesDir }), rolesDir });
|
||||
const first = loadWithCopies("acme", { dir, rolesDir, snapshot });
|
||||
assert.equal(first.roles.gate.definition, "merge-gate");
|
||||
// The root file now drops gate, adds one and excludes acme; the bus holds its snapshot.
|
||||
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { sec: { definition: "security-reviewer" }, gate: { definition: "merge-gate", excludeOrgs: ["acme"] } } });
|
||||
const later = loadWithCopies("acme", { dir, rolesDir, snapshot });
|
||||
assert.deepEqual(Object.keys(later.copies), ["gate"]);
|
||||
assert.equal(later.roles.gate.definition, "merge-gate");
|
||||
assert.equal(Object.hasOwn(later.roles, "sec"), false);
|
||||
// A broken root file is no different: nothing after boot reads it.
|
||||
writeFileSync(join(dir, "agents.json"), "{ not json");
|
||||
assert.equal(loadWithCopies("acme", { dir, rolesDir, snapshot }).roles.gate.definition, "merge-gate");
|
||||
// The next boot reads it again.
|
||||
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { sec: { definition: "security-reviewer" } } });
|
||||
const next = materializeCopies({ orgId: "acme", doc, root: loadRootAgents(dir, { rolesDir }), rolesDir });
|
||||
assert.deepEqual(Object.keys(next.copies), ["sec"]);
|
||||
// Nothing that runs after boot reads the root file: only the cli's boot step names loadRootAgents.
|
||||
for (const src of ["packages/bus/src/runtime.mjs", "packages/bus/src/broker.mjs", "packages/cli/src/launcher.mjs", "packages/organization/src/agent-copies.mjs"])
|
||||
assert.equal(/loadRootAgents\s*\(/.test(readFileSync(join(REPO, src), "utf8")), false, src);
|
||||
});
|
||||
|
||||
test("A3: project agents copy into their own project, and a refused union refuses every agent it defines", () => {
|
||||
const dir = tmp();
|
||||
mkdirSync(join(dir, "web"), { recursive: true });
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
|
||||
// web excludes an agent nothing defines, so its union refuses.
|
||||
d.projects.web = { root: join(dir, "web"), agents: { checker: { definition: "merge-gate" }, ghost: { exclude: true } } };
|
||||
return d;
|
||||
});
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({}));
|
||||
assert.equal(copyInstanceName("checker", "stack"), "checker.stack");
|
||||
assert.deepEqual({ ...snapshot.copies["checker.stack"], vars: undefined }, { name: "checker", level: "project", project: "stack", definition: "merge-gate", vars: undefined, state: "instance" });
|
||||
assert.equal(business.roles["checker.stack"].definition, "merge-gate");
|
||||
assert.equal(Object.hasOwn(business.roles, "checker.web"), false);
|
||||
const refusal = copyRefusal(business, "checker.web");
|
||||
assert.equal(refusal.code, "project-refused");
|
||||
assert.match(refusal.message, /^checker\.web can't launch in acme: project web refuses its agents: /);
|
||||
assert.match(refusal.message, /ghost/);
|
||||
// A project that stops being declared after boot takes its copy with it.
|
||||
const { stack, ...rest } = doc.projects;
|
||||
const later = withCopies({ ...doc, projects: rest }, businessFilePath("acme", dir), { rolesDir, snapshot });
|
||||
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
|
||||
assert.equal(copyRefusal(later, "checker.stack").code, "project-refused");
|
||||
});
|
||||
|
||||
test("A3: an organization instance of the same name wins over a root or project copy, at boot and after it, and the copy is gone, not refused", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
|
||||
// A raw org instance already named like the project copy wins at boot.
|
||||
d.roles["checker.stack"] = { definition: "security-reviewer" };
|
||||
return d;
|
||||
});
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
|
||||
assert.equal(business.copies.gate.state, "instance");
|
||||
assert.equal(Object.hasOwn(snapshot.copies, "checker.stack"), false);
|
||||
assert.equal(business.roles["checker.stack"].definition, "security-reviewer");
|
||||
assert.equal(copyRefusal(business, "checker.stack"), null);
|
||||
// An org instance added after boot replaces the root copy: the copy leaves the lookup, so nothing
|
||||
// refuses the organization's own instance.
|
||||
const later = withCopies({ ...doc, roles: { ...doc.roles, gate: { definition: "security-reviewer" } } }, businessFilePath("acme", dir), { rolesDir, snapshot });
|
||||
assert.equal(later.roles.gate.definition, "security-reviewer", "the organization file's own instance");
|
||||
assert.equal(Object.hasOwn(later.copies, "gate"), false);
|
||||
assert.equal(copyRefusal(later, "gate"), null);
|
||||
assert.equal(projectRefusal(later, "gate", "stack"), null);
|
||||
});
|
||||
|
||||
test("A3: two agents whose instance names come out the same both refuse, naming each; neither overwrites the other", () => {
|
||||
const dir = tmp();
|
||||
mkdirSync(join(dir, "a.b"), { recursive: true });
|
||||
mkdirSync(join(dir, "b"), { recursive: true });
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
|
||||
// Project a.b's c and project b's c.a both come out c.a.b.
|
||||
d.projects["a.b"] = { root: join(dir, "a.b"), agents: { c: { definition: "merge-gate" } } };
|
||||
d.projects.b = { root: join(dir, "b"), agents: { "c.a": { definition: "merge-gate" } } };
|
||||
return d;
|
||||
});
|
||||
// Root checker.stack is a different agent from project stack's checker.
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({ "checker.stack": { definition: "security-reviewer" } }));
|
||||
for (const [instance, claims] of [["checker.stack", /root agent checker\.stack and project stack's checker/], ["c.a.b", /project a\.b's c and project b's c\.a/]]) {
|
||||
assert.equal(snapshot.copies[instance].state, "refused", instance);
|
||||
assert.match(snapshot.copies[instance].reason, claims);
|
||||
assert.equal(Object.hasOwn(business.roles, instance), false, instance);
|
||||
assert.ok(copyRefusal(business, instance), instance);
|
||||
}
|
||||
// The view lists every claimant, so a reader finds each agent's copy by its name and project.
|
||||
assert.deepEqual(business.copies["checker.stack"].claims, [{ name: "checker.stack", project: null }, { name: "checker", project: "stack" }]);
|
||||
assert.deepEqual(business.copies["c.a.b"].claims, [{ name: "c", project: "a.b" }, { name: "c.a", project: "b" }]);
|
||||
});
|
||||
|
||||
test("A3: a collision the current file brings in after boot refuses every claimant it reaches, and lifts when the file drops it", () => {
|
||||
const dir = tmp();
|
||||
mkdirSync(join(dir, "a.b"), { recursive: true });
|
||||
mkdirSync(join(dir, "b"), { recursive: true });
|
||||
const file = businessFilePath("acme", dir);
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects["a.b"] = { root: join(dir, "a.b"), agents: { c: { definition: "merge-gate" } } };
|
||||
d.projects.b = { root: join(dir, "b") };
|
||||
return d;
|
||||
});
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({ "new.stack": { definition: "security-reviewer" } }));
|
||||
assert.equal(business.copies["c.a.b"].state, "instance");
|
||||
assert.equal(business.copies["new.stack"].state, "instance");
|
||||
// Project b's c.a and project stack's new now come out as the names of copies made at boot.
|
||||
const edited = structuredClone(doc);
|
||||
edited.projects.b.agents = { "c.a": { definition: "merge-gate" } };
|
||||
edited.projects.stack.agents = { new: { definition: "merge-gate" } };
|
||||
const later = withCopies(edited, file, { rolesDir, snapshot });
|
||||
for (const [instance, claims, reason] of [
|
||||
["c.a.b", [{ name: "c", project: "a.b" }, { name: "c.a", project: "b" }], "the instance name c.a.b is claimed by project a.b's c and project b's c.a"],
|
||||
["new.stack", [{ name: "new.stack", project: null }, { name: "new", project: "stack" }], "the instance name new.stack is claimed by root agent new.stack and project stack's new"],
|
||||
]) {
|
||||
assert.equal(later.copies[instance].state, "refused", instance);
|
||||
assert.deepEqual(later.copies[instance].claims, claims, instance);
|
||||
assert.equal(later.copies[instance].reason, reason, instance);
|
||||
assert.equal(Object.hasOwn(later.roles, instance), false, instance);
|
||||
assert.ok(copyRefusal(later, instance), instance);
|
||||
}
|
||||
// Without the new defines, both copies are instances again.
|
||||
const back = withCopies(doc, file, { rolesDir, snapshot });
|
||||
assert.equal(back.copies["c.a.b"].state, "instance");
|
||||
assert.equal(back.copies["new.stack"].state, "instance");
|
||||
});
|
||||
|
||||
test("A3: a project define whose vars change after boot refuses its copy until the next boot", () => {
|
||||
const dir = tmp();
|
||||
const file = businessFilePath("acme", dir);
|
||||
const doc = org(dir, "acme", (d) => { d.projects.stack.agents = { checker: { definition: "merge-gate" } }; return d; });
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({}));
|
||||
assert.equal(business.copies["checker.stack"].state, "instance");
|
||||
const edited = structuredClone(doc);
|
||||
edited.projects.stack.agents.checker.vars = { "limits.tools": ["read"] };
|
||||
const later = withCopies(edited, file, { rolesDir, snapshot });
|
||||
const r = copyRefusal(later, "checker.stack");
|
||||
assert.equal(r.code, "project-refused");
|
||||
assert.match(r.message, /project stack changed checker's vars since the bus started; the change takes effect at the next start$/);
|
||||
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
|
||||
// The next boot makes the copy from the new vars.
|
||||
const next = boot(dir, "acme", edited, root({})).business;
|
||||
assert.equal(next.copies["checker.stack"].state, "instance");
|
||||
assert.deepEqual(next.roles["checker.stack"].vars["limits.tools"], ["read"]);
|
||||
});
|
||||
|
||||
test("A3: each project's union is recomputed from the current file: a refused union refuses its own copy and every agent in it; exclude and narrow reach the instance", () => {
|
||||
const dir = tmp();
|
||||
mkdirSync(join(dir, "web"), { recursive: true });
|
||||
const file = businessFilePath("acme", dir);
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
|
||||
d.projects.web = { root: join(dir, "web") };
|
||||
d.roles["gate-org"] = { definition: "merge-gate" };
|
||||
return d;
|
||||
});
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
|
||||
assert.deepEqual(business.unions.stack, { ok: true, agents: [...Object.keys(doc.roles), "checker.stack", "gate"].sort() });
|
||||
assert.equal(projectRefusal(business, "checker.stack", "web").code, "project-agent-elsewhere");
|
||||
assert.equal(projectRefusal(business, "gate", "web"), null);
|
||||
const system = systemFor(dir);
|
||||
const tools = (b, instance) => resolveInstance({ system, business: b, instance }).limits.tools;
|
||||
assert.ok(tools(business, "gate").includes("grep"));
|
||||
// After boot: stack excludes something nothing defines, so its union refuses; web excludes gate and
|
||||
// narrows gate-org's tools.
|
||||
const edited = structuredClone(doc);
|
||||
edited.projects.stack.agents.ghost = { exclude: true };
|
||||
edited.projects.web.agents = { gate: { exclude: true }, "gate-org": { vars: { "limits.tools": ["read"] } } };
|
||||
const later = withCopies(edited, file, { rolesDir, snapshot });
|
||||
assert.equal(later.unions.stack.ok, false);
|
||||
assert.match(later.unions.stack.reason, /ghost/);
|
||||
// stack's own copy refuses, naming the project and why.
|
||||
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
|
||||
const own = copyRefusal(later, "checker.stack");
|
||||
assert.equal(own.code, "project-refused");
|
||||
assert.match(own.message, /^checker\.stack can't launch in acme: project stack refuses its agents: .*ghost/);
|
||||
// Every inherited agent refuses there too.
|
||||
for (const instance of ["gate", "coder", "gate-org"]) {
|
||||
const r = projectRefusal(later, instance, "stack");
|
||||
assert.equal(r.code, "project-refused", instance);
|
||||
assert.match(r.message, new RegExp(`^${instance} can't work in project stack: project stack refuses its agents: .*ghost`));
|
||||
}
|
||||
assert.match(projectRefusal(later, "gate", "web").message, /the project excludes it$/);
|
||||
assert.equal(projectRefusal(later, "coder", "web"), null);
|
||||
// The narrow reaches the instance itself, so its resolved tools and actions are narrowed.
|
||||
assert.deepEqual(later.roles["gate-org"].vars["limits.tools"], ["read"]);
|
||||
assert.deepEqual(tools(later, "gate-org"), ["read"]);
|
||||
// A project that stops defining its agent takes the copy with it.
|
||||
const dropped = structuredClone(doc);
|
||||
dropped.projects.stack.agents = { other: { definition: "merge-gate" } };
|
||||
assert.match(copyRefusal(withCopies(dropped, file, { rolesDir, snapshot }), "checker.stack").message, /project stack no longer defines checker as merge-gate$/);
|
||||
});
|
||||
|
||||
test("A3: a project narrowing a root copy narrows that copy; an organization with only project entries still gets its unions", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects.stack.agents = { gate: { vars: { "limits.tools": ["read", "ls"] } } };
|
||||
return d;
|
||||
});
|
||||
const { business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
|
||||
assert.deepEqual(resolveInstance({ system: systemFor(dir), business, instance: "gate" }).limits.tools, ["read", "ls"]);
|
||||
// No root agents and no copy: the project's entries still shape the business.
|
||||
const only = org(dir, "beta", (d) => { d.projects.stack.agents = { coder: { vars: { "limits.tools": ["read"] } } }; return d; });
|
||||
const plain = loadWithCopies("beta", { dir, rolesDir, snapshot: null });
|
||||
assert.deepEqual(plain.unions.stack.agents, Object.keys(only.roles).sort());
|
||||
assert.deepEqual(plain.roles.coder.vars["limits.tools"], ["read"]);
|
||||
assert.equal(Object.hasOwn(plain, "copies"), false);
|
||||
});
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ID_PATTERN } from "../../organization/src/vocabulary.mjs";
|
||||
|
||||
@@ -111,8 +111,12 @@ function systemBinds(node = process.execPath) {
|
||||
// sessionStore(), or null) and node. workspaceDest (row 269) is where the
|
||||
// workspace shows inside the sandbox when that isn't its host path: a legacy
|
||||
// thread session's, so its harness finds the cwd its history recorded.
|
||||
// `hidden` (#1873 A3) lists directories inside the workspace the session may not see: a project
|
||||
// checkout a launch before this one made, in a project that now excludes or refuses the instance.
|
||||
// Each gets an empty tmpfs over it, so its files stay on the host, untouched, and out of sight.
|
||||
// Each must be a real directory: a tmpfs mounted at a symlink would land on the link's target.
|
||||
// `readOnly` adds files, for tests only.
|
||||
export function binds({ runDir, workspace, workspaceDest = null, adapter, harnessMounts = [], brokerSocket, launchSocket = null, gitSocket = null, threadStore = null, node = process.execPath, readOnly = [] }) {
|
||||
export function binds({ runDir, workspace, workspaceDest = null, hidden = [], adapter, harnessMounts = [], brokerSocket, launchSocket = null, gitSocket = null, threadStore = null, node = process.execPath, readOnly = [] }) {
|
||||
const out = systemBinds(node);
|
||||
for (const f of RUNNER_FILES) out.push({ kind: "ro", src: f, why: "a repository file the runner loads" });
|
||||
out.push({ kind: "ro", src: adapter, why: "the harness adapter the runner starts each turn" });
|
||||
@@ -123,6 +127,14 @@ export function binds({ runDir, workspace, workspaceDest = null, adapter, harnes
|
||||
for (const f of readOnly) out.push({ kind: "ro", src: f, why: "a test fixture" });
|
||||
out.push(
|
||||
{ kind: "rw", src: workspace, ...(workspaceDest && workspaceDest !== workspace ? { dest: workspaceDest } : {}), why: "the instance's workspace, where the harness works" },
|
||||
...hidden.map((h) => {
|
||||
const inside = relative(workspace, h);
|
||||
if (!inside || inside.startsWith("..") || isAbsolute(inside)) throw new Error(`hidden path ${h} is not inside the workspace`);
|
||||
let real = false;
|
||||
try { real = lstatSync(h).isDirectory(); } catch { real = false; }
|
||||
if (!real) throw new Error(`hidden path ${h} is not a directory`);
|
||||
return { kind: "tmpfs", dest: join(workspaceDest ?? workspace, inside), why: "a project checkout this launch may not see, covered by an empty tmpfs" };
|
||||
}),
|
||||
{ kind: "rw", src: runDir, why: "the run directory: turns/, session/, tools.sock, turn.done, home/, tmp/" },
|
||||
{ kind: "ro", src: join(runDir, "bundle"), why: "the run's bundle stays as the host wrote it" },
|
||||
{ kind: "ro", src: join(runDir, "session.json"), why: "the session file stays as the host wrote it" },
|
||||
@@ -146,6 +158,7 @@ function argsOf(list) {
|
||||
else if (b.kind === "symlink") args.push("--symlink", b.src, b.dest);
|
||||
else if (b.kind === "dev") args.push("--dev", b.dest);
|
||||
else if (b.kind === "proc") args.push("--proc", b.dest);
|
||||
else if (b.kind === "tmpfs") args.push("--tmpfs", b.dest);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { Broker } from "../../bus/src/broker.mjs";
|
||||
@@ -91,6 +91,34 @@ test("every bind says why; the bundle and session file stay read-only over the r
|
||||
assert.ok(!list.some((b) => b.src === realpathSync(join(REPO, "node_modules"))), "no harness mounts unless the module names them");
|
||||
});
|
||||
|
||||
// #1873 A3: a checkout the launch may not see is covered by an empty tmpfs after the workspace bind,
|
||||
// at the path the session sees; a path outside the workspace is refused, never mounted.
|
||||
test("A3 a hidden checkout is a tmpfs over the workspace bind; a path outside the workspace, a symlink or a file refuses", (t) => {
|
||||
const root = scratch(t);
|
||||
const workspace = join(root, "ws");
|
||||
mkdirSync(join(workspace, "web"), { recursive: true });
|
||||
mkdirSync(join(workspace, "stack"));
|
||||
const args = { runDir: join(root, "run"), workspace, adapter: join(root, "a.sh"), brokerSocket: join(root, "bus.sock") };
|
||||
const list = binds({ ...args, hidden: [join(workspace, "web")] });
|
||||
const ws = list.findIndex((b) => b.src === workspace);
|
||||
const hide = list.findIndex((b) => b.kind === "tmpfs");
|
||||
assert.ok(ws >= 0 && hide > ws, "the tmpfs comes after the workspace bind");
|
||||
assert.equal(list[hide].dest, join(workspace, "web"));
|
||||
assert.ok(list[hide].why);
|
||||
const moved = binds({ ...args, workspaceDest: "/work", hidden: [join(workspace, "web")] });
|
||||
assert.equal(moved.find((b) => b.kind === "tmpfs").dest, "/work/web");
|
||||
assert.equal(binds(args).some((b) => b.kind === "tmpfs"), false);
|
||||
for (const bad of [workspace, join(root, "other"), join(workspace, "..", "ws2")]) {
|
||||
assert.throws(() => binds({ ...args, hidden: [bad] }), /is not inside the workspace/, bad);
|
||||
}
|
||||
// A tmpfs at a symlink would mount over its target, so only a real directory is covered.
|
||||
symlinkSync(join(workspace, "stack"), join(workspace, "linked"));
|
||||
writeFileSync(join(workspace, "file"), "x\n");
|
||||
for (const bad of [join(workspace, "linked"), join(workspace, "file"), join(workspace, "missing")]) {
|
||||
assert.throws(() => binds({ ...args, hidden: [bad] }), /is not a directory/, bad);
|
||||
}
|
||||
});
|
||||
|
||||
// Row 65: the harness module names its own mounts; binds() mounts each read-only, after the
|
||||
// adapter and before the broker socket, and names no harness itself.
|
||||
test("row 65 a harness module's mounts bind read-only after the adapter, each with the module's reason", async (t) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -154,6 +154,8 @@ const STATUS = {
|
||||
// Row 239 (#1745): a fire's or a delete's write refused after its revoke ran.
|
||||
'revision-conflict-revoked': 409, 'outcome-unknown-revoked': 503,
|
||||
'invalid-request': 400, 'unknown-role': 400, 'message-not-found': 400, 'request-too-large': 400,
|
||||
// #1873 A3: a root or project copy that can't launch.
|
||||
'credential-unbound': 409, 'copy-refused': 409, 'project-refused': 409, 'project-agent-elsewhere': 409,
|
||||
// Row 148: the spec and docs editor. A stale base, a busy or dirty repository and an unchanged
|
||||
// file are the user's to resolve by reloading; a path or content the project refuses is a 400.
|
||||
'stale-base': 409, 'project-busy': 409, 'project-dirty': 409,
|
||||
|
||||
@@ -23,6 +23,8 @@ const STATUS = {
|
||||
'project-exists': 409, 'project-archived': 409, 'thread-exists': 409, 'human-required': 403, unauthenticated: 403,
|
||||
'unknown-business': 403, 'read-only': 403, 'not-configured': 503, 'no-bus-host': 503,
|
||||
'thread-instance-missing': 503, 'thread-instance-ambiguous': 503, 'outcome-unknown': 503,
|
||||
// #1873 A3: a project agent named in a chat of another project, and a copy that can't launch.
|
||||
'project-agent-elsewhere': 409, 'credential-unbound': 409, 'copy-refused': 409, 'project-refused': 409,
|
||||
'session-not-found': 404, 'operation-not-found': 404, 'session-exists': 409,
|
||||
'session-op-conflict': 409, 'session-clear-complete': 409, 'session-name-too-long': 409, 'session-clearing': 409, 'session-revision-stale': 409,
|
||||
'session-retired': 409, 'processing-halted': 409, 'confirm-required': 400,
|
||||
@@ -37,6 +39,8 @@ function safeMessage(code) {
|
||||
if (code === 'unknown-verb' || code === 'channel-operation') return 'This session operation is unavailable until its dependency is installed.';
|
||||
if (code === 'channel-disabled') return 'Run mosaic plugins to read the revision, then mosaic plugins set --file PATCH with plugins.channel set to ["cli","webui"].';
|
||||
if (code === 'thread-not-owned') return 'This thread is not available to this user.';
|
||||
if (code === 'project-agent-elsewhere') return 'That project agent works only in its own project. Choose an agent of this project.';
|
||||
if (code === 'credential-unbound' || code === 'copy-refused' || code === 'project-refused') return 'That agent can\'t launch in this organization. The Agents grid says why.';
|
||||
if (code === 'outcome-unknown') return 'Outcome unknown. Read the current list or conversation before trying again. Nothing was retried.';
|
||||
return `Chat operation refused (${code}).`;
|
||||
}
|
||||
|
||||
@@ -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: () => {} });
|
||||
}
|
||||
@@ -60,6 +60,9 @@
|
||||
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
|
||||
const ensureThemes = async () => { if (!themes) themes = await data.themes().then(b => (Array.isArray(b?.themes) ? b.themes : [])).catch(() => []); return themes; };
|
||||
const instancesOf = value => { const v = value?.rows?.[0]?.instances ?? value?.instances; return v && typeof v === 'object' && !Array.isArray(v) ? v : null; };
|
||||
// #1873 A3: the root and project agent copies the bus booted with, from the same instances read.
|
||||
// An answer without the key (a bus with no copies, or one older than A3) gives an empty map.
|
||||
const copiesOf = value => { const v = value?.rows?.[0]?.copies ?? value?.copies; return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; };
|
||||
const running = item => (Array.isArray(item?.running) ? item.running.length : 0);
|
||||
const rowsOf = v => (Array.isArray(v) ? v : Array.isArray(v?.rows) ? v.rows : null);
|
||||
// An agent's activity is the state the bus derives for the run holding its role. A live process
|
||||
@@ -271,9 +274,20 @@
|
||||
|
||||
// ---------- agents levels (sample 08) ----------
|
||||
// Root and organization agents come from their files through /api/agents/levels (#1873 A1, read
|
||||
// only). Organization agents' status comes from each bus. Root and project agents are not launched
|
||||
// in A1, so they read "not running"; nothing here invents a status, a model or a memory.
|
||||
// only). Every status comes from the organization's bus: its instances, and since A3 the root and
|
||||
// project agent copies it booted with. Nothing here invents a status, a model or a memory.
|
||||
const NOT_RUNNING = ['s-idle', '–', 'not running'];
|
||||
// A copy's status: running or not as the bus reports its runs, or why it can't launch.
|
||||
const CANT = ['s-error', '▲', "can't launch"];
|
||||
const copyWhy = (c) => (c.state === 'unbound' ? `no binding in this organization for ${(c.missing || []).join(', ')}` : c.reason || 'refused');
|
||||
const copyDot = (name, c, agents) => (c.state === 'instance' ? activity(name, c, agents) : CANT);
|
||||
// The bus names each copy's instance. Find an agent's copy by its name and project, never by
|
||||
// rebuilding the instance name: a collision refuses one entry that lists every claimant.
|
||||
const copyFor = (copies, name, project = null) => {
|
||||
const mine = c => c && c.name === name && (c.project ?? null) === project;
|
||||
const hit = Object.entries(copies || {}).find(([, c]) => mine(c) || (Array.isArray(c?.claims) && c.claims.some(mine)));
|
||||
return hit ? { key: hit[0], copy: hit[1] } : { key: null, copy: null };
|
||||
};
|
||||
const LEVEL_CLS = { root: 'root', org: 'org', project: 'proj' };
|
||||
const projectName = (rows, id) => { const p = Array.isArray(rows) ? rows.find(r => r?.id === id) : null; return p?.name || id; };
|
||||
let agentsPick = null; // the project chosen in "Available in a project", kept across repaints
|
||||
@@ -307,7 +321,12 @@
|
||||
const sub = a => [a.definition && a.definition !== a.name ? a.definition : '', a.overrides?.length ? `replaces the ${a.overrides.map(o => (o.level === 'org' ? 'organization' : o.level)).join(' and ')} ${a.overrides.length > 1 ? 'definitions' : 'definition'}` : '', a.narrowed?.length ? `narrowed here: ${a.narrowed.join(', ')}` : ''].filter(Boolean).join(' · ');
|
||||
const status = a => {
|
||||
if (a.assistant) return dot('s-unknown', '?', 'status not reported', true);
|
||||
if (a.level !== 'org') return dot(...NOT_RUNNING, true);
|
||||
if (a.level !== 'org') {
|
||||
if (inst === null) return '<span class="muted">status unavailable</span>';
|
||||
const { key, copy: c } = copyFor(mine?.copies, a.name, a.level === 'project' ? a.project : null);
|
||||
if (!c) return '<span class="muted">starts with the next bus start</span>';
|
||||
return `${dot(...copyDot(key, c, mine.agents), true)}${c.state === 'instance' ? '' : `<span class="t2">${esc(copyWhy(c))}</span>`}`;
|
||||
}
|
||||
if (inst === null) return '<span class="muted">status unavailable</span>';
|
||||
return dot(...(inst[a.name] ? activity(a.name, inst[a.name], mine.agents) : NOT_RUNNING), true);
|
||||
};
|
||||
@@ -334,15 +353,15 @@
|
||||
|
||||
async function agentGrid(note = '', selected = window.MosaicOrg?.id) {
|
||||
const [list, levels] = await Promise.all([data.organizations().catch(() => null), data.agentLevels().catch(() => null), ensureThemes()]);
|
||||
const head = `<div class="ws-vh"><h1 tabindex="-1">Agents</h1><div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Root and project agents are read from their files and are not launched yet.${note ? ` ${note}` : ''}</p></div>`;
|
||||
const head = `<div class="ws-vh"><h1 tabindex="-1">Agents</h1><div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Each organization runs its own copy of a root agent, and a project's agents run only in that project. The copies are made when the organization's bus starts.${note ? ` ${note}` : ''}</p></div>`;
|
||||
if (!Array.isArray(list)) return `<section class="ws ws-agents" data-org-agents>${head}<p class="ws-panel tight note">Organizations unavailable.</p></section>`;
|
||||
const valid = list.filter(org => !org.invalid);
|
||||
const admitted = valid.some(org => org.id === selected);
|
||||
const [results, business] = await Promise.all([
|
||||
Promise.all(valid.map(org => Promise.all([
|
||||
data.instances(org.id).then(instancesOf).catch(() => null), data.agents(org.id).then(rowsOf).catch(() => null),
|
||||
data.instances(org.id).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) })).catch(() => ({ inst: null, copies: null })), data.agents(org.id).then(rowsOf).catch(() => null),
|
||||
projects.get(org.id) !== undefined ? projects.get(org.id) : data.projects(org.id).then(b => (Array.isArray(b?.rows) ? b.rows : null)).catch(() => null),
|
||||
]).then(([inst, agents, rows]) => ({ org, inst, agents, rows, lv: levels?.orgs?.find(o => o?.id === org.id) ?? null })))),
|
||||
]).then(([{ inst, copies }, agents, rows]) => ({ org, inst, copies, agents, rows, lv: levels?.orgs?.find(o => o?.id === org.id) ?? null })))),
|
||||
admitted ? data.business(selected).catch(() => null) : null,
|
||||
]);
|
||||
const band = `<div class="ws-span-band"><span class="ws-av all md" aria-hidden="true">${LOGO}</span><b>assistant</b><span class="ws-lvl root">root</span>${dot('s-unknown', '?', 'status not reported', true)}<span>Works across</span><span class="orgs">${valid.map(org => av(org)).join('')}</span><span class="muted ws-band-note">The assistant talks with you across organizations. Its status is not reported on this page; set it up under Settings, Assistant.</span></div>`;
|
||||
@@ -350,22 +369,32 @@
|
||||
const cell = (org, inner, cls = '') => `<div class="ws-o${cls}" style="${colours(org)}" data-org="${esc(org.id)}"><span class="mlabel">${av(org, 'xs')}${esc(label(org))}</span>${inner}</div>`;
|
||||
const endc = i => (i === results.length - 1 ? ' endc' : '');
|
||||
const pill = (name, item, agents) => { const cos = item?.definition === 'chief-of-staff' || name === 'chief'; const [acls, aglyph, aword] = activity(name, item, agents); return `<span class="ws-agent${cos ? ' cos' : ''}">${dot(acls, aglyph, aword)}${esc(name)}${cos && name !== 'chief' ? ' <small>chief</small>' : ''}</span>`; };
|
||||
const idle = (name, small) => `<span class="ws-agent" data-level-agent="${esc(name)}">${dot(...NOT_RUNNING)}${esc(name)} <small>${esc(small)}</small></span>`;
|
||||
// A root or project copy in one organization's cell: its status from that organization's bus, and
|
||||
// why it can't launch when it can't. No answer from the bus reads as unavailable, never as idle.
|
||||
const copyPill = (copies, agents, name, project, small) => {
|
||||
const { key, copy: c } = copyFor(copies, name, project);
|
||||
const mark = copies === null ? ['s-unknown', '?', 'status unavailable'] : !c ? ['s-unknown', '?', 'starts with the next bus start'] : copyDot(key, c, agents);
|
||||
const why = c && c.state !== 'instance' ? `<span class="note ws-off" data-copy-why="${esc(key)}">${esc(name)}: ${esc(copyWhy(c))}</span>` : '';
|
||||
return `<span class="ws-agent" data-level-agent="${esc(name)}" data-copy-state="${esc(c?.state || 'unknown')}">${dot(...mark)}${esc(name)} <small>${esc(small)}</small></span>${why}`;
|
||||
};
|
||||
// Root: one copy per organization, each running inside that organization; an opt-out is said.
|
||||
const root = levels?.root;
|
||||
const rootRow = !root ? '<div class="span endc"><span class="muted">Root agents unavailable. The levels read did not answer.</span></div>'
|
||||
: root.state === 'invalid' ? `<div class="span endc"><span class="muted">Root agents unavailable. ${esc(root.reason)}</span></div>`
|
||||
: !root.agents.some(a => a.definition !== 'assistant') ? '<div class="span endc"><span class="muted">No root agents. They are defined in agents.json in your configuration directory.</span></div>'
|
||||
: results.map(({ org }, i) => {
|
||||
: results.map(({ org, inst, copies, agents }, i) => {
|
||||
const mineRoot = root.agents.filter(a => a.definition !== 'assistant');
|
||||
const on = mineRoot.filter(a => !a.excludeOrgs.includes(org.id)), off = mineRoot.filter(a => a.excludeOrgs.includes(org.id));
|
||||
return cell(org, `${on.map(a => idle(a.name, 'copy')).join('') || '<span class="muted">none</span>'}${off.length ? `<span class="note ws-off">${esc(off.map(a => a.name).join(', '))} turned off for ${esc(label(org))}</span>` : ''}`, endc(i));
|
||||
// An organization agent of the same name replaces the root one there, so no copy is made.
|
||||
const own = a => Boolean(inst && Object.hasOwn(inst, a.name) && !copyFor(copies, a.name).copy);
|
||||
const kept = mineRoot.filter(a => !a.excludeOrgs.includes(org.id)), off = mineRoot.filter(a => a.excludeOrgs.includes(org.id));
|
||||
const on = kept.filter(a => !own(a)), replaced = kept.filter(own);
|
||||
return cell(org, `${on.map(a => copyPill(copies, agents, a.name, null, 'copy')).join('') || '<span class="muted">none</span>'}${replaced.length ? `<span class="note ws-off">${esc(replaced.map(a => a.name).join(', '))} replaced by ${esc(label(org))}'s own</span>` : ''}${off.length ? `<span class="note ws-off">${esc(off.map(a => a.name).join(', '))} turned off for ${esc(label(org))}</span>` : ''}`, endc(i));
|
||||
}).join('');
|
||||
const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? '<span class="muted">Agents unavailable</span>' : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(k, v, agents)).join('') : '<span class="muted">No agents configured</span>', endc(i))).join('');
|
||||
// Project: the agents a project defines itself. Narrowing and exclusion show in the table.
|
||||
const projRow = results.map(({ org, lv, rows }, i) => {
|
||||
const projRow = results.map(({ org, lv, rows, copies, agents }, i) => {
|
||||
if (lv?.state !== 'ok') return cell(org, `<span class="muted">${lv?.state === 'invalid' ? 'Project agents unavailable: the organization file was refused' : 'Project agents unavailable'}</span>`, ` last${endc(i)}`);
|
||||
const own = Object.entries(lv.defined).flatMap(([pid, entries]) => entries.filter(e => e.form === 'define').map(e => idle(e.name, projectName(rows, pid))));
|
||||
const own = Object.entries(lv.defined).flatMap(([pid, entries]) => entries.filter(e => e.form === 'define').map(e => copyPill(copies, agents, e.name, pid, projectName(rows, pid))));
|
||||
return cell(org, own.join('') || '<span class="muted">none</span>', ` last${endc(i)}`);
|
||||
}).join('');
|
||||
const levelsGrid = valid.length ? `<div class="ws-levels" style="grid-template-columns:200px repeat(${n},minmax(0,1fr))">
|
||||
|
||||
@@ -446,6 +446,12 @@ const AGENT_REASONS = {
|
||||
'unknown-role': 'the business has no such instance',
|
||||
'unknown-request': 'the bus has no such request',
|
||||
'instance-not-listed': 'the instance isn\'t on the launch list',
|
||||
// #1873 A3: a root or project copy that can't launch. The Agents grid names the agent and the
|
||||
// missing service or the reason; these lines carry no value.
|
||||
'credential-unbound': 'the agent needs a credential service this organization has no binding for; the Agents grid names it',
|
||||
'copy-refused': 'this organization refused its copy of the root agent; the Agents grid says why',
|
||||
'project-refused': 'the project agent was refused, or its project refuses its agents; the Agents grid says why',
|
||||
'project-agent-elsewhere': 'a project agent works only in its own project',
|
||||
};
|
||||
const reasonFor = (code, done) => {
|
||||
if (code === 'skill-not-enabled' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(done?.skill ?? ''))
|
||||
@@ -784,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');
|
||||
@@ -929,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));
|
||||
@@ -976,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).
|
||||
@@ -1006,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.
|
||||
@@ -1017,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.
|
||||
@@ -1179,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 }],
|
||||
|
||||
@@ -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,'');}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ownerAccounts } from './helpers/owner-accounts.mjs';
|
||||
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md section 9): the Console as an
|
||||
// OIDC relying party (--sso). Every provider is the fake OP in helpers/fake-oidc.mjs on loopback;
|
||||
// no test reaches a real provider, the live Console or a real config or slot file. The public name
|
||||
@@ -56,7 +57,7 @@ async function setup(t, { issuers = [{ name: 'a' }], provider = {}, before = nul
|
||||
const config = { publicOrigin: ORIGIN, providers: iss.map(i => providerFor(i, provider[i.name] ?? {})) };
|
||||
const lines = [];
|
||||
const log = line => { lines.push(line); ALL_LINES.push(line); };
|
||||
const sso = await createSso(config, { secrets: new Map(iss.map(i => [i.name, i.clientSecret])), fetch: op.fetch, log, retryFirst });
|
||||
const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map(iss.map(i => [i.name, i.clientSecret])), fetch: op.fetch, log, retryFirst });
|
||||
t.after(() => sso.close());
|
||||
const sends = [];
|
||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => [{ verb: v }]]));
|
||||
@@ -297,7 +298,7 @@ test('11: start refusals: HS or none algs, reserved names, HS-only discovery, an
|
||||
await assert.rejects(startServer({ port: 0, sso, preview: { host: HOST, header: 'remote-user', user: 'jason' } }), /--sso cannot be combined/);
|
||||
});
|
||||
|
||||
test('11: slot files: a symlink, mode 0644, mode 0640 and a file under the repository are refused, and no message holds the value', t => {
|
||||
test('11: slot files: a symlink, a directory, mode 0644, 0640 or 0604 and a file under the repository are refused, and no message holds the value', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-sso-slot-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const dir = join(root, 'config'), slots = join(dir, 'secrets', 'mosaic-stack');
|
||||
@@ -309,24 +310,34 @@ test('11: slot files: a symlink, mode 0644, mode 0640 and a file under the repos
|
||||
assert.equal(readSlot(dir, provider('good'), opts), value);
|
||||
writeFileSync(join(root, 'target'), value, { mode: 0o600 });
|
||||
symlinkSync(join(root, 'target'), join(slots, 'link'));
|
||||
for (const [slot, mode] of [['wide', 0o644], ['group', 0o640]]) { writeFileSync(join(slots, slot), value); chmodSync(join(slots, slot), mode); }
|
||||
for (const [slot, mode] of [['wide', 0o644], ['group', 0o640], ['other', 0o604]]) { writeFileSync(join(slots, slot), value); chmodSync(join(slots, slot), mode); }
|
||||
mkdirSync(join(slots, 'dir'), { mode: 0o700 });
|
||||
// M3 (#1873): a config whose secrets directory is a link into the repository, as stack-release's T57 once pinned.
|
||||
const linked = join(root, 'linked'), inRepo = join(opts.repo, 'secrets');
|
||||
mkdirSync(join(inRepo, 'mosaic-stack'), { recursive: true });
|
||||
mkdirSync(linked);
|
||||
writeFileSync(join(inRepo, 'mosaic-stack', 'good'), `${value}\n`, { mode: 0o600 });
|
||||
symlinkSync(inRepo, join(linked, 'secrets'));
|
||||
writeFileSync(join(slots, 'two'), `${value}\nmore\n`, { mode: 0o600 });
|
||||
writeFileSync(join(slots, 'empty'), '\n', { mode: 0o600 });
|
||||
const cases = [
|
||||
['missing', /slot is missing$/, opts],
|
||||
['link', /must not be a symbolic link$/, opts],
|
||||
['dir', /must be a regular file$/, opts],
|
||||
['wide', /must have no group or other permission bits$/, opts],
|
||||
['group', /must have no group or other permission bits$/, opts],
|
||||
['other', /must have no group or other permission bits$/, opts],
|
||||
['two', /must hold one non-empty line$/, opts],
|
||||
['empty', /must hold one non-empty line$/, opts],
|
||||
// The config dir itself under the repository, and under a --checkout.
|
||||
['good', /must not be inside the repository or a stack copy$/, { ...opts, repo: root }],
|
||||
['good', /must not be inside the repository or a stack copy$/, { ...opts, checkout: dir }],
|
||||
['good', /must not be inside the repository or a stack copy$/, { ...opts, copies: dir }],
|
||||
['good', /must not be inside the repository or a stack copy$/, opts, linked],
|
||||
];
|
||||
for (const [slot, why, o] of cases) {
|
||||
for (const [slot, why, o, cfg = dir] of cases) {
|
||||
let err = null;
|
||||
try { readSlot(dir, provider(slot), o); } catch (e) { err = e; }
|
||||
try { readSlot(cfg, provider(slot), o); } catch (e) { err = e; }
|
||||
assert.ok(err, `${slot} was read`);
|
||||
assert.match(err.message, /^console\.sso provider a client secret slot /);
|
||||
assert.match(err.message, why, slot);
|
||||
@@ -365,6 +376,7 @@ test('11: the CLI refuses --sso with the old flags and without console.sso, then
|
||||
assert.equal(wide.status, 2);
|
||||
assert.match(wide.stderr, /^refused: console\.sso provider a client secret slot must have no group or other permission bits$/m);
|
||||
chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o600);
|
||||
writeFileSync(join(root, 'cfg', 'accounts.json'), JSON.stringify({ accountsVersion: 1, revision: 1, users: ownerAccounts({ providers: [providerFor(a)] }).load().users.map(u => ({ ...u, created: '2026-11-10T00:00:00Z' })), memberships: [], invites: [] }), { mode: 0o600 });
|
||||
// The child trusts the fake OP's certificate through NODE_EXTRA_CA_CERTS; the code under test has no flag for it.
|
||||
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--sso', '--send'], { env: { ...env, NODE_EXTRA_CA_CERTS: join(root, 'ca.pem') } });
|
||||
t.after(() => child.kill('SIGKILL'));
|
||||
@@ -522,11 +534,11 @@ test('16: a provider with RS256 and ES256 takes either, and refuses PS256 with a
|
||||
refused(env, (await signIn(env, { opts: { key: ps } })).r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('17: 33 started logins leave 32 records, and the first one\'s callback finds none', async t => {
|
||||
test('17: 129 started logins leave 128 records, and the first one\'s callback finds none', async t => {
|
||||
const env = await setup(t);
|
||||
const first = await begin(env);
|
||||
for (let i = 0; i < 32; i++) await begin(env);
|
||||
assert.equal(env.sso.logins.size, 32);
|
||||
for (let i = 0; i < 128; i++) await begin(env);
|
||||
assert.equal(env.sso.logins.size, 128);
|
||||
refused(env, await callback(env, first, { code: grant(env, first, env.iss[0]).code }), 'login-record-missing');
|
||||
});
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ async function fixture(t, { writer = 'owner', send = true, afterRead = null, bef
|
||||
// afterRead and beforeWrite let a test act between the route's read and its write.
|
||||
const requestRead = async (verb, args, ctx) => { calls.push(['read', verb]); const r = await new Client({ path, console: { subject: ctx.subject } }).call(verb, args); await afterRead?.(); return r; };
|
||||
const systemTypesWrite = async (verb, args) => { await beforeWrite?.(verb); calls.push(['write', verb]); return runtime.broker.request(cap, { verb, args }); };
|
||||
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason' };
|
||||
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason', rootOwner: true };
|
||||
let active = true;
|
||||
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) };
|
||||
const organizations = {
|
||||
@@ -61,7 +61,7 @@ async function fixture(t, { writer = 'owner', send = true, afterRead = null, bef
|
||||
const project = runtime.broker.request(owner, { verb: 'project.create', args: { name: 'example' } }).id;
|
||||
const onDisk = () => JSON.parse(readFileSync(file)).plugins['system-type'];
|
||||
const ownerRequest = (verb, args) => runtime.broker.request(owner, { verb, args });
|
||||
return { base, file, project, calls, onDisk, ownerRequest, signOut: () => { active = false; } };
|
||||
return { base, file, project, calls, onDisk, ownerRequest, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } };
|
||||
}
|
||||
|
||||
function call(f, path, method = 'GET', body, headers = {}) {
|
||||
@@ -147,6 +147,21 @@ test('a sign-out while the read runs dispatches no write on either route', async
|
||||
assert.deepEqual(f.onDisk(), ['coding']);
|
||||
});
|
||||
|
||||
test('root authority revoked during a read cannot write through a surviving user session', async t => {
|
||||
for (const projectRoute of [false, true]) {
|
||||
let revokeOnRead = false;
|
||||
const f = await fixture(t, { afterRead: () => { if (revokeOnRead) f.revokeOwner(); } });
|
||||
const [, { revision }] = await call(f, ORG);
|
||||
revokeOnRead = true;
|
||||
const path = projectRoute ? `${ORG}/projects/${f.project}` : ORG;
|
||||
const body = projectRoute ? { systemTypes: ['coding'] } : { revision, enabled: [] };
|
||||
const [status, result] = await call(f, path, 'POST', body);
|
||||
assert.deepEqual([status, result.error], [401, 'sign-in-required'], path);
|
||||
assert.equal(f.calls.filter(c => c[0] === 'write' && c[1] !== 'plugins').length, 0);
|
||||
assert.deepEqual(f.onDisk(), ['coding']);
|
||||
}
|
||||
});
|
||||
|
||||
test('the enabled set and its revision come from one read, so a change in between is not undone', async t => {
|
||||
let raced = false;
|
||||
const f = await fixture(t, { beforeWrite: async verb => {
|
||||
|
||||
@@ -12,7 +12,7 @@ const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++)
|
||||
|
||||
// Three organizations as sample 01 and 03 show them: two running buses and one stopped. P2: each running
|
||||
// organization's System types read comes from the installed modules; `send` adds the owner's writes.
|
||||
function workspaceOrgs({ failThreads = false, seen = null, send = false, types = {} } = {}) {
|
||||
function workspaceOrgs({ failThreads = false, seen = null, send = false, types = {}, copies = {}, buses = {} } = {}) {
|
||||
const ORGS = [
|
||||
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
|
||||
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
|
||||
@@ -30,7 +30,9 @@ function workspaceOrgs({ failThreads = false, seen = null, send = false, types =
|
||||
const fakes = {};
|
||||
const FILES = { 'spring-launch': ['docs/north-star.md', 'docs/launch-plan.md', 'src/index.js'], 'brand-refresh': ['docs/brand-voice-guide.md'], 'api-gateway': [] };
|
||||
const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
|
||||
instances: Object.fromEntries(Object.entries(INSTANCES[id]).map(([n, [definition, running]]) => [n, { definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running, alive: [] }])) });
|
||||
instances: Object.fromEntries(Object.entries(INSTANCES[id]).map(([n, [definition, running]]) => [n, { definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running, alive: [] }])),
|
||||
// #1873 A3: the bus's copies key, present only when the test gives the organization copies.
|
||||
...(copies[id] ? { copies: copies[id] } : {}) });
|
||||
const business = id => ({ business: { id, human: 'jason', arbiters: ['chief'], projects: PROJECTS[id].map(p => p.id), vars: id === 'ddigital' ? { 'tracker.kind': 'vikunja' } : {},
|
||||
instances: Object.keys(INSTANCES[id]).map(n => ({ instance: n, vars: { model: 'sonnet-5.5' } })), appearance: { theme: ORGS.find(o => o.id === id).theme } } });
|
||||
return {
|
||||
@@ -45,7 +47,8 @@ function workspaceOrgs({ failThreads = false, seen = null, send = false, types =
|
||||
busHealthy: true, requestRead: st.requestRead, systemTypesWrite: send ? st.systemTypesWrite : null, ...(send ? { send: async () => ({}) } : {}),
|
||||
reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', notifier: { binding: 'discord' }, launch: { limits: { total: 4 } } }),
|
||||
queue: async () => ({ rows: id === 'ddigital' ? [{ state: 'done' }, { state: 'in-progress' }, { state: 'queued' }] : [] }), busStatus: async () => ({}) },
|
||||
bus: { instances: async () => instances(id), messages: async () => ({ messages: [], unread: 0 }), agents: async () => AGENTS[id], 'thread.claims': async () => CLAIMS[id], inbox: async () => [], tasks: async () => [] },
|
||||
// `buses` hands an organization's instance read to a real booted bus instead.
|
||||
bus: { instances: async () => (buses[id] ? buses[id]() : instances(id)), messages: async () => ({ messages: [], unread: 0 }), agents: async () => AGENTS[id], 'thread.claims': async () => CLAIMS[id], inbox: async () => [], tasks: async () => [] },
|
||||
chat: { projects: async () => PROJECTS[id], threads: async () => { if (failThreads) throw new Error('threads down'); return THREADS[id]; } },
|
||||
projectFiles: async ({ project }) => ({ head: 'a'.repeat(40), files: (FILES[project] || []).map(path => ({ path })) }),
|
||||
};
|
||||
@@ -426,13 +429,14 @@ test('agents grid shows each agent\'s level and where it is defined, per project
|
||||
['social-scheduler', 'PROJECT', 'Spring launch project', 'This organization only'],
|
||||
]);
|
||||
const byName = Object.fromEntries(rows.map(r => [r[0].split(' ')[0], r]));
|
||||
assert.equal(byName.researcher[4], '– not running'); assert.equal(byName.researcher[5], 'opus-5.5');
|
||||
// A3: this fixture bus booted with no copies, so a copy's status is the next boot.
|
||||
assert.equal(byName.researcher[4], 'starts with the next bus start'); assert.equal(byName.researcher[5], 'opus-5.5');
|
||||
assert.equal(byName.reviewer[5], 'not set'); assert.equal(byName.assistant[4], '? status not reported');
|
||||
// The model the root file sets for the assistant, shown as configured.
|
||||
assert.equal(byName.assistant[5], 'opus-5.5');
|
||||
assert.equal(byName.chief[4], '◐ waiting on you'); assert.equal(byName.chief[5], 'sonnet-5.5 Change');
|
||||
assert.match(byName.editor[0], /narrowed here: limits\.network/);
|
||||
assert.equal(byName['social-scheduler'][4], '– not running'); assert.equal(byName['social-scheduler'][5], 'haiku-5.5');
|
||||
assert.equal(byName['social-scheduler'][4], 'starts with the next bus start'); assert.equal(byName['social-scheduler'][5], 'haiku-5.5');
|
||||
assert.match(byName['social-scheduler'][0], /coder/);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-agents-available] .ws-away").innerText'), /Not available here: every Forge or Home agent\./);
|
||||
// Another project: its exclusion and the first project's own agent are said, not hidden.
|
||||
@@ -446,3 +450,131 @@ test('agents grid shows each agent\'s level and where it is defined, per project
|
||||
await waitFor(b)('document.querySelector("[data-org-agents] h2")?.innerText === "Agents in Home"');
|
||||
assert.match(await b.evaluate('document.querySelector("[data-agents-available]").innerText'), /reviewer \(turned off for Home\)/);
|
||||
});
|
||||
|
||||
// #1873 A3: the same grid, fed by a booted bus. Distinctly Digital's bus boots from a real organization
|
||||
// file and root file: gate is a bound copy with a real claim, researcher is unbound (its role needs
|
||||
// gitea), stack's checker is a bound project copy and web's union refuses (it excludes an agent
|
||||
// nothing defines), so web's lint says why. The test secret sits in the organization's coder token
|
||||
// and never reaches the page.
|
||||
test('agents grid takes a running copy, an unbound copy and a refused project from a booted bus', { timeout: 90000 }, async t => {
|
||||
const { join } = await import('node:path');
|
||||
const { mkdirSync, writeFileSync } = await import('node:fs');
|
||||
const { checkRootAgents } = await import('../../organization/src/index.mjs');
|
||||
const { launchDoc, tokenFile, writeJson } = await import('../../organization/tests/helpers.mjs');
|
||||
const { fixture, tmp, withoutHostCredentials } = await import('../../cli/tests/helpers.mjs');
|
||||
const { bootConfig, loadSystem } = await import('../../cli/src/config.mjs');
|
||||
const { startTimeOf } = await import('../../cli/src/host.mjs');
|
||||
const { startBroker } = await import('../../bus/src/runtime.mjs');
|
||||
const { Client } = await import('../../bus/src/client.mjs');
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
const VARS = { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' };
|
||||
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
|
||||
const dir = tmp(t, 'ws-copy-');
|
||||
const f = fixture(dir, 'ddigital', doc => {
|
||||
doc.roles.chief.vars = { harness: 'pi', model: 'claude-opus-5-5', thinking: 'high' };
|
||||
doc.roles.coder.vars.model = 'claude-sonnet-5-5';
|
||||
const secret = tokenFile(join(dir, 'secrets'), SECRET);
|
||||
writeFileSync(secret, SECRET);
|
||||
doc.roles.coder.credentials.gitea = { ...doc.roles.coder.credentials.gitea, file: secret };
|
||||
doc.launch = launchDoc('chief', ['coder'], { opus: 1, sonnet: 4 });
|
||||
doc.projects.stack.agents = { checker: { definition: 'merge-gate', vars: VARS } };
|
||||
doc.projects.web = { root: join(dir, 'web'), agents: { lint: { definition: 'merge-gate', vars: VARS }, ghost: { exclude: true } } };
|
||||
return doc;
|
||||
});
|
||||
mkdirSync(join(dir, 'web'), { recursive: true });
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
const rootDoc = { agentsVersion: 1, agents: { gate: { definition: 'merge-gate', vars: VARS }, researcher: { definition: 'research', vars: VARS } } };
|
||||
writeJson(join(dir, 'config', 'agents.json'), rootDoc, 0o600);
|
||||
const boot = (({ trackers, ...rest }) => rest)(bootConfig({ system: loadSystem({ env: f.env }), businessId: 'ddigital', env: f.env }));
|
||||
const runtime = await startBroker(boot); t.after(() => runtime.close());
|
||||
// gate runs: a launch bound to the bus and its claim, as the launcher makes them.
|
||||
const bound = await runtime.bindLaunch({ business: 'ddigital', role: 'gate', run: 'gate-run', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
await new Client({ path: runtime.path, cap: bound.cap }).call('role.claim');
|
||||
const human = runtime.broker.bindHuman({ business: 'ddigital', human: f.doc.human, via: 'cli', outsideAgent: true });
|
||||
const read = () => runtime.broker.request(human, { verb: 'instances' });
|
||||
assert.deepEqual(Object.fromEntries(Object.entries(read().copies).map(([k, c]) => [k, c.state])), { gate: 'instance', researcher: 'unbound', 'checker.stack': 'instance', 'lint.web': 'refused' });
|
||||
const orgs = workspaceOrgs({ buses: { ddigital: async () => read() } });
|
||||
const DOCS = { ddigital: f.doc, forge: { roles: { chief: { definition: 'chief-of-staff' } }, projects: {} }, home: { roles: {}, projects: {} } };
|
||||
const organizations = { ...orgs, members: async () => Object.entries(DOCS).map(([id, doc]) => ({ id, doc })) };
|
||||
const root = { state: 'ok', ...checkRootAgents(rootDoc, { rolesDir }) };
|
||||
const agentLevels = { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } };
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, agentLevels, log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/agents`);
|
||||
await waitFor(b)('document.querySelectorAll(".ws-levels [data-org=ddigital] [data-copy-state]").length === 4');
|
||||
const pills = await b.evaluate('[...document.querySelectorAll(".ws-levels [data-org=ddigital] [data-copy-state]")].map(p => [p.dataset.levelAgent, p.dataset.copyState, p.querySelector(".ws-dot").title])');
|
||||
assert.deepEqual(pills, [['gate', 'instance', 'running'], ['researcher', 'unbound', "can't launch"], ['checker', 'instance', 'not running'], ['lint', 'refused', "can't launch"]]);
|
||||
const why = await b.evaluate('Object.fromEntries([...document.querySelectorAll(".ws-levels [data-org=ddigital] [data-copy-why]")].map(n => [n.dataset.copyWhy, n.innerText.trim()]))');
|
||||
assert.deepEqual(Object.keys(why), ['researcher', 'lint.web']);
|
||||
assert.equal(why.researcher, 'researcher: no binding in this organization for gitea');
|
||||
assert.match(why['lint.web'], /^lint: project web refuses its agents: .*ghost/);
|
||||
const text = await b.evaluate('document.body.innerText');
|
||||
for (const value of [SECRET, dir]) assert.equal(text.includes(value), false, value);
|
||||
});
|
||||
|
||||
// #1873 A3: the grid's copy status comes from each bus's copies. Distinctly Digital runs its researcher
|
||||
// copy, its reviewer copy is unbound (the reason names the services, never a value), and Forge's
|
||||
// api-gateway project refuses, so its load-tester says why, naming the project.
|
||||
test('agents grid shows a running copy, an unbound copy and a refused project with their reasons', async t => {
|
||||
const { checkRootAgents } = await import('../../organization/src/index.mjs');
|
||||
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
|
||||
const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {
|
||||
assistant: { definition: 'assistant' },
|
||||
researcher: { definition: 'research', vars: { model: 'opus-5.5' } },
|
||||
reviewer: { definition: 'reviewer', excludeOrgs: ['home'] },
|
||||
} }, { rolesDir }) };
|
||||
const DOCS = {
|
||||
ddigital: { roles: { chief: { definition: 'chief-of-staff' } }, projects: { 'spring-launch': { root: '/p/spring', agents: { 'social-scheduler': { definition: 'research' } } } } },
|
||||
forge: { roles: { chief: { definition: 'chief-of-staff' } }, projects: { 'api-gateway': { root: '/p/api', agents: { 'load-tester': { definition: 'research' } } } } },
|
||||
home: { roles: {}, projects: {} },
|
||||
};
|
||||
const copy = (name, project, state, extra = {}) => ({ name, level: project ? 'project' : 'root', project, definition: 'research', state, running: [], ...extra });
|
||||
const copies = {
|
||||
ddigital: { researcher: copy('researcher', null, 'instance', { running: ['r9'] }), reviewer: copy('reviewer', null, 'unbound', { definition: 'reviewer', missing: ['gitea', 'vikunja'] }),
|
||||
'social-scheduler.spring-launch': copy('social-scheduler', 'spring-launch', 'instance') },
|
||||
forge: { researcher: copy('researcher', null, 'instance'), reviewer: copy('reviewer', null, 'unbound', { definition: 'reviewer', missing: ['gitea', 'vikunja'] }),
|
||||
'load-tester.api-gateway': copy('load-tester', 'api-gateway', 'refused', { reason: 'project api-gateway refuses its agents: instance load-tester: model is not one the organization lists' }) },
|
||||
};
|
||||
const orgs = workspaceOrgs({ copies });
|
||||
const organizations = { ...orgs, members: async () => Object.entries(DOCS).map(([id, doc]) => ({ id, doc })) };
|
||||
const agentLevels = { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } };
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, agentLevels, log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/agents`);
|
||||
await waitFor(b)('document.querySelectorAll(".ws-levels [data-copy-state]").length === 7');
|
||||
const pills = await b.evaluate('[...document.querySelectorAll(".ws-levels [data-org]")].flatMap(c => [...c.querySelectorAll("[data-copy-state]")].map(p => [c.dataset.org, p.dataset.levelAgent, p.dataset.copyState, p.querySelector(".ws-dot").title]))');
|
||||
assert.deepEqual(pills, [
|
||||
['ddigital', 'researcher', 'instance', 'running'], ['ddigital', 'reviewer', 'unbound', "can't launch"],
|
||||
['forge', 'researcher', 'instance', 'not running'], ['forge', 'reviewer', 'unbound', "can't launch"],
|
||||
// Home's bus is down: no answer reads as unavailable, never as idle.
|
||||
['home', 'researcher', 'unknown', 'status unavailable'],
|
||||
['ddigital', 'social-scheduler', 'instance', 'not running'], ['forge', 'load-tester', 'refused', "can't launch"],
|
||||
]);
|
||||
const why = await b.evaluate('Object.fromEntries([...document.querySelectorAll(".ws-levels [data-org]")].flatMap(c => [...c.querySelectorAll("[data-copy-why]")].map(n => [`${c.dataset.org}:${n.dataset.copyWhy}`, n.innerText.trim()])))');
|
||||
assert.deepEqual(why, {
|
||||
'ddigital:reviewer': 'reviewer: no binding in this organization for gitea, vikunja',
|
||||
'forge:reviewer': 'reviewer: no binding in this organization for gitea, vikunja',
|
||||
'forge:load-tester.api-gateway': 'load-tester: project api-gateway refuses its agents: instance load-tester: model is not one the organization lists',
|
||||
});
|
||||
// The table for Distinctly Digital's first project: the copies' status from its bus, the reason under an unbound one.
|
||||
const rows = await b.evaluate('Object.fromEntries([...document.querySelectorAll("[data-agents-available] tbody tr")].map(tr => [tr.cells[0].innerText.trim().split(/\\s/)[0], tr.cells[4].innerText.replace(/\\s+/g, " ").trim()]))');
|
||||
assert.equal(rows.researcher, '● running');
|
||||
assert.equal(rows.reviewer, "▲ can't launch no binding in this organization for gitea, vikunja");
|
||||
assert.equal(rows['social-scheduler'], '– not running');
|
||||
assert.doesNotMatch(await b.evaluate('document.body.innerText'), /not launched yet/);
|
||||
const shots = process.env.MOSAIC_UX_SHOTS;
|
||||
if (shots) {
|
||||
const { mkdir } = await import('node:fs/promises');
|
||||
const { join } = await import('node:path');
|
||||
await mkdir(shots, { recursive: true });
|
||||
for (const [width, height] of [[1440, 900], [390, 844]]) for (const mode of ['light', 'dark']) {
|
||||
await b.viewport(width, height);
|
||||
// The header's switch sets this browser's mode for the organization, so the theme's colours follow.
|
||||
await b.evaluate(`(() => { const s = document.querySelector("#theme-switch"); if ((s.getAttribute("aria-checked") === "true") !== ${mode === 'dark'}) s.click(); scrollTo(0, 0); document.querySelector("main, .ws-main")?.scrollTo?.(0, 0); })()`);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
await b.screenshot(join(shots, `a3-agents-${width}-${mode}.png`));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ownerAccounts } from './helpers/owner-accounts.mjs';
|
||||
// R6-N1 (#1807, row 294 N1): the user-session option swap in serve.mjs, driven through HTTP.
|
||||
// granted-user.test.mjs G4 stops every owner route at the userRoute gate, so it never reaches the
|
||||
// swap behind it. Here serve.mjs loads with that gate stubbed open (a load hook keyed on one query
|
||||
@@ -87,7 +88,7 @@ async function setup(t) {
|
||||
const userOptions = userOptionsFor({ owner: ownerCall, send: true, transport: name => { opened.push(name); return userTransport(name); } });
|
||||
const lines = [];
|
||||
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] };
|
||||
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), users: consoleAccess({ load, business: () => 'demo' }) });
|
||||
const sso = await createSso(config, { accounts: ownerAccounts(config), secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), users: consoleAccess({ load, business: () => 'demo' }) });
|
||||
t.after(() => sso.close());
|
||||
// Every owner option is a stub that records its own name.
|
||||
const stub = name => async () => { owner.push(['option', name]); return name === 'send' ? { id: 'msg-0001' } : [{ verb: name }]; };
|
||||
|
||||
@@ -203,6 +203,9 @@ if [ ! -e "$CONFIG_DIR" ]; then
|
||||
fi
|
||||
out="$("$REPO/scripts/bootstrap.sh" 2>&1)" || refuse "bootstrap: $(printf '%s' "$out" | tail -n 1)"
|
||||
say "config $CONFIG"
|
||||
# Case 1 and Case 2 share this installer. Deployment identity is instance-scoped,
|
||||
# never a switch that can be silently removed to enable the local dev owner.
|
||||
out="$(cd "$REPO" && node --input-type=module -e 'import { readFileSync } from "node:fs"; import { writeDeployment } from "./packages/cli/src/deployment.mjs"; writeDeployment(JSON.parse(readFileSync(process.argv[1], "utf8")));' "$CONFIG" 2>&1)" || refuse "deployment: $(printf '%s' "$out" | tail -n 1)"
|
||||
|
||||
# 3. The stack copy row 195's units run from.
|
||||
out="$("$STACK_COPY" copy "$COMMIT" --copies "$COPIES" 2>&1)" || refuse "stack-copy: $(printf '%s' "$out" | tail -n 1)"
|
||||
@@ -250,8 +253,12 @@ if ! out="$("$REPO/scripts/mosaic" business validate "$BUSINESS" 2>&1)"; then
|
||||
fi
|
||||
say "business $BUSINESS validates"
|
||||
|
||||
# 8. What comes next. Root's steps run from root's own checkout.
|
||||
say "done. Next: root runs case2-unit.sh --copy $COMMIT --business $BUSINESS (add --proxy when the config has a proxy section; design 10 step 2c), then 2d and 3."
|
||||
# 8. What comes next. Root's steps run from root's own checkout. The copy is stamped and .deployed
|
||||
# names this instance (step 2), so its Console starts only with SSO accounts (M3, #1873). The steps
|
||||
# before root's are the run sheet's (section 7), and nothing here does them: SSO is the operator's
|
||||
# config, accounts.json comes only from the human verb, and case2-unit.sh refuses until both exist.
|
||||
say "done. .deployed names this instance and the copy is stamped, so its Console starts only with SSO accounts. Before the units, in order (run sheet section 7): prepare console.sso and its client secret slots in $CONFIG; a human runs \`mosaic accounts init\` as $(id -un); $(id -un) stays a dedicated uid with a non-login shell, the only member of its primary group."
|
||||
say "Next: root runs case2-unit.sh --copy $COMMIT --business $BUSINESS with --sso in the serve flags (factory: --sso --send; add --proxy when the config has a proxy section; design 10 step 2c), then 2d and 3. It refuses before any write and names each step still missing."
|
||||
if [ "$PROFILE" = library ]; then
|
||||
say "Then Jason places the library token in $SLOT (step 4), and <user> runs case2-precheck.sh --business $BUSINESS --library-status (step 4a)."
|
||||
fi
|
||||
|
||||
+112
-13
@@ -5,9 +5,15 @@
|
||||
# mosaic-proxy.service under --proxy, from the existing templates, which it
|
||||
# doesn't change.
|
||||
#
|
||||
# case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH
|
||||
# [--profile factory|library] [--proxy] [--pm] [--claude PATH]
|
||||
# -- SERVE FLAGS
|
||||
# case2-unit.sh [--check|--check-new] --user NAME --home DIR --business ID
|
||||
# --copy ID --node PATH [--profile factory|library] [--proxy]
|
||||
# [--pm] [--claude PATH] -- SERVE FLAGS
|
||||
#
|
||||
# --check runs every check a render runs, the installed units' included, then
|
||||
# exits 0 without writing, enabling or starting anything. --check-new does the
|
||||
# same as a first install would, without the installed units' checks; it is for
|
||||
# stack-upgrade.sh --next-args, which moves the installed units aside before its
|
||||
# render (docs/guides/host-run-sheet.md section 7). Neither writes a file.
|
||||
#
|
||||
# --org ID is the same option as --business ID (rename design r2), in each of
|
||||
# two scopes: this script's own flags before --, and the serve flags after it.
|
||||
@@ -36,6 +42,10 @@
|
||||
# --proof-header NAME --proof-file PATH
|
||||
# It is proxy-preview read-only until Case 2 supports --sso (#412); --send
|
||||
# and --sso are explicitly refused. The proof still protects proxy preview reads.
|
||||
# That holds for an unstamped copy only. A stamped library copy (M3, #1873;
|
||||
# Jason's ruling 2026-11-10) signs in through SSO accounts like every
|
||||
# deployed Console: it takes --port, --business and a bare --sso, refuses
|
||||
# every proxy-preview and proof flag, and still refuses --send.
|
||||
# The factory profile takes --port N (1..1023) and --business ID, and an
|
||||
# optional bare --tester-socket marker. It also takes the paired bare
|
||||
# --sso --send (#415): the Console signs its owner in from console.sso, which
|
||||
@@ -43,6 +53,15 @@
|
||||
# preview and proof flags still refuse. Retired tester TCP refuses. Only
|
||||
# factory takes --pm and --claude.
|
||||
#
|
||||
# A stamped copy (.stack-build.json, from stack-copy.sh) starts only with
|
||||
# --sso, console.sso, a loadable accounts.json and a .deployed naming the
|
||||
# default instance (packages/webui/src/cli.mjs, M3 #1873). For a stamped copy,
|
||||
# in either profile, the render asks the stack user's own copy for those
|
||||
# facts and refuses before any write, naming every missing step in the run
|
||||
# sheet's order (docs/guides/host-run-sheet.md section 7). It writes none of
|
||||
# them and picks no subject: .deployed comes from case2-install.sh, and
|
||||
# accounts.json from the human verb `mosaic accounts init`.
|
||||
#
|
||||
# On a re-install the installed units must equal the render in everything
|
||||
# but the copy id, and all selected units must name one copy, so a re-pin or rollback
|
||||
# re-renders them together. A unit that was already running stays on its
|
||||
@@ -72,7 +91,7 @@ PROXY_UNIT=mosaic-proxy.service
|
||||
BUS_UNIT=[email protected]
|
||||
WEBUI_UNIT=mosaic-webui.service
|
||||
|
||||
usage() { echo 'usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS' >&2; exit 4; }
|
||||
usage() { echo 'usage: case2-unit.sh [--check|--check-new] --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS' >&2; exit 4; }
|
||||
die() { echo "case2-unit: $*" >&2; exit 1; }
|
||||
# A value the unit carries as itself: no space, %, $, quote, backslash, & or |.
|
||||
plain() { case "$1" in *[!A-Za-z0-9/._+@-]*) return 1 ;; esac; }
|
||||
@@ -103,7 +122,8 @@ serve_refused() {
|
||||
*) die "serve $2" ;;
|
||||
esac
|
||||
}
|
||||
PROFILE=library USER_NAME='' HOME_DIR='' BUSINESS='' COPY_ID='' NODE='' PROXY=0 PM=0 CLAUDE='' CLAUDE_SET=0
|
||||
PROFILE=library USER_NAME='' HOME_DIR='' BUSINESS='' COPY_ID='' NODE='' PROXY=0 PM=0 CLAUDE='' CLAUDE_SET=0 CHECK=''
|
||||
case "${1-}" in --check|--check-new) CHECK="$1"; shift ;; esac
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--business|--org) BUSINESS="${2-}"; selector_take outer id outer_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;;
|
||||
@@ -136,12 +156,19 @@ fi
|
||||
# The user, its uid, primary group and home, from the passwd database.
|
||||
[[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "--user must be a user name: $USER_NAME"
|
||||
PW="$("$GETENT" passwd "$USER_NAME")" || die "no user $USER_NAME in the passwd database"
|
||||
IFS=: read -r _ _ USER_UID USER_GID _ PW_HOME _ <<<"$PW"
|
||||
IFS=: read -r PW_NAME _ USER_UID USER_GID _ PW_HOME PW_SHELL <<<"$PW"
|
||||
[[ "$USER_UID" =~ ^[0-9]+$ ]] && [ "$USER_UID" -ne 0 ] || die "--user must not be root: $USER_NAME"
|
||||
[ "$PW_NAME" = "$USER_NAME" ] || die "passwd name does not match --user"
|
||||
case "$PW_SHELL" in /usr/sbin/nologin|/sbin/nologin|/bin/false|/usr/bin/false) ;; *) die "--user requires a non-login shell" ;; esac
|
||||
abs_plain "$HOME_DIR" '--home'
|
||||
[ "$HOME_DIR" = "$PW_HOME" ] || die "--home $HOME_DIR is not $USER_NAME's home in the passwd database ($PW_HOME)"
|
||||
GR="$("$GETENT" group "$USER_GID")" || die "no group $USER_GID in the group database"
|
||||
GROUP="${GR%%:*}"
|
||||
IFS=: read -r GROUP _ GROUP_GID GROUP_MEMBERS <<<"$GR"
|
||||
[ "$GROUP_GID" = "$USER_GID" ] || die "primary group id mismatch"
|
||||
IFS=, read -r -a PRIMARY_MEMBERS <<<"$GROUP_MEMBERS"
|
||||
for member in "${PRIMARY_MEMBERS[@]}"; do [ -z "$member" ] || [ "$member" = "$USER_NAME" ] || die "primary group must be sole-member"; done
|
||||
PASSWD_ALL="$("$GETENT" passwd)" || die "cannot enumerate primary group members"
|
||||
printf '%s\n' "$PASSWD_ALL" | awk -F: -v gid="$USER_GID" -v user="$USER_NAME" '$4 == gid && $1 != user { bad=1 } $4 == gid && $1 == user { seen++ } END { exit bad || seen != 1 }' || die "primary group must be sole-member"
|
||||
[[ "$GROUP" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "the primary group of $USER_NAME is not a group name: $GROUP"
|
||||
|
||||
case "$BUSINESS" in [a-z0-9]*) ;; *) die "--business must be a business id: $BUSINESS" ;; esac
|
||||
@@ -154,6 +181,9 @@ COPIES="$HOME_DIR/.local/share/mosaic-stack/copies"
|
||||
COPY="$COPIES/$COPY_ID"
|
||||
[ -d "$COPY" ] && [ ! -L "$COPY" ] || die "no copy directory $COPY"
|
||||
[ -f "$COPY/scripts/mosaic" ] || die "no copy $COPY_ID in $COPIES: $COPY/scripts/mosaic is missing"
|
||||
# M3: presence alone decides, as the Console's buildStamp does (lstat), so a broken link counts.
|
||||
STAMPED=0
|
||||
if [ -e "$COPY/.stack-build.json" ] || [ -L "$COPY/.stack-build.json" ]; then STAMPED=1; fi
|
||||
|
||||
# Owned by root and writable by no one else, the file and every directory above it.
|
||||
root_only() {
|
||||
@@ -382,11 +412,13 @@ serve_flags() {
|
||||
--tester-socket) [ "$PROFILE" = factory ] || die "the library profile refuses --tester-socket" ;;
|
||||
--checkout) die "refusing --checkout: Case 2 has no queue panel" ;;
|
||||
--board) die "refusing --board: Case 2 runs no control board" ;;
|
||||
--sso) [ "$PROFILE" = factory ] || die "refusing --sso: the Console signs people in through the proxy, with the preview set and the proof pair (Q175 phase 2 is a follow-up)" ;;
|
||||
--send) [ "$PROFILE" = factory ] || die "the library profile is read-only; refusing --send until Case 2 supports --sso (#412)" ;;
|
||||
--sso) [ "$PROFILE" = factory ] || [ "$STAMPED" = 1 ] || die "refusing --sso: the Console signs people in through the proxy, with the preview set and the proof pair (Q175 phase 2 is a follow-up)" ;;
|
||||
--send) [ "$PROFILE" = factory ] || [ "$STAMPED" = 0 ] || die "the library profile is read-only; refusing --send (a stamped library Console signs in with --sso and stays read-only)"
|
||||
[ "$PROFILE" = factory ] || die "the library profile is read-only; refusing --send until Case 2 supports --sso (#412)" ;;
|
||||
--port|--business|--org) ;;
|
||||
--public-host|--auth-header|--allow-user|--proof-header|--proof-file)
|
||||
[ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy" ;;
|
||||
[ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy"
|
||||
[ "$STAMPED" = 0 ] || die "refusing $name: $COPY_ID is a stamped copy, and proxy-preview admission and its proof do not apply to a stamped copy; its Console signs in through SSO accounts with --sso" ;;
|
||||
*) die "unknown serve flag: $name" ;;
|
||||
esac
|
||||
[ -z "${seen[$name]:-}" ] || die "serve flag $name given twice"
|
||||
@@ -427,7 +459,7 @@ serve_flags() {
|
||||
i=$((i + 2))
|
||||
done
|
||||
local required=(--port --business)
|
||||
[ "$PROFILE" = factory ] || required+=(--public-host --auth-header --allow-user --proof-header --proof-file)
|
||||
[ "$PROFILE" = factory ] || [ "$STAMPED" = 1 ] || required+=(--public-host --auth-header --allow-user --proof-header --proof-file)
|
||||
for name in "${required[@]}"; do
|
||||
[ -n "${seen[$name]:-}" ] || { [ "$name" = --business ] && [ -n "${seen[--org]:-}" ]; } || die "serve flag $name is required"
|
||||
done
|
||||
@@ -436,8 +468,10 @@ serve_flags() {
|
||||
# proof file is inferred. The Console refuses --sso without console.sso
|
||||
# at start; this asks the stack user's config first, before any write.
|
||||
[ "$sso" = 0 ] || [ -n "${seen[--send]:-}" ] || die "refusing --sso without --send in the factory profile"
|
||||
[ -z "${seen[--send]:-}" ] || [ "$sso" = 1 ] || die "the factory profile refuses --send without --sso"
|
||||
if [ "$sso" = 1 ]; then
|
||||
# A stamped copy without --sso is a missing step, which stamped_checks names with the others.
|
||||
[ -z "${seen[--send]:-}" ] || [ "$sso" = 1 ] || [ "$STAMPED" = 1 ] || die "the factory profile refuses --send without --sso"
|
||||
# A stamped copy's console.sso is one of stamped_checks' steps, asked in the same query.
|
||||
if [ "$sso" = 1 ] && [ "$STAMPED" = 0 ]; then
|
||||
local answer
|
||||
answer="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$SSO_QUERY" "$COPY/packages/cli/src/config.mjs")" || die "sso config query $(tester_capture_reason "$?")"
|
||||
[ "$answer" != '{"sso":false}' ] || die "--sso needs console.sso in $USER_NAME's system config"
|
||||
@@ -452,6 +486,8 @@ serve_flags() {
|
||||
fi
|
||||
echo "${flags# }"; return 0
|
||||
fi
|
||||
# A stamped library copy has no proxy-preview flag left to check; stamped_checks follows.
|
||||
if [ "$STAMPED" = 1 ]; then echo "${flags# }"; return 0; fi
|
||||
# Lead decision 101: the uid header carries the Authentik uid.
|
||||
if [ "${auth_header,,}" = x-authentik-uid ] && ! [[ "$allow_user" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
die "with --auth-header $auth_header the allowed user must be an Authentik uid, 64 lower-case hex characters (lead decision 101): $allow_user"
|
||||
@@ -478,6 +514,61 @@ NODE_DIR="$(dirname -- "$NODE")"
|
||||
UNIT_PATH="$NODE_DIR"
|
||||
for d in /usr/local/bin /usr/bin /bin; do [ "$d" = "$NODE_DIR" ] || UNIT_PATH="$UNIT_PATH:$d"; done
|
||||
FLAGS="$(serve_flags)" || exit 1
|
||||
SSO_FLAG=0
|
||||
case " $FLAGS " in *" --sso "*) SSO_FLAG=1 ;; esac
|
||||
|
||||
# The config query runs after dropping uid, through the bounded supervisor.
|
||||
# Root reads only checked facts and stats the two owned directories.
|
||||
HOST_FACTS="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$TESTER_QUERY" "$COPY/packages/cli/src/config.mjs" "$COPY/packages/webui/src/cli.mjs")" || die "host config query $(tester_capture_reason "$?")"
|
||||
tester_facts "$HOST_FACTS" || die "host config query has invalid facts"
|
||||
owned_directory() {
|
||||
local dir="$1" what="$2" real st
|
||||
abs_plain "$dir" "$what"
|
||||
[ -d "$dir" ] && [ ! -L "$dir" ] || die "$what directory must exist without a symlink"
|
||||
real="$(realpath -e -- "$dir")" || die "cannot resolve $what directory"
|
||||
[ "$real" = "$dir" ] || die "$what directory contains a symlink"
|
||||
st="$(stat -c '%u %a' -- "$dir")" || die "cannot stat $what directory"
|
||||
[ "${st% *}" = "$USER_UID" ] || die "$what directory must be owned by $USER_NAME uid $USER_UID"
|
||||
(( (8#${st#* } & 8#022) == 0 )) || die "$what directory must not be group or other writable"
|
||||
}
|
||||
owned_directory "$HOME_DIR/.config/mosaic-dev" config
|
||||
owned_directory "$TS_DATA" data-root
|
||||
|
||||
# M3 (#1873): a stamped copy's Console starts only with --sso, console.sso, accounts.json that
|
||||
# loads and a .deployed naming the default instance (packages/webui/src/cli.mjs). The stack user's
|
||||
# own copy answers, through the same bounded supervisor, with its loadSystem, loadAccounts and
|
||||
# assertDeployed, so the render asks exactly what the start will. Root reads three checked facts.
|
||||
# Every missing step is named, in the run sheet's order, before any write. Without console.sso an
|
||||
# accounts.json is "unchecked": its subjects name providers SSO has not configured yet, so the SSO
|
||||
# step comes first and the next render checks it. Nothing here writes .deployed or accounts.json,
|
||||
# or picks a subject.
|
||||
STAMP_QUERY='import {join} from "node:path";import {homedir} from "node:os";import {pathToFileURL} from "node:url";const u=(f)=>import(pathToFileURL(f).href);const [c,d,a]=await Promise.all(process.argv.slice(1,4).map(u));const s=c.loadSystem();const dir=join(homedir(),".config/mosaic-dev");const fact=(file,check)=>{if(!d.present(file))return "missing";try{check();return "ok"}catch{return "invalid"}};const accounts=!s.console?.sso&&d.present(join(dir,"accounts.json"))?"unchecked":fact(join(dir,"accounts.json"),()=>a.loadAccounts(join(dir,"accounts.json"),{providers:s.console?.sso?.providers??[]}));const deployed=fact(join(s.dataRoot,".deployed"),()=>d.assertDeployed(s,dir));console.log(JSON.stringify({sso:Boolean(s.console&&s.console.sso),accounts,deployed}));'
|
||||
stamped_checks() {
|
||||
[ "$STAMPED" = 1 ] || return 0
|
||||
local answer pattern='^\{"sso":(true|false),"accounts":"(ok|missing|invalid|unchecked)","deployed":"(ok|missing|invalid)"\}$'
|
||||
local -a steps=()
|
||||
answer="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$STAMP_QUERY" "$COPY/packages/cli/src/config.mjs" "$COPY/packages/cli/src/deployment.mjs" "$COPY/packages/organization/src/accounts.mjs")" || die "stamped copy query $(tester_capture_reason "$?")"
|
||||
[[ "$answer" =~ $pattern ]] || die "stamped copy query has invalid facts"
|
||||
local sso="${BASH_REMATCH[1]}" accounts="${BASH_REMATCH[2]}" deployed="${BASH_REMATCH[3]}"
|
||||
case "$deployed" in
|
||||
missing) steps+=("check or create .deployed: $USER_NAME runs case2-install.sh, whose deployment step writes $TS_DATA/.deployed") ;;
|
||||
invalid) steps+=("check .deployed: $TS_DATA/.deployed is invalid or names another instance; case2-install.sh's deployment step reports why and never replaces it") ;;
|
||||
esac
|
||||
[ "$sso" = true ] || steps+=("prepare SSO: console.sso in $USER_NAME's system config $HOME_DIR/.config/mosaic-dev/config.json, with its client secret slots")
|
||||
case "$accounts" in
|
||||
missing) steps+=("run \`mosaic accounts init\` as $USER_NAME, a human verb: no $HOME_DIR/.config/mosaic-dev/accounts.json") ;;
|
||||
invalid) steps+=("fix $HOME_DIR/.config/mosaic-dev/accounts.json: it does not load (its owner or mode, an active operator, or a subject console.sso does not name); a human fixes it with the accounts verbs") ;;
|
||||
esac
|
||||
if [ "$SSO_FLAG" = 0 ]; then
|
||||
if [ "$PROFILE" = factory ]; then steps+=("add --sso --send to the serve flags, then re-pin")
|
||||
else steps+=("add --sso to the serve flags, then re-pin"); fi
|
||||
fi
|
||||
[ "${#steps[@]}" -gt 0 ] || return 0
|
||||
local text="refusing the units for $COPY_ID: a stamped copy's Console starts only with SSO accounts; missing steps, in order:" i=1 step
|
||||
for step in "${steps[@]}"; do text+=$'\n'" $i. $step"; i=$((i + 1)); done
|
||||
die "$text"
|
||||
}
|
||||
stamped_checks
|
||||
|
||||
HARDENING="ProtectSystem=strict
|
||||
ReadWritePaths=$HOME_DIR
|
||||
@@ -636,6 +727,7 @@ else
|
||||
|| die "installed $PROXY_UNIT requires --proxy; nothing was changed"
|
||||
fi
|
||||
for pair in "${PAIRS[@]}"; do
|
||||
[ "$CHECK" != --check-new ] || break
|
||||
IFS=: read -r kind name var <<<"$pair"
|
||||
path="$UNIT_DIR/$name"
|
||||
[ ! -L "$path" ] || die "refusing: $path is a symlink"
|
||||
@@ -653,6 +745,13 @@ read -r -a ids <<<"$installed_ids"
|
||||
for old in "${ids[@]}"; do
|
||||
[ "$old" = "${ids[0]}" ] || die "the installed units name different copies, ${ids[0]} and $old"
|
||||
done
|
||||
if [ "$CHECK" = --check ]; then
|
||||
echo "case2-unit: check passed: the units for copy $COPY_ID render, and the installed ones match them; nothing was written"
|
||||
exit 0
|
||||
elif [ "$CHECK" = --check-new ]; then
|
||||
echo "case2-unit: check passed: the units for copy $COPY_ID render as a first install; the installed ones were not compared; nothing was written"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# `systemctl start` leaves a running unit on the copy it started from, so
|
||||
# note which units run before the write (row 195 owns the restart).
|
||||
|
||||
@@ -81,6 +81,7 @@ copy_tree() {
|
||||
git -C "$1" archive --format=tar "$full" -- "${COPY_PATHS[@]}" | tar -xf - -C "$tmp" || die "git archive failed; no copy written"
|
||||
[ -f "$tmp/package.json" ] && [ -f "$tmp/package-lock.json" ] || die 'package.json or package-lock.json missing in temp copy; npm ci not run'
|
||||
(cd "$tmp" && npm ci --no-audit --no-fund) >&2 || die 'npm ci failed; no copy written'
|
||||
printf '{"commit":"%s"}\n' "$full" > "$tmp/.stack-build.json"
|
||||
find "$tmp" -type f -exec chmod a-w {} +
|
||||
find "$tmp" -type d -exec chmod 0555 {} +
|
||||
if ! mv -T "$tmp" "$target" 2>/dev/null; then
|
||||
|
||||
+218
-17
@@ -354,6 +354,25 @@ check_target() {
|
||||
refuse already-active "$TO is the pointer's copy"
|
||||
}
|
||||
|
||||
# M3 (#1873): an accounts instance runs dedicated system units, never the operator's systemd user
|
||||
# manager. webui-service.sh install refuses the same four signs, but only after the run stopped the
|
||||
# units; this refuses before anything stops. The run goes through --units external: root renders
|
||||
# the units with case2-unit.sh, and activate, confirm, revert and rollback stage and gate them.
|
||||
# pm-on has no external form, so it passes its own ending ($1). TO names the copy to check.
|
||||
user_units_allowed() {
|
||||
local why
|
||||
why="$(node -e '
|
||||
const fs=require("fs"),p=require("path"),[root,dir,file]=process.argv.slice(1);
|
||||
const present=(f)=>{try{fs.lstatSync(f);return true}catch(e){if(e.code==="ENOENT")return false;throw e}};
|
||||
if(present(p.join(root,".stack-build.json"))){console.log(root+" is a stamped copy");process.exit(1)}
|
||||
if(present(p.join(dir,"accounts.json"))){console.log(p.join(dir,"accounts.json")+" is present");process.exit(1)}
|
||||
if(present(file)){let s;try{s=JSON.parse(fs.readFileSync(file,"utf8"))}catch(e){console.log(file+" cannot be read as JSON");process.exit(1)}
|
||||
if(s.console?.sso){console.log(file+" holds console.sso");process.exit(1)}
|
||||
if(typeof s.dataRoot==="string"&&present(p.join(s.dataRoot,".deployed"))){console.log(p.join(s.dataRoot,".deployed")+" is present");process.exit(1)}}
|
||||
' -- "$COPIES/$TO" "$MOSAIC_INSTANCE_CONFIG_DIR" "$MOSAIC_INSTANCE_CONFIG" 2>&1)" ||
|
||||
refuse accounts-user-unit "${why:-the accounts signs cannot be read}; an accounts instance runs system units, so root renders them with case2-unit.sh${1:- and the $OP runs with --units external}"
|
||||
}
|
||||
|
||||
# --- Plug-in kinds (row 169) ---
|
||||
|
||||
# Unlike copy_schema, these import the copy's own code, as a business-check does: its kinds.mjs
|
||||
@@ -1552,6 +1571,7 @@ do_activate() {
|
||||
run_external migrate
|
||||
return
|
||||
fi
|
||||
user_units_allowed
|
||||
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; config_checks; plan; exit 0; fi
|
||||
precheck
|
||||
plugin_checks
|
||||
@@ -1583,6 +1603,7 @@ do_rollback() {
|
||||
run_external restore
|
||||
return
|
||||
fi
|
||||
user_units_allowed
|
||||
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; config_checks; plan; exit 0; fi
|
||||
precheck
|
||||
plugin_checks
|
||||
@@ -1935,6 +1956,8 @@ do_pm_on() {
|
||||
out="$(plugins harness "$P_COPY" 2>&1)" || refuse harness-unset "$P_COPY's loader refuses the business file: $out"
|
||||
[ "$out" != none ] || refuse harness-unset "the business file sets no harness; Jason sets it with plugins set first"
|
||||
node -e 'const h=JSON.parse(process.argv[1]);process.exit(Array.isArray(h)&&h.length>0?0:1)' -- "$out" 2>/dev/null || refuse harness-unset "the business file's harness is $out, not a non-empty list"
|
||||
TO="$P_COPY"
|
||||
user_units_allowed ", and the PM is a flag in root's bus unit"
|
||||
instance_busy
|
||||
drop_in_override
|
||||
read_units || refuse unit-flags-drift "cannot read $BUS_UNIT or $CONSOLE_UNIT"
|
||||
@@ -2018,12 +2041,16 @@ r_gone() {
|
||||
while r_alive "$1"; do [ "$SECONDS" -lt "$deadline" ] || return 1; sleep 1; done
|
||||
}
|
||||
|
||||
# Stops the Console, then the host, with SIGTERM within --timeout; one still there gets SIGKILL
|
||||
# and a FAIL line. The host has to leave the store's writer.lock behind it.
|
||||
# Stops the Console, the host, then the rehearsal provider, with SIGTERM within --timeout; one
|
||||
# still there gets SIGKILL and a FAIL line. The host has to leave the store's writer.lock behind it.
|
||||
r_stop() {
|
||||
local name pid
|
||||
for name in console host; do
|
||||
if [ "$name" = console ]; then pid="$R_CONSOLE_PID"; R_CONSOLE_PID=""; else pid="$R_HOST_PID"; R_HOST_PID=""; fi
|
||||
for name in console host provider; do
|
||||
case "$name" in
|
||||
console) pid="$R_CONSOLE_PID"; R_CONSOLE_PID="" ;;
|
||||
host) pid="$R_HOST_PID"; R_HOST_PID="" ;;
|
||||
provider) pid="$R_PROVIDER_PID"; R_PROVIDER_PID="" ;;
|
||||
esac
|
||||
[ -n "$pid" ] || continue
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
if r_gone "$pid"; then r_pass "$name-stopped" "pid $pid"
|
||||
@@ -2045,7 +2072,7 @@ r_end() {
|
||||
r_stop
|
||||
if [ -n "$R" ]; then
|
||||
if [ "$(r_tree_sums "$R_BK")" = "$(cat "$R/backup.before")" ]; then r_pass backup-unchanged; else r_fail backup-unchanged "$R_BK differs from $R/backup.before"; fi
|
||||
if [ "$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" = "$(cat "$R/live.before")" ]; then r_pass live-config-unchanged; else r_fail live-config-unchanged "$LIVE_CFG or $LIVE_BIZ differs from $R/live.before"; fi
|
||||
if [ "$(sha256sum "${LIVE_FILES[@]}" 2>&1)" = "$(cat "$R/live.before")" ]; then r_pass live-config-unchanged; else r_fail live-config-unchanged "${LIVE_FILES[*]} differs from $R/live.before"; fi
|
||||
fi
|
||||
if [ "$R_RC" = 0 ]; then r_line REHEARSAL_PASS; else r_line REHEARSAL_FAIL; fi
|
||||
[ -z "$R" ] || echo "rehearsal directory: $R"
|
||||
@@ -2056,10 +2083,96 @@ r_port_free() {
|
||||
node -e 'const s=require("node:net").createServer();s.once("error",()=>process.exit(1));s.listen(Number(process.argv[1]),"127.0.0.1",()=>s.close(()=>process.exit(0)));' -- "$1"
|
||||
}
|
||||
|
||||
# The scratch config: the live one with dataRoot set to the scratch data root.
|
||||
# The scratch config: the live one with dataRoot set to the scratch data root. M3 (#1873): a
|
||||
# console.sso keeps its provider ids, client ids, algorithms and subjects; the public origin moves to
|
||||
# rehearsal.invalid, a name that never resolves (RFC 6761). For a stamped copy, whose Console signs
|
||||
# in, every issuer moves to the rehearsal's own provider (R_PROVIDER_JS, the origin argument). An
|
||||
# unstamped copy's Console runs without --sso, so its issuers move to rehearsal.invalid too. No
|
||||
# subject is added; the live config's own are the only ones.
|
||||
R_CONFIG_JS='
|
||||
const fs=require("node:fs");const [file,data]=process.argv.slice(1);
|
||||
const c=JSON.parse(fs.readFileSync(file,"utf8"));c.dataRoot=data;process.stdout.write(JSON.stringify(c,null,2)+"\n");'
|
||||
const fs=require("node:fs");const [file,data,op]=process.argv.slice(1);
|
||||
const c=JSON.parse(fs.readFileSync(file,"utf8"));c.dataRoot=data;
|
||||
const sso=c.console?.sso;
|
||||
if(sso&&typeof sso==="object"){
|
||||
sso.publicOrigin="https://rehearsal.invalid";
|
||||
for(const p of Array.isArray(sso.providers)?sso.providers:[])if(p&&typeof p==="object")p.issuer=`${op||"https://rehearsal.invalid"}/${encodeURIComponent(String(p.id))}${op?"/":""}`;
|
||||
}
|
||||
process.stdout.write(JSON.stringify(c,null,2)+"\n");'
|
||||
|
||||
# M3 (#1873): the rehearsal's own OpenID provider, for a stamped copy's --sso Console. An https
|
||||
# server on 127.0.0.1, port 0, with a certificate openssl makes for this run in DIR. Its TLS key is
|
||||
# read into memory and its file removed before the server listens; cert.pem stays for the scratch
|
||||
# Console's NODE_EXTRA_CA_CERTS. Per configured provider, at /<id>/, it serves discovery and a JWKS
|
||||
# holding one public key made in memory for the first of the provider's algorithms it can make.
|
||||
# Authorization and token answer 403 to any method and query; another method on discovery or the
|
||||
# JWKS is 405 and any other route 404: it issues no code, no token and no sign-in. Each request is a line in requests.log. It writes origin when listening and exits when
|
||||
# PARENT (the rehearsal) is gone, on SIGTERM, or on an error.
|
||||
R_PROVIDER_JS='
|
||||
const fs=require("node:fs"),path=require("node:path"),https=require("node:https"),crypto=require("node:crypto"),{spawnSync}=require("node:child_process");
|
||||
const [dir,cfg,parent]=process.argv.slice(1);
|
||||
const die=(m)=>{console.error(`rehearsal provider: ${m}`);process.exit(1)};
|
||||
const sso=JSON.parse(fs.readFileSync(cfg,"utf8")).console?.sso;
|
||||
if(!sso||!Array.isArray(sso.providers)||!sso.providers.length)die("the live config has no console.sso providers");
|
||||
const GEN={RS:["rsa",{modulusLength:2048}],PS:["rsa",{modulusLength:2048}],ES256:["ec",{namedCurve:"P-256"}],ES384:["ec",{namedCurve:"P-384"}],ES512:["ec",{namedCurve:"P-521"}],Ed25519:["ed25519",{}],EdDSA:["ed25519",{}]};
|
||||
const keyFor=(alg)=>{
|
||||
const g=GEN[alg]??GEN[String(alg).slice(0,2)]??(/^ML-DSA-(44|65|87)$/.test(alg)?[alg.toLowerCase(),{}]:null);
|
||||
if(!g)return null;
|
||||
try{const {publicKey}=crypto.generateKeyPairSync(...g);return {...publicKey.export({format:"jwk"}),kid:`rehearsal-${crypto.randomBytes(4).toString("hex")}`,use:"sig",alg}}catch{return null}
|
||||
};
|
||||
const providers=new Map();
|
||||
for(const p of sso.providers){
|
||||
const id=String(p?.id),algs=Array.isArray(p?.algs)?p.algs:[];
|
||||
let jwk=null;for(const a of algs){jwk=keyFor(a);if(jwk)break}
|
||||
if(!jwk)die(`provider ${id}: none of ${algs.join(", ")||"no algorithms"} is one this provider can make a key for`);
|
||||
providers.set(encodeURIComponent(id),jwk);
|
||||
}
|
||||
const tls=path.join(dir,"tls-key.pem"),certFile=path.join(dir,"cert.pem");
|
||||
const r=spawnSync("openssl",["req","-x509","-newkey","ec","-pkeyopt","ec_paramgen_curve:P-256","-nodes","-days","1","-subj","/CN=127.0.0.1","-addext","subjectAltName=IP:127.0.0.1","-keyout",tls,"-out",certFile],{encoding:"utf8"});
|
||||
let key,cert;
|
||||
try{if(r.status!==0){const why=String(r.stderr??"").trim().split("\n").pop();throw new Error(`openssl exit ${r.status??r.error?.code}${why?`: ${why}`:""}`)};key=fs.readFileSync(tls);cert=fs.readFileSync(certFile);fs.chmodSync(certFile,0o600)}
|
||||
catch(e){die(`cannot make the certificate: ${e.message}`)}
|
||||
finally{fs.rmSync(tls,{force:true})}
|
||||
const log=(q,status)=>fs.appendFileSync(path.join(dir,"requests.log"),`${q.method} ${q.url} ${status}\n`);
|
||||
let origin;
|
||||
const server=https.createServer({key,cert,requestTimeout:5000,headersTimeout:5000},(q,res)=>{
|
||||
const send=(status,body)=>{log(q,status);res.writeHead(status,{"content-type":"application/json","cache-control":"no-store"});res.end(JSON.stringify(body))};
|
||||
let at;try{at=new URL(q.url,origin).pathname}catch{at=""}
|
||||
const m=/^\/([^/]+)\/(\.well-known\/openid-configuration|jwks|authorize|token)$/.exec(at);
|
||||
const jwk=m?providers.get(m[1]):undefined;
|
||||
if(!jwk)return send(404,{error:"not found"});
|
||||
if(m[2]==="authorize"||m[2]==="token")return send(403,{error:"the rehearsal provider issues no code and no token"});
|
||||
if(q.method!=="GET")return send(405,{error:"GET only"});
|
||||
const issuer=`${origin}/${m[1]}/`;
|
||||
if(m[2]==="jwks")return send(200,{keys:[jwk]});
|
||||
send(200,{issuer,authorization_endpoint:`${issuer}authorize`,token_endpoint:`${issuer}token`,jwks_uri:`${issuer}jwks`,response_types_supported:["code"],subject_types_supported:["public"],id_token_signing_alg_values_supported:[jwk.alg],code_challenge_methods_supported:["S256"]});
|
||||
});
|
||||
server.on("error",(e)=>die(e.message));
|
||||
server.listen(0,"127.0.0.1",()=>{
|
||||
origin=`https://127.0.0.1:${server.address().port}`;
|
||||
fs.writeFileSync(path.join(dir,"origin.tmp"),`${origin}\n`,{mode:0o600});fs.renameSync(path.join(dir,"origin.tmp"),path.join(dir,"origin"));
|
||||
console.log(`rehearsal provider: ${origin}, ${providers.size} provider(s)`);
|
||||
});
|
||||
const stop=()=>{server.close();server.closeAllConnections();process.exit(0)};
|
||||
process.on("SIGTERM",stop);process.on("SIGINT",stop);process.on("SIGHUP",stop);
|
||||
setInterval(()=>{if(!fs.existsSync(`/proc/${parent}`))stop()},1000);'
|
||||
|
||||
# M3 (#1873): one 0600 placeholder per console.sso client secret slot, in the scratch config
|
||||
# directory where readSlot looks. Never the live slot: the rehearsal reads no secret.
|
||||
R_SLOTS_JS='
|
||||
const fs=require("node:fs"),path=require("node:path");const [file]=process.argv.slice(1);
|
||||
const dir=path.join(path.dirname(file),"secrets","mosaic-stack");
|
||||
fs.mkdirSync(dir,{recursive:true,mode:0o700});
|
||||
for(const p of JSON.parse(fs.readFileSync(file,"utf8")).console.sso.providers){
|
||||
const slot=p?.clientSecret?.slot;
|
||||
if(typeof slot!=="string"||!/^[a-z0-9-]{1,64}$/.test(slot))throw new Error("a console.sso provider has no valid clientSecret.slot");
|
||||
try{fs.writeFileSync(path.join(dir,slot),"rehearsal-placeholder-secret\n",{mode:0o600,flag:"wx"})}catch(e){if(e.code!=="EEXIST")throw e}
|
||||
}'
|
||||
|
||||
# M3 (#1873): the scratch .deployed, written by the new copy's own writeDeployment, so it names
|
||||
# the instance that copy derives from the scratch config directory.
|
||||
R_DEPLOYED_JS='
|
||||
const {pathToFileURL}=require("node:url");const [copy,data,dir]=process.argv.slice(1);
|
||||
import(pathToFileURL(`${copy}/packages/cli/src/deployment.mjs`).href).then((d)=>d.writeDeployment({dataRoot:data},dir));'
|
||||
|
||||
# The scratch business file (mos-0962, mos-0963): the live file without its tracker.* variables,
|
||||
# business and role, and with every credential reference, the roles and tracker.sync, moved to its
|
||||
@@ -2094,10 +2207,13 @@ fs.writeFileSync(out,JSON.stringify(b,null,2)+"\n",{mode:0o600,flag:"wx"});'
|
||||
# notify-off the notifier config is {"notifyVersion":1,"binding":null}, 0600 (mos-0962)
|
||||
# tracker-configured bootConfig sets no trackers (mos-0962)
|
||||
# credential-ref-live every credential reference is a file under placeholders/ (mos-0963)
|
||||
# sso-isolated the console.sso origin is rehearsal.invalid, every issuer is the
|
||||
# rehearsal provider's (OP, a stamped copy) or on rehearsal.invalid, and every
|
||||
# client secret slot is a scratch placeholder (M3, #1873)
|
||||
# Exit 0 when all pass, 1 on the first FAIL.
|
||||
R_GUARD_JS='
|
||||
const fs=require("node:fs"),path=require("node:path");const {pathToFileURL}=require("node:url");
|
||||
const [copy,id,live,scratch,ph]=process.argv.slice(1);
|
||||
const [copy,id,live,scratch,ph,op]=process.argv.slice(1);
|
||||
const real=(p)=>{try{return fs.realpathSync(p)}catch{return null}};
|
||||
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,"utf8");return s[s.lastIndexOf(")")+2]!=="Z"}catch{return false}};
|
||||
const pass=(n,w)=>console.log(`PASS ${n}${w?": "+w:""}`);
|
||||
@@ -2136,6 +2252,19 @@ const fail=(n,w)=>{console.log(`FAIL ${n}: ${w}`);process.exit(1)};
|
||||
if(f===null||phr===null||path.dirname(f)!==phr)fail("credential-ref-live",`${what} is ${ref?.file}, not a file in ${ph}`);
|
||||
}
|
||||
pass("credential-ref-live",`${refs.length} reference(s), each a placeholder`);
|
||||
const sso=system.console?.sso;
|
||||
if(!sso){pass("sso-isolated","no console.sso");return}
|
||||
const host=(u)=>{try{return new URL(u).hostname}catch{return null}};
|
||||
if(host(sso.publicOrigin)!=="rehearsal.invalid")fail("sso-isolated",`console.sso.publicOrigin is ${sso.publicOrigin}, not https://rehearsal.invalid`);
|
||||
const sd=real(path.join(path.dirname(path.resolve(process.env.MOSAIC_CONFIG)),"secrets","mosaic-stack"));
|
||||
for(const p of sso.providers){
|
||||
const want=op?`${op}/${encodeURIComponent(String(p.id))}/`:null;
|
||||
if(op&&p.issuer!==want)fail("sso-isolated",`console.sso provider ${p.id} issuer is ${p.issuer}, not ${want} at the rehearsal provider`);
|
||||
if(!op&&host(p.issuer)!=="rehearsal.invalid")fail("sso-isolated",`console.sso provider ${p.id} issuer is ${p.issuer}, not on rehearsal.invalid`);
|
||||
const f=real(path.join(path.dirname(path.resolve(process.env.MOSAIC_CONFIG)),"secrets","mosaic-stack",p.clientSecret.slot));
|
||||
if(f===null||sd===null||path.dirname(f)!==sd||fs.readFileSync(f,"utf8")!=="rehearsal-placeholder-secret\n")fail("sso-isolated",`console.sso provider ${p.id} has no scratch placeholder secret`);
|
||||
}
|
||||
pass("sso-isolated",`${sso.providers.length} provider(s), each ${op?`at the rehearsal provider ${op}`:"at rehearsal.invalid"} with a placeholder secret`);
|
||||
})().catch((e)=>fail("guard",String(e?.message??e).split("\n")[0]));'
|
||||
|
||||
# Opens a data root's store with a copy's Store, as the bus does at start, with no migration
|
||||
@@ -2203,6 +2332,18 @@ process.stdout.write(why);' -- "$out" "$BUSINESS" "$R_HOST_PID" "$START_MS" 2>&1
|
||||
R_GATE=healthz
|
||||
node -e '
|
||||
fetch(`http://127.0.0.1:${process.argv[1]}/healthz`,{signal:AbortSignal.timeout(5000)}).then((r)=>process.exit(r.status===200?0:1),()=>process.exit(1));' -- "$R_PORT" || return 1
|
||||
# M3 (#1873): a stamped copy's Console must have found every configured provider at the
|
||||
# rehearsal provider, not started degraded.
|
||||
if [ -n "$R_PROVIDER" ]; then
|
||||
R_GATE=sso-ready
|
||||
R_WHY="$(node -e '
|
||||
const ids=JSON.parse(require("node:fs").readFileSync(process.argv[2],"utf8")).console.sso.providers.map((p)=>String(p.id));
|
||||
fetch(`http://127.0.0.1:${process.argv[1]}/healthz`,{signal:AbortSignal.timeout(5000)}).then((r)=>r.json()).then((h)=>{
|
||||
const s=h?.sso&&typeof h.sso==="object"?h.sso:{};
|
||||
process.stdout.write(ids.filter((id)=>s[id]!=="ready").map((id)=>`provider ${id} is ${s[id]??"missing"}, not ready`).join("; "));
|
||||
},(e)=>process.stdout.write(`healthz unreadable: ${e.message}`));' -- "$R_PORT" "$R/config/config.json" 2>&1)" || R_WHY="${R_WHY:-healthz unreadable}"
|
||||
[ -z "$R_WHY" ] || return 1
|
||||
fi
|
||||
R_GATE=copy-match
|
||||
tr '\0' ' ' < "/proc/$R_HOST_PID/cmdline" | grep -qF " $copy/packages/cli/src/cli.mjs bus start $BUSINESS --no-launch" || { R_WHY="the host's command line is not $copy's cli.mjs bus start $BUSINESS --no-launch"; return 1; }
|
||||
tr '\0' ' ' < "/proc/$R_CONSOLE_PID/cmdline" | grep -qF " $copy/packages/webui/src/cli.mjs serve " || { R_WHY="the Console's command line is not $copy's webui cli.mjs serve"; return 1; }
|
||||
@@ -2215,7 +2356,43 @@ r_gate() {
|
||||
[ "$SECONDS" -lt "$deadline" ] || { r_fail "$R_GATE" "${R_WHY:-no answer within ${TIMEOUT}s}"; return 1; }
|
||||
sleep 1
|
||||
done
|
||||
r_pass gate "host-up, bus-status, console-up, healthz and copy-match on $TO"
|
||||
r_pass gate "host-up, bus-status, console-up, healthz${R_PROVIDER:+, sso-ready} and copy-match on $TO"
|
||||
}
|
||||
|
||||
# M3 (#1873): starts R_PROVIDER_JS for a stamped copy, in $R/provider, and waits for its origin.
|
||||
r_provider() {
|
||||
local deadline=$((SECONDS + TIMEOUT))
|
||||
mkdir -m 700 "$R/provider" || r_abort provider-start "cannot make $R/provider"
|
||||
env -i HOME="$HOME" PATH="$PATH" node -e "$R_PROVIDER_JS" -- "$R/provider" "$LIVE_CFG" "$$" > "$R/provider/provider.log" 2>&1 &
|
||||
R_PROVIDER_PID=$!
|
||||
until [ -s "$R/provider/origin" ]; do
|
||||
r_alive "$R_PROVIDER_PID" || { wait "$R_PROVIDER_PID" 2>/dev/null || true; R_PROVIDER_PID=""; r_abort provider-start "the rehearsal provider exited: $(tail -n 1 "$R/provider/provider.log" 2>/dev/null)"; }
|
||||
[ "$SECONDS" -lt "$deadline" ] || r_abort provider-start "the rehearsal provider wrote no origin within ${TIMEOUT}s"
|
||||
sleep 0.2
|
||||
done
|
||||
R_PROVIDER="$(cat "$R/provider/origin")"
|
||||
[[ "$R_PROVIDER" =~ ^https://127\.0\.0\.1:[0-9]{1,5}$ ]] || r_abort provider-start "the rehearsal provider's origin $R_PROVIDER is not https on 127.0.0.1"
|
||||
[ ! -e "$R/provider/tls-key.pem" ] || r_abort provider-start "the rehearsal provider left its TLS key at $R/provider/tls-key.pem"
|
||||
r_pass provider-start "pid $R_PROVIDER_PID at $R_PROVIDER: discovery and a JWKS from keys made for this run, no code and no token; its certificate $R/provider/cert.pem is the trust the scratch Console alone gets"
|
||||
}
|
||||
|
||||
# M3 (#1873): the Console fetched each provider's discovery and JWKS from the rehearsal provider and
|
||||
# asked it for no code and no token.
|
||||
r_provider_seen() {
|
||||
local why
|
||||
why="$(node -e '
|
||||
const fs=require("node:fs");const [cfg,log]=process.argv.slice(1);
|
||||
const lines=fs.existsSync(log)?fs.readFileSync(log,"utf8").split("\n").filter(Boolean):[];
|
||||
const why=[];
|
||||
for(const p of JSON.parse(fs.readFileSync(cfg,"utf8")).console.sso.providers){
|
||||
const seg=encodeURIComponent(String(p.id));
|
||||
for(const what of [".well-known/openid-configuration","jwks"])if(!lines.includes(`GET /${seg}/${what} 200`))why.push(`no GET /${seg}/${what} 200`);
|
||||
}
|
||||
for(const l of lines)if(/\/(authorize|token)\b/.test(l))why.push(`a request for ${l}`);
|
||||
process.stdout.write(why.join("; "));' -- "$R/config/config.json" "$R/provider/requests.log" 2>&1)" || why="${why:-cannot read $R/provider/requests.log}"
|
||||
if [ -z "$why" ]; then r_pass provider-discovery "the Console fetched each provider's discovery and JWKS from $R_PROVIDER and asked for no code or token"
|
||||
else r_fail provider-discovery "$why; see $R/provider/requests.log"
|
||||
fi
|
||||
}
|
||||
|
||||
# No role session: the host wrote no sessions.json and no launches directory or log.
|
||||
@@ -2258,30 +2435,50 @@ do_rehearse() {
|
||||
LIVE_CFG="$(realpath -m "${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}")"
|
||||
LIVE_BIZ="$(dirname "$LIVE_CFG")/businesses/$BUSINESS.json"
|
||||
[ -f "$LIVE_BIZ" ] || r_abort business-missing "no $LIVE_BIZ"
|
||||
# M3 (#1873): a stamped copy serves only with --sso, console.sso, a loadable accounts.json and
|
||||
# a .deployed naming its instance. The rehearsal gives its Console all four in scratch. The
|
||||
# live instance's console.sso and accounts.json are a human's steps; without them the stamped
|
||||
# copy cannot start live either, so the rehearsal refuses and names the step.
|
||||
LIVE_FILES=("$LIVE_CFG" "$LIVE_BIZ") R_STAMPED=""
|
||||
if [ -e "$COPIES/$TO/.stack-build.json" ] || [ -L "$COPIES/$TO/.stack-build.json" ]; then
|
||||
R_STAMPED=1
|
||||
node -e 'process.exit(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")).console?.sso?0:1)' -- "$LIVE_CFG" 2>/dev/null ||
|
||||
r_abort sso-not-prepared "$TO is a stamped copy, which serves only with --sso, and $LIVE_CFG has no console.sso; prepare SSO for this instance (run sheet) before re-pinning to it"
|
||||
[ -f "$(dirname "$LIVE_CFG")/accounts.json" ] ||
|
||||
r_abort accounts-not-initialized "$TO is a stamped copy, which needs an accounts file, and there is no $(dirname "$LIVE_CFG")/accounts.json; a human runs mosaic accounts init (run sheet) before re-pinning to it"
|
||||
LIVE_FILES+=("$(dirname "$LIVE_CFG")/accounts.json")
|
||||
fi
|
||||
|
||||
local stamp before live
|
||||
before="$(r_tree_sums "$R_BK")" && live="$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" || r_abort sums "cannot read $R_BK or the live config files"
|
||||
before="$(r_tree_sums "$R_BK")" && live="$(sha256sum "${LIVE_FILES[@]}" 2>&1)" || r_abort sums "cannot read $R_BK or the live config files"
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)-${TO:0:12}"
|
||||
(umask 077; mkdir -p "$DATA/rehearsals" && mkdir "$DATA/rehearsals/$stamp") || r_abort rehearsal-dir "cannot make $DATA/rehearsals/$stamp (one rehearsal per second and copy)"
|
||||
R="$DATA/rehearsals/$stamp" STEPS="$DATA/rehearsals/$stamp/steps.log"
|
||||
printf '%s\n' "${R_EARLY[@]}" > "$STEPS" && printf '%s\n' "$before" > "$R/backup.before" && printf '%s\n' "$live" > "$R/live.before" ||
|
||||
r_abort scratch-build "cannot write the sums to $R"
|
||||
[ -z "$R_STAMPED" ] || r_provider
|
||||
(
|
||||
umask 077
|
||||
mkdir "$R/config" "$R/config/businesses" "$R/placeholders" "$R/data" "$R/data/bus" "$R/data/notify" "$R/data/notify/$BUSINESS" &&
|
||||
cp "$R_BK"/bus/* "$R/data/bus/" &&
|
||||
node -e "$R_CONFIG_JS" -- "$LIVE_CFG" "$R/data" > "$R/config/config.json" &&
|
||||
node -e "$R_CONFIG_JS" -- "$LIVE_CFG" "$R/data" "$R_PROVIDER" > "$R/config/config.json" &&
|
||||
node -e "$R_BUSINESS_JS" -- write "$LIVE_BIZ" "$R/config/businesses/$BUSINESS.json" "$R/placeholders" &&
|
||||
printf '{"notifyVersion":1,"binding":null}\n' > "$R/data/notify/$BUSINESS/notify.json"
|
||||
printf '{"notifyVersion":1,"binding":null}\n' > "$R/data/notify/$BUSINESS/notify.json" &&
|
||||
{ ! node -e 'process.exit(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")).console?.sso?0:1)' -- "$R/config/config.json" ||
|
||||
node -e "$R_SLOTS_JS" -- "$R/config/config.json"; } &&
|
||||
{ [ -z "$R_STAMPED" ] || {
|
||||
cp "$(dirname "$LIVE_CFG")/accounts.json" "$R/config/accounts.json" &&
|
||||
r_env node -e "$R_DEPLOYED_JS" -- "$COPIES/$TO" "$R/data" "$R/config"; }; }
|
||||
) 2>> "$STEPS" || r_abort scratch-build "cannot build the scratch root under $R; see $STEPS"
|
||||
local rc=0
|
||||
diff -u --label "$LIVE_BIZ (as JSON.stringify writes it)" --label "$R/config/businesses/$BUSINESS.json" \
|
||||
<(node -e "$R_BUSINESS_JS" -- normalize "$LIVE_BIZ") "$R/config/businesses/$BUSINESS.json" > "$R/business.diff" || rc=$?
|
||||
[ "$rc" -le 1 ] || r_abort scratch-build "cannot write $R/business.diff"
|
||||
r_pass scratch-root "$R/data from the backup, config and business file under $R/config, business.diff beside them"
|
||||
[ -z "$R_STAMPED" ] || r_pass deployment "$TO is stamped: $R/data/.deployed from its writeDeployment, the live accounts.json copied to $R/config, the Console starts with --sso"
|
||||
|
||||
local out
|
||||
rc=0; out="$(r_env node -e "$R_GUARD_JS" -- "$COPIES/$TO" "$BUSINESS" "$DATA" "$R/data" "$R/placeholders" 2>&1)" || rc=$?
|
||||
rc=0; out="$(r_env node -e "$R_GUARD_JS" -- "$COPIES/$TO" "$BUSINESS" "$DATA" "$R/data" "$R/placeholders" "$R_PROVIDER" 2>&1)" || rc=$?
|
||||
while IFS= read -r i; do r_line "$i"; done <<< "$out"
|
||||
[ "$rc" = 0 ] || { R_RC=1; r_end; }
|
||||
|
||||
@@ -2295,9 +2492,13 @@ do_rehearse() {
|
||||
R_HOST_PID=$!
|
||||
local -a tester_rehearsal=()
|
||||
if grep -q 'MOSAIC_TESTER_SOCKET_V1' "$COPIES/$TO/packages/webui/src/cli.mjs"; then tester_rehearsal=(--tester-socket); fi
|
||||
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" node "$COPIES/$TO/packages/webui/src/cli.mjs" serve --port "$R_PORT" --business "$BUSINESS" "${tester_rehearsal[@]}" > "$R/console.log" 2>&1 &
|
||||
[ -z "$R_STAMPED" ] || tester_rehearsal+=(--sso)
|
||||
# M3 (#1873): the rehearsal provider's certificate is trusted by the scratch Console only.
|
||||
local -a r_ca=()
|
||||
[ -z "$R_PROVIDER" ] || r_ca=(NODE_EXTRA_CA_CERTS="$R/provider/cert.pem")
|
||||
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" "${r_ca[@]}" node "$COPIES/$TO/packages/webui/src/cli.mjs" serve --port "$R_PORT" --business "$BUSINESS" "${tester_rehearsal[@]}" > "$R/console.log" 2>&1 &
|
||||
R_CONSOLE_PID=$!
|
||||
if r_gate; then r_no_session; fi
|
||||
if r_gate; then r_no_session; [ -z "$R_PROVIDER" ] || r_provider_seen; fi
|
||||
r_end
|
||||
}
|
||||
|
||||
@@ -2335,7 +2536,7 @@ FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT
|
||||
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
|
||||
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
|
||||
BASE_CONSOLE=() SERVE_FLAGS=() SSO_PLAN="" SSO_STEP="" SSO_DROP="" SSO_NO_TARGET=""
|
||||
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
|
||||
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_PROVIDER_PID="" R_PROVIDER="" R_STARTED="" LIVE_CFG="" LIVE_BIZ="" LIVE_FILES=() R_STAMPED=""
|
||||
O_ACT="" FLOOR=0
|
||||
case "$OP" in
|
||||
adopt) do_adopt ;;
|
||||
|
||||
+128
-18
@@ -6,8 +6,9 @@
|
||||
# agents/darkwing/work/queue-233/BUILD.md.
|
||||
#
|
||||
# stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal]
|
||||
# [--rehearse-port N] [--timeout S]
|
||||
# stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
|
||||
# [--rehearse-port N] [--next-args] [--timeout S]
|
||||
# stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--next-args]
|
||||
# [--timeout S]
|
||||
#
|
||||
# Root runs it from a root-owned checkout of the reviewed commit, never from a copy, because the
|
||||
# stack's user can write the copies. The unit values come from /etc/mosaic-stack/<instance>.args,
|
||||
@@ -20,6 +21,20 @@
|
||||
# output but exit codes and the rehearsal's BACKUP_STORE line (sage-1653), which it copies into its
|
||||
# step log after a match against a fixed pattern and decides nothing on.
|
||||
#
|
||||
# Before anything stops, case2-unit.sh --check renders the installed copy from the args file and
|
||||
# compares the installed units with it, then checks the target's render the same way; either
|
||||
# refusal is render-refused, with the units left running (#1873 M3).
|
||||
#
|
||||
# --next-args changes the serve flags, which a re-render otherwise refuses (row 195's installed-unit
|
||||
# match): /etc/mosaic-stack/<instance>.args.next, root's with no group or other write, must equal
|
||||
# the args file before `--` but for --copy's value, and differ from it after `--`. The target
|
||||
# renders from it through case2-unit.sh --check-new before anything stops. After A3, root moves the
|
||||
# installed unit files into a new <instance>.replaced.XXXXXX directory beside the args file, and A4
|
||||
# renders from the next args as a first install. A failure from there moves the replaced files back
|
||||
# unchanged, restarts them on the old copy and gates it, with no render. After confirm, the next
|
||||
# args become the args file, and the replaced directory keeps the old unit files and args file for
|
||||
# a later rollback with --next-args (docs/guides/host-run-sheet.md section 7).
|
||||
#
|
||||
# --no-rehearsal is for a stack whose log records no backup. The rehearsal runs either way: exit 3
|
||||
# from it (no-backup) goes on only with the flag, and exit 0 with the flag refuses
|
||||
# rehearsal-required (sage-1650, sage-1652).
|
||||
@@ -47,8 +62,8 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'USAGE'
|
||||
usage: stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal] [--rehearse-port N] [--timeout S]
|
||||
stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
|
||||
usage: stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal] [--rehearse-port N] [--next-args] [--timeout S]
|
||||
stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--next-args] [--timeout S]
|
||||
USAGE
|
||||
exit 4
|
||||
}
|
||||
@@ -70,7 +85,7 @@ ARGV=("$@")
|
||||
[ $# -gt 0 ] || usage
|
||||
OP="$1"; shift
|
||||
case "$OP" in upgrade|rollback) ;; *) usage ;; esac
|
||||
INSTANCE="" BUNDLE="" SHA="" MIGRATE=0 NO_REHEARSAL=0 R_PORT=7339 TIMEOUT=60 TO="" RESTORE="" EVENTS=""
|
||||
INSTANCE="" BUNDLE="" SHA="" MIGRATE=0 NO_REHEARSAL=0 R_PORT=7339 TIMEOUT=60 TO="" RESTORE="" EVENTS="" NEXT=0 ASIDE="" MOVED=() REPLACED_NOTE=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--instance) [ $# -ge 2 ] || usage; INSTANCE="$2"; shift 2 ;;
|
||||
@@ -79,6 +94,7 @@ while [ $# -gt 0 ]; do
|
||||
--migrate) [ "$OP" = upgrade ] || usage; MIGRATE=1; shift ;;
|
||||
--no-rehearsal) [ "$OP" = upgrade ] || usage; NO_REHEARSAL=1; shift ;;
|
||||
--rehearse-port) [ "$OP" = upgrade ] && [ $# -ge 2 ] && [[ "$2" =~ ^[1-9][0-9]{3,4}$ ]] && [ "$2" -le 65535 ] || usage; R_PORT="$2"; shift 2 ;;
|
||||
--next-args) NEXT=1; shift ;;
|
||||
--timeout) [ $# -ge 2 ] && [[ "$2" =~ ^[1-9][0-9]{0,4}$ ]] || usage; TIMEOUT="$2"; shift 2 ;;
|
||||
--to) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9a-f]{40}$ ]] || usage; TO="$2"; shift 2 ;;
|
||||
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ $ID_RE ]] || usage; RESTORE="$2"; shift 2 ;;
|
||||
@@ -125,8 +141,8 @@ logl "start rc=0 argv:$(printf ' %q' "$0" "${ARGV[@]}")"
|
||||
# The selector scopes: selector.sh defines functions only, from this root-owned checkout, as RENDER is.
|
||||
. "$REPO/scripts/selector.sh" || refuse checkout-unreadable "cannot read $REPO/scripts/selector.sh"
|
||||
args_business_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]; }
|
||||
args_outer_refused() { refuse args-unreadable "$ETC/$INSTANCE.args before --: $2"; }
|
||||
args_serve_refused() { refuse args-unreadable "$ETC/$INSTANCE.args after --: $2"; }
|
||||
args_outer_refused() { refuse args-unreadable "$ARGS_FILE before --: $2"; }
|
||||
args_serve_refused() { refuse args-unreadable "$ARGS_FILE after --: $2"; }
|
||||
|
||||
# I2's file: one case2-unit.sh argument per line, owned by root with no group or other write. Sets
|
||||
# ARGS (the lines), S_USER, S_HOME, BUSINESS, NODE, ARGS_COPY, ARGS_PROFILE and ARGS_PROXY, and the paths derived
|
||||
@@ -140,7 +156,8 @@ args_serve_refused() { refuse args-unreadable "$ETC/$INSTANCE.args after --: $2"
|
||||
# says which of them take a value, so a value is never taken for a selector; a token that grammar
|
||||
# lacks refuses, as the render would.
|
||||
read_args() {
|
||||
local f="$ETC/$INSTANCE.args" st i sep copies=0 v profile=library served
|
||||
ARGS_FILE="${1:-$ETC/$INSTANCE.args}"
|
||||
local f="$ARGS_FILE" st i sep copies=0 v profile=library served
|
||||
[ -f "$f" ] && [ ! -L "$f" ] || refuse args-unreadable "no regular file $f"
|
||||
st="$(stat -c '%u %a' "$f")" || refuse args-unreadable "cannot stat $f"
|
||||
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || refuse args-unreadable "$f must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })"
|
||||
@@ -224,17 +241,82 @@ legacy_console() {
|
||||
done
|
||||
}
|
||||
|
||||
# RENDER_ARGS: the args file's lines with copy $1 in place of --copy's value.
|
||||
# RENDER_ARGS: the lines of array $2 (default ARGS) with copy $1 in place of --copy's value.
|
||||
args_with() {
|
||||
local i past=""
|
||||
local -n lines_="${2:-ARGS}"
|
||||
RENDER_ARGS=()
|
||||
for ((i = 0; i < ${#ARGS[@]}; i++)); do
|
||||
RENDER_ARGS+=("${ARGS[i]}")
|
||||
[ "${ARGS[i]}" != -- ] || past=1
|
||||
if [ -z "$past" ] && [ "${ARGS[i]}" = --copy ]; then RENDER_ARGS+=("$1"); i=$((i + 1)); fi
|
||||
for ((i = 0; i < ${#lines_[@]}; i++)); do
|
||||
RENDER_ARGS+=("${lines_[i]}")
|
||||
[ "${lines_[i]}" != -- ] || past=1
|
||||
if [ -z "$past" ] && [ "${lines_[i]}" = --copy ]; then RENDER_ARGS+=("$1"); i=$((i + 1)); fi
|
||||
done
|
||||
}
|
||||
|
||||
# OLD_ARGS: the args file's lines, which the installed units were rendered from. Under --next-args,
|
||||
# ARGS becomes the next file's lines, which must equal OLD_ARGS before -- but for --copy's value and
|
||||
# differ after it.
|
||||
read_next() {
|
||||
OLD_ARGS=("${ARGS[@]}")
|
||||
[ "$NEXT" = 1 ] || return 0
|
||||
local f="$ETC/$INSTANCE.args.next" a b i copy="$ARGS_COPY"
|
||||
unset SELECTOR_SEEN_outer SELECTOR_VALUE_outer SELECTOR_SEEN_serve SELECTOR_VALUE_serve
|
||||
read_args "$f"
|
||||
# read_old checks the installed units against the args file's copy, not the next file's.
|
||||
ARGS_COPY="$copy"
|
||||
args_with COPY OLD_ARGS; a=("${RENDER_ARGS[@]}")
|
||||
args_with COPY; b=("${RENDER_ARGS[@]}")
|
||||
for ((i = 0; i < ${#a[@]} && i < ${#b[@]}; i++)); do
|
||||
[ "${a[i]}" = "${b[i]}" ] || refuse next-args-differ "$f must equal $ETC/$INSTANCE.args before -- but for --copy's value; line $((i + 1)) differs"
|
||||
[ "${a[i]}" != -- ] || break
|
||||
done
|
||||
[ "${a[*]}" != "${b[*]}" ] || [ "${#a[@]}" != "${#b[@]}" ] ||
|
||||
refuse next-args-same "$f has the same serve flags as $ETC/$INSTANCE.args; run without --next-args"
|
||||
}
|
||||
|
||||
# Before anything stops: the installed units against OLD's render from the args file, so a rollback
|
||||
# render-old works, then copy $1's render, against the installed units or, under --next-args, as a
|
||||
# first install from the next args.
|
||||
render_checks() {
|
||||
args_with "$OLD" OLD_ARGS
|
||||
step render-old-check "$RENDER" --check "${RENDER_ARGS[@]}" ||
|
||||
refuse render-refused "case2-unit.sh --check refuses $OLD's units from $ETC/$INSTANCE.args, so a rollback could not render them; its message is above"
|
||||
args_with "$1"
|
||||
if [ "$NEXT" = 1 ]; then
|
||||
step render-check "$RENDER" --check-new "${RENDER_ARGS[@]}" ||
|
||||
refuse render-refused "case2-unit.sh --check-new refuses $1's units from $ETC/$INSTANCE.args.next; its message is above"
|
||||
else
|
||||
step render-check "$RENDER" --check "${RENDER_ARGS[@]}" ||
|
||||
refuse render-refused "case2-unit.sh --check refuses $1's units from $ETC/$INSTANCE.args; its message is above. A re-pin that changes the serve flags takes --next-args (docs/guides/host-run-sheet.md section 7)"
|
||||
fi
|
||||
}
|
||||
|
||||
# --next-args, after A3: the installed unit files into a new directory beside the args file, so A4
|
||||
# renders as a first install. MOVED lists the files moved, for units_back.
|
||||
units_aside() {
|
||||
local u rc=0
|
||||
local -a files=([email protected] "$CON_UNIT")
|
||||
[ "$ARGS_PROXY" = 0 ] || files+=("$PROXY_UNIT")
|
||||
ASIDE="$(mktemp -d "$ETC/$INSTANCE.replaced.XXXXXX")" && chmod 0700 "$ASIDE" || rc=1
|
||||
if [ "$rc" = 0 ]; then
|
||||
for u in "${files[@]}"; do
|
||||
mv -T -- "$UNIT_DIR/$u" "$ASIDE/$u" || { rc=1; break; }
|
||||
MOVED+=("$u")
|
||||
done
|
||||
fi
|
||||
logl "units-aside rc=$rc dir: ${ASIDE:-none} moved: ${MOVED[*]}"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# The moved files back, unchanged, over whatever A4 wrote, then the replaced directory removed.
|
||||
units_back() {
|
||||
local u rc=0
|
||||
for u in "${MOVED[@]}"; do mv -fT -- "$ASIDE/$u" "$UNIT_DIR/$u" || rc=1; done
|
||||
[ "$rc" != 0 ] || rmdir -- "$ASIDE" || rc=1
|
||||
logl "units-back rc=$rc dir: $ASIDE moved: ${MOVED[*]}"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# The new args file, written beside the old one before anything stops, moved into place only after
|
||||
# confirm passes. $1 is the copy.
|
||||
prepare_args() {
|
||||
@@ -334,9 +416,25 @@ rollback_to_old() {
|
||||
stop_wait || { stop_units; finish 2 rollback-failed "$cause failed and the units did not stop for the rollback; $STOPPED_UNITS are stopped"; }
|
||||
step revert "${AS_USER[@]}" "$SR" revert "$id" "$BUSINESS" --units external --copies "$COPIES_DIR" ||
|
||||
{ stop_units; finish 2 rollback-failed "$cause failed and revert failed; $STOPPED_UNITS are stopped"; }
|
||||
args_with "$OLD"
|
||||
step render-old "$RENDER" "${RENDER_ARGS[@]}" ||
|
||||
{ stop_units; finish 2 rollback-failed "$cause failed and the render of $OLD failed; $STOPPED_UNITS are stopped"; }
|
||||
if [ -n "$ASIDE" ]; then
|
||||
# --next-args: the replaced files back as they were, then the start render-old would do.
|
||||
units_back ||
|
||||
{ stop_units; finish 2 rollback-failed "$cause failed and the replaced units in $ASIDE did not all move back; $STOPPED_UNITS are stopped"; }
|
||||
step daemon-reload "$SYSTEMCTL" daemon-reload ||
|
||||
{ stop_units; finish 2 rollback-failed "$cause failed and daemon-reload failed after the replaced units moved back; $STOPPED_UNITS are stopped"; }
|
||||
if [ "$ARGS_PROXY" = 1 ]; then
|
||||
step start-proxy "$SYSTEMCTL" start "$PROXY_UNIT" ||
|
||||
{ stop_units; finish 2 rollback-failed "$cause failed and the proxy did not start for $OLD; $STOPPED_UNITS are stopped"; }
|
||||
fi
|
||||
step start-bus "$SYSTEMCTL" start "$BUS_UNIT" ||
|
||||
{ stop_units; finish 2 rollback-failed "$cause failed and the bus did not start for $OLD; $STOPPED_UNITS are stopped"; }
|
||||
step start-console "$SYSTEMCTL" start "$CON_UNIT" ||
|
||||
{ stop_units; finish 2 rollback-failed "$cause failed and the Console did not start for $OLD; $STOPPED_UNITS are stopped"; }
|
||||
else
|
||||
args_with "$OLD" OLD_ARGS
|
||||
step render-old "$RENDER" "${RENDER_ARGS[@]}" ||
|
||||
{ stop_units; finish 2 rollback-failed "$cause failed and the render of $OLD failed; $STOPPED_UNITS are stopped"; }
|
||||
fi
|
||||
# confirm --rollback exits 1 when OLD passed its gate and 2 when it failed; anything else is a
|
||||
# refusal, with OLD's gate never run.
|
||||
local rc=0
|
||||
@@ -360,18 +458,27 @@ run_steps() {
|
||||
*) rollback_to_old "$id" A3 ;;
|
||||
esac
|
||||
# A4
|
||||
if [ "$NEXT" = 1 ]; then units_aside || rollback_to_old "$id" A4; fi
|
||||
args_with "$id"
|
||||
step render "$RENDER" "${RENDER_ARGS[@]}" || rollback_to_old "$id" A4
|
||||
# A5
|
||||
step confirm "${AS_USER[@]}" "$SR" confirm "$id" "$BUSINESS" --units external --copies "$COPIES_DIR" --timeout "$TIMEOUT" ||
|
||||
rollback_to_old "$id" A5
|
||||
if [ "$NEXT" = 1 ]; then
|
||||
# The old args file joins the replaced units before the next one takes its place.
|
||||
cp -p -- "$ETC/$INSTANCE.args" "$ASIDE/$INSTANCE.args" && rm -f -- "$ETC/$INSTANCE.args.next" &&
|
||||
logl "next-args rc=0 replaced: $ASIDE" ||
|
||||
{ logl "next-args rc=1 replaced: $ASIDE"; finish 5 args-failed "$id is live and confirmed, but the old args file did not reach $ASIDE; $ETC/$INSTANCE.args still names $OLD; copy it there, then put $NEW_ARGS in its place and remove $ETC/$INSTANCE.args.next"; }
|
||||
fi
|
||||
if mv -f "$NEW_ARGS" "$ETC/$INSTANCE.args"; then logl "args rc=0 copy: $id"
|
||||
else logl "args rc=1 copy: $id"; finish 5 args-failed "$id is live and confirmed, but $ETC/$INSTANCE.args still names $OLD; put $NEW_ARGS in its place"
|
||||
fi
|
||||
REPLACED_NOTE="${ASIDE:+; the replaced units and args file are in $ASIDE}"
|
||||
}
|
||||
|
||||
do_upgrade() {
|
||||
read_args
|
||||
read_next
|
||||
legacy_console
|
||||
# Before A1: the bundle into the stack's user's clone, then the copy from it (I1).
|
||||
step fetch "${AS_USER[@]}" git -C "$CLONE" fetch -q "$BUNDLE" refs/heads/refactor:refs/remotes/origin/refactor ||
|
||||
@@ -390,9 +497,10 @@ do_upgrade() {
|
||||
local -a migrate=()
|
||||
[ "$MIGRATE" = 0 ] || migrate=(--migrate)
|
||||
plan activate "$SHA" "$BUSINESS" "${migrate[@]}"
|
||||
render_checks "$SHA"
|
||||
rehearse
|
||||
run_steps "$SHA" activate "$SHA" "$BUSINESS" "${migrate[@]}"
|
||||
finish 0 ok "upgrade ok: $SHA is live for $BUSINESS"
|
||||
finish 0 ok "upgrade ok: $SHA is live for $BUSINESS$REPLACED_NOTE"
|
||||
}
|
||||
|
||||
# The rehearsal against the log's newest backup, before anything stops. Its output goes to the
|
||||
@@ -419,6 +527,7 @@ rehearse() {
|
||||
# check: the target is an older copy than this checkout, and no rehearsal.
|
||||
do_rollback() {
|
||||
read_args
|
||||
read_next
|
||||
legacy_console
|
||||
[ -x "$COPIES_DIR/$TO/scripts/mosaic" ] || refuse copy-missing "no $COPIES_DIR/$TO/scripts/mosaic"
|
||||
hostcheck
|
||||
@@ -427,8 +536,9 @@ do_rollback() {
|
||||
[ -z "$RESTORE" ] || restore=(--restore "$RESTORE")
|
||||
[ -z "$EVENTS" ] || restore+=(--events "$EVENTS")
|
||||
plan rollback "$BUSINESS" --to "$TO" "${restore[@]}"
|
||||
render_checks "$TO"
|
||||
run_steps "$TO" rollback "$BUSINESS" --to "$TO" "${restore[@]}"
|
||||
finish 0 ok "rollback ok: $TO is live for $BUSINESS"
|
||||
finish 0 ok "rollback ok: $TO is live for $BUSINESS$REPLACED_NOTE"
|
||||
}
|
||||
|
||||
case "$OP" in
|
||||
|
||||
@@ -318,6 +318,20 @@ render() {
|
||||
case "$REPO" in *[!A-Za-z0-9/._+-]*) die "repository path contains a character the unit cannot carry: $REPO" ;; esac
|
||||
local root
|
||||
root="$(unit_root)" || exit 1
|
||||
# Accounts instances use dedicated system services, never a human/seat's
|
||||
# systemd user manager. The check runs before rendering or installing.
|
||||
node --input-type=module -e '
|
||||
import { lstatSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
const [root, dir, file] = process.argv.slice(1);
|
||||
const present = p => { try { lstatSync(p); return true; } catch(e) { if(e.code === "ENOENT") return false; throw e; } };
|
||||
let accounts = present(join(root, ".stack-build.json")) || present(join(dir, "accounts.json"));
|
||||
if (present(file)) {
|
||||
const s = JSON.parse(readFileSync(file, "utf8"));
|
||||
accounts ||= Boolean(s.console?.sso) || present(join(s.dataRoot, ".deployed"));
|
||||
}
|
||||
if (accounts) { console.error("webui-service: accounts user-unit path refused; use system units from case2-unit.sh"); process.exit(1); }
|
||||
' "$root" "$MOSAIC_INSTANCE_CONFIG_DIR" "$MOSAIC_INSTANCE_CONFIG" || exit 1
|
||||
if [ "$TESTER_SOCKET" = 1 ]; then
|
||||
grep -q 'MOSAIC_TESTER_SOCKET_V1' "$root/packages/webui/src/cli.mjs" || die "tester socket target lacks support"
|
||||
fi
|
||||
|
||||
Reference in new issue
Block a user