diff --git a/docs/TOOLS.md b/docs/TOOLS.md index b1e623e62..131a13771 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -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 `/.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 `/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 `/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 diff --git a/docs/guides/host-run-sheet.md b/docs/guides/host-run-sheet.md index 7ffa4dcb3..ca45d1487 100644 --- a/docs/guides/host-run-sheet.md +++ b/docs/guides/host-run-sheet.md @@ -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 `/.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' /.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 ` 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 --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 : 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 /.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`. `` 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 | ` 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//` | `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 --library-status`: `library-last-error library-credential-missing` until it's placed, then `library last fetched ...` | | `/srv/mosaic/.config/mosaic-dev/secrets/agents///` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. | diff --git a/docs/plans/2026-11-10_accounts-design.md b/docs/plans/2026-11-10_accounts-design.md index 1e3a79600..1c5c89e9d 100644 --- a/docs/plans/2026-11-10_accounts-design.md +++ b/docs/plans/2026-11-10_accounts-design.md @@ -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 `, as the unit script - already describes. Leave accounts.json, its receipts and `.deployed` in + `stack-upgrade.sh rollback --instance NAME --to ` 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 diff --git a/packages/cli/README.md b/packages/cli/README.md index 114c3412b..ba5ef1de4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 `/.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. diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index b177cebb9..e825364c6 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -8,6 +8,7 @@ // host (src/host.mjs) the systemd unit mosaic-bus@ 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") }); diff --git a/packages/cli/src/deployment.mjs b/packages/cli/src/deployment.mjs new file mode 100644 index 000000000..4b52a2837 --- /dev/null +++ b/packages/cli/src/deployment.mjs @@ -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); } +} diff --git a/packages/cli/tests/accounts.test.mjs b/packages/cli/tests/accounts.test.mjs index 377479129..5bb5cb876 100644 --- a/packages/cli/tests/accounts.test.mjs +++ b/packages/cli/tests/accounts.test.mjs @@ -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()); +}); diff --git a/packages/cli/tests/case2-install.test.mjs b/packages/cli/tests/case2-install.test.mjs index 63db8cb84..030352908 100644 --- a/packages/cli/tests/case2-install.test.mjs +++ b/packages/cli/tests/case2-install.test.mjs @@ -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); +}); diff --git a/packages/cli/tests/case2-unit.test.mjs b/packages/cli/tests/case2-unit.test.mjs index 49c09d770..cc0a9a1ea 100644 --- a/packages/cli/tests/case2-unit.test.mjs +++ b/packages/cli/tests/case2-unit.test.mjs @@ -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 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/); +}); diff --git a/packages/cli/tests/deployment-boundary.test.mjs b/packages/cli/tests/deployment-boundary.test.mjs new file mode 100644 index 000000000..72a70730e --- /dev/null +++ b/packages/cli/tests/deployment-boundary.test.mjs @@ -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); +}); diff --git a/packages/cli/tests/request-lifecycle-fixture.mjs b/packages/cli/tests/request-lifecycle-fixture.mjs index 89cb79727..8175c2a5a 100644 --- a/packages/cli/tests/request-lifecycle-fixture.mjs +++ b/packages/cli/tests/request-lifecycle-fixture.mjs @@ -1,3 +1,4 @@ +import { ownerAccounts } from '../../webui/tests/helpers/owner-accounts.mjs'; import assert from 'node:assert/strict'; import { fork } from 'node:child_process'; import { once } from 'node:events'; @@ -119,7 +120,7 @@ export async function lifecycleFixture(t) { const issuer = op.issuer('fixture'); sso = await createSso({ publicOrigin: ORIGIN, providers: [{ id: 'fixture', issuer: issuer.issuer, clientId: issuer.clientId, clientSecret: { slot: 'fixture' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'jason' }] }] }, - { secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} }); + { accounts: ownerAccounts({ providers: [{ id: 'fixture', subjects: [{ sub: 'owner', name: 'jason' }] }] }), secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} }); const transport = async (verb, args, ctx) => { try { return await rpc('console', { verb, args, subject: ctx.subject }); } catch (e) { appendFileSync(join(root, 'console-errors.jsonl'), JSON.stringify({ verb, code: e.code, detail: e.detail })+'\n'); throw new BusReadError(e.code ?? 'outcome-unknown', 503); } diff --git a/packages/cli/tests/stack-copy.test.mjs b/packages/cli/tests/stack-copy.test.mjs index 3e3c75cd6..6457cdbf5 100644 --- a/packages/cli/tests/stack-copy.test.mjs +++ b/packages/cli/tests/stack-copy.test.mjs @@ -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;}} diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index 22f4c404f..d884ff89d 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -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- 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/); diff --git a/packages/cli/tests/stack-upgrade.test.mjs b/packages/cli/tests/stack-upgrade.test.mjs index 34a6f171f..2df0580fc 100644 --- a/packages/cli/tests/stack-upgrade.test.mjs +++ b/packages/cli/tests/stack-upgrade.test.mjs @@ -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- exits 1 first; faults/render-probe- (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-, else 0, +// writing nothing. faults/render-fails- exits 1 first; faults/render-probe- (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 `