Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7813339b5 | ||
|
|
3e82bc16c3 | ||
|
|
57052c53dd | ||
|
|
930182b54a | ||
|
|
d0cfebe286 | ||
|
|
9acc378555 | ||
|
|
db822c5b9d | ||
|
|
554bc5116d | ||
|
|
6db8fb2dce | ||
|
|
85122dd2bc | ||
|
|
82dcba2d45 | ||
|
|
29f7c3af85 | ||
|
|
17a48217f6 | ||
|
|
9d00f9ad54 | ||
|
|
a3e82efe23 | ||
|
|
c8ff8ea128 | ||
|
|
ddf4f895cd | ||
|
|
7035ce61b3 | ||
|
|
54bf239e32 | ||
|
|
a4eed1e219 | ||
|
|
1e1bbaa6dd | ||
|
|
78bda9d6a4 | ||
|
|
6b39af7888 | ||
|
|
96c900d210 | ||
|
|
307fe928ac |
No files matched your search
+84
-6
@@ -807,13 +807,52 @@ the port and the backup's `SHA256SUMS`. Then it makes
|
||||
Roles come from the target copy's `roles/`, as the live bus reads them.
|
||||
- `steps.log` holds the same PASS and FAIL lines the run prints, with times;
|
||||
`host.log`, `console.log` and, after a migration, `migrate.log` sit beside it.
|
||||
- When the live config has `console.sso` (M3, #1873), the scratch copy keeps
|
||||
its provider ids, client ids, algorithms and subjects and moves the public
|
||||
origin to `rehearsal.invalid`, a name that never resolves. For a stamped
|
||||
target each issuer becomes `ORIGIN/ID/` at the rehearsal provider below;
|
||||
otherwise it moves to `rehearsal.invalid` too. Each client secret slot is a 0600 file under `config/secrets/mosaic-stack/`
|
||||
holding `rehearsal-placeholder-secret`; the live secret is never read.
|
||||
|
||||
A stamped target copy (one with `.stack-build.json`) serves only with `--sso`,
|
||||
so its rehearsal needs the live instance's human-made SSO steps. Without a live
|
||||
`console.sso` it refuses `sso-not-prepared`; without the live `accounts.json`
|
||||
beside the config it refuses `accounts-not-initialized`. Both name the run-sheet
|
||||
step. Otherwise it copies the live `accounts.json` into `config/`, writes
|
||||
`data/.deployed` with the target copy's own `writeDeployment`, prints
|
||||
`PASS deployment`, and starts the Console with `--sso`.
|
||||
|
||||
Before that, a stamped run starts a rehearsal provider and prints
|
||||
`PASS provider-start`. It is a node process on `127.0.0.1` port 0 that serves
|
||||
HTTPS with a certificate made for this run, under `provider/` (mode 0700). For
|
||||
each live provider id it serves discovery and a JWKS whose public key it made
|
||||
in memory for the first algorithm it can make from that provider's list. Its
|
||||
authorize and token routes answer 403 to any method and query: it issues no
|
||||
code and no token, and
|
||||
it reads no live secret. It logs each request to `provider/requests.log`,
|
||||
deletes its TLS key file once it has read it, and exits on SIGTERM or when
|
||||
the rehearsal's process is gone. `provider-start` fails when it exits early,
|
||||
writes no origin within the timeout, writes an origin that is not
|
||||
`https://127.0.0.1:PORT`, or leaves its key file. Only the scratch Console gets
|
||||
`NODE_EXTRA_CA_CERTS` naming `provider/cert.pem`; the host does not.
|
||||
|
||||
The scratch Console runs every startup check against that provider. The gate
|
||||
adds `sso-ready`: `/healthz` must report each provider `ready`. After the gate,
|
||||
`provider-discovery` requires a 200 discovery and JWKS request per provider and
|
||||
no authorize or token request. The end stops the provider with the Console and
|
||||
host and prints `provider-stopped`. The live `accounts.json` joins the end check
|
||||
`live-config-unchanged`. The rehearsal adds no subject and no account.
|
||||
|
||||
A guard then loads the scratch config with the target's code and refuses
|
||||
with a named check: `data-root-live` when the scratch data root resolves to
|
||||
the live one, `sessions-live` when a `sessions.json` there names a running
|
||||
pid (the run never deletes the file), `data-root-scratch`, `notify-off`,
|
||||
`tracker-configured` when `bootConfig` still sets a tracker, and
|
||||
`credential-ref-live` for an env reference or a file outside `placeholders/`.
|
||||
`tracker-configured` when `bootConfig` still sets a tracker,
|
||||
`credential-ref-live` for an env reference or a file outside `placeholders/`,
|
||||
and `sso-isolated` when a `console.sso` origin is not on `rehearsal.invalid`,
|
||||
an issuer is not `ORIGIN/ID/` at the rehearsal provider (not on
|
||||
`rehearsal.invalid` for an unstamped target), or a slot is not its scratch
|
||||
placeholder.
|
||||
When the backup's schema differs from the target's, the run checks that the
|
||||
target refuses the store, migrates it, opens it, migrates it again as a
|
||||
no-op, and that the pointer's copy refuses the migrated store.
|
||||
@@ -822,7 +861,8 @@ It starts `scripts/mosaic bus start BUSINESS --no-launch` and the Console's
|
||||
`serve --port N --business BUSINESS` from the target copy, without `--send`.
|
||||
The gate needs the host's `launches disabled` line, a live `bus status` host
|
||||
with this run's pid and a boot from this start, the Console's address line,
|
||||
`/healthz` 200, and both command lines on the target copy. Then it checks
|
||||
`/healthz` 200 (and `sso-ready` for a stamped target), and both command lines
|
||||
on the target copy. Then it checks
|
||||
that the host wrote no `sessions.json` and no `launches`, stops the Console
|
||||
and the host (SIGTERM, then SIGKILL and a FAIL after the timeout), checks that
|
||||
the host released `writer.lock`, and compares the backup and the live config
|
||||
@@ -834,6 +874,21 @@ Usage exits 4. Nothing prunes rehearsal directories.
|
||||
starts no thread ticker, and leaves an earlier host's `sessions.json` and
|
||||
its runs alone. No unit passes it, and `--pm` with it is a usage error.
|
||||
|
||||
An accounts instance runs system units (M3, #1873). Without `--units external`,
|
||||
`activate`, `rollback` and `pm-on` refuse `accounts-user-unit`, before
|
||||
anything stops and before `--plan` prints, when the target copy holds a
|
||||
`.stack-build.json` (a stamped copy), when `accounts.json` sits beside the
|
||||
config, when the config holds `console.sso`, or when `<dataRoot>/.deployed`
|
||||
exists. A config that cannot be read as JSON refuses as well. The message
|
||||
names the first sign found and says root renders the units with
|
||||
`case2-unit.sh`. For `activate` and `rollback` it adds that the run takes
|
||||
`--units external`. For `pm-on` it says the PM is a flag in root's bus unit.
|
||||
So a user-unit run never turns `--sso` on any more. The `--sso`, `sso-slot`
|
||||
and `--no-sso` text above describes the pointers that pre-M3 hosts left, and
|
||||
the Console's own start checks the slot (`readSlot` in
|
||||
`packages/webui/src/sso.mjs`). A rollback or `--no-sso` from such a pointer
|
||||
runs once `console.sso` is gone and no other sign is present.
|
||||
|
||||
#### Root-owned units (`--units external`, row 232)
|
||||
|
||||
On a host whose bus and Console are root's system units, the stack user runs
|
||||
@@ -894,8 +949,9 @@ the target migrates the store and the run lacks `--migrate` (row 233). Under
|
||||
|
||||
```
|
||||
scripts/stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal]
|
||||
[--rehearse-port N] [--timeout S]
|
||||
scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
|
||||
[--rehearse-port N] [--next-args] [--timeout S]
|
||||
scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--next-args]
|
||||
[--timeout S]
|
||||
```
|
||||
|
||||
Root runs it from a root-owned checkout of the reviewed commit, never from a
|
||||
@@ -935,7 +991,13 @@ user's clone `<home>/src/mosaic-stack`:
|
||||
(`args-differ`), and
|
||||
`activate SHA BUSINESS --units external --plan --expect-from OLD` exits 0
|
||||
(`plan-refused`, which covers `open-activation`, `migrate-required` and
|
||||
`pointer-differs`).
|
||||
`pointer-differs`). Then the render checks (#1873 M3):
|
||||
`case2-unit.sh --check` renders OLD from the args file and compares the
|
||||
installed units with it, so a rollback could render them, then checks
|
||||
SHA's render the same way. Either refusal is `render-refused`. A re-pin
|
||||
that changes the serve flags fails the second check, because an
|
||||
installed unit must equal the render but for the copy id (row 195); it
|
||||
takes `--next-args`, below.
|
||||
4. The rehearsal, `rehearse SHA BUSINESS --backup last --port 7339`
|
||||
(`--rehearse-port`). Exit 0 goes on. Exit 3 means no backup in the log
|
||||
and goes on only with `--no-rehearsal`, logging `rehearsal-skipped`;
|
||||
@@ -950,6 +1012,22 @@ user's clone `<home>/src/mosaic-stack`:
|
||||
still names the old one, and root moves the new file (the result line
|
||||
names it) into place by hand.
|
||||
|
||||
`--next-args` changes the serve flags, as the SSO migration does
|
||||
(`docs/guides/host-run-sheet.md` section 7). The run reads
|
||||
`/etc/mosaic-stack/dev.args.next` as it reads the args file
|
||||
(`args-unreadable`). It must equal the args file before `--` but for
|
||||
`--copy`'s value (`next-args-differ`) and differ from it after `--`
|
||||
(`next-args-same`). The target's check is `case2-unit.sh --check-new`, a
|
||||
first install's checks from the next args, without the installed units'.
|
||||
After A3, root moves the installed unit files into a new 0700 directory
|
||||
`/etc/mosaic-stack/dev.replaced.XXXXXX` (step `units-aside`), so A4 renders
|
||||
as a first install. A failure from there moves them back unchanged
|
||||
(`units-back`), runs `daemon-reload` and starts the old units with no render.
|
||||
After A5 the old args file is copied into the replaced directory, the next
|
||||
args file is removed (step `next-args`), and the new args file, made from
|
||||
the next args, moves into place. The result line names the replaced
|
||||
directory. Nothing removes it.
|
||||
|
||||
A failure in A2 or a stage that exits 3 starts the old units again and gates
|
||||
them (A2f). A later failure stops both units, runs `revert`, renders the old
|
||||
copy and runs `confirm --rollback OLD --cause A3|A4|A5`. `rollback` is the
|
||||
|
||||
@@ -386,10 +386,15 @@ then the bus, then the proxy, re-render all three onto the selected copy, and st
|
||||
then the Console. Recovery after a failed upgrade restores all three to the old copy. A missing or
|
||||
disagreeing proxy unit, or an installed proxy without `--proxy` in the args file, refuses before anything stops.
|
||||
|
||||
For the `library` profile, the serve argv after `--` keeps the proxy-preview trio and required proof pair
|
||||
but omits `--send`. That Console is read-only until Case 2 gains real OIDC in #412; `case2-unit.sh`
|
||||
explicitly refuses `--send`. Proxy preview and proof-protected reads remain available. The `factory`
|
||||
profile and its private tester socket are unchanged.
|
||||
A stamped copy, which is every copy `stack-copy.sh` makes, signs in through SSO accounts in both profiles
|
||||
(M3, #1873; Jason's ruling 2026-11-10). After `--`, the `factory` profile takes `--sso --send` with its
|
||||
private tester socket. The `library` profile takes a bare `--sso`, still without `--send`, so its Console
|
||||
stays read-only. A stamped library copy refuses the proxy-preview trio (`--public-host`, `--auth-header`,
|
||||
`--allow-user`) and the proof pair (`--proof-header`, `--proof-file`): proxy-preview admission does not
|
||||
apply to a stamped copy. Only an unstamped library copy, which no host install makes, keeps the old
|
||||
proxy-preview argv and refuses `--sso`. Before writing anything, `case2-unit.sh` refuses a stamped copy
|
||||
that lacks `--sso` or any prerequisite and names each missing step. The migration below lists them in
|
||||
order.
|
||||
|
||||
A library host installed before #385 has `--send` in two places, because the earlier `case2-unit.sh`
|
||||
required it: a `--send` line after `--` in the args file, and `--send` in the installed Console unit's
|
||||
@@ -418,10 +423,77 @@ grep -n '^ExecStart=' /etc/systemd/system/mosaic-webui.service | grep -c -- ' --
|
||||
lead with the command's output. Removing only the args line does not help: the run then refuses
|
||||
`console-unit-legacy`.
|
||||
|
||||
The Console stays read-only after the upgrade, until #412 (#385).
|
||||
The library Console stays read-only after the upgrade (#385). From M3 it signs in through SSO
|
||||
accounts, as the migration below sets up.
|
||||
|
||||
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` or `console-unit-legacy` on any of these
|
||||
rules, before it stops a unit.
|
||||
### Migrating a host to SSO accounts (M3, #1873)
|
||||
|
||||
A host re-pinned onto an M3 copy migrates first, in this order. The order is the same for the `factory`
|
||||
(Case 1 and .53) and `library` (.54) profiles. Each step is a live step that needs Mos's go. Nothing in the
|
||||
stack picks or invents a subject: the subjects come from `console.sso`, which a human prepares, and
|
||||
`accounts.json` comes only from the human verb `mosaic accounts init`.
|
||||
|
||||
1. Check or create `.deployed` through the installer. The stack's user's `case2-install.sh` (section 6)
|
||||
writes `<dataRoot>/.deployed` naming the instance at its step 2. It never replaces an existing one, and
|
||||
it refuses one that is invalid or names another instance. Check it read-only:
|
||||
`stat -c '%U %a' <dataRoot>/.deployed` prints `mosaic 444`.
|
||||
2. Prepare SSO. A human adds `console.sso` to `/srv/mosaic/.config/mosaic-dev/config.json`, with its
|
||||
providers, their subjects and its client-secret slots, per
|
||||
`docs/plans/2026-11-10_accounts-design.md`. Jason places the client secrets (section 9).
|
||||
3. Run `mosaic accounts init --name <name>` as `mosaic`. This is a human verb, run from a human shell and
|
||||
never by an agent. It creates the one operator account from the configured `console.sso` subjects and
|
||||
refuses if `accounts.json` exists.
|
||||
4. Provision the dedicated uid. `mosaic` must keep section 1's shape: a non-login shell, the only member
|
||||
of its primary group, and owner of its config directory and data root. An accounts instance never runs
|
||||
under a human or development seat uid, and never under the `webui-service.sh` user units.
|
||||
5. Re-pin with `--next-args`. Leave `/etc/mosaic-stack/dev.args` as it is. Write the new flags to
|
||||
`/etc/mosaic-stack/dev.args.next`, root's, mode 0640: the same lines before `--`, and after `--` the
|
||||
serve flags with `--sso` added (`factory`: `--sso --send`). For `library`, also remove the
|
||||
proxy-preview trio (`--public-host`, `--auth-header`, `--allow-user`) and the proof pair
|
||||
(`--proof-header`, `--proof-file`). Then run
|
||||
`stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA --next-args`.
|
||||
|
||||
A plain re-pin refuses `render-refused` here, because an installed unit must equal the render but for
|
||||
the copy id. With `--next-args`, before anything stops, the driver checks the installed copy against
|
||||
`dev.args` and the target as a first install from `dev.args.next`. It refuses `next-args-differ` when
|
||||
the files differ before `--` (other than `--copy`), and `next-args-same` when they do not differ after
|
||||
it. After the stage it moves the installed unit files into a new 0700 directory
|
||||
`/etc/mosaic-stack/dev.replaced.XXXXXX` and renders the target as a first install. A failure from
|
||||
there moves them back unchanged and starts the old copy again. After `confirm` passes,
|
||||
`dev.args.next` becomes `dev.args`, and the replaced directory keeps the old unit files and the old
|
||||
`dev.args`. The result line names the directory. Nothing removes it; root removes it by hand once no
|
||||
rollback needs it.
|
||||
|
||||
To roll back past the migration, put the replaced args back as the next args and roll back with
|
||||
`--next-args`:
|
||||
|
||||
```sh
|
||||
install -m 0640 -o root -g root /etc/mosaic-stack/dev.replaced.XXXXXX/dev.args /etc/mosaic-stack/dev.args.next
|
||||
stack-upgrade.sh rollback --instance dev --to <old copy id> --next-args
|
||||
```
|
||||
|
||||
For `library` this is the only rollback to a copy older than M3: `case2-unit.sh` refuses `--sso` for
|
||||
an unstamped library copy (#416), so the old copy comes back with its old flags, proxy-preview
|
||||
admission included, as before the migration. A `factory` rollback may instead keep `--sso --send`
|
||||
with a plain `rollback`.
|
||||
|
||||
If a step is missing, `case2-unit.sh` refuses before it writes a unit and prints the missing steps,
|
||||
numbered in this order, for example:
|
||||
|
||||
```text
|
||||
case2-unit: refusing the units for <sha>: a stamped copy's Console starts only with SSO accounts; missing steps, in order:
|
||||
1. check or create .deployed: mosaic runs case2-install.sh, whose deployment step writes <dataRoot>/.deployed
|
||||
2. prepare SSO: console.sso in mosaic's system config /srv/mosaic/.config/mosaic-dev/config.json, with its client secret slots
|
||||
3. run `mosaic accounts init` as mosaic, a human verb: no /srv/mosaic/.config/mosaic-dev/accounts.json
|
||||
4. add --sso to the serve flags, then re-pin
|
||||
```
|
||||
|
||||
Without `console.sso`, the render does not check `accounts.json`, because its subjects name providers SSO
|
||||
has not configured yet. The next render after step 2 checks it. A rollback never removes accounts.json,
|
||||
its receipts or `.deployed` (`docs/plans/2026-11-10_accounts-design.md`, section 7).
|
||||
|
||||
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable`, `console-unit-legacy`, `render-refused`,
|
||||
`next-args-differ` or `next-args-same` on any of these rules, before it stops a unit.
|
||||
|
||||
## 8. Enrollment, once per host
|
||||
|
||||
@@ -450,7 +522,7 @@ file with `stat -c '%U %a %n' PATH`, never `cat`. `<business>` is the host's bus
|
||||
| Path | File | Mode | Owner | Profile | Precheck line |
|
||||
|---|---|---|---|---|---|
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | `claude-session.env`, one line `ZAI_API_KEY=...` | 0600 | mosaic | library | `<path> sets ZAI_API_KEY` |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | the Console's proof file, the path the args file's `--proof-file` names: one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | before M3 only, and an unstamped copy only: the Console's proof file, the path the args file's `--proof-file` names. A stamped copy refuses `--proof-file` (section 7). The file is one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | After a unit start, wait for loopback `/healthz` with the bounded retry in section 10 before `case2-precheck.sh --business <business> --library-status`: `library-last-error library-credential-missing` until it's placed, then `library <project> last fetched ...` |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/agents/<business>/<agent>/` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. |
|
||||
|
||||
|
||||
@@ -108,7 +108,10 @@ configured subject (`packages/cli/src/config.mjs:141`), and
|
||||
`packages/webui/src/cli.mjs serve` from that copy. Its flag set is
|
||||
`--port`, `--business`, `--board`, `--sso`, `--send` and the header
|
||||
preview set (`webui-service.sh:98`). It has no dev-owner flag.
|
||||
`scripts/case2-unit.sh` renders Case 2 units the same way.
|
||||
`scripts/case2-unit.sh` renders Case 2 units the same way. At BASE its
|
||||
library profile takes the preview set and refuses `--sso`. Under the
|
||||
owner ruling of 2026-11-10 a stamped library copy signs in through SSO
|
||||
accounts instead (section 6, item 5).
|
||||
|
||||
### Organizations and who may read them
|
||||
|
||||
@@ -1231,7 +1234,13 @@ The executable boundary:
|
||||
this instance, and accounts.json loads. The Case 1 and Case 2 installers
|
||||
write `.deployed`.
|
||||
5. **The unit renderers have no dev flag.** `webui-service.sh` and
|
||||
`case2-unit.sh` accept no dev flag today (`webui-service.sh:98`). M3 adds
|
||||
`case2-unit.sh` accept no dev flag today (`webui-service.sh:98`). For a
|
||||
stamped copy, `case2-unit.sh` renders either profile, factory or library,
|
||||
only with `--sso`, `console.sso`, a loading accounts.json and a valid
|
||||
`.deployed`, and refuses before any write, naming each missing step. A
|
||||
stamped library unit refuses proxy-preview admission and its proof flags:
|
||||
proxy-preview does not apply to a stamped copy. The dev and unstamped
|
||||
paths keep the preview set. M3 adds
|
||||
a test that pins it.
|
||||
|
||||
The instance boundary, which also covers a checkout started by mistake on a
|
||||
@@ -1289,13 +1298,22 @@ business file.
|
||||
|
||||
### Rollback
|
||||
|
||||
Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off.
|
||||
Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off,
|
||||
except the library rollback past the migration below.
|
||||
|
||||
- The supported rollback is a pinned code and config rollback: install the
|
||||
earlier stack copy with the same unit flags, including `--sso`, through
|
||||
`webui-service.sh install --copy <earlier id>`, as the unit script
|
||||
already describes. Leave accounts.json, its receipts and `.deployed` in
|
||||
`stack-upgrade.sh rollback --instance NAME --to <earlier id>` for the
|
||||
dedicated system units. The accounts instance must not use the
|
||||
`webui-service.sh` user-unit path. Leave accounts.json, its receipts and `.deployed` in
|
||||
place. BASE code ignores them, and the next roll-forward needs them.
|
||||
- A library host rolled back to a copy older than M3 is the exception.
|
||||
`case2-unit.sh` refuses `--sso` for an unstamped library copy (#416), so
|
||||
that rollback renders the old copy from the args file the migration
|
||||
replaced, through `stack-upgrade.sh rollback ... --next-args`
|
||||
(`docs/guides/host-run-sheet.md` section 7). The old copy comes back with
|
||||
its old flags, proxy-preview admission included, as before the migration.
|
||||
accounts.json and `.deployed` stay in place.
|
||||
- Before the first accounts org exists, that rollback restores today's
|
||||
behavior completely. `console.sso` owners sign in as before, and FED R6
|
||||
users keep their root grants. Account-only users and pending invites stop
|
||||
@@ -1450,6 +1468,9 @@ Acceptance tests, against a fake OIDC provider on port 0:
|
||||
- `--dev-local-owner` from a checkout refuses with accounts.json present,
|
||||
with `console.sso`, and with `.deployed`.
|
||||
- `webui-service.sh render` and `case2-unit.sh` refuse a dev flag.
|
||||
- For a stamped copy, `case2-unit.sh` renders a library or factory unit
|
||||
only with `--sso` and the prerequisites, names each missing step before
|
||||
any write, and refuses the proxy-preview and proof flags.
|
||||
- `case2-unit.sh` accepts the host run sheet's `mosaic` account fixture only
|
||||
when its shell is non-login, its primary group has no other members, and
|
||||
it owns the config directory and data root. Fixtures for a human login and
|
||||
@@ -1666,6 +1687,8 @@ and draft 3 applies them. No question from draft 2 remains open.
|
||||
| May an invite bind to an email domain? | "agreed" | No domain binding. Section 4 binding, test in M6. |
|
||||
| May members read the board and write tasks? | "Similar to 1. grantable right via RBAC. Default owner R/W, member R" | The `board.read` and `task.write` rights. `member` holds `board.read` only. Section 3, slices M1, M5 and M7. |
|
||||
|
||||
The library Console signs in through SSO accounts like every deployed Console; proxy-preview admission does not apply to a stamped copy.
|
||||
|
||||
### Choices made under the rulings
|
||||
|
||||
These are decided in this draft, not open. Each one is a single reviewed
|
||||
|
||||
@@ -189,8 +189,15 @@ those messages stranded: no later holder receives or reads them, and `agents`
|
||||
lists them under `stranded` (Agent state, below). `message.read` is an explicit acknowledgement. `trail(messageId)` lets
|
||||
an operator inspect content and delivery evidence after an uncertain result.
|
||||
|
||||
Broker socket refusals retain `ok:false`, `error` and any scoped `reason`, and add
|
||||
`rail` and `nextStep` from `src/refusals.mjs`. Guidance is static and never
|
||||
quotes caller input or file bytes. The runner and typed tools carry the guidance
|
||||
to the agent. `src/rails.mjs` in the organization package supplies the shared
|
||||
prompt/live projection; `decisionAutonomy` is the extension point for AU.
|
||||
|
||||
| Verb | Arguments and result |
|
||||
|---|---|
|
||||
| `my_rails` | No args. Read only the current caller's held actions and routes, resolve rights with fixed autonomy levels, network, credential service/scope names and messaging targets. Recomputed from live policy on every call; never returns credential references or values, writes evidence, or raises a decision. Exposed to every managed role as `my_rails`. |
|
||||
| `role.claim` | No args; claims capability's role/run. One current holder per business/instance; a revoked run cannot reclaim that role, including after restart. |
|
||||
| `role.release` | No args; only its current holder releases. |
|
||||
| `role.revoke` | `{role,decision}`; needs an approved decision for this exact target and holder incarnation, routed as REQ-DEC-2 says: the human's for an uncited gated raise, the arbiter's for one citing the current spec. A role whose file lists `role.end` (the chief of staff, row 123, REQ-COS-1) sends `{role,reason}` instead: no decision, a reason of 1 to 500 characters, logged in the `action.allowed` body. Sending both is `invalid-request`; the caller's own run is `role-end-refused`; any other role sending `reason` gets `decision-required`. An instance whose definition is in `END_PROTECTED` (the assistant) is ended or revoked only by the chief of staff or with a decision the human resolved through the CLI (`via` `cli`); anyone else's reason, or a decision an arbiter resolved, is `role-end-refused` (Jason's ruling Q169, row 139). A target whose definition is `THREAD_DEFINITION` (the assistant) has no role claim: the end revokes the current claim of each of its threads, refuses `not-held` when none is held, and returns `{revoked:true,threads:N}` (row 155, #1649). |
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ownRails } from '../../organization/src/rails.mjs';
|
||||
import { refusalGuidance } from './refusals.mjs';
|
||||
import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';
|
||||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||||
import { validManagementKey, proxyTiming } from '../../model-proxy/src/validation.mjs';
|
||||
@@ -245,10 +247,11 @@ const canonical = (x) =>
|
||||
Number.isFinite(Date.parse(x)) &&
|
||||
new Date(x).toISOString() === x;
|
||||
export class BusError extends Error {
|
||||
// Only explicitly scoped refusals carry a reason; the envelope passes it on.
|
||||
// Guidance is static policy text; never quote arguments or file bytes here.
|
||||
constructor(code, reason) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
Object.assign(this, refusalGuidance(code));
|
||||
if (reason !== undefined) this.reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -1217,6 +1220,11 @@ export class Broker {
|
||||
this.#businesses[name] = next;
|
||||
this.#scheduleRequests();
|
||||
}
|
||||
// #1873 A3: host-only, for the launch boundary's admission: the code that refuses `instance` in code
|
||||
// project `repo` under the business's current document (the one the last reload swapped in), or null.
|
||||
projectBarred(business, instance, repo) {
|
||||
return this.#projectRefused(this.#business(business), instance, repo)?.code ?? null;
|
||||
}
|
||||
// Host-only current role resolver shared by the broker and built-in task tracker.
|
||||
taskRoles(business) {
|
||||
const roles = this.#business(business).roles;
|
||||
@@ -1531,11 +1539,40 @@ export class Broker {
|
||||
this.#processingWork();
|
||||
const b = this.#business(s.business);
|
||||
if ((b.retired ?? []).includes(instance)) fail('instance-retired');
|
||||
this.#copyRefused(b, instance);
|
||||
if (!Object.hasOwn(b.roles, instance)) fail('unknown-role');
|
||||
if (this.#threaded(s.business, instance)) fail(thread);
|
||||
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked') fail('launch-revoked');
|
||||
if (this.#firing(s.business, instance)) fail('instance-firing');
|
||||
}
|
||||
// #1873 A3: a root or project copy the boot left unbound or refused isn't in `roles`. Its refusal
|
||||
// names the agent and the missing services or the reason, never a value (organization
|
||||
// agent-copies.mjs copyRefusal; inlined because this module also loads in the sandbox client).
|
||||
#copyRefused(b, instance) {
|
||||
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
|
||||
if (!copy || copy.state === 'instance') return;
|
||||
if (copy.state === 'unbound') throw new BusError('credential-unbound', `${instance} has no binding in ${b.id} for ${copy.missing.join(', ')}`);
|
||||
throw new BusError(copy.project === null ? 'copy-refused' : 'project-refused', `${instance} can't launch in ${b.id}: ${copy.reason}`);
|
||||
}
|
||||
// #1873 A3: why `instance` can't work in code project `repo`, as a BusError, or null when it can
|
||||
// (organization agent-copies.mjs projectRefusal, inlined for the same reason). A project copy works
|
||||
// only in its own project; in a project whose union refuses no agent works; an instance the
|
||||
// project excludes doesn't work there. The reason names the instance, the project and why.
|
||||
#projectRefused(b, instance, repo) {
|
||||
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
|
||||
if (copy && copy.project !== null && copy.project !== repo)
|
||||
return new BusError('project-agent-elsewhere', `${instance} works only in project ${copy.project}`);
|
||||
const union = repo === null || !b.unions || !Object.hasOwn(b.unions, repo) ? null : b.unions[repo];
|
||||
if (!union) return null;
|
||||
if (!union.ok) return new BusError('project-refused', `${instance} can't work in project ${repo}: project ${repo} refuses its agents: ${union.reason}`);
|
||||
if (!union.agents.includes(instance)) return new BusError('project-refused', `${instance} can't work in project ${repo}: the project excludes it`);
|
||||
return null;
|
||||
}
|
||||
// #1873 A3: the union refusal of code project `repo`, as a BusError naming the project and why, or null.
|
||||
#unionRefused(b, repo) {
|
||||
const union = repo === null || !b.unions || !Object.hasOwn(b.unions, repo) ? null : b.unions[repo];
|
||||
return union && !union.ok ? new BusError('project-refused', `project ${repo} refuses its agents: ${union.reason}`) : null;
|
||||
}
|
||||
// W-N1 (webby-0014): a fire of I stopped its runs, and no fire of I has finished since.
|
||||
#firing(business, instance) {
|
||||
return !!this.#store.get(
|
||||
@@ -1849,7 +1886,15 @@ export class Broker {
|
||||
this.#revokeLost('access-expiry', true, doc);
|
||||
rows = this.#threadRows('t.business=?', business);
|
||||
}
|
||||
return rows.map(({ owner, project, ...r }) => ({ ...r, ...(this.#archived(business, project) ? { archived: true } : {}), ...(lost(r, owner, project) ? { revoked: true } : {}) }));
|
||||
// #1873 A3: a thread whose instance no longer works in its project's code project (the project's
|
||||
// union refuses, or it excludes the instance) says so, so the launcher refuses its launch.
|
||||
const b = this.#business(business);
|
||||
const refusedIn = (instance, project) => {
|
||||
if (!b.unions && !b.copies) return null;
|
||||
const e = this.#projectRefused(b, instance, this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', project, business)?.repo ?? null);
|
||||
return e ? { projectRefused: { code: e.code, reason: e.reason } } : null;
|
||||
};
|
||||
return rows.map(({ owner, project, ...r }) => ({ ...r, ...(this.#archived(business, project) ? { archived: true } : {}), ...(lost(r, owner, project) ? { revoked: true } : {}), ...refusedIn(r.instance, project) }));
|
||||
}
|
||||
// Row 121 round 2 (#1615, Dewey's F3 in 28591): the launcher's idle stop. The tick decides
|
||||
// from a threadsState snapshot, and a message the run took after it would be delivered to a
|
||||
@@ -6107,6 +6152,12 @@ export class Broker {
|
||||
entry.running = this.#running(s.business, name);
|
||||
entry.alive = this.#aliveAfterFire(s.business, name);
|
||||
}
|
||||
// #1873 A3: the copies this bus booted with, and each one's running runs. The file's own
|
||||
// instances above carry no copy; a copy carries its state, missing service names or reason.
|
||||
if (request.verb === "instances" && result?.instances && typeof result.instances === "object" && this.#business(s.business).copies)
|
||||
result.copies = Object.fromEntries(Object.entries(this.#business(s.business).copies).map(([name, c]) => [name, {
|
||||
...structuredClone(c), running: c.state === 'instance' ? this.#running(s.business, name) : [],
|
||||
}]));
|
||||
return result;
|
||||
}
|
||||
return this.#store.transaction(() => {
|
||||
@@ -6938,6 +6989,13 @@ export class Broker {
|
||||
// Preserve the common request gates and refuse additions until the vocabulary names them.
|
||||
if (verb.startsWith('tasks.') && !PERSON_TASK_VERBS.includes(verb)) fail('unknown-verb');
|
||||
switch (verb) {
|
||||
case 'my_rails': {
|
||||
keys(a, []);
|
||||
this.#agent(s);
|
||||
const entry = b.roles[s.role], definition = b.definitions?.[entry.definition];
|
||||
return ownRails({ instance: s.role, authority: entry.authority, network: entry.network ?? definition?.network,
|
||||
credentials: definition?.credentials ?? [], messaging: definition?.messaging, arbiters: b.arbiters, launcher: b.launch?.by });
|
||||
}
|
||||
case 'role.claim': {
|
||||
keys(a, []);
|
||||
if (s.human) fail('agent-required');
|
||||
@@ -8023,7 +8081,17 @@ export class Broker {
|
||||
// #797 MVP3: an archived project takes no new room threads and no new requests; reading it
|
||||
// (room.read) and every existing record stay open.
|
||||
if ((opening || sending) && this.#archived(s.business, project)) fail('project-archived');
|
||||
const members = Object.keys(b.roles).filter((instance) => this.#threaded(s.business, instance)).sort();
|
||||
// #1873 A3: the room's members are the thread instances that work in its code project. A project
|
||||
// whose union refuses takes no new room thread or request, naming the project and why; its
|
||||
// existing threads stay readable.
|
||||
const repo = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', project, s.business)?.repo ?? null;
|
||||
const unionRefused = this.#unionRefused(b, repo);
|
||||
if ((opening || sending) && unionRefused) throw unionRefused;
|
||||
const members = Object.keys(b.roles).filter((instance) => {
|
||||
if (!this.#threaded(s.business, instance)) return false;
|
||||
const refused = this.#projectRefused(b, instance, repo);
|
||||
return !refused || (unionRefused !== null && refused.code === 'project-refused');
|
||||
}).sort();
|
||||
if (!members.length) fail('thread-instance-missing');
|
||||
const owner = this.#owner(s);
|
||||
// Preflight all collisions/missing threads before open creates or send writes anything.
|
||||
@@ -8111,7 +8179,13 @@ export class Broker {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['project', 'name', 'instance'], ['project', 'name']);
|
||||
if (!name(a.name)) fail('invalid-request');
|
||||
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
// #1873 A3: a root or project copy takes a thread only when the caller names it, so leaving the
|
||||
// instance out still chooses among the organization file's own thread instances.
|
||||
const copies = b.copies ?? {};
|
||||
// #1873 A3: the code project the chat project maps to decides which agents work in it.
|
||||
const repo = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', identifier(a.project), s.business)?.repo ?? null;
|
||||
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r) &&
|
||||
(a.instance !== undefined || (!Object.hasOwn(copies, r) && !this.#projectRefused(b, r, repo))));
|
||||
const instance = a.instance === undefined
|
||||
? (instances.length === 1 ? instances[0] : null)
|
||||
: identifier(a.instance);
|
||||
@@ -8125,9 +8199,18 @@ export class Broker {
|
||||
fail('project-not-found');
|
||||
// #797 MVP3: an archived project refuses new threads (and the room's requests, above).
|
||||
if (this.#archived(s.business, identifier(a.project))) fail('project-archived');
|
||||
// #1873 A3: a named copy the boot left unbound or refused says why, and a project whose union
|
||||
// refuses takes no thread, naming the project and why.
|
||||
if (a.instance !== undefined && instance) this.#copyRefused(b, instance);
|
||||
const unionRefused = this.#unionRefused(b, repo);
|
||||
if (unionRefused) throw unionRefused;
|
||||
// The human selects an instance; omission is compatible only with one candidate.
|
||||
if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
|
||||
if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
|
||||
// #1873 A3: a project agent works only in its own project, and an agent the project excludes
|
||||
// doesn't work in it.
|
||||
const elsewhere = this.#projectRefused(b, instance, repo);
|
||||
if (elsewhere) throw elsewhere;
|
||||
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
|
||||
const tid = randomUUID();
|
||||
this.#store.run(
|
||||
|
||||
@@ -16,6 +16,7 @@ export function busOrganization(business, resolved) {
|
||||
!r.limits?.authority
|
||||
)
|
||||
throw new BusError('invalid-resolution');
|
||||
out.roles[role].network = r.limits.network;
|
||||
out.roles[role].authority = structuredClone(r.limits.authority);
|
||||
// REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs.
|
||||
out.roles[role].web = (r.limits.tools ?? []).includes('web');
|
||||
|
||||
@@ -88,7 +88,7 @@ function launchOp(m) {
|
||||
if (m.op === 'request.release') return runtime.requestRelease(m.cap, m.record);
|
||||
if (m.op === 'request.manifest') return runtime.requestManifest(m.record);
|
||||
if (m.op === 'request.chief') return runtime.requestChief(m.business);
|
||||
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, thread: m.thread, session: m.session });
|
||||
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session });
|
||||
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
|
||||
if (m.op === 'modelAdmission') return runtime.modelAdmission(m);
|
||||
if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// Static refusal table: each listed code maps to one rail and next step. These strings
|
||||
// never interpolate caller arguments, credential references, policy values or file bytes.
|
||||
const entries = [];
|
||||
const group = (codes, rail, nextStep) => {
|
||||
const guidance = Object.freeze({ rail, nextStep });
|
||||
for (const code of codes.split(' ')) {
|
||||
if (entries.some(([known]) => known === code)) throw new Error('duplicate refusal guidance');
|
||||
entries.push([code, guidance]);
|
||||
}
|
||||
};
|
||||
group(
|
||||
"access-refused access-revoked share-exists share-not-covered share-not-found tester-dev-only tester-off tester-refused tester-required tester-target-not-running thread-not-allowed thread-not-owned",
|
||||
"R7: caller access and scoped session boundary",
|
||||
"Use only the caller's own permitted scope. Ask the owner to inspect access or sharing through the supported controls; never borrow another identity."
|
||||
);
|
||||
group(
|
||||
"adapter-failed check-threw duplicate-run malformed-answer no-verdict not-a-merge not-approved not-collected not-human not-merge-option not-resolved not-thread-launch peercred-required reply-limits-unreadable socket-exists store-unreadable target-branch workspace-rename-failed",
|
||||
"R3: fail closed on an unsatisfied operation precondition",
|
||||
"Inspect the supported status and report the refusal code to the responsible owner; do not bypass it or retry an uncertain side effect."
|
||||
);
|
||||
group(
|
||||
"addressee-ambiguous invalid-approval-choice invalid-arbiter invalid-authority invalid-business invalid-choice invalid-class invalid-domain invalid-envelope invalid-harness invalid-messaging invalid-options invalid-plugins invalid-recommendation invalid-request invalid-snapshot invalid-spec-citation invalid-state invalid-task-ref invalid-timestamp name-is-instance name-is-role name-taken project-name-is-repo thread-instance-ambiguous unknown-action unknown-addressee unknown-business unknown-commit unknown-model unknown-plugin unknown-repo unknown-request unknown-requirement unknown-role unknown-run unknown-verb",
|
||||
"R3: validated vocabulary and request shape",
|
||||
"Check the tool schema and permitted identifiers, correct the request, then retry; do not guess around a refusal."
|
||||
);
|
||||
group(
|
||||
"agent-required",
|
||||
"Role/session: agent identity required",
|
||||
"Use this tool only from an authorized managed agent session."
|
||||
);
|
||||
group(
|
||||
"agent-running fire-in-progress instance-fired instance-firing instance-not-listed instance-retired instance-running instance-starting launch-record-mismatch launch-revoked launcher-released not-drained not-drained-run not-exited not-held not-holder not-recipient not-retired revoke-refused revoked role-already-held room-not-open room-thread-name-taken run-ended run-not-ended run-revoked session-clear session-clear-cancelled session-clear-complete session-clearing session-exists session-name-too-long session-not-found session-op-conflict session-retired session-revision-stale thread-already-held thread-exists thread-instance-mismatch thread-instance-missing thread-not-found thread-required thread-required-reply-to-thread-message thread-required-use-thread-send thread-revoked",
|
||||
"Role/session: current holder and lifecycle fence",
|
||||
"Read current session/status and ask the launcher or owner to finish the supported lifecycle transition. A stale or ended run must not reclaim another holder's identity."
|
||||
);
|
||||
group(
|
||||
"apikey-invalid callback-url-invalid harness-off harness-refused key-quota-no-model-key login-state-unknown model-catalogue-unavailable model-harness-incompatible model-key-collision model-provider-unavailable models-ambiguous models-invalid models-unconfirmed models-write-refused priority-invalid priority-unconfirmed priority-write-refused provider-not-allowed proxy-config-invalid proxy-down proxy-first-output-invalid proxy-init-failed proxy-key-invalid proxy-key-issue-failed proxy-key-refused proxy-key-revoking proxy-open proxy-revoke-revision-conflict proxy-rotate-grace-invalid proxy-uncertain thinking-unsupported",
|
||||
"R1/R3: owner-managed model and provider configuration",
|
||||
"Ask the owner to inspect the provider configuration through its supported controls; never put keys or callback contents in messages."
|
||||
);
|
||||
group(
|
||||
"block-recipient block-reply-required block-sender message-recipient-refused message-sender-refused tester-reply-required tester-thread-human user-reply-required user-thread-human",
|
||||
"Role messaging: permitted sender, recipient and reply context",
|
||||
"Read my_rails and use only a permitted target and the required reply context. Ask the owner if the permitted route is unavailable."
|
||||
);
|
||||
group(
|
||||
"command-not-granted credential-file credential-revoke-failed credential-unknown credential-leak gitea-reach-unknown gitea-reach-wider gitea-user-admin gitea-user-disabled gitea-user-foreign gitea-user-gone gitea-user-orphan grant-admin-missing grant-admin-nothing-left grant-admin-rotation-due grant-busy grant-chief-only grant-lost grant-revoke-failed grant-revoke-running grant-token-unknown grants-revoking remote-not-granted remote-host-mismatch remote-not-fork scope-changed",
|
||||
"R1/R8: scoped and revocable access",
|
||||
"Ask the owner to inspect the scoped access and binding state through the supported access controls; do not supply credentials in a call or widen your own grants."
|
||||
);
|
||||
group(
|
||||
"outcome-unknown outcome-unknown-revoked revision-conflict revision-conflict-revoked",
|
||||
"R2/R3: current revision and verified effect",
|
||||
"Read the latest status, revision and receipts before retrying. Verify whether an uncertain effect already happened, and use the current revision for any new attempt."
|
||||
);
|
||||
group(
|
||||
"confirm-required",
|
||||
"R2/R3: explicit confirmation of the current operation",
|
||||
"Read the current status and obtain the required confirmation through the supported human path."
|
||||
);
|
||||
group(
|
||||
"decision-closed decision-consumed decision-mismatch decision-not-approved decision-not-eligible decision-not-found decision-not-held decision-stale supersede-refused",
|
||||
"Role decisions: current, matching, approved and single-use decision",
|
||||
"Read the current decision, its route and its resolution and consumption receipts. Obtain the operation's matching approval from the permitted resolver before acting; a consumed approval cannot be reused."
|
||||
);
|
||||
group(
|
||||
"decision-not-allowed",
|
||||
"Role authority: no free-text decision channel",
|
||||
"Use the role's permitted message path to report the need to a person; do not use another decision channel."
|
||||
);
|
||||
group(
|
||||
"decision-required",
|
||||
"Role authority and R4: approval before acting",
|
||||
"Read my_rails. Raise a decision for the action and its permitted arbiter; hard waits go to a person. Use blocking false when there is no task."
|
||||
);
|
||||
group(
|
||||
"decision-route-refused",
|
||||
"Role messaging: decision close route",
|
||||
"Ask the owner to inspect the raiser and resolver messaging policy; do not route around the refused close path."
|
||||
);
|
||||
group(
|
||||
"definition-not-hireable instance-kept launch-not-configured no-chief-of-staff organization-busy organization-definition-refused organization-instance-referenced organization-instance-retired role-end-refused staff-request-refused",
|
||||
"Role configuration and protected instance controls",
|
||||
"Read the permitted configuration/status and ask the owner or configured chief to repair it through the supported path. Do not edit policy to bypass the refusal."
|
||||
);
|
||||
group(
|
||||
"field-writer task-already-archived task-archived task-conflict task-create-conflict task-create-uncertain task-not-archived task-not-found task-position-full task-project-refused task-scope-required task-subject-required",
|
||||
"Role task ownership, allowed fields and current task state",
|
||||
"Read the current task and assigned role. Use the permitted task operation with current evidence; ask the PM for scope or assignment changes through the decision path."
|
||||
);
|
||||
group(
|
||||
"task-required",
|
||||
"Role decisions: a blocking decision belongs to a task",
|
||||
"Supply the task_ref for this work, or raise with blocking false when there is no task."
|
||||
);
|
||||
group(
|
||||
"fork-foreign gitea-admin-unavailable gitea-failed gitea-not-configured gitea-not-declared host-busy host-changed host-creative-role host-enrolled host-failed host-in-use host-key-mismatch host-line-left host-not-configured host-not-declared host-not-enrolled host-not-granted host-not-ready host-remove-failed host-unknown publish-busy publish-unknown",
|
||||
"Role access: declared service, host or remote and verified outcome",
|
||||
"Check the supported access/status read and ask the owner to repair the declared service or grant. If an effect is uncertain, verify its receipt before retrying."
|
||||
);
|
||||
group(
|
||||
"human-required",
|
||||
"R4/R8: person-only operation",
|
||||
"A person must perform or approve this operation through its supported human path; an agent must not retry as a person."
|
||||
);
|
||||
group(
|
||||
"land-unknown no-push not-a-reviewer ref-moved self-review stale-commit verdict-exists verdict-not-approve verdict-not-found verdict-stale work-not-yours work-ref",
|
||||
"Role review and landing: independent verdict on the current work",
|
||||
"Read the latest work and review receipts. Obtain the required independent review or matching merge decision for the current commit before retrying."
|
||||
);
|
||||
group(
|
||||
"library-fetch-no-grant library-not-configured library-notice-store-failed library-write-no-grant library-write-refused web-failed web-not-configured web-not-granted",
|
||||
"Role tools: granted and configured web/library access",
|
||||
"Read my_rails and the permitted service status. Use only granted tools; ask the owner to repair unavailable service configuration."
|
||||
);
|
||||
group(
|
||||
"message-not-found no-subject not-assistant operation-not-found operation-not-open",
|
||||
"R7/role scope: visible object and allowed operation",
|
||||
"Use the caller's permitted reads to locate current context, then retry only the supported operation. Ask the owner if it remains unavailable."
|
||||
);
|
||||
group(
|
||||
"not-authorized",
|
||||
"Role authority: held action or approved decision",
|
||||
"Read my_rails and use the applicable decision path; ask the owner to inspect grants if no supported path exists."
|
||||
);
|
||||
group(
|
||||
"not-configured project-already-archived project-archived project-exists project-not-archived project-not-configured project-not-found project-record-invalid project-remote-configured project-remote-invalid project-remote-library project-remote-protected project-remote-unknown project-spec-human-only system-type-not-enabled tracker-not-store usage-not-configured",
|
||||
"R3/role scope: configured project and service state",
|
||||
"Read the supported project/service status and ask its owner to repair missing configuration or state before retrying."
|
||||
);
|
||||
group(
|
||||
"not-resolver",
|
||||
"Role resolve right and decision route",
|
||||
"Only the routed resolver holding the matching decision.resolve right may resolve. Read my_rails and notify that resolver."
|
||||
);
|
||||
group(
|
||||
"prd-arbiter-unavailable prd-authority-refused prd-changes-requested prd-decision-closed prd-not-approved prd-self-approval request-access-refused request-closed request-evidence-required request-expired request-failed request-not-configured request-not-found request-not-taken request-origin-refused request-project-refused request-revision-conflict request-selection-refused request-tracker-refused request-verification-refused requirement-snapshot-refused requirement-snapshot-required requirement-snapshot-revoked spec-unconfirmed stale-spec",
|
||||
"Role workflow: current admitted request and approved requirements",
|
||||
"Read the current request, requirements and decision state. Ask the responsible PM or routed arbiter to supply the missing current evidence before continuing."
|
||||
);
|
||||
group(
|
||||
"processing-audit-conflict processing-control-unsafe processing-uninitialized processing-generation-changed processing-halted processing-state-invalid storage-refused storage-unavailable",
|
||||
"R2/R3: available audited storage and processing state",
|
||||
"Stop the refused operation and report the processing or storage state to the owner. Resume only after the supported status shows the condition is repaired."
|
||||
);
|
||||
group(
|
||||
"read-only",
|
||||
"Role/session: read-only capability",
|
||||
"Use the permitted reads. Ask the owner or an authorized writer to perform the change."
|
||||
);
|
||||
group(
|
||||
"reserved-event",
|
||||
"R2: trusted append-only evidence",
|
||||
"Use the typed operation that records its own evidence; never emit a reserved event directly."
|
||||
);
|
||||
group(
|
||||
"role-not-chief",
|
||||
"R9: only the configured chief manages hires",
|
||||
"Send the staffing need to the configured chief through an allowed message path."
|
||||
);
|
||||
group(
|
||||
"unauthenticated",
|
||||
"R7: authenticated caller identity",
|
||||
"Ask the launcher to restore the caller's own authenticated session; never borrow another capability."
|
||||
);
|
||||
group(
|
||||
"request-timeout request-too-large incomplete-request invalid-response response-too-large unknown-tool tool-failed capacity-full",
|
||||
"R3/role tools: bounded protocol and available capacity",
|
||||
"Check the tool schema and narrow oversized requests. For a timeout, inspect status and receipts before retrying; never assume an uncertain operation did not happen."
|
||||
);
|
||||
// Finite error vocabularies forwarded by the project's, library's and request's
|
||||
// typed adapters are part of the same table, not an unhelpful unknown-code path.
|
||||
group(
|
||||
"project-busy library-sync-busy",
|
||||
"Role storage: exclusive write or sync already in progress",
|
||||
"Wait for the current operation to finish, read its status, then retry once the lock is clear."
|
||||
);
|
||||
group(
|
||||
"stale-base library-accept-stale",
|
||||
"R2/R3: current file or served revision",
|
||||
"Read the latest file or library status and its current revision. Reconcile the intended change before retrying with that base; never overwrite a newer change blindly."
|
||||
);
|
||||
group(
|
||||
"citation-cross-universe citation-invalid citation-missing citation-unavailable citation-unfetched citations-over-limit playground-cited-as-canon reply-invalid",
|
||||
"Role replies: validated citations, provenance and scope",
|
||||
"Use permitted library, spec or web reads and supply a valid reply with current citations in the allowed universe and scope. Narrow oversized citation lists; never invent provenance."
|
||||
);
|
||||
group(
|
||||
"invalid-request-admission invalid-request-effect invalid-request-event invalid-request-plan invalid-request-projects artifact-path-invalid artifact-type-refused",
|
||||
"R3: typed request plan, delivery and evidence schema",
|
||||
"Check the current request's tool schema and correct the plan, artifact path or evidence. Delivery requires the supported self-contained HTML artifact and matching verification evidence."
|
||||
);
|
||||
group(
|
||||
"prd-admission-revoked prd-author-invalid prd-candidate-invalid prd-candidate-required prd-candidate-size prd-candidate-stale prd-requirement-invalid",
|
||||
"Role PRD workflow: current candidate, authors and admission",
|
||||
"Read the current request, candidate, requirements, mode and approval. Ask the PM to correct or submit the current candidate through the supported workflow; never approve your own authorship."
|
||||
);
|
||||
group(
|
||||
"request-question-open request-question-closed request-question-mismatch request-phase-refused request-attachment-mismatch request-delivery-required request-document-refused",
|
||||
"Role request workflow: current phase, question and selected attachment",
|
||||
"Read intake_status and follow its current question, wait or delivery step. A pending question needs its answer before another question; use only the operation and attachment valid for the current phase."
|
||||
);
|
||||
group(
|
||||
"request-effect-conflict request-effect-unprepared request-mode-revision-conflict request-operation-conflict request-plan-binding-refused request-plan-path-collision request-plan-path-refused request-project-collision",
|
||||
"R2/R3: current request revision and bound operation",
|
||||
"Read the current request status, plan and operation receipts. Recover an existing operation before retrying; use a new operation ID only for a new intended operation, and never bypass a path or project collision."
|
||||
);
|
||||
group(
|
||||
"request-notification-unavailable request-services-unavailable library-credential-missing library-human-missing library-mapping-invalid library-problem-code library-remote-invalid library-state-invalid library-diverged library-accept-invalid",
|
||||
"R1/R3: owner-configured service, mapping and accepted library state",
|
||||
"Read the supported service or library status and ask the owner to repair the missing binding, mapping or accepted state. Check existing operation receipts before retrying an uncertain effect."
|
||||
);
|
||||
group(
|
||||
"library-commit library-git library-push-failed project-commit-uncertain project-init-cleanup",
|
||||
"R2/R3: verified repository effect and recovery",
|
||||
"Read current repository status and receipts to determine what already happened. Report an uncertain commit, push or cleanup to the owner before any new write; do not retry an uncertain effect blindly."
|
||||
);
|
||||
group(
|
||||
"project-author project-dirty project-git project-id project-invalid project-no-commit project-not-declared project-not-empty project-path project-read-commit project-read-content project-read-missing project-read-path project-record-path project-records-conflict project-records-missing project-remote project-repository tester-project-area",
|
||||
"Role project scope: declared repository, clean state and allowed paths",
|
||||
"Read the permitted project status and file metadata. Correct the declared project or allowed path when possible; ask the owner to repair repository state or identity. Preserve existing changes and never bypass repository or path checks."
|
||||
);
|
||||
group(
|
||||
"project-record-unchanged",
|
||||
"R2: no new record effect for unchanged content",
|
||||
"Read the current record and its existing commit receipt. If it already matches the intended content, use that evidence instead of retrying the same write."
|
||||
);
|
||||
group(
|
||||
"library-write-story-path library-revert-not-agent",
|
||||
"Role library writes: supporting paths and permitted agent-owned commits",
|
||||
"Use only the supporting paths and agent-owned commit reversals permitted by the library tool. Preserve story and canon files; ask the owner when the requested edit is outside that scope."
|
||||
);
|
||||
group(
|
||||
"query-argument-invalid query-chapter-ambiguous query-item-too-large query-universe-unknown",
|
||||
"Role library reads: known universe, unambiguous selection and bounded result",
|
||||
"Read the permitted library index, choose a known universe and an unambiguous book or chapter, and narrow the query or requested range before retrying."
|
||||
);
|
||||
export const REFUSALS = Object.freeze(Object.fromEntries(entries));
|
||||
const UNKNOWN = Object.freeze({ rail: "R3: fail closed", nextStep: "Report the refusal code to the responsible owner and inspect supported status; do not bypass the refusal." });
|
||||
export const refusalGuidance = code => Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : UNKNOWN;
|
||||
@@ -35,7 +35,7 @@ import { instanceSettings } from '../../organization/src/instance-settings.mjs';
|
||||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
|
||||
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
||||
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { loadRole } from '../../organization/src/role.mjs';
|
||||
import { resolveInstance } from '../../organization/src/resolve.mjs';
|
||||
import { canonicalJson } from '../../organization/src/util.mjs';
|
||||
@@ -284,7 +284,14 @@ export async function startBroker({
|
||||
builtinBinding = null,
|
||||
modelCatalogue = null, // in-process fixture seam; boot JSON cannot supply a service
|
||||
makeProviderLoader = createLoader, // in-process fixture seam; boot JSON cannot supply a function
|
||||
// #1873 A3: the boot's root and project agent copies (cli config.mjs copiesAtBoot), plain data. Every
|
||||
// reload of the organization file below re-applies this snapshot, never the root file.
|
||||
copies = null,
|
||||
}) {
|
||||
if (copies !== null && (typeof copies !== 'object' || Array.isArray(copies) || typeof copies.org !== 'string'
|
||||
|| !Object.hasOwn(businesses ?? {}, copies.org) || copies.copies === null || typeof copies.copies !== 'object' || Array.isArray(copies.copies)))
|
||||
throw new BusError('invalid-host-request');
|
||||
const loadOrg = id => loadWithCopies(id, { dir: organization.dir, rolesDir: organization.rolesDir, snapshot: copies?.org === id ? copies : null });
|
||||
if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) ||
|
||||
Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512)))
|
||||
throw new BusError('invalid-host-request');
|
||||
@@ -564,7 +571,7 @@ export async function startBroker({
|
||||
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
|
||||
function reload(id) {
|
||||
try {
|
||||
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const b = loadOrg(id);
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
|
||||
const next = withoutPending(id, busOrganization(b, resolved));
|
||||
@@ -595,7 +602,7 @@ export async function startBroker({
|
||||
if (!found || found.after !== organization_revision) refuse('bootstrap-receipt-mismatch');
|
||||
// Only this bootstrap's own post-add revision may be admitted: any later organization write means the confirmed world moved (#800).
|
||||
if (revision !== found.after) refuse('bootstrap-revision-stale');
|
||||
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const b = loadOrg(id);
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
|
||||
const full = busOrganization(b, resolved);
|
||||
@@ -622,7 +629,11 @@ export async function startBroker({
|
||||
// product the broker holds is ready only while it has a complete marker and a committed activate receipt: the
|
||||
// answer is read from both on every call, so a marker removed after the activation closes it again, and the
|
||||
// broker's reload now follows both readiness records (#802).
|
||||
function admission({ business: id, project, projects, thread, session }) {
|
||||
// With `instance` (#1873 A3), the launch boundary also asks whether that instance may work in each
|
||||
// project as the business stands now: a project that excludes it, or whose union refuses, is not ready.
|
||||
// `instance` comes with `revision`, the organization file revision the launch resolved its bundle
|
||||
// from; a file that has changed since (a narrow, a project define's vars, an exclude) is not ready.
|
||||
function admission({ business: id, project, projects, instance, revision: launched, thread, session }) {
|
||||
if (projects !== undefined) {
|
||||
const no = code => ({ ready: false, revision: null, code });
|
||||
if (typeof id !== 'string' || !Object.hasOwn(businesses, id) || !Array.isArray(projects) || projects.some(p => typeof p !== 'string')) return no('project-unknown');
|
||||
@@ -631,9 +642,21 @@ export async function startBroker({
|
||||
if (!row) return no('session-not-found');
|
||||
if (row.archived) return no('project-archived');
|
||||
}
|
||||
if (instance !== undefined && (typeof instance !== 'string' || typeof launched !== 'string')) return no('project-unknown');
|
||||
if (instance !== undefined) {
|
||||
let now;
|
||||
try { now = writer?.read(id).revision; } catch { return no('organization-unreadable'); }
|
||||
if (now === undefined) return no('organization-unreadable');
|
||||
if (now !== launched) return no('organization-changed');
|
||||
}
|
||||
for (const p of projects) {
|
||||
const result = admission({ business: id, project: p });
|
||||
if (!result.ready) return result;
|
||||
if (instance !== undefined) {
|
||||
// The broker holds the document the last writer reload swapped in; `businesses` is the boot one.
|
||||
const barred = broker.projectBarred(id, instance, p);
|
||||
if (barred) return no(barred);
|
||||
}
|
||||
}
|
||||
return { ready: true, revision: null, code: null };
|
||||
}
|
||||
@@ -753,7 +776,7 @@ export async function startBroker({
|
||||
// plugins.set reloads the business's limit checks in the background.
|
||||
organization:settings?(proxyOrganization?.wrap ?? (operation => operation))((id,verb,args,actor,context)=>{
|
||||
if (verb === 'project.remote.get.console' || verb === 'project.remote.set.console') {
|
||||
const current = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const current = loadOrg(id);
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(current.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: current, instance });
|
||||
const live = withoutPending(id, busOrganization(current, resolved));
|
||||
|
||||
@@ -9,11 +9,12 @@ const LIMIT = 65536;
|
||||
const PROJECT_SAVE_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
|
||||
const LIBRARY_SAVE_AT_START = /"verb":"library\.file\.save"/;
|
||||
const SAVE_VERB_AT_START = { test: text => PROJECT_SAVE_AT_START.test(text) || LIBRARY_SAVE_AT_START.test(text) };
|
||||
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
|
||||
// only explicitly scoped refusals do, so every other envelope is byte-identical to before.
|
||||
const refusal = (e, other) => e instanceof BusError
|
||||
? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) }
|
||||
: { ok: false, error: other };
|
||||
// Preserve every code and scoped reason, adding only static rail/next-step guidance.
|
||||
const refusal = (e, other) => {
|
||||
const error = e instanceof BusError ? e : new BusError(other);
|
||||
return { ok: false, error: error.code, rail: error.rail, nextStep: error.nextStep,
|
||||
...(typeof error.reason === 'string' ? { reason: error.reason } : {}) };
|
||||
};
|
||||
// One request per connection. There is deliberately no reconnect/retry or SQL verb.
|
||||
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
|
||||
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
|
||||
@@ -48,16 +49,16 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
socket.end(JSON.stringify(result) + '\n');
|
||||
};
|
||||
socket.setTimeout(timeout, () => {
|
||||
finish({ ok: false, error: 'request-timeout' });
|
||||
finish(refusal(new BusError('request-timeout')));
|
||||
socket.destroySoon();
|
||||
});
|
||||
socket.on('data', async (chunk) => {
|
||||
if (done) return;
|
||||
if (processing) return finish({ ok: false, error: 'invalid-envelope' });
|
||||
if (processing) return finish(refusal(new BusError('invalid-envelope')));
|
||||
const total = input.length + chunk.length;
|
||||
if (total > LIMIT && !(total <= PROJECT_SAVE_REQUEST_MAX &&
|
||||
SAVE_VERB_AT_START.test(Buffer.concat([input, chunk]).subarray(0, 1024).toString('latin1')))) {
|
||||
finish({ ok: false, error: 'request-too-large' });
|
||||
finish(refusal(new BusError('request-too-large')));
|
||||
return;
|
||||
}
|
||||
input = Buffer.concat([input, chunk]);
|
||||
@@ -187,7 +188,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
}
|
||||
});
|
||||
socket.on('end', () => {
|
||||
if (!done && !processing) finish({ ok: false, error: 'incomplete-request' });
|
||||
if (!done && !processing) finish(refusal(new BusError('incomplete-request')));
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// #1873 Phase A slice A3: the broker half of root and project agent copies. The business carries the
|
||||
// boot's `copies` view (organization agent-copies.mjs withCopies): a bound copy is also in `roles`,
|
||||
// an unbound or refused one is not. The cli half, with real runners, is cli agent-copies.test.mjs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { BusError, Broker } from '../src/broker.mjs';
|
||||
|
||||
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
|
||||
const copy = (name, definition, project, state, extra = {}) => ({ name, level: project === null ? 'root' : 'project', project, definition, state, ...extra });
|
||||
const demo = () => ({
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' },
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, companion: { messaging: shipped('companion').messaging } },
|
||||
projects: { stack: { root: '/nonexistent/stack' }, web: { root: '/nonexistent/web' }, docs: { root: '/nonexistent/docs' } },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', ...none },
|
||||
pm: { definition: 'pm', ...none },
|
||||
assistant: { definition: 'assistant', ...none },
|
||||
gate: { definition: 'merge-gate', ...none },
|
||||
'companion.stack': { definition: 'companion', ...none },
|
||||
buddy: { definition: 'companion', ...none },
|
||||
},
|
||||
copies: {
|
||||
gate: copy('gate', 'merge-gate', null, 'instance'),
|
||||
buddy: copy('buddy', 'companion', null, 'instance'),
|
||||
'companion.stack': copy('companion', 'companion', 'stack', 'instance'),
|
||||
researcher: copy('researcher', 'research', null, 'unbound', { missing: ['gitea'] }),
|
||||
wide: copy('wide', 'merge-gate', null, 'refused', { reason: 'model <value> is not in launch.models' }),
|
||||
'lint.web': copy('lint', 'security-reviewer', 'web', 'refused', { reason: 'project web refuses its agents: ghost is excluded, but nothing defines it' }),
|
||||
},
|
||||
// Each code project's union as withCopies computes it: web excludes buddy, docs refuses.
|
||||
unions: {
|
||||
stack: { ok: true, agents: ['assistant', 'buddy', 'chief', 'companion.stack', 'gate', 'pm'] },
|
||||
web: { ok: true, agents: ['assistant', 'chief', 'gate', 'pm'] },
|
||||
docs: { ok: false, reason: 'projects.docs.agents.ghost: names no root or organization agent to exclude' },
|
||||
},
|
||||
});
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-copies-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const b = new Broker({ store, businesses: { demo: demo() } });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (verb, args = {}) => b.request(human, { verb, args });
|
||||
const refusal = (fn) => {
|
||||
try { fn(); } catch (e) { assert.ok(e instanceof BusError, String(e)); return e; }
|
||||
assert.fail('no refusal');
|
||||
};
|
||||
const trail = () => store.all('SELECT kind, body FROM events').map((e) => `${e.kind} ${e.body}`).join('\n');
|
||||
return { b, store, call, refusal, trail };
|
||||
}
|
||||
|
||||
test('A3 bus: a bound copy takes a launch request; an unbound or refused one refuses at the verb by name, with the code alone in the trail', (t) => {
|
||||
const f = setup(t);
|
||||
assert.match(f.call('instance.launch', { instance: 'gate' }).request, /\S/);
|
||||
const unbound = f.refusal(() => f.call('instance.launch', { instance: 'researcher' }));
|
||||
assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']);
|
||||
const refused = f.refusal(() => f.call('instance.launch', { instance: 'wide' }));
|
||||
assert.equal(refused.code, 'copy-refused');
|
||||
assert.match(refused.reason, /^wide can't launch in demo: /);
|
||||
const project = f.refusal(() => f.call('instance.launch', { instance: 'lint.web' }));
|
||||
assert.equal(project.code, 'project-refused');
|
||||
assert.match(project.reason, /^lint\.web can't launch in demo: project web refuses its agents: /);
|
||||
// Regenerate takes the same check.
|
||||
assert.equal(f.refusal(() => f.call('instance.regenerate', { instance: 'researcher' })).code, 'credential-unbound');
|
||||
// A name that is no copy and no instance is still unknown.
|
||||
assert.equal(f.refusal(() => f.call('instance.launch', { instance: 'nobody' })).code, 'unknown-role');
|
||||
// The trail holds each refusal's code, never its reason.
|
||||
const trail = f.trail();
|
||||
assert.match(trail, /credential-unbound/);
|
||||
for (const text of ['has no binding', "can't launch", 'refuses its agents', SECRET]) assert.equal(trail.includes(text), false, text);
|
||||
});
|
||||
|
||||
test('A3 bus: a project agent takes a thread only in its own project, and only when named', (t) => {
|
||||
const f = setup(t);
|
||||
const stack = f.call('project.create', { name: 'stack work', repo: 'stack' }).id;
|
||||
const web = f.call('project.create', { name: 'web work', repo: 'web' }).id;
|
||||
const loose = f.call('project.create', { name: 'loose' }).id;
|
||||
// Unnamed, the organization file's own thread instance is still the one choice.
|
||||
const plain = f.call('thread.create', { project: stack, name: 'plain' }).id;
|
||||
assert.equal(f.store.get('SELECT instance FROM threads WHERE id=?', plain).instance, 'assistant');
|
||||
const own = f.call('thread.create', { project: stack, name: 'own', instance: 'companion.stack' });
|
||||
assert.match(own.id, /\S/);
|
||||
for (const project of [web, loose])
|
||||
assert.equal(f.refusal(() => f.call('thread.create', { project, name: 'elsewhere', instance: 'companion.stack' })).code, 'project-agent-elsewhere');
|
||||
// A named copy the boot refused says why, before the thread instance check.
|
||||
const unbound = f.refusal(() => f.call('thread.create', { project: stack, name: 'r', instance: 'researcher' }));
|
||||
assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']);
|
||||
});
|
||||
|
||||
test('A3 bus: a room holds only the thread instances that work in its project; an excluded agent takes no thread there; a refused union takes none at all, naming the project', (t) => {
|
||||
const f = setup(t);
|
||||
const project = (repo) => f.call('project.create', { name: `${repo ?? 'loose'} work`, ...(repo ? { repo } : {}) }).id;
|
||||
const [stack, web, docs, loose] = [project('stack'), project('web'), project('docs'), project(null)];
|
||||
const members = (id) => f.call('room.open', { project: id }).threads.map((x) => x.instance).sort();
|
||||
// Another project's agent never joins, and the project's own exclusion holds.
|
||||
assert.deepEqual(members(stack), ['assistant', 'buddy', 'companion.stack']);
|
||||
assert.deepEqual(members(web), ['assistant']);
|
||||
assert.deepEqual(members(loose), ['assistant', 'buddy']);
|
||||
assert.deepEqual(f.call('room.send', { project: web, body: 'hello' }).to, ['assistant']);
|
||||
const excluded = f.refusal(() => f.call('thread.create', { project: web, name: 'b', instance: 'buddy' }));
|
||||
assert.deepEqual([excluded.code, excluded.reason], ['project-refused', "buddy can't work in project web: the project excludes it"]);
|
||||
// docs's union refuses: no thread, named or not, and no room, each naming docs and why.
|
||||
for (const fn of [
|
||||
() => f.call('thread.create', { project: docs, name: 'plain' }),
|
||||
() => f.call('thread.create', { project: docs, name: 'a', instance: 'assistant' }),
|
||||
() => f.call('room.open', { project: docs }),
|
||||
]) {
|
||||
const e = f.refusal(fn);
|
||||
assert.equal(e.code, 'project-refused');
|
||||
assert.match(e.reason, /^project docs refuses its agents: .*ghost/);
|
||||
}
|
||||
assert.equal(f.trail().includes('refuses its agents'), false, 'the trail holds the code only');
|
||||
});
|
||||
@@ -37,7 +37,9 @@ const baselines = {
|
||||
// #1839: artifact Console replies alone may use 2 MiB; the proof and other limits are unchanged.
|
||||
// #1873 U1: humanCall, busCaller, userCall and userCaller take an optional signal and absolute
|
||||
// deadline; userCall shares transport(). Nonces, argv and limits are unchanged.
|
||||
"packages/webui/src/bus.mjs": "3ef8064dd7911f9eee4475b52c74a1d551583a065474b1bcee968cd45f722747",
|
||||
// #1873 A3: STATUS maps credential-unbound, copy-refused, project-refused and project-agent-elsewhere
|
||||
// to 409; no proof or transport change.
|
||||
"packages/webui/src/bus.mjs": "d8f3e0eb5299fdb192e98a1238ca693aae7c36af9a21de72d5f5d39610589377",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { connect } from 'node:net';
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { FIXED_TEXTS, GuardError } from '../../library/src/guard.mjs';
|
||||
import { git } from '../../library/tests/helpers.mjs';
|
||||
@@ -138,7 +139,7 @@ test('C2-06 page store is session-local, caps 20 fetches and 200 KiB, drops at d
|
||||
await assert.rejects(f.call(replacement, 'message.send', { to: 'human', body: citedBody([{ url: 'https://fixture.invalid/21', quote: 'xxxx' }]) }), { code: 'citation-unfetched' });
|
||||
});
|
||||
|
||||
test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope is byte-identical', async t => {
|
||||
test('C2-06 transport passes safe capped guard reason; ordinary refusal retains its code and adds static rail guidance', async t => {
|
||||
const f = citationWorld(t, { guard: async () => { throw new GuardError('citation-invalid', 'guard reason '.repeat(50)); } });
|
||||
f.call(f.agent, 'role.claim'); const path = join(f.root, 'socket');
|
||||
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
|
||||
@@ -148,7 +149,7 @@ test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope
|
||||
const socket = connect(path); let text = ''; socket.on('error', reject); socket.on('data', b => { text += b; }); socket.on('end', () => resolve(text));
|
||||
socket.on('connect', () => socket.write(JSON.stringify({ cap: f.agent, verb: 'unknown-fixture' }) + '\n'));
|
||||
});
|
||||
assert.equal(raw, '{"ok":false,"error":"unknown-verb"}\n');
|
||||
assert.deepEqual(JSON.parse(raw), { ok: false, error: 'unknown-verb', ...refusalGuidance('unknown-verb') });
|
||||
});
|
||||
|
||||
test('C2-06 non-library businesses retain role.claim shape and unguarded free sends', async t => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Row 288: broker authority, exact notice and strict Console revert API. Fixture grant rows only.
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
@@ -180,7 +181,7 @@ test('C2-02 socket: a save name nested in proof cannot enlarge an agent envelope
|
||||
socket.on('error', reject); socket.on('connect', () => socket.write(request));
|
||||
socket.on('data', bytes => { result += bytes; if (result.includes('\n')) { socket.destroy(); resolve(JSON.parse(result)); } });
|
||||
});
|
||||
assert.deepEqual(response, { ok: false, error: 'request-too-large' });
|
||||
assert.deepEqual(response, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
assert.equal(f.seen.length, 0);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
||||
import { fixture, git } from './spec-fixtures.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import { PROJECT_FILE_VERBS } from '../src/broker.mjs';
|
||||
// Row 148 (#1642): the human-only verbs behind the Console's spec and docs editor.
|
||||
const refusals = f => f.store.all("SELECT body FROM events WHERE kind='action.refused'").map(r => JSON.parse(r.body).code);
|
||||
@@ -109,11 +110,11 @@ test('a request that names the save verb first and another verb last is refused
|
||||
const f = await socketFixture(t);
|
||||
const pad = 'x'.repeat(100000);
|
||||
const sneaky = `{"human":{"fixture":true},"verb":"project.file.save","verb":"agents","args":{"pad":"${pad}"}}\n`;
|
||||
assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large' });
|
||||
assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
const unmarked = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'agents', args: { pad } }) + '\n');
|
||||
assert.deepEqual(unmarked, { ok: false, error: 'request-too-large' });
|
||||
assert.deepEqual(unmarked, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
const over = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'project.file.save', args: { pad: 'x'.repeat(1024 * 1024 + 80000) } }) + '\n');
|
||||
assert.deepEqual(over, { ok: false, error: 'request-too-large' });
|
||||
assert.deepEqual(over, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
|
||||
});
|
||||
|
||||
test('two saves racing for one repository make one commit', async t => {
|
||||
|
||||
@@ -13,6 +13,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
|
||||
// Rows330/331: pure verb/refusal vocabulary, never the host management implementation.
|
||||
"import { ownRails } from '../../organization/src/rails.mjs';",
|
||||
"import { refusalGuidance } from './refusals.mjs';",
|
||||
"import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';",
|
||||
// Row 210 (#1707): createHash gives host.run events the argv, stdin and output sha256 values.
|
||||
"import { randomUUID, randomBytes, createHash } from 'node:crypto';",
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { connect } from 'node:net';
|
||||
import { PROXY_PAGE_CODES } from '../src/proxy-page-vocabulary.mjs';
|
||||
import { Broker, BusError } from '../src/broker.mjs';
|
||||
import { REFUSALS, refusalGuidance } from '../src/refusals.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { busOrganization } from '../src/organization-context.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { resolveInstance, validateBusinessDocument } from '../../organization/src/index.mjs';
|
||||
import { businessDoc, REPO_ROLES, systemFor } from '../../organization/tests/helpers.mjs';
|
||||
import { railsText } from '../../organization/src/rails.mjs';
|
||||
import { GUARD_CODES } from '../../library/src/reply-protocol.mjs';
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'rails-'));
|
||||
const business = validateBusinessDocument(businessDoc(root), join(root, 'acme.json'), { rolesDir: REPO_ROLES });
|
||||
const resolve = b => Object.fromEntries(Object.keys(b.roles).map(instance => [instance, resolveInstance({ system: systemFor(root), business: b, instance })]));
|
||||
writeFileSync(business.roles.coder.credentials.gitea.file, SECRET);
|
||||
const resolved = resolve(business), store = new Store(root);
|
||||
const broker = new Broker({ store, businesses: { acme: busOrganization(business, resolved) } });
|
||||
const cap = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'coder-run', harness: 'pi' });
|
||||
broker.request(cap, { verb: 'role.claim' });
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
return { root, business, resolved, resolve, broker, store, cap };
|
||||
}
|
||||
|
||||
test('my_rails equals the resolved prompt content, reads live narrowing, and writes/raises nothing', t => {
|
||||
const f = fixture(t), call = args => f.broker.request(f.cap, { verb: 'my_rails', args });
|
||||
const before = f.store.get('SELECT count(*) n FROM events').n;
|
||||
assert.deepEqual(call({}), f.resolved.coder.rails);
|
||||
const result = call({}); result.actions.length = 0;
|
||||
assert.notEqual(call({}).actions.length, 0, 'returned rails do not mutate policy');
|
||||
const business = structuredClone(f.business);
|
||||
business.roles.coder.vars['limits.authority'] = ['message.send'];
|
||||
const narrowed = f.resolve(business);
|
||||
f.broker.reloadBusiness('acme', busOrganization(business, narrowed));
|
||||
assert.deepEqual(call({}), narrowed.coder.rails);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM events').n, before);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
|
||||
});
|
||||
|
||||
test('my_rails cannot select another identity or expose credential refs and file/call values', t => {
|
||||
const f = fixture(t);
|
||||
for (const args of [{ agent: 'cto' }, { role: 'cto' }, { instance: 'cto' }, { business: 'other' }, { token: SECRET }]) {
|
||||
assert.throws(() => f.broker.request(f.cap, { verb: 'my_rails', args }), { code: 'invalid-request' });
|
||||
}
|
||||
const rails = f.broker.request(f.cap, { verb: 'my_rails' });
|
||||
const text = JSON.stringify(rails) + railsText(rails);
|
||||
for (const value of [SECRET, 'placeholder-not-a-token', f.resolved.coder.credentials.gitea.file, f.resolved.cto.credentials.gitea.file]) assert.ok(!text.includes(value), value);
|
||||
const human = f.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
assert.throws(() => f.broker.request(human, { verb: 'my_rails' }), { code: 'agent-required' });
|
||||
});
|
||||
|
||||
function raw(path, request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect(path); let out = '';
|
||||
socket.on('error', reject);
|
||||
socket.on('connect', () => socket.end(JSON.stringify(request) + '\n'));
|
||||
socket.on('data', b => out += b);
|
||||
socket.on('end', () => resolve(JSON.parse(out)));
|
||||
});
|
||||
}
|
||||
test('cross-role refusal envelope names its rail and next step without changing code or outcome', async t => {
|
||||
const f = fixture(t), path = join(f.root, 'bus.sock');
|
||||
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
|
||||
const request = { cap: f.cap, verb: 'decision.resolve', args: { id: 'missing', choice: SECRET } };
|
||||
const refused = await raw(path, request);
|
||||
assert.equal(refused.ok, false); assert.equal(refused.error, 'decision-not-found');
|
||||
assert.deepEqual({ rail: refused.rail, nextStep: refused.nextStep }, REFUSALS['decision-not-found']);
|
||||
assert.ok(!JSON.stringify(refused).includes(SECRET));
|
||||
await assert.rejects(new Client({ path, cap: f.cap }).call('decision.resolve', request.args), e => {
|
||||
assert.equal(e.code, 'decision-not-found'); assert.equal(e.nextStep, refused.nextStep); return true;
|
||||
});
|
||||
const decision = f.broker.request(f.cap, { verb: 'decision.raise', args: { action: 'task.scope.change', domain: 'delivery',
|
||||
question: 'Scope?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'no', blocking: false } });
|
||||
const cross = await raw(path, { cap: f.cap, verb: 'decision.resolve', args: { id: decision.id, choice: 'yes' } });
|
||||
assert.equal(cross.error, 'not-resolver'); assert.match(cross.rail, /resolve right/); assert.match(cross.nextStep, /routed resolver/);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', decision.id).n, 0);
|
||||
// Exercise an actual cross-role action without its required decision, not just resolve.
|
||||
const live = busOrganization(f.business, f.resolved);
|
||||
live.roles.coder.authority.crossRole.push('role.end');
|
||||
f.broker.reloadBusiness('acme', live);
|
||||
const decisions = f.store.get('SELECT count(*) n FROM decisions').n;
|
||||
const required = await raw(path, { cap: f.cap, verb: 'role.revoke', args: { role: 'reviewer', reason: SECRET } });
|
||||
assert.equal(required.ok, false); assert.equal(required.error, 'decision-required');
|
||||
assert.deepEqual({ rail: required.rail, nextStep: required.nextStep }, REFUSALS['decision-required']);
|
||||
assert.ok(!JSON.stringify(required).includes(SECRET));
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, decisions);
|
||||
});
|
||||
|
||||
test('every literal fail branch in broker has static guidance; guidance never contains payload values', () => {
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
// Balance each call (including ternaries), so a following statement cannot masquerade
|
||||
// as a fail code. Every literal branch, with either quote style, must be in the table.
|
||||
for (const match of source.matchAll(/\bfail\s*\(/g)) {
|
||||
let depth = 1, i = match.index + match[0].length;
|
||||
while (depth && i < source.length) {
|
||||
const char = source[i++];
|
||||
if (char === "'" || char === '"') {
|
||||
const start = i - 1;
|
||||
let value = '';
|
||||
while (i < source.length && source[i] !== char) {
|
||||
if (source[i] === "\\") { i += 2; continue; }
|
||||
value += source[i++];
|
||||
}
|
||||
i++;
|
||||
if (/^[a-z][a-z0-9-]*$/.test(value) && (value.includes('-') || !source.slice(match.index + match[0].length, start).trim())) assert.ok(Object.hasOwn(REFUSALS, value), value);
|
||||
} else if (char === '(') depth++;
|
||||
else if (char === ')') depth--;
|
||||
}
|
||||
assert.equal(depth, 0, 'balanced fail call');
|
||||
}
|
||||
// Finite delegated fail(code) sources must be covered as well as literal branches.
|
||||
for (const code of [...PROXY_PAGE_CODES, ...GUARD_CODES, 'project-busy', 'remote-host-mismatch', 'remote-not-fork',
|
||||
'processing-control-unsafe', 'processing-uninitialized', 'processing-generation-changed']) {
|
||||
assert.ok(Object.hasOwn(REFUSALS, code), code);
|
||||
}
|
||||
for (const [code, guidance] of Object.entries(REFUSALS)) {
|
||||
assert.ok(guidance.rail.length > 0 && guidance.nextStep.length > 0, code);
|
||||
assert.deepEqual({ rail: new BusError(code).rail, nextStep: new BusError(code).nextStep }, guidance);
|
||||
assert.ok(!JSON.stringify(guidance).includes(SECRET));
|
||||
}
|
||||
assert.deepEqual(refusalGuidance(SECRET), refusalGuidance('unknown-external-code'));
|
||||
});
|
||||
|
||||
test('finite forwarded project, library and request errors have static next steps', () => {
|
||||
const modules = [
|
||||
...['index', 'git', 'paths'].map(name => new URL(`../../project/src/${name}.mjs`, import.meta.url)),
|
||||
...readdirSync(new URL('../../library/src/', import.meta.url)).filter(f => f.endsWith('.mjs'))
|
||||
.map(name => new URL(`../../library/src/${name}`, import.meta.url)),
|
||||
...['request-store', 'request-lifecycle'].map(name => new URL(`../src/${name}.mjs`, import.meta.url)),
|
||||
];
|
||||
for (const file of modules) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
for (const match of source.matchAll(/\b(?:new\s+(?:ProjectError|LibraryError|RequestError|GuardError)|raise|refuse|fail)\s*\(\s*['"]([a-z][a-z0-9-]*)['"]/g)) {
|
||||
assert.ok(Object.hasOwn(REFUSALS, match[1]), `${file.pathname}: ${match[1]}`);
|
||||
}
|
||||
}
|
||||
assert.doesNotMatch(REFUSALS['outcome-unknown'].nextStep, /confirmation|human/);
|
||||
assert.doesNotMatch(REFUSALS['revision-conflict'].nextStep, /confirmation|human/);
|
||||
assert.match(REFUSALS['task-required'].nextStep, /task_ref.*blocking false/);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { Store } from '../src/store.mjs';
|
||||
import { Broker, TASK_VERBS } from '../src/broker.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { readProcess, verifyHuman } from '../src/human.mjs';
|
||||
import { verifyTester, testerNonce } from '../src/tester.mjs';
|
||||
@@ -387,9 +388,9 @@ test('an envelope carries exactly one of cap, human and tester; no tester authen
|
||||
const cap = b.bindReader({ business: 'demo' });
|
||||
const role = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' });
|
||||
for (const r of [{ tester: p, cap }, { tester: p, cap: role }, { tester: p, human: p }, { tester: p, human: p, cap }])
|
||||
assert.deepEqual(await raw(path, { ...r, verb: 'agents' }), { ok: false, error: 'invalid-envelope' }, Object.keys(r).join('+'));
|
||||
assert.deepEqual(await raw(path, { ...r, verb: 'agents' }), { ok: false, error: 'invalid-envelope', ...refusalGuidance('invalid-envelope') }, Object.keys(r).join('+'));
|
||||
assert.equal(calls, 0);
|
||||
assert.deepEqual(await raw(bare, { tester: p, verb: 'agents' }), { ok: false, error: 'tester-required' });
|
||||
assert.deepEqual(await raw(bare, { tester: p, verb: 'agents' }), { ok: false, error: 'tester-required', ...refusalGuidance('tester-required') });
|
||||
assert.deepEqual(await raw(path, { tester: p, verb: 'agents' }), { ok: true, result: [] });
|
||||
assert.equal(calls, 1);
|
||||
// The tester's capability lasts one request, as the human's does.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { refusalGuidance } from '../src/refusals.mjs';
|
||||
import { views } from '../src/views.mjs';
|
||||
const businesses = {
|
||||
demo: {
|
||||
@@ -72,7 +73,10 @@ test('malformed, oversized and identity-forging envelopes refuse without echoing
|
||||
const out = await raw(input);
|
||||
assert.equal(JSON.parse(out).ok, false);
|
||||
assert.ok(!out.includes('not-json'));
|
||||
assert.ok(out.length < 150);
|
||||
const refusal = JSON.parse(out);
|
||||
assert.ok(['invalid-envelope', 'request-too-large'].includes(refusal.error));
|
||||
assert.deepEqual(refusal, { ok: false, error: refusal.error, ...refusalGuidance(refusal.error) });
|
||||
assert.ok(out.length < 1024); // A bounded static description, independent of input size.
|
||||
}
|
||||
});
|
||||
test('client preserves UTF-8 when a response divides a multibyte character', async (t) => {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/agent-copies.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/agent-copies.mjs";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/agent-levels-write.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/agent-levels-write.mjs";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/config-dir-trust.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/config-dir-trust.mjs";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Compatibility path for the organization package's caller-rails projection.
|
||||
export * from "../../organization/src/rails.mjs";
|
||||
@@ -1 +1 @@
|
||||
{"business":"acme","contract":"<ROOT>/roles/coder.md","credentials":{"gitea":{"file":"<ROOT>/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"<ROOT>/secrets/coder-vikunja.token","service":"vikunja"}},"definition":"coder","holder":null,"instance":"coder","launch":null,"limits":{"authority":{"crossRole":["task.scope.change"],"withinRole":["task.update.assigned","git.push.working","review.request","message.send","record.write"]},"network":"none","tools":["read","write","edit","bash","grep","find","ls"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","gitea.baseUrl":"business:acme","harness":"business:acme:roles.coder","limits.network":["business:acme:roles.coder"],"tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"business:acme","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":["ms-unslop","ms-sdlc","ms-file-read","ms-file-write"],"tracker":{"bot":"bot-acme-coder","botId":5},"vars":{"bus.tester":false,"dataRoot":"<ROOT>/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"glm-5.3-flash","execution.provider":"zai","gitea.baseUrl":"https://git.example","harness":"pi","limits.network":"none","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":60,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
{"business":"acme","contract":"<ROOT>/roles/coder.md","credentials":{"gitea":{"file":"<ROOT>/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"<ROOT>/secrets/coder-vikunja.token","service":"vikunja"}},"definition":"coder","holder":null,"instance":"coder","launch":null,"limits":{"authority":{"crossRole":["task.scope.change"],"withinRole":["task.update.assigned","git.push.working","review.request","message.send","record.write"]},"network":"none","tools":["read","write","edit","bash","grep","find","ls"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","gitea.baseUrl":"business:acme","harness":"business:acme:roles.coder","limits.network":["business:acme:roles.coder"],"tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"business:acme","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"rails":{"actions":[{"action":"task.update.assigned","routes":[{"to":"alone"}]},{"action":"git.push.working","routes":[{"to":"alone"}]},{"action":"review.request","routes":[{"to":"alone"}]},{"action":"message.send","routes":[{"to":"alone"}]},{"action":"record.write","routes":[{"to":"alone"}]},{"action":"task.scope.change","routes":[{"domain":"delivery","to":"pm"},{"domain":"technical","to":"cto"}]}],"credentials":[{"scopes":["write:issue","write:repository","read:user"],"service":"gitea"},{"scopes":{"projects":["views_buckets_tasks"],"tasks":["read_one","update"],"tasks_comments":["create"]},"service":"vikunja"}],"messaging":null,"network":"none","resolve":[]},"skills":["ms-unslop","ms-sdlc","ms-file-read","ms-file-write"],"tracker":{"bot":"bot-acme-coder","botId":5},"vars":{"bus.tester":false,"dataRoot":"<ROOT>/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"glm-5.3-flash","execution.provider":"zai","gitea.baseUrl":"https://git.example","harness":"pi","limits.network":"none","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":60,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
@@ -67,6 +67,91 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"rails": {
|
||||
"actions": [
|
||||
{
|
||||
"action": "task.update.assigned",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "git.push.working",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "review.request",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "message.send",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "record.write",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "task.scope.change",
|
||||
"routes": [
|
||||
{
|
||||
"domain": "delivery",
|
||||
"to": "pm"
|
||||
},
|
||||
{
|
||||
"domain": "technical",
|
||||
"to": "cto"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"resolve": [],
|
||||
"network": "none",
|
||||
"credentials": [
|
||||
{
|
||||
"service": "gitea",
|
||||
"scopes": [
|
||||
"write:issue",
|
||||
"write:repository",
|
||||
"read:user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "vikunja",
|
||||
"scopes": {
|
||||
"tasks": [
|
||||
"read_one",
|
||||
"update"
|
||||
],
|
||||
"tasks_comments": [
|
||||
"create"
|
||||
],
|
||||
"projects": [
|
||||
"views_buckets_tasks"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"messaging": null
|
||||
},
|
||||
"credentials": {
|
||||
"gitea": {
|
||||
"service": "gitea",
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"business":"acme","contract":"/synthetic/roles/coder.md","credentials":{},"definition":"coder","holder":"example","instance":"coder","launch":null,"limits":{"authority":{"crossRole":[],"withinRole":["message.send"]},"network":"none","tools":["read","write"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","limits.network":["business:acme:roles.coder"],"model":"business:acme:roles.coder","tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"project:stack:roles.coder","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":[],"tracker":null,"vars":{"bus.tester":false,"dataRoot":"/synthetic/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"system-model","execution.provider":"zai","limits.network":"none","model":"agent-model","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":15,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
{"business":"acme","contract":"/synthetic/roles/coder.md","credentials":{},"definition":"coder","holder":"example","instance":"coder","launch":null,"limits":{"authority":{"crossRole":[],"withinRole":["message.send"]},"network":"none","tools":["read","write"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","limits.network":["business:acme:roles.coder"],"model":"business:acme:roles.coder","tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"project:stack:roles.coder","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"rails":{"actions":[{"action":"message.send","routes":[{"to":"alone"}]}],"credentials":[],"messaging":null,"network":"none","resolve":[]},"skills":[],"tracker":null,"vars":{"bus.tester":false,"dataRoot":"/synthetic/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"system-model","execution.provider":"zai","limits.network":"none","model":"agent-model","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":15,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
|
||||
+17
-1
@@ -54,10 +54,26 @@
|
||||
"crossRole": []
|
||||
}
|
||||
},
|
||||
"rails": {
|
||||
"actions": [
|
||||
{
|
||||
"action": "message.send",
|
||||
"routes": [
|
||||
{
|
||||
"to": "alone"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"resolve": [],
|
||||
"network": "none",
|
||||
"credentials": [],
|
||||
"messaging": null
|
||||
},
|
||||
"credentials": {},
|
||||
"tracker": null,
|
||||
"launch": null,
|
||||
"webSource": null,
|
||||
"skills": [],
|
||||
"digest": "975144f5fcd6989c3393175b1713908c632c04f378b5f28a95cb2f4e552fbefa"
|
||||
"digest": "5a8df768f2359fd8c0131eb5e8ce8d427f68d6d465ba20d42b413a280a922ef0"
|
||||
}
|
||||
@@ -117,7 +117,7 @@ test('installed wrapper pins v1 validate and resolve output, preimage and digest
|
||||
'installed-resolve.json': normalized(resolved),
|
||||
'installed-preimage.json': normalized(canonicalJson(preimage)) + '\n',
|
||||
};
|
||||
assert.equal(sha256(snapshots['installed-preimage.json'].slice(0, -1)), '5fc9d6258f4725b0e260d99e5cf81058a05d21b8ff298bb53653efbbc8013da9');
|
||||
assert.equal(sha256(snapshots['installed-preimage.json'].slice(0, -1)), '0a12b35f55d033b91bd89745859028d252be52b05ca4083964b778a27ec20fd6');
|
||||
for (const [name, actual] of Object.entries(snapshots)) {
|
||||
assert.equal(actual, fixture(name), name);
|
||||
}
|
||||
|
||||
+12
-2
@@ -1304,8 +1304,10 @@ inactive. Project settings changes are append-only and require human proof.
|
||||
`mosaic accounts` administers the instance-level accounts store locally, as the
|
||||
effective uid owning the config directory. It requires a human shell, rejects `--tester`,
|
||||
and takes no business selector or bus connection. It has no organization rights
|
||||
verbs. Console sign-in, sessions and membership evaluation are later Phase M
|
||||
slices; these commands alone do not turn accounts mode on.
|
||||
verbs. Console OIDC sign-in re-reads these accounts on every request. A disabled
|
||||
account or changed authentication generation loses account authority immediately;
|
||||
a legacy access.json root grant is checked independently. Organization membership
|
||||
evaluation follows in later Phase M slices.
|
||||
|
||||
```sh
|
||||
mosaic accounts init --name Operator
|
||||
@@ -1327,3 +1329,11 @@ organization (including archived organizations) or any registry entry lacks its
|
||||
owner metadata during creation, and removes its memberships in
|
||||
the same write. List reports rollback rows needing repair; its JSON omits invite
|
||||
bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets.
|
||||
|
||||
Managed accounts instances use the system units from `scripts/case2-unit.sh`.
|
||||
The selected uid must use a non-login shell, be the only member of its primary
|
||||
group, and own its config directory and data root. The shared Case 1/Case 2
|
||||
installer writes `<dataRoot>/.deployed` as mode 0444 naming the instance.
|
||||
`webui-service.sh` refuses accounts and deployed instances because its user
|
||||
manager runs under a human or development seat uid. The receipt uid identifies
|
||||
the invoking process; it does not attribute a human or resist tampering by that uid.
|
||||
@@ -0,0 +1,195 @@
|
||||
// #1873 Phase A slice A2: the owner's terminal verbs for the root and project agent levels (design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
|
||||
//
|
||||
// mosaic agents root list [--json]
|
||||
// mosaic agents root set NAME [--definition ROLE] [--var KEY=VALUE]... [--var-json KEY=JSON]...
|
||||
// [--unset-var KEY]... [--exclude-orgs ID,ID | --exclude-orgs none] [--revision R]
|
||||
// mosaic agents root remove NAME [--revision R]
|
||||
// mosaic project agents PROJECT --org ID list [--json]
|
||||
// mosaic project agents PROJECT --org ID set NAME --definition ROLE [--var KEY=VALUE]... [--var-json KEY=JSON]... [--revision R]
|
||||
// mosaic project agents PROJECT --org ID narrow NAME --var KEY=VALUE... [--var-json KEY=JSON]... [--revision R]
|
||||
// mosaic project agents PROJECT --org ID exclude NAME [--revision R]
|
||||
// mosaic project agents PROJECT --org ID remove NAME [--revision R]
|
||||
//
|
||||
// Every write is the owner's: an agent run is refused before anything is read (exit 3), and the
|
||||
// verbs run only on a named dev instance, as `mosaic org` does, with the local dev owner as actor.
|
||||
// Without --revision a write reads the revision just before it writes, so a concurrent change
|
||||
// between that read and the write still refuses, but one made before the command started does
|
||||
// not. Pass --revision from a `list` to make the write conditional on what was listed.
|
||||
// A write prints its receipt; refusals name keys, never values.
|
||||
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { OrgError } from '../../organization/src/org-registry.mjs';
|
||||
import { OrganizationError, organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { changeRootAgent, patchRootEntry, projectAgentSummary, readRootAgentsFile, rootAgentSummary } from '../../organization/src/agent-levels-write.mjs';
|
||||
import { loadSystem, rolesDir } from './config.mjs';
|
||||
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const AGENTS_ROOT_USAGE = 'mosaic agents root list [--json] | set NAME [--definition ROLE] [--var KEY=VALUE]... [--var-json KEY=JSON]... [--unset-var KEY]... [--exclude-orgs ID,ID|none] [--revision R] | remove NAME [--revision R]';
|
||||
export const PROJECT_AGENTS_USAGE = 'mosaic project agents PROJECT --org ID list [--json] | set NAME --definition ROLE [--var KEY=VALUE]... [--revision R] | narrow NAME --var KEY=VALUE... [--revision R] | exclude NAME [--revision R] | remove NAME [--revision R]';
|
||||
|
||||
const usage = (text, line) => new CliError(`${text}\nusage: ${line}`, 4);
|
||||
const REPEATED = ['--var', '--var-json', '--unset-var'];
|
||||
const SINGLE = ['--definition', '--exclude-orgs', '--revision', '--org'];
|
||||
|
||||
// Option values are never echoed back: a refusal names the option only.
|
||||
function parse(args, line) {
|
||||
const positional = [], single = {}, repeated = { '--var': [], '--var-json': [], '--unset-var': [] }, flags = new Set();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--json') { if (flags.has(a)) throw usage('duplicate --json', line); flags.add(a); }
|
||||
else if (REPEATED.includes(a) || SINGLE.includes(a)) {
|
||||
if (i + 1 >= args.length) throw usage(`${a} needs one value`, line);
|
||||
const value = args[++i];
|
||||
if (REPEATED.includes(a)) repeated[a].push(value);
|
||||
else if (Object.hasOwn(single, a)) throw usage(`duplicate ${a}`, line);
|
||||
else single[a] = value;
|
||||
} else if (a.startsWith('--')) throw usage('unknown option', line);
|
||||
else positional.push(a);
|
||||
}
|
||||
return { positional, single, repeated, flags };
|
||||
}
|
||||
|
||||
function only(parsed, allowed, line) {
|
||||
for (const key of [...Object.keys(parsed.single), ...REPEATED.filter(k => parsed.repeated[k].length), ...parsed.flags])
|
||||
if (!allowed.includes(key)) throw usage(`unexpected ${key}`, line);
|
||||
}
|
||||
|
||||
// KEY=VALUE pairs; a --var-json value is parsed as JSON. A malformed pair names its key at most.
|
||||
function vars(parsed, line) {
|
||||
const out = {};
|
||||
const put = (key, value) => {
|
||||
if (!key) throw usage('a var needs KEY=VALUE', line);
|
||||
if (Object.hasOwn(out, key)) throw usage(`var ${key} is given twice`, line);
|
||||
out[key] = value;
|
||||
};
|
||||
for (const pair of parsed.repeated['--var']) {
|
||||
const at = pair.indexOf('=');
|
||||
if (at < 1) throw usage('a var needs KEY=VALUE', line);
|
||||
put(pair.slice(0, at), pair.slice(at + 1));
|
||||
}
|
||||
for (const pair of parsed.repeated['--var-json']) {
|
||||
const at = pair.indexOf('=');
|
||||
if (at < 1) throw usage('a var needs KEY=JSON', line);
|
||||
const key = pair.slice(0, at);
|
||||
try { put(key, JSON.parse(pair.slice(at + 1))); }
|
||||
catch (e) { if (e instanceof CliError) throw e; throw usage(`var ${key} is not valid JSON`, line); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const revisionOf = (parsed, read) => parsed.single['--revision'] ?? read();
|
||||
function owner(io) {
|
||||
refuseInsideAgent(io.env);
|
||||
// Its refusal is one fixed line naming no path.
|
||||
let instance;
|
||||
try { instance = devOwnerInstance(io.env); } catch (error) { throw error instanceof CliError ? error : new CliError(String(error?.message), 2); }
|
||||
// The instance's system config must exist and validate, marker included, as `mosaic org` checks:
|
||||
// a missing or broken config refuses (exit 3) before anything is read or written.
|
||||
loadSystem({ env: io.env, instance });
|
||||
return { dir: configDir(io.env), roles: rolesDir(io.env) };
|
||||
}
|
||||
// Only the writers' own coded refusals print their text (it names keys and ids, never a value).
|
||||
// Anything else, a filesystem error above all, prints its code and a fixed line: its message can
|
||||
// hold a host path. So does a coded refusal whose text happens to hold an absolute path.
|
||||
const ABSOLUTE_PATH = /(?:^|[\s('"=:])\/[^\s/]/;
|
||||
function refusal(error) {
|
||||
if (error instanceof CliError) return error;
|
||||
const code = typeof error?.code === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(error.code) ? error.code : null;
|
||||
const ours = (error instanceof OrgError || error instanceof OrganizationError || error?.name === 'BusinessError') && code !== null;
|
||||
const text = ours && typeof error.message === 'string' && !ABSOLUTE_PATH.test(error.message) ? error.message : 'the operation failed; the error text is not shown';
|
||||
// A refusal after the replace says the change landed; the receipt may be missing.
|
||||
const landed = error?.committed ? ' (the change was written; its receipt may be missing)' : '';
|
||||
return new CliError(`${code ? `${code}: ` : ''}${text}${landed}`, 2);
|
||||
}
|
||||
|
||||
export async function agentsRootCommand(args, io) {
|
||||
const line = AGENTS_ROOT_USAGE;
|
||||
const parsed = parse(args, line);
|
||||
const [op, name, ...extra] = parsed.positional;
|
||||
try {
|
||||
const { dir, roles } = owner(io);
|
||||
if (op === 'list') {
|
||||
if (name !== undefined) throw usage('list takes no name', line);
|
||||
only(parsed, ['--json'], line);
|
||||
const read = await readRootAgentsFile(dir);
|
||||
const listing = { revision: read.revision, state: read.exists ? 'present' : 'absent', agents: rootAgentSummary(read.document) };
|
||||
io.stdout.write(parsed.flags.has('--json') ? `${JSON.stringify(listing, null, 2)}\n` : formatRoot(listing));
|
||||
return;
|
||||
}
|
||||
if (!['set', 'remove'].includes(op) || !name || extra.length) throw usage('root takes list, set NAME or remove NAME', line);
|
||||
only(parsed, op === 'set' ? ['--definition', '--var', '--var-json', '--unset-var', '--exclude-orgs', '--revision'] : ['--revision'], line);
|
||||
let entry = null;
|
||||
if (op === 'set') {
|
||||
const patch = {};
|
||||
if (Object.hasOwn(parsed.single, '--definition')) patch.definition = parsed.single['--definition'];
|
||||
const set = vars(parsed, line);
|
||||
for (const key of parsed.repeated['--unset-var']) {
|
||||
if (Object.hasOwn(set, key)) throw usage(`var ${key} is both set and unset`, line);
|
||||
set[key] = null;
|
||||
}
|
||||
if (Object.keys(set).length) patch.vars = set;
|
||||
if (Object.hasOwn(parsed.single, '--exclude-orgs')) {
|
||||
const text = parsed.single['--exclude-orgs'];
|
||||
patch.excludeOrgs = text === 'none' ? [] : text.split(',');
|
||||
}
|
||||
if (!Object.keys(patch).length) throw usage('set needs --definition, a var or --exclude-orgs', line);
|
||||
entry = old => patchRootEntry(old, patch);
|
||||
}
|
||||
const revision = await revisionOf(parsed, async () => (await readRootAgentsFile(dir)).revision);
|
||||
const receipt = await changeRootAgent(dir, { revision, name, entry }, { actor: DEV_OWNER, owner: DEV_OWNER, rolesDir: roles });
|
||||
io.stdout.write(`${JSON.stringify(receipt)}\n`);
|
||||
} catch (error) { throw refusal(error); }
|
||||
}
|
||||
|
||||
export async function projectAgentsCommand(args, io) {
|
||||
const line = PROJECT_AGENTS_USAGE;
|
||||
const parsed = parse(args, line);
|
||||
const [project, op, name, ...extra] = parsed.positional;
|
||||
try {
|
||||
const { dir, roles } = owner(io);
|
||||
const org = parsed.single['--org'];
|
||||
if (!project || !org) throw usage('project agents needs PROJECT and --org ID', line);
|
||||
const writer = organizationWriter({ dir, rolesDir: roles });
|
||||
if (op === 'list') {
|
||||
if (name !== undefined) throw usage('list takes no name', line);
|
||||
only(parsed, ['--org', '--json'], line);
|
||||
const { document, revision } = writer.read(org);
|
||||
if (!Object.hasOwn(document.projects ?? {}, project)) throw new OrgError('project-not-found', `project ${project} is not in organization ${org}`);
|
||||
const listing = { organization: org, project, revision, agents: projectAgentSummary(document.projects[project].agents ?? {}) };
|
||||
io.stdout.write(parsed.flags.has('--json') ? `${JSON.stringify(listing, null, 2)}\n` : formatProject(listing));
|
||||
return;
|
||||
}
|
||||
if (!['set', 'narrow', 'exclude', 'remove'].includes(op) || !name || extra.length) throw usage('project agents takes list, set, narrow, exclude or remove NAME', line);
|
||||
const allowed = { set: ['--definition', '--var', '--var-json'], narrow: ['--var', '--var-json'], exclude: [], remove: [] }[op];
|
||||
only(parsed, ['--org', '--revision', ...allowed], line);
|
||||
let entry = null;
|
||||
if (op === 'set') {
|
||||
if (!Object.hasOwn(parsed.single, '--definition')) throw usage('set needs --definition', line);
|
||||
const v = vars(parsed, line);
|
||||
entry = { definition: parsed.single['--definition'], ...(Object.keys(v).length ? { vars: v } : {}) };
|
||||
} else if (op === 'narrow') {
|
||||
const v = vars(parsed, line);
|
||||
if (!Object.keys(v).length) throw usage('narrow needs at least one --var', line);
|
||||
entry = { vars: v };
|
||||
} else if (op === 'exclude') entry = { exclude: true };
|
||||
const revisionBefore = revisionOf(parsed, () => writer.read(org).revision);
|
||||
const result = writer.setProjectAgent(org, { revision: revisionBefore, project, name, entry }, DEV_OWNER.sub);
|
||||
// The receipt: names and revisions only. The writer's `previous` is a host path; it isn't printed.
|
||||
const receipt = { verb: 'project.agents.set', organization: org, project, agent: name, form: op === 'set' ? 'define' : op, revisionBefore, revisionAfter: result.revision, transaction: result.transaction };
|
||||
io.stdout.write(`${JSON.stringify(receipt)}\n`);
|
||||
} catch (error) { throw refusal(error); }
|
||||
}
|
||||
|
||||
function formatRoot({ revision, state, agents }) {
|
||||
if (!agents.length) return `root agents: none (${state === 'absent' ? 'no file' : 'empty'}), revision ${revision}\n`;
|
||||
return `root agents (revision ${revision}):\n` + agents.map(a =>
|
||||
` ${a.name}: ${a.definition}${a.model ? `, model ${a.model}` : ''}${a.varKeys.length ? `, vars ${a.varKeys.join(', ')}` : ''}${a.excludeOrgs.length ? `, not in ${a.excludeOrgs.join(', ')}` : ''}\n`).join('');
|
||||
}
|
||||
function formatProject({ organization, project, revision, agents }) {
|
||||
if (!agents.length) return `project ${project} in ${organization}: no project agent entries, revision ${revision}\n`;
|
||||
return `project ${project} in ${organization} (revision ${revision}):\n` + agents.map(a =>
|
||||
` ${a.name}: ${a.form}${a.definition ? ` ${a.definition}` : ''}${a.varKeys?.length ? `, vars ${a.varKeys.join(', ')}` : ''}\n`).join('');
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
// host (src/host.mjs) the systemd unit mosaic-bus@<business> runs; with
|
||||
// --pm it also launches the business's PM (src/launcher.mjs).
|
||||
|
||||
import { assertBuildStart, assertDevInstance } from './deployment.mjs';
|
||||
import { readFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
@@ -48,6 +49,7 @@ import { consoleRestart, consoleStart, consoleStatus, consoleStop } from "./org-
|
||||
|
||||
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
|
||||
import { orgCommand, ORG_USAGE } from "./org.mjs";
|
||||
import { agentsRootCommand, AGENTS_ROOT_USAGE, projectAgentsCommand, PROJECT_AGENTS_USAGE } from "./agent-levels.mjs";
|
||||
import { ORG_CONFIG, runConfigCommand } from "../../organization/src/config-command.mjs";
|
||||
import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from "./dev-fixture.mjs";
|
||||
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
|
||||
@@ -65,6 +67,8 @@ export const USAGE = `usage:
|
||||
${ORG_INSTANCES_USAGE}
|
||||
mosaic business validate|resolve|instances|instance|veto: the old spelling of mosaic org config validate|resolve and mosaic org instances|instance|veto, same arguments
|
||||
${PROJECT_USAGE}
|
||||
${PROJECT_AGENTS_USAGE}
|
||||
${AGENTS_ROOT_USAGE}
|
||||
${PROJECT_BUS_USAGE}
|
||||
${NORTH_STAR_USAGE}
|
||||
${INTERVIEW_USAGE}
|
||||
@@ -611,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") });
|
||||
@@ -655,7 +661,7 @@ async function bus(rest, io, deps) {
|
||||
// tracker gets (the bus runtime makes the same choice, runtime.mjs trackerOf).
|
||||
const taskModule = boot.businesses?.[businessId]?.plugins?.["task-tracker"];
|
||||
const tracker = Boolean(boot.trackers?.[businessId]) || taskModule === undefined || taskModule === "store";
|
||||
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
|
||||
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, copies: boot.copies ?? null, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
|
||||
await launcher.listen();
|
||||
if (instance) io.stdout.write(`DEV LOCAL OWNER MODE: console owner local-dev accepted (named instance ${instance})\n`);
|
||||
if (instance && parsed.flags.has("--pm")) io.stdout.write('DEV LOCAL OWNER MODE: launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only\n');
|
||||
@@ -731,6 +737,9 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === 'org' && rest[0] === 'config') return orgConfig(rest.slice(1), io);
|
||||
if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
if (verb === 'org') return orgCommand(rest, io, deps);
|
||||
// A2 (#1873): the owner's root and project agent levels. `mosaic agents` without `root` stays the bus read.
|
||||
if (verb === "agents" && rest[0] === "root") return agentsRootCommand(rest.slice(1), io);
|
||||
if (verb === "project" && rest[0] === "agents") return projectAgentsCommand(rest.slice(1), io);
|
||||
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
||||
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
|
||||
if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
|
||||
|
||||
@@ -10,6 +10,10 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { managedState } from "../../organization/src/project-bootstrap.mjs";
|
||||
import { BusinessError, accessFilePath, configDir, loadAccess, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../organization/src/index.mjs";
|
||||
import { loadRootAgents } from "../../organization/src/agent-levels.mjs";
|
||||
import { materializeCopies, withCopies } from "../../organization/src/agent-copies.mjs";
|
||||
import { organizationWriter } from "../../organization/src/organization.mjs";
|
||||
import { businessFilePath } from "../../organization/src/organization-config.mjs";
|
||||
import { busOrganization } from "../../bus/src/organization-context.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
@@ -86,7 +90,7 @@ function trackerFor(system, b, warn, dataRoot) {
|
||||
if (dataRoot && managedState({ dataRoot, business: b.id, project: id, entry }) === 'reserved') continue;
|
||||
if (!existsSync(projectFilePath(entry.root))) continue;
|
||||
const project = loadProject(entry.root);
|
||||
if (project.id !== id) throw new CliError(`project file ${project.file} has id ${project.id}, but business ${b.id} declares it as ${id}`, 3);
|
||||
if (project.id !== id) throw new CliError(`project file ${project.file} has id that does not match the declared project key`, 3);
|
||||
const vars = resolveInstance({ system, business: b, project, instance: first }).vars;
|
||||
if (vars["tracker.project"] !== undefined) named.push({ id, vars });
|
||||
}
|
||||
@@ -144,10 +148,29 @@ export const consoleOwners = (sso) =>
|
||||
// The `{op: 'boot'}` config for packages/bus/src/process.mjs, for one
|
||||
// business. The host holds a reader capability for the notifier and binds
|
||||
// launches later through bindLaunch, so `launches` starts empty.
|
||||
// #1873 A3: the organization's root and project agent copies, read once here. The root file is read
|
||||
// through loadRootAgents and the organization file through the organization's own writer; the
|
||||
// snapshot is plain data that the broker and the launcher re-apply to every later read of the
|
||||
// organization file, so a root file change takes effect at the next boot only. A root file that
|
||||
// refuses hides the root level (design section 6): no root copy, and the organization still boots.
|
||||
export function copiesAtBoot({ businessId, env = process.env, warn = () => {} }) {
|
||||
const dir = configDir(env), roles = rolesDir(env);
|
||||
loadBusiness(businessId, { dir, rolesDir: roles });
|
||||
const { document } = organizationWriter({ dir, rolesDir: roles }).read(businessId);
|
||||
let root = null;
|
||||
try { root = loadRootAgents(dir, { rolesDir: roles }); }
|
||||
catch (error) {
|
||||
if (!(error instanceof BusinessError)) throw error;
|
||||
warn(`root agent file refused, so ${businessId} runs no root copies until it is fixed and the bus restarts: ${error.message}`);
|
||||
}
|
||||
const snapshot = materializeCopies({ orgId: businessId, doc: document, root, rolesDir: roles });
|
||||
return { snapshot, business: withCopies(document, businessFilePath(businessId, dir), { rolesDir: roles, snapshot }) };
|
||||
}
|
||||
|
||||
export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env = process.env, warn = () => {} }) {
|
||||
return business(() => {
|
||||
const vars = systemVars(system);
|
||||
const b = loadBusiness(businessId, { dir: configDir(env), rolesDir: rolesDir(env) });
|
||||
const { snapshot, business: b } = copiesAtBoot({ businessId, env, warn });
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: vars, business: b, instance });
|
||||
const config = {
|
||||
@@ -158,6 +181,8 @@ export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env
|
||||
organization: {dir:configDir(env),rolesDir:rolesDir(env),system:vars},
|
||||
launches: [],
|
||||
readers: [b.id],
|
||||
// Present only when the organization has a copy, so a boot with none is the boot it was.
|
||||
...(Object.keys(snapshot.copies).length ? { copies: snapshot } : {}),
|
||||
};
|
||||
const tracker = trackerFor(vars, b, warn, orgRoot);
|
||||
if (tracker) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// M3: executable and instance boundaries. Presence is checked with lstat, so
|
||||
// broken links and unreadable metadata never turn a deployed path into dev.
|
||||
import { constants, lstatSync, openSync, fstatSync, readFileSync, closeSync, writeFileSync, fchmodSync, mkdirSync } from 'node:fs';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
const configDir = (env = process.env) => dirname(resolve(env.MOSAIC_CONFIG || join(homedir(), '.config/mosaic-dev/config.json')));
|
||||
|
||||
const CODE_ROOT = resolve(import.meta.dirname, '../../..');
|
||||
export function present(file) {
|
||||
try { lstatSync(file); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; }
|
||||
}
|
||||
function metadata(file, label, check) {
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
const st = fstatSync(fd);
|
||||
if (!st.isFile() || st.size > 4096 || !check(st)) throw new Error('metadata refused');
|
||||
return JSON.parse(readFileSync(fd, 'utf8'));
|
||||
} catch { throw new Error(`${label} is missing or invalid`); }
|
||||
finally { if (fd !== undefined) closeSync(fd); }
|
||||
}
|
||||
export function buildStamp(root = CODE_ROOT) {
|
||||
const file = join(root, '.stack-build.json');
|
||||
if (!present(file)) return null;
|
||||
const doc = metadata(file, 'build stamp', st => !(st.mode & 0o222));
|
||||
if (!doc || Object.keys(doc).length !== 1 || !/^[0-9a-f]{40}$/.test(doc.commit) || doc.commit !== basename(root)) throw new Error('build stamp does not name this copy commit');
|
||||
return doc;
|
||||
}
|
||||
export function assertBuildStart({ serve = false, sso = false, devOwner = false, devBind = null, devNames = [] } = {}) {
|
||||
const stamp = buildStamp();
|
||||
if (stamp && (devOwner || devBind !== null || devNames.length)) throw new Error('stamped build refuses dev-local-owner and LAN flags');
|
||||
if (stamp && serve && !sso) throw new Error('stamped build serve requires --sso');
|
||||
return stamp;
|
||||
}
|
||||
export function assertDevInstance(system, env = process.env) {
|
||||
if (present(join(configDir(env), 'accounts.json'))) throw new Error('--dev-local-owner refuses accounts.json');
|
||||
if (system.console?.sso) throw new Error('--dev-local-owner refuses console.sso');
|
||||
if (present(join(system.dataRoot, '.deployed'))) throw new Error('--dev-local-owner refuses .deployed');
|
||||
}
|
||||
const instanceName = dir => /^mosaic-([a-z][a-z0-9]{1,15})$/.exec(basename(dir))?.[1] ?? 'dev';
|
||||
export function assertDeployed(system, dir = configDir()) {
|
||||
const doc = metadata(join(system.dataRoot, '.deployed'), '.deployed', st => (st.mode & 0o777) === 0o444 && st.uid === process.geteuid());
|
||||
if (!doc || Object.keys(doc).length !== 1 || doc.instance !== instanceName(dir)) throw new Error('.deployed does not name this instance');
|
||||
}
|
||||
// Both managed host cases install through case2-install.sh. Existing metadata
|
||||
// must validate; an install never replaces it or follows a destination link.
|
||||
export function writeDeployment(system, dir = configDir()) {
|
||||
// Default-instance bootstrap creates the config before its data directory.
|
||||
mkdirSync(system.dataRoot, { recursive: true, mode: 0o700 });
|
||||
const root = lstatSync(system.dataRoot);
|
||||
if (!root.isDirectory() || root.uid !== process.geteuid() || (root.mode & 0o022)) throw new Error('.deployed data root must be an owned directory without public write');
|
||||
const file = join(system.dataRoot, '.deployed');
|
||||
if (present(file)) {
|
||||
const doc = metadata(file, '.deployed', st => (st.mode & 0o777) === 0o444 && st.uid === process.geteuid());
|
||||
if (!doc || Object.keys(doc).length !== 1 || doc.instance !== instanceName(dir)) throw new Error('.deployed does not name this instance');
|
||||
return;
|
||||
}
|
||||
const fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o444);
|
||||
try { writeFileSync(fd, JSON.stringify({ instance: instanceName(dir) }) + '\n'); fchmodSync(fd, 0o444); } finally { closeSync(fd); }
|
||||
}
|
||||
@@ -143,9 +143,10 @@ import { createHash } from 'node:crypto';
|
||||
import { watchModelOutput } from './model-stall.mjs';
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../organization/src/index.mjs";
|
||||
import { configDir, resolveInstance, systemVars } from "../../organization/src/index.mjs";
|
||||
import { organizationWriter } from "../../organization/src/organization.mjs";
|
||||
import { businessFilePath, validateBusinessDocument } from "../../organization/src/organization-config.mjs";
|
||||
import { businessFilePath } from "../../organization/src/organization-config.mjs";
|
||||
import { copyOf, copyRefusal, loadWithCopies, projectRefusal, withCopies } from "../../organization/src/agent-copies.mjs";
|
||||
import { selectModelProvider } from "../../plugins/src/model-provider-selection.mjs";
|
||||
import { THREAD_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
|
||||
import { limitHit } from "../../organization/src/launch.mjs";
|
||||
@@ -244,6 +245,9 @@ export function createLauncher({
|
||||
git = {},
|
||||
stopOptions = {},
|
||||
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
|
||||
// #1873 A3: the root and project agent copies the bus booted with (config.mjs copiesAtBoot), or
|
||||
// null. Every read of the organization file here re-applies this snapshot, never the root file.
|
||||
copies = null,
|
||||
}) {
|
||||
if (testProxy !== null && system.proxy)
|
||||
throw Object.assign(new Error("testProxy skips P2 admission, so it is refused with system.proxy set"), { code: "test-proxy-refused" });
|
||||
@@ -257,7 +261,8 @@ export function createLauncher({
|
||||
}
|
||||
// Row 64 (#1557): where a role's skills are read from; scripts/skill.sh activate fills it.
|
||||
const skillsDir = join(system.instanceRoot ?? dataRoot, "skills-enabled");
|
||||
let b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
|
||||
const loadOrg = () => loadWithCopies(host.business, { dir: configDir(env), rolesDir: rolesDir(env), snapshot: copies?.org === host.business ? copies : null });
|
||||
let b = loadOrg();
|
||||
const vars = systemVars(system);
|
||||
|
||||
// Sessions an earlier host left in sessions.json: it died without ending
|
||||
@@ -355,10 +360,17 @@ export function createLauncher({
|
||||
try {
|
||||
const snapshot = organizationWriter({dir:configDir(env),rolesDir:rolesDir(env)}).read(host.business);
|
||||
businessRevision = snapshot.revision;
|
||||
b = validateBusinessDocument(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env)});
|
||||
b = withCopies(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env), snapshot: copies?.org === host.business ? copies : null});
|
||||
} catch(e){throw new Refusal("organization-refused",e.message);}
|
||||
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
|
||||
// #1873 A3: an unbound or refused copy never launches. The message names the copy, its project
|
||||
// and the missing service names or the refusal, never a value.
|
||||
const copyRefused = copyRefusal(b, instance);
|
||||
if (copyRefused) throw new Refusal(copyRefused.code, copyRefused.message);
|
||||
// #1873 A3: a bound copy isn't on the chief's list. It launches when the human asks, or for a
|
||||
// thread, which only a human or a granted user makes, naming the copy.
|
||||
const boundCopy = copyOf(b, instance)?.state === "instance" && Object.hasOwn(b.roles, instance);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)) || (thread && boundCopy))) throw new Refusal("instance-not-listed");
|
||||
const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
|
||||
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
|
||||
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
|
||||
@@ -539,12 +551,12 @@ export function createLauncher({
|
||||
await pickRoute(() => standIn);
|
||||
await gate(model, harness, null);
|
||||
admission(admissionGeneration);
|
||||
return { resolved, model, account: null, harness, route, routeReason, admissionGeneration, resolver };
|
||||
return { resolved, model, account: null, harness, route, routeReason, admissionGeneration, resolver, revision: businessRevision };
|
||||
}
|
||||
const sessionProxy = await pickRoute(proxyFor);
|
||||
const account = await gate(model, harness, sessionProxy);
|
||||
admission(admissionGeneration);
|
||||
return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy, resolver };
|
||||
return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy, resolver, revision: businessRevision };
|
||||
}
|
||||
|
||||
// Reread host state on every new-run path, including opens/clears queued before archive.
|
||||
@@ -554,6 +566,8 @@ export function createLauncher({
|
||||
const row = rows.find(r => r.thread === thread && r.session === session);
|
||||
if (!row) throw new Refusal("session-not-found");
|
||||
if (row.archived) throw new Refusal("project-archived");
|
||||
// #1873 A3: the thread's project no longer takes its instance: the union refuses, or excludes it.
|
||||
if (row.projectRefused) throw new Refusal(row.projectRefused.code, row.projectRefused.reason);
|
||||
}
|
||||
|
||||
// Bundle, spawn, bind, register, hand over the capability.
|
||||
@@ -616,13 +630,24 @@ export function createLauncher({
|
||||
// (a mapping published before the seed and records finished) is left out and the launch goes on without
|
||||
// it. Ordinary mappings also recheck archive state through the same host admission.
|
||||
const codeProjects = {};
|
||||
// #1873 A3: an instance gets only the code projects it works in: a project agent its own one, and
|
||||
// no instance a project that excludes it or whose union refuses. A project agent refuses when its
|
||||
// own project isn't admitted. A checkout an earlier launch left in the workspace for a project
|
||||
// the instance may not work in now is hidden from the session, never deleted (sandbox.mjs).
|
||||
const own = copyOf(b, instance)?.project ?? null;
|
||||
const declared = new Set();
|
||||
for (const [project, entry] of Object.entries(b.projects)) {
|
||||
if (entry.kind === 'library') continue;
|
||||
declared.add(project);
|
||||
if (own !== null && project !== own) continue;
|
||||
const unavailable = projectRefusal(b, instance, project);
|
||||
if (unavailable) { record({ event: "checkout-skipped", instance, project, reason: unavailable.code }); continue; }
|
||||
let answer = null;
|
||||
try { answer = await host.op({ op: 'request.admission', business: b.id, project }); } catch (e) { answer = { ready: false, code: e.code ?? 'admission-unavailable' }; }
|
||||
if (answer?.ready !== true) { record({ event: "checkout-skipped", instance, project, reason: answer?.code ?? 'project-pending' }); continue; }
|
||||
codeProjects[project] = entry;
|
||||
}
|
||||
if (own !== null && !Object.hasOwn(codeProjects, own)) throw new Refusal("project-refused", `${instance} works only in project ${own}, which isn't ready`);
|
||||
await threadAdmission(thread, session);
|
||||
let checkouts;
|
||||
try {
|
||||
@@ -646,9 +671,30 @@ export function createLauncher({
|
||||
catch (error) { throw new Refusal(error.code ?? 'proxy-down', error.code ?? 'proxy-down'); }
|
||||
if (!admittedProxy || typeof admittedProxy.key !== 'string' || !admittedProxy.key || admittedProxy.baseUrl !== `http://127.0.0.1:${system.proxy.port}`) throw new Refusal('proxy-down', 'proxy-down');
|
||||
}
|
||||
let harness, route, routeReason, sessionProxy, resolver;
|
||||
({resolved,model,account,harness,route,routeReason,sessionProxy,resolver}=await check(instance,{pm:instance===b.launch.by,thread,session,human,proxyContext:admittedProxy,proxyReady:true}));
|
||||
let harness, route, routeReason, sessionProxy, resolver, revision;
|
||||
({resolved,model,account,harness,route,routeReason,sessionProxy,resolver,revision}=await check(instance,{pm:instance===b.launch.by,thread,session,human,proxyContext:admittedProxy,proxyReady:true}));
|
||||
admission(admissionGeneration);
|
||||
// #1873 A3: check() read the organization again. A project that excluded or refused this
|
||||
// instance while its checkouts were prepared refuses the launch; the next one leaves it out.
|
||||
for (const project of Object.keys(codeProjects)) {
|
||||
const unavailable = projectRefusal(b, instance, project);
|
||||
if (unavailable) throw new Refusal(unavailable.code, unavailable.message);
|
||||
}
|
||||
// #1873 A3: every declared code project this launch leaves out, in the document it started from
|
||||
// or the one check() read, is hidden if an earlier launch left its checkout in the workspace,
|
||||
// whatever kept it out (not admitted, excluded, refused, another project's agent). Only a real
|
||||
// directory can be covered: a tmpfs mounted at a symlink would land on its target, so anything
|
||||
// else at that path refuses the launch, and nothing on the host is changed or removed.
|
||||
for (const [project, entry] of Object.entries(b.projects)) if (entry.kind !== 'library') declared.add(project);
|
||||
const hidden = [];
|
||||
for (const project of declared) {
|
||||
if (Object.hasOwn(codeProjects, project)) continue;
|
||||
const path = join(workspace, project);
|
||||
let stat;
|
||||
try { stat = lstatSync(path); } catch (e) { if (e.code === "ENOENT") continue; throw new Refusal("checkout-unsafe", `${instance}'s workspace path for project ${project} can't be read`); }
|
||||
if (!stat.isDirectory()) throw new Refusal("checkout-unsafe", `${instance}'s workspace path for project ${project} isn't a directory, so it can't be hidden; a person removes it before ${instance} launches again`);
|
||||
hidden.push(path);
|
||||
}
|
||||
const { run, runDir } = newRun(dataRoot, b.id);
|
||||
let modelKeyId;
|
||||
if (admittedProxy) {
|
||||
@@ -744,6 +790,7 @@ export function createLauncher({
|
||||
gitSocket: git,
|
||||
threadStore: store,
|
||||
readOnly,
|
||||
hidden,
|
||||
};
|
||||
admission(admissionGeneration);
|
||||
await threadAdmission(thread, session);
|
||||
@@ -754,7 +801,9 @@ export function createLauncher({
|
||||
}
|
||||
// One synchronous broker snapshot covers the thread and the entire manifest after every
|
||||
// asynchronous preparation read. Admission is the launch boundary; bindLaunch remains a fence.
|
||||
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), thread, session });
|
||||
// #1873 A3: `revision` is the organization file check() resolved the bundle from; a file that
|
||||
// changed since (a narrow, a define's vars, an exclude) refuses, and the next launch resolves anew.
|
||||
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), instance, revision, thread, session });
|
||||
if (finalAdmission?.ready !== true) throw new Refusal(finalAdmission?.code ?? "project-pending");
|
||||
const managed = registerRuntime(dataRoot, { kind: 'session', business: b.id, run });
|
||||
let child;
|
||||
@@ -889,6 +938,8 @@ export function createLauncher({
|
||||
for (const i of pending) {
|
||||
const session = registry.running(b.id, i.target, null);
|
||||
if (!session || !i.project || stopping.has(session.run)) continue;
|
||||
// #1873 A3: a manifest never gains a project its instance doesn't work in.
|
||||
if (projectRefusal(b, i.target, i.project)) continue;
|
||||
let projects = [];
|
||||
try { projects = JSON.parse(readFileSync(join(session.runDir, 'bundle/manifest.json'), 'utf8')).projects ?? []; } catch {}
|
||||
if (!projects.includes(i.project)) {
|
||||
@@ -1348,7 +1399,7 @@ export function createLauncher({
|
||||
if (typeof instance === 'string') await host.op({ op: 'request.launchResult', record: { business: b.id, instance, code: out.ok ? null : out.error, run: out.ok ? out.result.run : null } });
|
||||
return out;
|
||||
};
|
||||
try { b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) }); }
|
||||
try { b = loadOrg(); }
|
||||
catch { return reply({ ok: false, error: 'organization-refused' }); }
|
||||
// Product enums and clones are frozen in a session's manifest. A legitimate chief
|
||||
// launch may request a drain, never interrupt the turn or start a privileged worker.
|
||||
|
||||
@@ -353,7 +353,7 @@ test("T2 a bad user add refuses before any write", async (t) => {
|
||||
}
|
||||
const checked = [
|
||||
[USER_ADD("bob", { subject: "github:bob-sub" }), /provider must be a configured provider/],
|
||||
[USER_ADD("alice", { subject: "authentik:alice-2" }), /duplicate user id: alice/],
|
||||
[USER_ADD("alice", { subject: "authentik:alice-2" }), /duplicate user id/],
|
||||
[USER_ADD("tester"), /a bus principal/],
|
||||
[USER_ADD("jason"), /a business's human/],
|
||||
[USER_ADD("bob", { subject: "authentik:jason-sub" }), /owner sign-in subject/],
|
||||
|
||||
@@ -69,3 +69,35 @@ test('host boundary and receipt use effective uid, not real uid (current design
|
||||
try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); }
|
||||
finally { process.getuid = realUid; }
|
||||
});
|
||||
|
||||
// The uid identifies this process, not the human who invoked it. The instance
|
||||
// uid can rewrite its receipt; this is neither human attribution nor tamper evidence.
|
||||
test('M3 CLI integration refuses foreign effective uid before any account mutation, and receipts identify the process', async t => {
|
||||
const { spawnSync } = await import('node:child_process'); const { fixture } = await import('./helpers.mjs');
|
||||
const root=mkdtempSync(join(tmpdir(),'ai-')); t.after(()=>rmSync(root,{recursive:true,force:true})); const f=fixture(root);
|
||||
const config=JSON.parse(readFileSync(f.env.MOSAIC_CONFIG)); config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'pocket',issuer:'https://id.test',clientId:'test',clientSecret:{slot:'pocket'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}};
|
||||
writeFileSync(f.env.MOSAIC_CONFIG,JSON.stringify(config));
|
||||
const prelude=join(root,'uid.mjs'); const cli=new URL('../src/cli.mjs',import.meta.url).pathname;
|
||||
const run=args=>spawnSync(process.execPath,['--import',prelude,cli,'accounts',...args],{env:{...process.env,...f.env},encoding:'utf8',timeout:10000});
|
||||
writeFileSync(prelude,`process.geteuid=()=>${process.geteuid()+1};`); const refused=run(['init']); assert.equal(refused.status,3,refused.stderr); assert.match(refused.stderr,/accounts-unsafe: (a config directory ancestor|config directory) must belong to (root or )?uid \d+/);
|
||||
for(const name of ['accounts.json','accounts.lock','accounts-receipts.jsonl']) assert.equal(existsSync(join(root,'config',name)),false);
|
||||
// Different real uid, same effective uid: the effective identity is recorded.
|
||||
writeFileSync(prelude,`process.getuid=()=>${process.geteuid()+2};`); const ok=run(['init']); assert.equal(ok.status,0,ok.stderr);
|
||||
const receipt=JSON.parse(readFileSync(join(root,'config/accounts-receipts.jsonl'),'utf8')); assert.equal(receipt.uid,process.geteuid());
|
||||
});
|
||||
|
||||
// org-acct-anchor (#1873): `mosaic accounts init` refuses under an ancestor another user could
|
||||
// rename, before any lock or write, and the refusal names the rule, never the path.
|
||||
test('org-acct-anchor: init, list and remove refuse a 0777 ancestor before any lock; 1777 passes', async t => {
|
||||
const top = mkdtempSync(join(tmpdir(), 'a-')); t.after(() => { chmodSync(top, 0o700); rmSync(top, { recursive: true, force: true }); });
|
||||
const parent = join(top, 'sk-test-NOT-A-SECRET-1234'), configDir = join(parent, 'cfg'); mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
||||
let output = ''; const io = { env: {}, stdout: { write: s => output += s }, stderr: { write: () => {} } };
|
||||
const deps = { configDir, instance: 'test', system: { console: { sso: { providers } } } };
|
||||
chmodSync(parent, 0o777);
|
||||
for (const args of [['init'], ['list'], ['user', 'remove', 'u-000000000001', '--revision', '1']]) {
|
||||
await assert.rejects(accountsCommand(args, io, deps), e => e.exitCode === 3 && /^refused: accounts-unsafe: a config directory ancestor must belong to root or uid \d+ and not be writable by others without the sticky bit$/.test(e.message) && !e.message.includes('sk-test') && !e.message.includes('/'));
|
||||
for (const name of ['accounts.json', 'accounts.lock', 'accounts-receipts.jsonl', 'org-runtime.lock']) assert.equal(existsSync(join(configDir, name)), false, name);
|
||||
}
|
||||
chmodSync(parent, 0o1777);
|
||||
await accountsCommand(['init'], io, deps); assert.match(output, /accounts init: u-[0-9a-f]{12}, revision 1/);
|
||||
});
|
||||
@@ -0,0 +1,595 @@
|
||||
import { requestHostOps } from './request-host-ops.mjs';
|
||||
// #1873 Phase A slice A3: root and project agent copies through the real bus and launcher. The
|
||||
// cli's boot step reads the root file once and hands the snapshot to the broker and the launcher;
|
||||
// a bound copy launches as an ordinary instance of the organization, an unbound or refused one
|
||||
// refuses at the verb, and a project agent gets only its own project. Real runners in the bwrap
|
||||
// sandbox on the fake adapter, with the in-process host lifecycle.test.mjs uses.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { businessDoc, launchDoc, tokenFile, writeJson } from "../../organization/tests/helpers.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { launchLogFile } from "../../seat/src/session.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
|
||||
|
||||
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
const VARS = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
|
||||
async function until(fn, ms = 15000) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
throw new Error("timed out waiting");
|
||||
}
|
||||
|
||||
function gitRepo(dir) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const git = (...args) => {
|
||||
const r = spawnSync("git", ["-c", "maintenance.auto=false", "-c", "gc.auto=0", "-c", "user.name=test", "-c", "[email protected]", "-C", dir, ...args], { env: gitEnv(), encoding: "utf8" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
};
|
||||
git("init", "-q", "-b", "main");
|
||||
writeFileSync(join(dir, "file.txt"), "one\n");
|
||||
git("add", "file.txt");
|
||||
git("commit", "-q", "-m", "one");
|
||||
}
|
||||
|
||||
// acme: two code projects, stack and web. stack defines the project agent checker. The root file
|
||||
// holds gate (merge-gate, no credential: bound), researcher (research, needs gitea: unbound in every
|
||||
// organization) and sec (security-reviewer, excluded from acme). The coder's gitea token file is
|
||||
// named for, and holds, the test secret, so it is bound elsewhere in the organization.
|
||||
function prepare(t, edit = (doc) => doc) {
|
||||
const root = tmp(t, "mosaic-copy-");
|
||||
const f = fixture(root, "acme", (doc) => {
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
doc.roles.coder.vars.model = "claude-sonnet-5-5";
|
||||
const secret = tokenFile(join(root, "secrets"), SECRET);
|
||||
writeFileSync(secret, SECRET);
|
||||
doc.roles.coder.credentials.gitea = { ...doc.roles.coder.credentials.gitea, file: secret };
|
||||
doc.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
|
||||
doc.projects.stack.agents = { checker: { definition: "merge-gate", vars: VARS } };
|
||||
doc.projects.web = { root: join(root, "web") };
|
||||
return edit(doc);
|
||||
});
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
gitRepo(f.doc.projects.stack.root);
|
||||
gitRepo(f.doc.projects.web.root);
|
||||
writeJson(join(root, "config", "agents.json"), { agentsVersion: 1, agents: {
|
||||
gate: { definition: "merge-gate", vars: VARS },
|
||||
researcher: { definition: "research", vars: VARS },
|
||||
sec: { definition: "security-reviewer", vars: VARS, excludeOrgs: ["acme"] },
|
||||
} }, 0o600);
|
||||
const adapter = join(root, "adapter.sh");
|
||||
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
|
||||
return { root, f, adapter };
|
||||
}
|
||||
|
||||
async function start(t, { f, adapter }, { businessId = "acme", orgRoot } = {}) {
|
||||
const system = loadSystem({ env: f.env });
|
||||
const { trackers, ...boot } = bootConfig({ system, businessId, env: f.env, ...(orgRoot ? { orgRoot } : {}) });
|
||||
const runtime = await startBroker(boot);
|
||||
const dataRoot = boot.dataRoot;
|
||||
mkdirSync(join(dataRoot, "bus-host"), { recursive: true, mode: 0o700 });
|
||||
const ops = {
|
||||
...requestHostOps(runtime),
|
||||
identity: (m) => runtime.identity(m.cap),
|
||||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||
endLaunch: (m) => runtime.endLaunch(m.record),
|
||||
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
||||
threadsState: (m) => runtime.threadsState(m.business),
|
||||
threadIdle: (m) => runtime.threadIdle(m.record),
|
||||
mayLaunch: (m) => runtime.mayLaunch(m),
|
||||
landHeld: (m) => runtime.landHeld(m.record),
|
||||
landHolders: (m) => runtime.landHolders(m.business),
|
||||
landRelease: (m) => runtime.landRelease(m.record),
|
||||
lifecycleRequests: (m) => runtime.lifecycleRequests(m.business),
|
||||
lifecycleTake: (m) => runtime.lifecycleTake(m.record),
|
||||
lifecycleDelete: (m) => runtime.lifecycleDelete(m.record),
|
||||
lifecycleDone: (m) => runtime.lifecycleDone(m.record),
|
||||
};
|
||||
const refusing = (what, fn) => async (m) => {
|
||||
try {
|
||||
return fn(m);
|
||||
} catch (e) {
|
||||
const code = e instanceof BusError ? e.code : `${what}-refused`;
|
||||
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
|
||||
}
|
||||
};
|
||||
const hooks = [];
|
||||
let closing = null;
|
||||
const host = {
|
||||
business: businessId,
|
||||
path: runtime.path,
|
||||
dataRoot,
|
||||
op: refusing("launch-op", (m) => {
|
||||
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
|
||||
return ops[m.op](m);
|
||||
}),
|
||||
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
|
||||
beforeClose: (fn) => void hooks.push(fn),
|
||||
};
|
||||
const logs = [];
|
||||
const launcher = createLauncher({
|
||||
host,
|
||||
system: orgRoot ? { ...system, dataRoot } : system,
|
||||
env: f.env,
|
||||
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
|
||||
sessionDefaults: { pollInterval: 100 },
|
||||
harnessVersion: "0.85.1",
|
||||
readOnly: [FAKE],
|
||||
threadTickMs: 3600000,
|
||||
stopTimeoutMs: 1000,
|
||||
log: (l) => logs.push(l),
|
||||
copies: boot.copies ?? null,
|
||||
});
|
||||
const close = () =>
|
||||
(closing ??= (async () => {
|
||||
for (const fn of hooks) await fn(0);
|
||||
await runtime.close();
|
||||
})());
|
||||
t.after(close);
|
||||
await launcher.listen();
|
||||
const human = runtime.broker.bindHuman({ business: businessId, human: f.doc.human, via: "cli", outsideAgent: true });
|
||||
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
|
||||
const carry = async (request) => {
|
||||
await launcher.tick();
|
||||
return call("instance.request", { request });
|
||||
};
|
||||
const running = (instance) => launcher.registry.sessions.filter((s) => s.instance === instance);
|
||||
return { boot, dataRoot, runtime, launcher, host, call, carry, running, logs, close };
|
||||
}
|
||||
|
||||
// What the bus's trail holds: every event body, as text.
|
||||
function trail(dataRoot) {
|
||||
const db = new DatabaseSync(join(dataRoot, "bus", "bus.sqlite"), { readOnly: true });
|
||||
try {
|
||||
return db.prepare("SELECT kind, body FROM events").all().map((e) => `${e.kind} ${e.body}`).join("\n");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
const refusedAt = (s, instance) => {
|
||||
try {
|
||||
s.call("instance.launch", { instance });
|
||||
} catch (e) {
|
||||
assert.ok(e instanceof BusError, String(e));
|
||||
return e;
|
||||
}
|
||||
assert.fail(`${instance} launched`);
|
||||
};
|
||||
|
||||
test("A3: a bound root copy launches as an instance of the organization; the excluded one and the root assistant aren't there", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
assert.deepEqual(Object.keys(s.boot.copies.copies).sort(), ["checker.stack", "gate", "researcher"]);
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("gate");
|
||||
assert.equal(run.run, done.done.run);
|
||||
// The grid read names it running from the bus's own claims.
|
||||
await until(() => readFileSync(join(s.dataRoot, "launches", "acme", run.run, "runner.log"), "utf8").includes("claimed by run"));
|
||||
const grid = s.call("instances");
|
||||
assert.equal(grid.copies.gate.state, "instance");
|
||||
assert.deepEqual(grid.copies.gate.running, [run.run]);
|
||||
// sec is excluded from acme: no copy, no instance, and a launch names it unknown.
|
||||
assert.equal(Object.hasOwn(grid.copies, "sec"), false);
|
||||
assert.equal(refusedAt(s, "sec").code, "unknown-role");
|
||||
});
|
||||
|
||||
test("A3: an unbound copy refuses at the verb by name and binding; no value reaches the refusal, the trail or the grid", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const e = refusedAt(s, "researcher");
|
||||
assert.equal(e.code, "credential-unbound");
|
||||
assert.match(e.reason, /^researcher has no binding in acme for gitea$/);
|
||||
const grid = JSON.stringify(s.call("instances"));
|
||||
assert.match(grid, /"researcher":\{[^}]*"state":"unbound"/);
|
||||
for (const text of [e.message, e.reason, grid, trail(s.dataRoot), s.logs.join("\n")]) {
|
||||
assert.equal(text.includes(SECRET), false);
|
||||
assert.equal(text.includes(p.root), false, "no host path");
|
||||
}
|
||||
// Nothing was asked of the launcher.
|
||||
await s.launcher.tick();
|
||||
assert.equal(s.running("researcher").length, 0);
|
||||
});
|
||||
|
||||
test("A3: a project agent launches with only its own project's mapping; a project whose union refuses refuses every agent in it", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "checker.stack" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("checker.stack");
|
||||
const manifest = JSON.parse(readFileSync(join(run.runDir, "bundle", "manifest.json"), "utf8"));
|
||||
assert.deepEqual(manifest.projects, ["stack"]);
|
||||
// An org instance of the same role takes both code projects.
|
||||
const coder = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
|
||||
assert.equal(coder.done.refusal, null, JSON.stringify(coder));
|
||||
const [c] = s.running("coder");
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(c.runDir, "bundle", "manifest.json"), "utf8")).projects.sort(), ["stack", "web"]);
|
||||
|
||||
// web excludes an agent nothing defines: its union refuses, so its agents refuse, naming web.
|
||||
const q = prepare(t, (doc) => {
|
||||
doc.projects.web.agents = { checker: { definition: "merge-gate", vars: VARS }, lint: { definition: "security-reviewer", vars: VARS }, ghost: { exclude: true } };
|
||||
return doc;
|
||||
});
|
||||
const r = await start(t, q);
|
||||
for (const name of ["checker.web", "lint.web"]) {
|
||||
const e = refusedAt(r, name);
|
||||
assert.equal(e.code, "project-refused");
|
||||
assert.match(e.reason, new RegExp(`^${name.replace(".", "\\.")} can't launch in acme: project web refuses its agents: .*ghost`));
|
||||
}
|
||||
// The other project's agent is untouched.
|
||||
assert.equal((await r.carry(r.call("instance.launch", { instance: "checker.stack" }).request)).done.refusal, null);
|
||||
// An inherited root copy and an organization instance still launch, without web's mount.
|
||||
for (const instance of ["gate", "coder"]) {
|
||||
const out = await r.carry(r.call("instance.launch", { instance }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const [inherited] = r.running(instance);
|
||||
assert.deepEqual(projectsOf(inherited), ["stack"], instance);
|
||||
}
|
||||
});
|
||||
|
||||
const projectsOf = (run) => JSON.parse(readFileSync(join(run.runDir, "bundle", "manifest.json"), "utf8")).projects.sort();
|
||||
const bundleOf = (run, file) => JSON.parse(readFileSync(join(run.runDir, "bundle", file), "utf8"));
|
||||
|
||||
test("A3: a project's exclude takes its mount from an inherited agent, and its narrow reaches the agent's real bundle", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t, (doc) => {
|
||||
doc.projects.stack.agents.gate = { vars: { "limits.tools": ["read"] } };
|
||||
doc.projects.web.agents = { gate: { exclude: true } };
|
||||
return doc;
|
||||
});
|
||||
const s = await start(t, p);
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack"]);
|
||||
const tools = bundleOf(run, "policy.json").tools;
|
||||
assert.ok(tools.includes("read"), JSON.stringify(tools));
|
||||
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
|
||||
// The launch record names the skipped project and the code, never a reason text.
|
||||
const skipped = readFileSync(launchLogFile(s.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l)).filter((e) => e.event === "checkout-skipped");
|
||||
assert.deepEqual(skipped.map(({ instance, project, reason }) => ({ instance, project, reason })), [{ instance: "gate", project: "web", reason: "project-refused" }]);
|
||||
});
|
||||
|
||||
// Writes the organization file the way a human edit does, outside any writer verb.
|
||||
const editOrg = (p, change) => {
|
||||
const file = join(p.root, "config", "businesses", "acme.json");
|
||||
const doc = JSON.parse(readFileSync(file, "utf8"));
|
||||
change(doc);
|
||||
writeFileSync(file, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
|
||||
};
|
||||
// A writer verb reloads the broker from the file. Keeping coder twice changes nothing and refuses,
|
||||
// so a repeat reload drops the keep instead.
|
||||
const reload = (s) => {
|
||||
try { return s.call("veto.set", { revision: s.call("instances").revision, op: "keep", name: "coder" }); }
|
||||
catch (e) { if (e.code !== "organization-no-change") throw e; }
|
||||
return s.call("veto.set", { revision: s.call("instances").revision, op: "unkeep", name: "coder" });
|
||||
};
|
||||
// What the run's first turn said, from inside the real sandbox, about the web checkout.
|
||||
async function webSeen(s, run) {
|
||||
await until(() => existsSync(join(run.runDir, "runner.log")) && readFileSync(join(run.runDir, "runner.log"), "utf8").includes("claimed by run"));
|
||||
s.call("message.send", { to: run.instance, class: "REQUEST", body: "fixture" });
|
||||
const said = join(run.runDir, "turns", "0001.stderr");
|
||||
await until(() => existsSync(said) && /WEB-(READABLE|ABSENT)/.test(readFileSync(said, "utf8")));
|
||||
return readFileSync(said, "utf8").match(/WEB-(READABLE|ABSENT)/)[1];
|
||||
}
|
||||
|
||||
test("A3: a checkout an earlier launch made is out of sight in the sandbox once its project excludes the agent, and stays on the host", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
// The adapter runs inside the sandbox and says only whether the web checkout's file is readable.
|
||||
writeFileSync(p.adapter, `if test -r "$MOSAIC_WORKSPACE/web/file.txt"; then echo WEB-READABLE >&2; else echo WEB-ABSENT >&2; fi\nexec '${process.execPath}' '${FAKE}'\n`);
|
||||
const s = await start(t, p);
|
||||
let out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
let [run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack", "web"]);
|
||||
assert.equal(await webSeen(s, run), "READABLE");
|
||||
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
||||
await until(() => s.running("gate").length === 0);
|
||||
for (const exclude of [{ gate: { exclude: true } }, { ghost: { exclude: true } }]) {
|
||||
// The project excludes gate, then refuses its whole union.
|
||||
editOrg(p, (d) => { d.projects.web.agents = exclude; });
|
||||
reload(s);
|
||||
out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
[run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack"]);
|
||||
assert.equal(await webSeen(s, run), "ABSENT");
|
||||
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
||||
await until(() => s.running("gate").length === 0);
|
||||
}
|
||||
// The earlier work is still on the host, untouched.
|
||||
assert.equal(readFileSync(join(s.dataRoot, "workspaces", "acme", "gate", "web", "file.txt"), "utf8"), "one\n");
|
||||
});
|
||||
|
||||
test("A3: a project that excludes the agent while its launch is prepared refuses the launch; the bus's last admission asks too", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const op = s.host.op;
|
||||
let changed = false;
|
||||
s.host.op = async (m) => {
|
||||
const out = await op(m);
|
||||
if (!changed && m.op === "request.admission" && m.project === "web") {
|
||||
changed = true;
|
||||
editOrg(p, (d) => { d.projects.web.agents = { gate: { exclude: true } }; });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(changed, true);
|
||||
assert.equal(out.done.refusal, "project-refused", JSON.stringify(out));
|
||||
assert.equal(s.running("gate").length, 0);
|
||||
// Once the bus has the change, its launch-boundary admission refuses the instance there too.
|
||||
reload(s);
|
||||
const revision = s.call("instances").revision;
|
||||
assert.deepEqual(s.runtime.admission({ business: "acme", projects: ["stack", "web"], instance: "gate", revision }).code, "project-refused");
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate", revision }).ready, true);
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack", "web"] }).ready, true);
|
||||
// The launch boundary takes an instance only with the revision its bundle came from.
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate" }).code, "project-unknown");
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate", revision: "0".repeat(64) }).code, "organization-changed");
|
||||
});
|
||||
|
||||
// Launches gate with both projects, reads web from inside the sandbox, and ends the run, so the
|
||||
// workspace keeps a web checkout an earlier launch made.
|
||||
async function launchBoth(s) {
|
||||
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const [run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack", "web"]);
|
||||
assert.equal(await webSeen(s, run), "READABLE");
|
||||
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
||||
await until(() => s.running("gate").length === 0);
|
||||
}
|
||||
const readsWeb = (p) => writeFileSync(p.adapter, `if test -r "$MOSAIC_WORKSPACE/web/file.txt"; then echo WEB-READABLE >&2; else echo WEB-ABSENT >&2; fi\nexec '${process.execPath}' '${FAKE}'\n`);
|
||||
|
||||
test("A3: a checkout of a project the bus doesn't admit now is out of sight too, not only an excluded one", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
readsWeb(p);
|
||||
const s = await start(t, p);
|
||||
await launchBoth(s);
|
||||
// web is declared and gate is in its union, but the bus doesn't admit the project for this launch.
|
||||
const op = s.host.op;
|
||||
s.host.op = async (m) => (m.op === "request.admission" && m.project === "web" ? { ready: false, revision: null, code: "project-pending" } : op(m));
|
||||
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const [run] = s.running("gate");
|
||||
assert.deepEqual(projectsOf(run), ["stack"]);
|
||||
assert.equal(await webSeen(s, run), "ABSENT");
|
||||
assert.equal(readFileSync(join(s.dataRoot, "workspaces", "acme", "gate", "web", "file.txt"), "utf8"), "one\n");
|
||||
});
|
||||
|
||||
test("A3: a symlink or a file where a hidden checkout would be refuses the launch, and nothing on the host is touched", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
readsWeb(p);
|
||||
const s = await start(t, p);
|
||||
await launchBoth(s);
|
||||
const ws = join(s.dataRoot, "workspaces", "acme", "gate");
|
||||
// The run's own workspace moves the checkout aside and leaves a link at web, then web excludes gate.
|
||||
renameSync(join(ws, "web"), join(ws, "kept"));
|
||||
symlinkSync(join(ws, "kept"), join(ws, "web"));
|
||||
editOrg(p, (d) => { d.projects.web.agents = { gate: { exclude: true } }; });
|
||||
reload(s);
|
||||
let out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, "checkout-unsafe", JSON.stringify(out));
|
||||
assert.equal(s.running("gate").length, 0);
|
||||
assert.equal(lstatSync(join(ws, "web")).isSymbolicLink(), true);
|
||||
assert.equal(readFileSync(join(ws, "kept", "file.txt"), "utf8"), "one\n");
|
||||
// A plain file there refuses the same way.
|
||||
renameSync(join(ws, "web"), join(ws, "link"));
|
||||
writeFileSync(join(ws, "web"), "not a checkout\n");
|
||||
out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(out.done.refusal, "checkout-unsafe", JSON.stringify(out));
|
||||
assert.equal(readFileSync(join(ws, "web"), "utf8"), "not a checkout\n");
|
||||
});
|
||||
|
||||
test("A3: a narrow that lands after the launcher's recheck and before the bus's last admission refuses the launch; the next one takes it", { skip, timeout: 90000 }, async (t) => {
|
||||
for (const [instance, narrow, after] of [
|
||||
// An inherited agent's narrow in its project.
|
||||
["gate", (d) => { d.projects.stack.agents.gate = { vars: { "limits.tools": ["read"] } }; }, "launches"],
|
||||
// A project agent's define: its vars change after boot.
|
||||
["checker.stack", (d) => { d.projects.stack.agents.checker.vars["limits.tools"] = ["read"]; }, "project-refused"],
|
||||
]) {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const op = s.host.op;
|
||||
let landed = false;
|
||||
// The narrow lands after every launcher read, just before the bus's last admission answers.
|
||||
s.host.op = async (m) => {
|
||||
if (!landed && m.op === "request.admission" && m.projects !== undefined) { landed = true; editOrg(p, narrow); reload(s); }
|
||||
return op(m);
|
||||
};
|
||||
let out = await s.carry(s.call("instance.launch", { instance }).request);
|
||||
assert.equal(landed, true, instance);
|
||||
assert.equal(out.done.refusal, "organization-changed", `${instance} ${JSON.stringify(out)}`);
|
||||
assert.equal(s.running(instance).length, 0, instance);
|
||||
// The next launch resolves from the narrowed file.
|
||||
if (after === "launches") {
|
||||
out = await s.carry(s.call("instance.launch", { instance }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const tools = bundleOf(s.running(instance)[0], "policy.json").tools;
|
||||
assert.ok(tools.includes("read"), JSON.stringify(tools));
|
||||
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
|
||||
} else {
|
||||
assert.equal(refusedAt(s, instance).code, after, instance);
|
||||
}
|
||||
await s.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("A3: a project define whose vars change after boot refuses until the next start rather than launch with the old ones", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
let s = await start(t, p);
|
||||
editOrg(p, (d) => { d.projects.stack.agents.checker.vars["limits.tools"] = ["read"]; });
|
||||
reload(s);
|
||||
const copy = s.call("instances").copies["checker.stack"];
|
||||
assert.equal(copy.state, "refused");
|
||||
assert.match(copy.reason, /^project stack changed checker's vars since the bus started/);
|
||||
assert.equal(refusedAt(s, "checker.stack").code, "project-refused");
|
||||
await s.close();
|
||||
s = await start(t, p);
|
||||
const out = await s.carry(s.call("instance.launch", { instance: "checker.stack" }).request);
|
||||
assert.equal(out.done.refusal, null, JSON.stringify(out));
|
||||
const tools = bundleOf(s.running("checker.stack")[0], "policy.json").tools;
|
||||
assert.ok(tools.includes("read"), JSON.stringify(tools));
|
||||
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
|
||||
});
|
||||
|
||||
test("A3: a collision a project brings in after boot refuses the copy it collides with, naming both claimants", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const rootFile = join(p.root, "config", "agents.json");
|
||||
const root = JSON.parse(readFileSync(rootFile, "utf8"));
|
||||
root.agents["new.stack"] = { definition: "security-reviewer", vars: VARS };
|
||||
writeFileSync(rootFile, JSON.stringify(root));
|
||||
const s = await start(t, p);
|
||||
assert.equal(s.call("instances").copies["new.stack"].state, "instance");
|
||||
editOrg(p, (d) => { d.projects.stack.agents.new = { definition: "merge-gate", vars: VARS }; });
|
||||
reload(s);
|
||||
const copy = s.call("instances").copies["new.stack"];
|
||||
assert.equal(copy.state, "refused");
|
||||
assert.deepEqual(copy.claims, [{ name: "new.stack", project: null }, { name: "new", project: "stack" }]);
|
||||
assert.equal(copy.reason, "the instance name new.stack is claimed by root agent new.stack and project stack's new");
|
||||
assert.equal(refusedAt(s, "new.stack").code, "copy-refused");
|
||||
// Taking the project's define back out frees the root copy again.
|
||||
editOrg(p, (d) => { delete d.projects.stack.agents.new; });
|
||||
reload(s);
|
||||
assert.equal(s.call("instances").copies["new.stack"].state, "instance");
|
||||
});
|
||||
|
||||
test("A3: an organization instance written over a root copy after boot launches as itself, with its own role", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
assert.equal(s.call("instances").copies.gate.state, "instance");
|
||||
const file = join(p.root, "config", "businesses", "acme.json");
|
||||
const doc = JSON.parse(readFileSync(file, "utf8"));
|
||||
doc.roles.gate = { definition: "security-reviewer", vars: VARS };
|
||||
writeFileSync(file, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
|
||||
// A writer verb reloads the broker from the file, as any human edit would.
|
||||
const { revision } = s.call("instances");
|
||||
s.call("veto.set", { revision, op: "keep", name: "coder" });
|
||||
assert.equal(Object.hasOwn(s.call("instances").copies ?? {}, "gate"), false);
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("gate");
|
||||
assert.equal(bundleOf(run, "manifest.json").definition, "security-reviewer");
|
||||
});
|
||||
|
||||
test("A3: two organizations each launch their own gate copy; runs, sessions and trails stay under each one's data root", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const beta = businessDoc(p.root, "beta");
|
||||
for (const r of Object.values(beta.roles)) r.vars = { ...VARS, ...r.vars, model: r.vars?.model ?? "claude-sonnet-5-5" };
|
||||
beta.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
beta.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
|
||||
beta.plugins = { ...beta.plugins, harness: ["pi", "claude-code"] };
|
||||
writeJson(join(p.root, "config", "businesses", "beta.json"), beta);
|
||||
const a = await start(t, p);
|
||||
mkdirSync(join(p.root, "ob"), { mode: 0o700 });
|
||||
const b = await start(t, p, { businessId: "beta", orgRoot: join(p.root, "ob") });
|
||||
const runs = {};
|
||||
for (const [id, s] of [["acme", a], ["beta", b]]) {
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = s.running("gate");
|
||||
runs[id] = run;
|
||||
assert.ok(run.runDir.startsWith(join(s.dataRoot, "launches", id) + "/"), run.runDir);
|
||||
await until(() => existsSync(join(run.runDir, "session")));
|
||||
await until(() => readFileSync(join(run.runDir, "runner.log"), "utf8").includes("claimed by run"));
|
||||
assert.deepEqual(s.call("instances").copies.gate.running, [run.run]);
|
||||
}
|
||||
assert.notEqual(runs.acme.run, runs.beta.run);
|
||||
// Neither bus, trail or data root holds the other's run.
|
||||
for (const [mine, other, s] of [["acme", "beta", a], ["beta", "acme", b]]) {
|
||||
assert.equal(trail(s.dataRoot).includes(runs[other].run), false, mine);
|
||||
assert.equal(existsSync(join(s.dataRoot, "launches", other)), false, mine);
|
||||
assert.equal(readdirSync(s.dataRoot, { recursive: true }).join("\n").includes(runs[other].run), false, mine);
|
||||
}
|
||||
assert.equal(b.running("gate").some((r) => r.run === runs.acme.run), false);
|
||||
});
|
||||
|
||||
test("A3: a root file change while the bus runs reaches no relaunch or reload; the next boot takes it", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const rootFile = join(p.root, "config", "agents.json");
|
||||
writeJson(rootFile, { agentsVersion: 1, agents: { fresh: { definition: "merge-gate", vars: VARS } } }, 0o600);
|
||||
// A launch, a relaunch and a reload all keep the boot's copy set.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
await until(() => s.running("gate").length === 1 && readFileSync(join(s.running("gate")[0].runDir, "runner.log"), "utf8").includes("claimed by run"));
|
||||
const { revision } = s.call("instances");
|
||||
s.call("veto.set", { revision, op: i ? "unkeep" : "keep", name: "coder" });
|
||||
assert.deepEqual(Object.keys(s.call("instances").copies).sort(), ["checker.stack", "gate", "researcher"]);
|
||||
assert.equal(refusedAt(s, "fresh").code, "unknown-role");
|
||||
await s.carry(s.call("instance.end", { instance: "gate" }).request);
|
||||
await until(() => s.running("gate").length === 0);
|
||||
}
|
||||
await s.close();
|
||||
// A restart reads the file again.
|
||||
const next = await start(t, p);
|
||||
assert.deepEqual(Object.keys(next.boot.copies.copies).sort(), ["checker.stack", "fresh"]);
|
||||
assert.equal(refusedAt(next, "gate").code, "unknown-role");
|
||||
assert.equal((await next.carry(next.call("instance.launch", { instance: "fresh" }).request)).done.refusal, null);
|
||||
});
|
||||
|
||||
test("A3: the chief can't launch a copy; only the human or a thread names one", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const chief = await s.host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
await new Client({ path: s.runtime.path, cap: chief.cap }).call("role.claim");
|
||||
for (const instance of ["gate", "checker.stack"]) {
|
||||
const out = await s.launcher.fromSession(chief.cap, instance);
|
||||
assert.deepEqual(out, { ok: false, error: "instance-not-listed" }, instance);
|
||||
}
|
||||
const unbound = await s.launcher.fromSession(chief.cap, "researcher");
|
||||
assert.deepEqual(unbound, { ok: false, error: "credential-unbound" });
|
||||
assert.equal(s.launcher.registry.sessions.length, 0);
|
||||
});
|
||||
|
||||
test("A3: a second organization boots its own copies from the same root file and shares no record, request or session with the first", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const beta = businessDoc(p.root, "beta");
|
||||
for (const r of Object.values(beta.roles)) r.vars = { ...VARS, ...r.vars, model: r.vars?.model ?? "claude-sonnet-5-5" };
|
||||
beta.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
beta.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
|
||||
beta.plugins = { ...beta.plugins, harness: ["pi", "claude-code"] };
|
||||
writeJson(join(p.root, "config", "businesses", "beta.json"), beta);
|
||||
const a = await start(t, p);
|
||||
mkdirSync(join(p.root, "ob"), { mode: 0o700 });
|
||||
const b = await start(t, p, { businessId: "beta", orgRoot: join(p.root, "ob") });
|
||||
assert.notEqual(a.dataRoot, b.dataRoot);
|
||||
assert.equal(a.boot.copies.org, "acme");
|
||||
assert.equal(b.boot.copies.org, "beta");
|
||||
// beta isn't excluded from sec, and has no project agent of acme's.
|
||||
assert.deepEqual(Object.keys(b.boot.copies.copies).sort(), ["gate", "researcher", "sec"]);
|
||||
const { request } = a.call("instance.launch", { instance: "gate" });
|
||||
const done = await a.carry(request);
|
||||
assert.equal(done.done.refusal, null, JSON.stringify(done));
|
||||
const [run] = a.running("gate");
|
||||
// beta's bus holds none of it: no request, no claim, no run, no session.
|
||||
assert.throws(() => b.call("instance.request", { request }), BusError);
|
||||
assert.deepEqual(b.call("instances").copies.gate.running, []);
|
||||
assert.equal(trail(b.dataRoot).includes(run.run), false);
|
||||
assert.equal(existsSync(join(b.dataRoot, "launches", "acme")), false);
|
||||
const sessions = (root) => existsSync(join(root, "sessions")) ? readdirSync(join(root, "sessions"), { recursive: true }).join("\n") : "";
|
||||
assert.equal(sessions(b.dataRoot).includes(run.run), false);
|
||||
assert.equal(b.running("gate").length, 0);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
// #1873 A2: `mosaic agents root` and `mosaic project agents`, end to end through scripts/mosaic on a
|
||||
// bootstrapped named instance (the org CLI's own setup, org.test.mjs).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const repo = resolve(import.meta.dirname, '../../..');
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
const ABSENT = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
||||
|
||||
function instance() {
|
||||
const home = mkdtempSync(join(tmpdir(), 'a2cli-'));
|
||||
const config = join(home, '.config/mosaic-atwo/config.json');
|
||||
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home, MOSAIC_CONFIG: config };
|
||||
const boot = spawnSync(join(repo, 'scripts/bootstrap.sh'), ['--instance', 'atwo'], { cwd: repo, env, encoding: 'utf8' });
|
||||
assert.equal(boot.status, 0, boot.stderr);
|
||||
const run = (args, extra = {}) => spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env: { ...env, ...extra }, encoding: 'utf8' });
|
||||
const ok = (...args) => { const r = run(args); assert.equal(r.status, 0, `${args.join(' ')}: ${r.stderr}`); return r.stdout; };
|
||||
const file = join(home, 'alpha.json');
|
||||
ok('org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file);
|
||||
ok('org', 'create', 'alpha', '--from', file);
|
||||
const project = Object.keys(JSON.parse(readFileSync(join(home, '.config/mosaic-atwo/businesses/alpha.json'), 'utf8')).projects)[0];
|
||||
return { home, env, run, ok, project, configDir: join(home, '.config/mosaic-atwo') };
|
||||
}
|
||||
|
||||
test('agents root list, set, remove: each write prints its receipt, values stay out of receipts and refusals', () => {
|
||||
const { run, ok, configDir } = instance();
|
||||
assert.deepEqual(JSON.parse(ok('agents', 'root', 'list', '--json')), { revision: ABSENT, state: 'absent', agents: [] });
|
||||
assert.match(ok('agents', 'root', 'list'), /^root agents: none \(no file\)/);
|
||||
const added = JSON.parse(ok('agents', 'root', 'set', 'researcher', '--definition', 'research', '--var', 'model=claude-opus-5-5'));
|
||||
assert.equal(added.verb, 'root.agent.set'); assert.equal(added.agent, 'researcher'); assert.deepEqual(added.changed, ['added']);
|
||||
assert.equal(added.revisionBefore, ABSENT);
|
||||
const patched = run(['agents', 'root', 'set', 'researcher', '--var', 'limits.network=none', '--exclude-orgs', 'home']);
|
||||
assert.equal(patched.status, 0, patched.stderr);
|
||||
assert.deepEqual(JSON.parse(patched.stdout).changed, ['vars.limits.network', 'excludeOrgs']);
|
||||
assert.ok(!patched.stdout.includes('none') && !patched.stdout.includes('claude-opus'));
|
||||
const listed = JSON.parse(ok('agents', 'root', 'list', '--json'));
|
||||
assert.equal(listed.state, 'present');
|
||||
assert.deepEqual(listed.agents, [{ name: 'researcher', definition: 'research', model: 'claude-opus-5-5', varKeys: ['limits.network'], excludeOrgs: ['home'] }]);
|
||||
// A stale --revision refuses and the file is unchanged.
|
||||
const before = readFileSync(join(configDir, 'agents.json'), 'utf8');
|
||||
const stale = run(['agents', 'root', 'set', 'researcher', '--var', 'limits.network=api-only', '--revision', added.revisionAfter]);
|
||||
assert.equal(stale.status, 2); assert.match(stale.stderr, /revision-conflict/);
|
||||
// An invalid value refuses with the key named and the value absent.
|
||||
const bad = run(['agents', 'root', 'set', 'researcher', '--var', `model=${SECRET.repeat(10)}`]);
|
||||
assert.equal(bad.status, 2); assert.match(bad.stderr, /agents-invalid: .*model/); assert.ok(!bad.stderr.includes(SECRET), bad.stderr);
|
||||
const badJson = run(['agents', 'root', 'set', 'researcher', '--var-json', `limits.tools=${SECRET}`]);
|
||||
assert.equal(badJson.status, 4); assert.match(badJson.stderr, /var limits\.tools is not valid JSON/); assert.ok(!badJson.stderr.includes(SECRET));
|
||||
const chief = run(['agents', 'root', 'set', 'boss', '--definition', 'chief-of-staff']);
|
||||
assert.equal(chief.status, 2); assert.match(chief.stderr, /chief-of-staff can't be a root agent/);
|
||||
assert.equal(readFileSync(join(configDir, 'agents.json'), 'utf8'), before);
|
||||
// An agent run is refused before anything is read.
|
||||
for (const marker of ['MOSAIC_RUN_ID', 'MOSAIC_BUS_CAP', 'MOSAIC_AGENT_RUN']) {
|
||||
const inside = run(['agents', 'root', 'remove', 'researcher'], { [marker]: '1' });
|
||||
assert.equal(inside.status, 3, `${marker}: ${inside.stderr}`);
|
||||
}
|
||||
assert.equal(readFileSync(join(configDir, 'agents.json'), 'utf8'), before);
|
||||
const removed = JSON.parse(ok('agents', 'root', 'remove', 'researcher'));
|
||||
assert.equal(removed.verb, 'root.agent.remove'); assert.deepEqual(removed.changed, ['removed']);
|
||||
const receipts = readFileSync(join(configDir, 'agents-receipts.jsonl'), 'utf8');
|
||||
assert.equal(receipts.trim().split('\n').length, 3);
|
||||
assert.ok(!receipts.includes('claude-opus') && !receipts.includes(SECRET));
|
||||
// Usage refusals never echo option values.
|
||||
const unknown = run(['agents', 'root', 'set', 'researcher', `--${SECRET}`]);
|
||||
assert.equal(unknown.status, 4); assert.ok(!unknown.stderr.includes(SECRET));
|
||||
});
|
||||
|
||||
test('a filesystem failure prints its code and a fixed line, never a host path', () => {
|
||||
const { run, configDir, home } = instance();
|
||||
// A directory where the receipt file goes: opening it fails EISDIR, whose message holds the path.
|
||||
mkdirSync(join(configDir, 'agents-receipts.jsonl'));
|
||||
const r = run(['agents', 'root', 'set', 'researcher', '--definition', 'research']);
|
||||
assert.equal(r.status, 2, r.stderr);
|
||||
assert.match(r.stderr, /EISDIR: the operation failed; the error text is not shown/);
|
||||
assert.ok(!r.stderr.includes(home), r.stderr);
|
||||
assert.equal(existsSync(join(configDir, 'agents.json')), false);
|
||||
});
|
||||
|
||||
test('a missing, malformed or unclaimed instance config refuses before anything is read or written', () => {
|
||||
const verbs = [['agents', 'root', 'set', 'researcher', '--definition', 'research'], ['agents', 'root', 'list'], ['project', 'agents', 'work', '--org', 'alpha', 'exclude', 'coder']];
|
||||
const check = (env, dir, why) => {
|
||||
for (const args of verbs) {
|
||||
const r = spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env, encoding: 'utf8' });
|
||||
assert.equal(r.status, 3, `${why} ${args.join(' ')}: ${r.stderr}`); assert.match(r.stderr, /system config problem/);
|
||||
}
|
||||
assert.equal(existsSync(join(dir, 'agents.json')), false, why);
|
||||
assert.equal(existsSync(join(dir, 'agents-receipts.jsonl')), false, why);
|
||||
};
|
||||
// Missing: the instance's config directory exists, its config.json does not.
|
||||
const home = mkdtempSync(join(tmpdir(), 'a2cfg-'));
|
||||
const dir = join(home, '.config/mosaic-atwo');
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home, MOSAIC_CONFIG: join(dir, 'config.json') };
|
||||
check(env, dir, 'missing');
|
||||
writeFileSync(join(dir, 'config.json'), '{', { mode: 0o600 });
|
||||
check(env, dir, 'malformed');
|
||||
// Unclaimed: a bootstrapped instance whose data root marker names another instance.
|
||||
const booted = instance();
|
||||
const { dataRoot } = JSON.parse(readFileSync(booted.env.MOSAIC_CONFIG, 'utf8'));
|
||||
writeFileSync(join(dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'other' }));
|
||||
check(booted.env, booted.configDir, 'marker');
|
||||
});
|
||||
|
||||
test('the default instance refuses the agent level verbs', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'a2def-'));
|
||||
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home };
|
||||
for (const args of [['agents', 'root', 'list'], ['project', 'agents', 'work', '--org', 'alpha', 'list']]) {
|
||||
const r = spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env, encoding: 'utf8' });
|
||||
assert.equal(r.status, 2, r.stderr); assert.match(r.stderr, /named instance/);
|
||||
}
|
||||
assert.equal(existsSync(join(home, '.config/mosaic-dev/agents.json')), false);
|
||||
});
|
||||
|
||||
test('project agents: set, narrow, exclude and remove print receipts; a widening narrow and the chief refuse', () => {
|
||||
const { run, ok, project, home } = instance();
|
||||
ok('agents', 'root', 'set', 'researcher', '--definition', 'research');
|
||||
ok('agents', 'root', 'set', 'scout', '--definition', 'research');
|
||||
const p = (...args) => ['project', 'agents', project, '--org', 'alpha', ...args];
|
||||
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, []);
|
||||
const defined = JSON.parse(ok(...p('set', 'checker', '--definition', 'fact-checker', '--var', 'model=claude-sonnet-5-5')));
|
||||
assert.deepEqual({ ...defined, revisionBefore: undefined, revisionAfter: undefined, transaction: undefined },
|
||||
{ verb: 'project.agents.set', organization: 'alpha', project, agent: 'checker', form: 'define', revisionBefore: undefined, revisionAfter: undefined, transaction: undefined });
|
||||
assert.notEqual(defined.revisionBefore, defined.revisionAfter);
|
||||
assert.ok(!JSON.stringify(defined).includes(home), 'no host path in the receipt');
|
||||
assert.equal(JSON.parse(ok(...p('narrow', 'researcher', '--var', 'limits.network=none'))).form, 'narrow');
|
||||
assert.equal(JSON.parse(ok(...p('exclude', 'scout'))).form, 'exclude');
|
||||
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, [
|
||||
{ name: 'checker', form: 'define', definition: 'fact-checker', varKeys: ['model'] },
|
||||
{ name: 'researcher', form: 'narrow', varKeys: ['limits.network'] },
|
||||
{ name: 'scout', form: 'exclude' },
|
||||
]);
|
||||
const file = join(home, '.config/mosaic-atwo/businesses/alpha.json');
|
||||
const before = readFileSync(file, 'utf8');
|
||||
const widen = run(p('narrow', 'scout', '--var', `model=${SECRET}`));
|
||||
assert.equal(widen.status, 2); assert.match(widen.stderr, /can only narrow limits\.tools, limits\.network, limits\.authority, not "model"/); assert.ok(!widen.stderr.includes(SECRET));
|
||||
const chief = run(p('exclude', 'chief'));
|
||||
assert.equal(chief.status, 2); assert.match(chief.stderr, /project-agents-invalid/);
|
||||
const stale = run(p('remove', 'checker', '--revision', defined.revisionBefore));
|
||||
assert.equal(stale.status, 2); assert.match(stale.stderr, /revision-conflict/);
|
||||
const inside = run(p('remove', 'checker'), { MOSAIC_RUN_ID: 'r1' });
|
||||
assert.equal(inside.status, 3);
|
||||
assert.equal(readFileSync(file, 'utf8'), before);
|
||||
for (const name of ['checker', 'researcher', 'scout']) assert.equal(JSON.parse(ok(...p('remove', name))).form, 'remove');
|
||||
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, []);
|
||||
const missing = run(['project', 'agents', 'nope', '--org', 'alpha', 'exclude', 'scout']);
|
||||
assert.equal(missing.status, 2); assert.match(missing.stderr, /project-not-found/);
|
||||
});
|
||||
@@ -100,9 +100,15 @@ function sessionPairs(pairs) {
|
||||
// Safety net for a removed-outer-bound change. It is fixture cleanup, never
|
||||
// product supervision. The guard reads only fixture records and checks sid
|
||||
// immediately before every kill. Normal outer-bound cases return before it.
|
||||
export function fixtureGuard(t,files,milliseconds=9500) {
|
||||
const program=`const fs=require('node:fs');setTimeout(()=>{for(const file of JSON.parse(process.argv[1])){let rows=[];try{rows=fs.readFileSync(file,'utf8').trim().split(String.fromCharCode(10))}catch{}for(const row of rows){const [pid,sid]=row.trim().split(' ').map(Number);if(!(pid>0&&sid>0))continue;try{const raw=fs.readFileSync('/proc/'+pid+'/stat','utf8'),f=raw.slice(raw.lastIndexOf(') ')+2).split(' ');if(Number(f[3])===sid&&f[0]!=='Z')process.kill(pid,'SIGKILL')}catch{}}}},Number(process.argv[2]));`;
|
||||
const child=spawn(process.execPath,['-e',program,JSON.stringify(files),String(milliseconds)],{stdio:'ignore'});
|
||||
export function fixtureGuard(t,files,milliseconds=9500,startFile) {
|
||||
// P10 already records the instant its observed probe starts. Arm its safety
|
||||
// net there, so unrelated host-fixture setup cannot consume the probe budget.
|
||||
// Callers without an observer retain the original immediate-start behavior.
|
||||
const program=`const fs=require('node:fs');
|
||||
function cleanup(){for(const file of JSON.parse(process.argv[1])){let rows=[];try{rows=fs.readFileSync(file,'utf8').trim().split(String.fromCharCode(10))}catch{}for(const row of rows){const [pid,sid]=row.trim().split(' ').map(Number);if(!(pid>0&&sid>0))continue;try{const raw=fs.readFileSync('/proc/'+pid+'/stat','utf8'),f=raw.slice(raw.lastIndexOf(') ')+2).split(' ');if(Number(f[3])===sid&&f[0]!=='Z')process.kill(pid,'SIGKILL')}catch{}}}}
|
||||
function arm(){setTimeout(cleanup,Number(process.argv[2]));}
|
||||
if(!process.argv[3])arm();else{const poll=setInterval(()=>{let text='';try{text=fs.readFileSync(process.argv[3],'utf8')}catch{}if(/^begin [0-9]+[.][0-9]{2}\\n/.test(text)){clearInterval(poll);arm();}},10);}`;
|
||||
const child=spawn(process.execPath,['-e',program,JSON.stringify(files),String(milliseconds),...(startFile?[startFile]:[])],{stdio:'ignore'});
|
||||
const stop=async()=>{
|
||||
if(child.exitCode!==null||child.signalCode!==null)return;
|
||||
const exited=new Promise(resolve=>child.once('exit',resolve));
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fixtureGuard } from './case-sudo-fixture.mjs';
|
||||
|
||||
test('fixture cleanup guard starts its budget at the probe marker', async t => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'guard-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const records = join(dir, 'pids'), startFile = join(dir, 'probe');
|
||||
writeFileSync(startFile, '');
|
||||
const child = spawn(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { stdio: 'ignore' });
|
||||
const exited = once(child, 'exit');
|
||||
t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); });
|
||||
const raw = readFileSync(`/proc/${child.pid}/stat`, 'utf8');
|
||||
const sid = Number(raw.slice(raw.lastIndexOf(') ') + 2).split(' ')[3]);
|
||||
writeFileSync(records, `${child.pid} ${sid}\n`);
|
||||
const stop = fixtureGuard(t, [records], 100, startFile);
|
||||
// An empty marker is setup, not the start of the observed probe. This wait
|
||||
// exceeds the cleanup budget deliberately; no production bound is changed.
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
assert.equal(child.signalCode, null, 'setup must not consume the probe cleanup budget');
|
||||
writeFileSync(startFile, 'begin 100.00\n');
|
||||
const terminal = await Promise.race([exited, new Promise((_, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('armed fixture guard did not clean up')), 3000);
|
||||
timer.unref();
|
||||
})]);
|
||||
assert.deepEqual(terminal, [null, 'SIGKILL']);
|
||||
await stop();
|
||||
});
|
||||
@@ -429,7 +429,7 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
|
||||
assert.equal(baseline.status,0,baseline.out+baseline.err);
|
||||
assert.deepEqual(await pollFixtureGone(files),[],'baseline leaves no session');
|
||||
}
|
||||
const capture=cleanupFixture(t,files);const stop=fixtureGuard(t,files,7000);
|
||||
const capture=cleanupFixture(t,files);
|
||||
if(fault==='inner')put(join(h.hc,'peer-hang'),'');
|
||||
if(fault==='outer'){put(join(h.hc,'stall-probe'),'');put(join(h.hc,'stall-output'),'200 997 997\n');}
|
||||
if(fault==='overflow'){put(join(h.hc,'peer-output'),'200 997 997\n'+'X'.repeat(256));put(join(h.hc,'peer-status'),'0');}
|
||||
@@ -440,6 +440,7 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
|
||||
put(join(h.dir,'selector.sh'),read(join(REPO,'scripts','selector.sh')),0o644);
|
||||
assert.equal(removeP10Observers(read(observedScript)),read(SCRIPT),'observer reversal equals a fresh shipped script read');
|
||||
}
|
||||
const stop=fixtureGuard(t,files,7000,timingFile);
|
||||
const start=performance.now(),r=run(h,timingFile?{ROW299_TIMING_FILE:timingFile}:{},undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
|
||||
assert.equal(r.status,0,r.out+r.err);
|
||||
const reason=fault==='inner'?'inner-timeout':fault==='outer'?'outer-timeout':'output-limit';
|
||||
|
||||
@@ -71,7 +71,7 @@ function world(t, { profile = "library" } = {}) {
|
||||
script(join(bin, "stack-copy"), `echo "stack-copy $*" >>'${log}'
|
||||
[ "$1" = copy ] && [ "$3" = --copies ] || exit 4
|
||||
mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2"
|
||||
for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/organization/src/vocabulary.mjs packages/organization/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do
|
||||
for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/organization/src/rails.mjs packages/bus/src/refusals.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/organization/src/vocabulary.mjs packages/organization/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do
|
||||
mkdir -p "$4/$2/$(dirname "$file")" && cp '${REPO}'"$file" "$4/$2/$file" || exit 1
|
||||
done
|
||||
mkdir -p "$4/$2/node_modules" && cp -R '${REPO}node_modules/yaml' "$4/$2/node_modules/yaml"`);
|
||||
@@ -148,6 +148,8 @@ test("the library install writes the business file, personas and slot dir, makes
|
||||
const x = world(t);
|
||||
const r = install(x);
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.equal(mode(join(x.dataRoot, '.deployed')), 0o444);
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(x.dataRoot, '.deployed'), 'utf8')), { instance: 'dev' });
|
||||
assert.equal(mode(x.business), 0o600);
|
||||
const doc = JSON.parse(readFileSync(x.business, "utf8"));
|
||||
for (const [instance, name] of [["bean", "Bean"], ["chet", "Chet"]]) {
|
||||
@@ -494,6 +496,9 @@ function dependencyFreeClone(t) {
|
||||
for (const file of ["case2-install.sh", "case2-persona-import.mjs", "selector.sh"]) {
|
||||
cpSync(join(REPO, "scripts", file), join(clone, "scripts", file));
|
||||
}
|
||||
mkdirSync(join(clone, 'packages/cli/src'), { recursive: true });
|
||||
cpSync(join(REPO, 'packages/cli/src/deployment.mjs'), join(clone, 'packages/cli/src/deployment.mjs'));
|
||||
writeJson(x.config, { dataRoot: x.dataRoot });
|
||||
// Isolate the persona step; bootstrap and business validation are covered above.
|
||||
script(join(clone, "scripts", "bootstrap.sh"), "exit 0");
|
||||
script(join(clone, "scripts", "mosaic"), "exit 0");
|
||||
@@ -565,3 +570,14 @@ test('row334 install Next tells root to select proxy only when configured', t =>
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.match(r.out, /Next: root runs case2-unit.sh[^\n]+add --proxy when the config has a proxy section/);
|
||||
});
|
||||
|
||||
test('M3 install Next names the SSO, accounts-init and dedicated-uid steps before root re-pins with --sso', t => {
|
||||
const x = world(t), r = install(x);
|
||||
assert.equal(r.code, 0, r.out);
|
||||
const done = r.out.split('\n').findIndex(l => l.startsWith('case2-install: done. .deployed names this instance'));
|
||||
assert.ok(done >= 0, r.out);
|
||||
assert.match(r.out.split('\n')[done], /in order \(run sheet section 7\): prepare console\.sso and its client secret slots in .+; a human runs `mosaic accounts init` as [^;]+; [^ ]+ stays a dedicated uid with a non-login shell, the only member of its primary group\.$/);
|
||||
assert.match(r.out.split('\n')[done + 1], /^case2-install: Next: root runs case2-unit\.sh --copy [0-9a-f]{40} --business \S+ with --sso in the serve flags \(factory: --sso --send;/);
|
||||
// The installer names the human step; it never runs it.
|
||||
assert.equal(existsSync(join(x.configDir, 'accounts.json')), false);
|
||||
});
|
||||
@@ -66,7 +66,7 @@ function hub(t, { uid = UID } = {}) {
|
||||
const bin = join(root, "bin");
|
||||
const log = join(root, "systemctl.log");
|
||||
script(join(bin, "getent"), `case "$1" in
|
||||
passwd) [ "$2" = mosaic ] && echo "mosaic:x:${uid}:${GID}::${home}:/bin/bash" ;;
|
||||
passwd) { [ -z "$2" ] || [ "$2" = mosaic ]; } && echo "mosaic:x:${uid}:${GID}::${home}:/usr/sbin/nologin" ;;
|
||||
group) [ "$2" = "${GID}" ] && echo "mosaic:x:${GID}:" ;;
|
||||
*) exit 2 ;;
|
||||
esac`);
|
||||
@@ -364,7 +364,7 @@ test("the unit takes --org or --business on either side of --, renders --busines
|
||||
test("the unit refuses a bad selector in either scope, and an outer and serve pair that differ, before writing a unit", (t) => {
|
||||
const h = hub(t);
|
||||
const serve = ["--port", "443", "--business", "acme"];
|
||||
const usage = "usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n";
|
||||
const usage = "usage: case2-unit.sh [--check|--check-new] --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n";
|
||||
const head = ["--user", "mosaic", "--home", h.home, "--copy", COPY_A, "--node", USR_NODE, "--profile", "factory"];
|
||||
for (const [label, argv, code, err] of [
|
||||
["outer unequal, old first", [...head, "--business", "acme", "--org", "beta", "--", ...serve], 4, "case2-unit: --business acme and --org beta conflict\n"],
|
||||
@@ -415,6 +415,8 @@ test("the read-only library profile explicitly refuses --send before writing a u
|
||||
test("PATH starts with the --node directory, for a node outside the default list and for /usr/bin/node", (t) => {
|
||||
const h = hub(t);
|
||||
const node = outsideNode();
|
||||
// This metadata-only test uses a root-owned executable as a path stand-in.
|
||||
writeFileSync(join(h.root, 'query-output'), JSON.stringify({ dataRoot: h.dataRoot, socket: true })); writeFileSync(join(h.root, 'query-status'), '0');
|
||||
assert.equal(unit(h, { node }).code, 0);
|
||||
for (const f of [busFile(h), webuiFile(h)]) assert.deepEqual(lines(read(f), "Environment"), [`PATH=${dirname(node)}:/usr/local/bin:/usr/bin:/bin`]);
|
||||
assert.match(lines(read(webuiFile(h)), "ExecStart")[0], new RegExp(`^${node} `));
|
||||
@@ -550,6 +552,7 @@ test("a --node that is relative, not root-owned or under <home> refuses", (t) =>
|
||||
test("a re-install with a different --node or different serve flags refuses", (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h).code, 0);
|
||||
writeFileSync(join(h.root, 'query-output'), JSON.stringify({ dataRoot: h.dataRoot, socket: true })); writeFileSync(join(h.root, 'query-status'), '0');
|
||||
// A new node moves PATH too, so the installed unit fails its own Environment= check first.
|
||||
refuses(unit(h, { node: outsideNode() }), /installed .*: the Environment= lines must be exactly/);
|
||||
const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f));
|
||||
@@ -705,7 +708,9 @@ test("#415 factory --sso refuses before any unit write when the stack user's con
|
||||
noUnits(h);
|
||||
});
|
||||
|
||||
test("#416 library still refuses --sso, alone and with --send, before writing a unit", (t) => {
|
||||
// M3 (#1873; Jason's ruling 2026-11-10): only an unstamped library copy keeps proxy preview. The
|
||||
// stamped copies below sign in through SSO accounts.
|
||||
test("#416 an unstamped library copy still refuses --sso, alone and with --send, before writing a unit", (t) => {
|
||||
const h = hub(t);
|
||||
ssoConfig(h);
|
||||
refuses(unit(h, { flags: [...libraryFlags(h), "--sso"] }), /refusing --sso: the Console signs people in through the proxy/);
|
||||
@@ -713,6 +718,233 @@ test("#416 library still refuses --sso, alone and with --send, before writing a
|
||||
noUnits(h);
|
||||
});
|
||||
|
||||
// ---- M3 (#1873): stamped copies ----
|
||||
// A stamped copy (stack-copy.sh's read-only .stack-build.json) serves only with --sso, console.sso,
|
||||
// an accounts.json that loads and a .deployed naming the default instance. The render asks the
|
||||
// stack user's copy through the bounded sudo query, with the copy's real deployment.mjs and
|
||||
// organization accounts.mjs, and refuses before any write, naming every missing step in order.
|
||||
// No real host, config or credential: the issuer is on .invalid and nothing connects to it.
|
||||
const ISSUER = "https://idp.invalid/a";
|
||||
const STAMP_ACCOUNTS = { accountsVersion: 1, revision: 1, users: [{ id: "u-000000000001", subjects: [{ provider: "a", sub: "owner" }], name: "Owner", instanceRole: "operator", state: "active", authGen: 1, created: "2026-11-10T00:00:00Z" }], memberships: [], invites: [] };
|
||||
const accountsFile = (h) => join(h.home, ".config", "mosaic-dev", "accounts.json");
|
||||
const deployedFile = (h) => join(h.dataRoot, ".deployed");
|
||||
function stamp(h, id = COPY_A) {
|
||||
const dir = copyDir(h, id);
|
||||
cpSync(join(REPO, "packages/cli/src/deployment.mjs"), join(dir, "packages/cli/src/deployment.mjs"));
|
||||
for (const pkg of ["organization", "seat"]) symlinkSync(join(REPO, "packages", pkg), join(dir, "packages", pkg));
|
||||
writeFileSync(join(dir, ".stack-build.json"), JSON.stringify({ commit: id }) + "\n");
|
||||
chmodSync(join(dir, ".stack-build.json"), 0o444);
|
||||
}
|
||||
const stampSso = (h) => writeJson(h.query, { dataRoot: h.dataRoot, console: { sso: { publicOrigin: "https://console.invalid", providers: [{ id: "a", issuer: ISSUER }] } } });
|
||||
function stampAccounts(h, doc = STAMP_ACCOUNTS) {
|
||||
writeFileSync(accountsFile(h), JSON.stringify(doc));
|
||||
chmodSync(accountsFile(h), 0o600);
|
||||
}
|
||||
function stampDeployed(h, instance = "dev") {
|
||||
writeFileSync(deployedFile(h), JSON.stringify({ instance }) + "\n");
|
||||
chmodSync(deployedFile(h), 0o444);
|
||||
}
|
||||
function stamped(t, { sso = true, accounts = true, deployed = true } = {}) {
|
||||
const h = hub(t);
|
||||
stamp(h);
|
||||
if (sso) stampSso(h);
|
||||
if (accounts) stampAccounts(h);
|
||||
if (deployed) stampDeployed(h);
|
||||
return h;
|
||||
}
|
||||
const STAMP_LIBRARY = ["--port", "443", "--business", "acme", "--sso"];
|
||||
const STAMP_FACTORY = ["--port", "443", "--business", "acme", "--tester-socket", "--sso", "--send"];
|
||||
const STEP = {
|
||||
deployed: (h) => `check or create .deployed: mosaic runs case2-install.sh, whose deployment step writes ${h.dataRoot}/.deployed`,
|
||||
deployedBad: (h) => `check .deployed: ${h.dataRoot}/.deployed is invalid or names another instance; case2-install.sh's deployment step reports why and never replaces it`,
|
||||
sso: (h) => `prepare SSO: console.sso in mosaic's system config ${h.home}/.config/mosaic-dev/config.json, with its client secret slots`,
|
||||
accounts: (h) => `run \`mosaic accounts init\` as mosaic, a human verb: no ${accountsFile(h)}`,
|
||||
accountsBad: (h) => `fix ${accountsFile(h)}: it does not load (its owner or mode, an active operator, or a subject console.sso does not name); a human fixes it with the accounts verbs`,
|
||||
library: () => "add --sso to the serve flags, then re-pin",
|
||||
factory: () => "add --sso --send to the serve flags, then re-pin",
|
||||
};
|
||||
// The exact refusal: one header line, then the steps numbered in the run sheet's order.
|
||||
function refusesSteps(h, r, steps) {
|
||||
assert.equal(r.code, 1, `expected a refusal, got ${r.code}: ${r.out}${r.err}`);
|
||||
const want = [`case2-unit: refusing the units for ${COPY_A}: a stamped copy's Console starts only with SSO accounts; missing steps, in order:`, ...steps.map((s, i) => ` ${i + 1}. ${s(h)}`)].join("\n") + "\n";
|
||||
assert.equal(r.err, want);
|
||||
}
|
||||
// Nothing the render must not write: no unit, no systemctl, and none of the prerequisites.
|
||||
function wroteNothing(h, { accounts = false, deployed = false } = {}) {
|
||||
noUnits(h);
|
||||
assert.equal(existsSync(accountsFile(h)), accounts, "the render never writes accounts.json");
|
||||
assert.equal(existsSync(deployedFile(h)), deployed, "the render never writes .deployed");
|
||||
}
|
||||
|
||||
test("M3 a stamped library unit renders only with --sso and the prerequisites, and runs the Console without proxy preview", (t) => {
|
||||
const h = stamped(t);
|
||||
const r = unit(h, { flags: STAMP_LIBRARY });
|
||||
assert.equal(r.code, 0, r.out + r.err);
|
||||
const webui = read(webuiFile(h));
|
||||
assert.deepEqual(lines(webui, "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve --port 443 --business acme --sso`]);
|
||||
assert.doesNotMatch(webui.split("\n").filter((l) => !l.startsWith("#")).join("\n"), /--public-host|--auth-header|--allow-user|--proof-header|--proof-file|--send/);
|
||||
assert.deepEqual(lines(read(busFile(h)), "ExecStart"), [`${copyDir(h)}/scripts/mosaic bus start %i`]);
|
||||
// The prerequisites were asked as the stack user, through the copy's own modules.
|
||||
const sudo = read(h.sudoLog);
|
||||
assert.match(sudo, new RegExp(`^-u mosaic -H env -i HOME=${h.home} PATH=`, "m"));
|
||||
assert.match(sudo, /a\.loadAccounts\(.*d\.assertDeployed\(s,dir\)/);
|
||||
assert.ok(sudo.includes(`${copyDir(h)}/packages/organization/src/accounts.mjs`));
|
||||
assert.match(r.out, /enabled and started mosaic-bus@acme\.service, then mosaic-webui\.service\n$/);
|
||||
// The render read them and left each as it was.
|
||||
assert.deepEqual(JSON.parse(read(accountsFile(h))), STAMP_ACCOUNTS);
|
||||
assert.equal(statSync(deployedFile(h)).mode & 0o777, 0o444);
|
||||
});
|
||||
|
||||
test("M3 a stamped library unit refuses proxy-preview admission, its proof and --send, before any write", (t) => {
|
||||
const h = stamped(t);
|
||||
for (const [flag, value] of [["--public-host", "books.example"], ["--auth-header", "X-Auth-User"], ["--allow-user", "melanie"], ["--proof-header", "X-Proxy-Proof"], ["--proof-file", h.proof]]) {
|
||||
const r = unit(h, { flags: [...STAMP_LIBRARY, flag, value] });
|
||||
assert.equal(r.code, 1, flag);
|
||||
assert.equal(r.err, `case2-unit: refusing ${flag}: ${COPY_A} is a stamped copy, and proxy-preview admission and its proof do not apply to a stamped copy; its Console signs in through SSO accounts with --sso\n`);
|
||||
}
|
||||
// The whole old preview set refuses at its first flag, with or without --sso.
|
||||
refuses(unit(h, { flags: libraryFlags(h) }), /refusing --public-host: .* is a stamped copy, and proxy-preview admission and its proof do not apply/);
|
||||
refuses(unit(h, { flags: [...STAMP_LIBRARY, "--send"] }), /the library profile is read-only; refusing --send \(a stamped library Console signs in with --sso and stays read-only\)/);
|
||||
wroteNothing(h, { accounts: true, deployed: true });
|
||||
assert.equal(existsSync(h.sudoLog), false, "a flag refusal comes before any query");
|
||||
});
|
||||
|
||||
test("M3 a stamped library unit without its prerequisites refuses before any write and names every missing step in order", (t) => {
|
||||
const none = stamped(t, { sso: false, accounts: false, deployed: false });
|
||||
refusesSteps(none, unit(none, { flags: ["--port", "443", "--business", "acme"] }), [STEP.deployed, STEP.sso, STEP.accounts, STEP.library]);
|
||||
wroteNothing(none);
|
||||
refusesSteps(none, unit(none, { flags: STAMP_LIBRARY }), [STEP.deployed, STEP.sso, STEP.accounts]);
|
||||
wroteNothing(none);
|
||||
// Each prerequisite alone.
|
||||
for (const [name, opts, steps] of [
|
||||
["no .deployed", { deployed: false }, [STEP.deployed]],
|
||||
["no console.sso", { sso: false }, [STEP.sso]],
|
||||
["no accounts.json", { accounts: false }, [STEP.accounts]],
|
||||
]) {
|
||||
const h = stamped(t, opts);
|
||||
refusesSteps(h, unit(h, { flags: STAMP_LIBRARY }), steps);
|
||||
wroteNothing(h, { accounts: opts.accounts !== false, deployed: opts.deployed !== false });
|
||||
}
|
||||
const flagless = stamped(t);
|
||||
refusesSteps(flagless, unit(flagless, { flags: ["--port", "443", "--business", "acme"] }), [STEP.library]);
|
||||
wroteNothing(flagless, { accounts: true, deployed: true });
|
||||
});
|
||||
|
||||
test("M3 a stamped copy refuses an invalid .deployed or accounts.json with the step that names it", (t) => {
|
||||
const cases = [
|
||||
[".deployed names another instance", (h) => { rmSync(deployedFile(h)); stampDeployed(h, "other"); }, [STEP.deployedBad]],
|
||||
[".deployed writable", (h) => chmodSync(deployedFile(h), 0o644), [STEP.deployedBad]],
|
||||
[".deployed broken link", (h) => { rmSync(deployedFile(h)); symlinkSync(join(h.root, "missing"), deployedFile(h)); }, [STEP.deployedBad]],
|
||||
["accounts without an active operator", (h) => stampAccounts(h, { ...STAMP_ACCOUNTS, users: [{ ...STAMP_ACCOUNTS.users[0], state: "disabled" }] }), [STEP.accountsBad]],
|
||||
["accounts subject on an unconfigured provider", (h) => stampAccounts(h, { ...STAMP_ACCOUNTS, users: [{ ...STAMP_ACCOUNTS.users[0], subjects: [{ provider: "b", sub: "owner" }] }] }), [STEP.accountsBad]],
|
||||
["accounts writable by group", (h) => chmodSync(accountsFile(h), 0o620), [STEP.accountsBad]],
|
||||
["accounts broken link", (h) => { rmSync(accountsFile(h)); symlinkSync(join(h.root, "missing"), accountsFile(h)); }, [STEP.accountsBad]],
|
||||
];
|
||||
for (const [name, change, steps] of cases) {
|
||||
for (const [profile, flags] of [["library", STAMP_LIBRARY], ["factory", STAMP_FACTORY]]) {
|
||||
const h = stamped(t);
|
||||
change(h);
|
||||
refusesSteps(h, unit(h, { profile, flags }), steps);
|
||||
noUnits(h);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("M3 a stamped factory or Case 1 unit renders only with --sso --send and the prerequisites, and names each missing step", (t) => {
|
||||
const h = stamped(t);
|
||||
const r = unit(h, { profile: "factory", flags: STAMP_FACTORY });
|
||||
assert.equal(r.code, 0, r.out + r.err);
|
||||
assert.deepEqual(lines(read(webuiFile(h)), "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${STAMP_FACTORY.join(" ")}`]);
|
||||
const none = stamped(t, { sso: false, accounts: false, deployed: false });
|
||||
refusesSteps(none, unit(none, { profile: "factory" }), [STEP.deployed, STEP.sso, STEP.accounts, STEP.factory]);
|
||||
wroteNothing(none);
|
||||
refusesSteps(none, unit(none, { profile: "factory", flags: STAMP_FACTORY }), [STEP.deployed, STEP.sso, STEP.accounts]);
|
||||
wroteNothing(none);
|
||||
// A stamped copy without the flag pair names it as the last step, with the prerequisites present.
|
||||
const flagless = stamped(t);
|
||||
refusesSteps(flagless, unit(flagless, { profile: "factory" }), [STEP.factory]);
|
||||
refusesSteps(flagless, unit(flagless, { profile: "factory", flags: [...factoryFlags(), "--send"] }), [STEP.factory]);
|
||||
wroteNothing(flagless, { accounts: true, deployed: true });
|
||||
// The pair stays a pair: --sso alone still refuses, as for an unstamped copy.
|
||||
refuses(unit(flagless, { profile: "factory", flags: [...factoryFlags(), "--sso"] }), /refusing --sso without --send in the factory profile/);
|
||||
noUnits(flagless);
|
||||
});
|
||||
|
||||
test("M3 a stamped copy's query refuses on noise or a failed query, before any write", (t) => {
|
||||
// The copy's accounts module stands in for the stack user's code: noise on stdout, a failed exit.
|
||||
for (const [body, pattern] of [
|
||||
['console.log("forged");export const loadAccounts=()=>{};', /stamped copy query has invalid facts/],
|
||||
["process.exit(1);export const loadAccounts=()=>{};", /stamped copy query inner-command/],
|
||||
]) {
|
||||
const h = stamped(t);
|
||||
const org = join(copyDir(h), "packages/organization");
|
||||
rmSync(org);
|
||||
mkdirSync(join(org, "src"), { recursive: true });
|
||||
writeFileSync(join(org, "src/accounts.mjs"), body);
|
||||
refuses(unit(h, { flags: STAMP_LIBRARY }), pattern);
|
||||
noUnits(h);
|
||||
}
|
||||
});
|
||||
|
||||
// #1873 M3: stack-upgrade.sh checks each render before it stops anything. --check runs a render's
|
||||
// checks, the installed units' included, and writes nothing; --check-new skips only the installed
|
||||
// units' checks, for --next-args, which moves them aside before its render.
|
||||
const checkArgv = (h, { mode = "--check", profile = "library", copy = COPY_A, flags } = {}) =>
|
||||
[mode, "--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", copy, "--node", USR_NODE, "--profile", profile, "--",
|
||||
...(flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h)))];
|
||||
const unitsNow = (h) => [busFile(h), webuiFile(h)].map((f) => (existsSync(f) ? read(f) : null));
|
||||
const logNow = (h) => (existsSync(h.log) ? read(h.log) : "");
|
||||
|
||||
test("#1873 --check passes on a matching install and on a first install, refuses an edited unit, and never writes or calls systemctl", (t) => {
|
||||
const h = hub(t);
|
||||
const empty = unit(h, { argv: checkArgv(h) });
|
||||
assert.equal(empty.code, 0, empty.out + empty.err);
|
||||
assert.equal(empty.out, `case2-unit: check passed: the units for copy ${COPY_A} render, and the installed ones match them; nothing was written\n`);
|
||||
noUnits(h);
|
||||
assert.equal(unit(h).code, 0);
|
||||
const units = unitsNow(h), log = logNow(h);
|
||||
for (const copy of [COPY_A, COPY_B]) {
|
||||
const r = unit(h, { argv: checkArgv(h, { copy }) });
|
||||
assert.equal(r.code, 0, r.out + r.err);
|
||||
assert.deepEqual(unitsNow(h), units, copy);
|
||||
assert.equal(logNow(h), log, `${copy}: no systemctl call`);
|
||||
}
|
||||
// The installed-unit match stays: an edit, or other serve flags, refuses as the render would.
|
||||
writeFileSync(busFile(h), units[0].replace(/^Description=.*$/m, "Description=edited"));
|
||||
refuses(unit(h, { argv: checkArgv(h, { copy: COPY_B }) }), /differs from the render in more than the copy id/);
|
||||
writeFileSync(busFile(h), units[0]);
|
||||
const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f));
|
||||
refuses(unit(h, { argv: checkArgv(h, { copy: COPY_B, flags }) }), /installed .*: ExecStart= must be exactly once/);
|
||||
assert.deepEqual(unitsNow(h), units);
|
||||
assert.equal(logNow(h), log);
|
||||
});
|
||||
|
||||
for (const profile of ["library", "factory"]) {
|
||||
test(`#1873 an installed pre-M3 ${profile} unit: --check and the render refuse a stamped copy's SSO flags, and --check-new passes with the prerequisites, writing nothing`, (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h, { profile }).code, 0);
|
||||
stamp(h, COPY_B);
|
||||
stampSso(h);
|
||||
stampAccounts(h);
|
||||
stampDeployed(h);
|
||||
const flags = profile === "factory" ? STAMP_FACTORY : STAMP_LIBRARY;
|
||||
const units = unitsNow(h), log = logNow(h);
|
||||
refuses(unit(h, { argv: checkArgv(h, { profile, copy: COPY_B, flags }) }), /installed .*: ExecStart= must be exactly once/);
|
||||
refuses(unit(h, { profile, copy: COPY_B, flags }), /installed .*: ExecStart= must be exactly once/);
|
||||
const r = unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B, flags }) });
|
||||
assert.equal(r.code, 0, r.out + r.err);
|
||||
assert.equal(r.out, `case2-unit: check passed: the units for copy ${COPY_B} render as a first install; the installed ones were not compared; nothing was written\n`);
|
||||
// --check-new still runs the stamped copy's checks: the old flags, or a missing prerequisite, refuse.
|
||||
refuses(unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B }) }), /a stamped copy's Console starts only with SSO accounts|is a stamped copy, and proxy-preview admission/);
|
||||
rmSync(accountsFile(h));
|
||||
refuses(unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B, flags }) }), /run `mosaic accounts init` as mosaic, a human verb/);
|
||||
assert.deepEqual(unitsNow(h), units);
|
||||
assert.equal(logNow(h), log, "no systemctl call");
|
||||
assert.equal(existsSync(accountsFile(h)), false, "the check never writes accounts.json");
|
||||
assert.equal(statSync(deployedFile(h)).mode & 0o777, 0o444);
|
||||
});
|
||||
}
|
||||
|
||||
test("row253 factory refuses retired TCP and a value after the socket marker", (t) => {
|
||||
const h = hub(t);
|
||||
refuses(unit(h, { profile: "factory", flags: ['--port','443','--business','acme','--tester-port','7333'] }), /case2-unit.sh retires tester TCP; use --tester-socket/);
|
||||
@@ -1320,3 +1552,26 @@ test('row334 proxy installed path rejects symlink and non-regular file', t => {
|
||||
mkdirSync(proxyFile(h));
|
||||
refuses(proxyUnit(h), /is not a regular file/);
|
||||
});
|
||||
|
||||
// M3: the managed uid is a dedicated non-login service identity. These are
|
||||
// database fixtures only: no real passwd/group or instance directories change.
|
||||
test('M3 dedicated host account: login shells, other primary/group members and wrong directory owners refuse', t => {
|
||||
for (const [kind, shell, extraPasswd, members] of [
|
||||
['human login','/bin/bash','',''], ['interactive seat','/usr/bin/zsh','',''],
|
||||
['another primary member','/usr/sbin/nologin',`human:x:${UID+1}:${GID}::/fictional:/bin/bash`,''],
|
||||
['supplementary primary-group member','/usr/sbin/nologin','','human'],
|
||||
]) {
|
||||
const h = hub(t);
|
||||
script(join(h.bin,'getent'), `case "$1" in
|
||||
passwd) echo 'mosaic:x:${UID}:${GID}::${h.home}:${shell}'; [ -n "$2" ] || echo '${extraPasswd}';;
|
||||
group) echo 'mosaic:x:${GID}:${members}';;
|
||||
esac`);
|
||||
const r = unit(h); assert.notEqual(r.code,0,kind); assert.match(r.err,/non-login|sole.member/,kind); assert.equal(existsSync(webuiFile(h)),false);
|
||||
}
|
||||
const h = hub(t); for (const flag of ['--dev-local-owner','--dev-insecure-bind','--dev-allow-host']) { const r = unit(h, { flags: [...libraryFlags(h), flag, '127.0.0.1'] }); assert.notEqual(r.code,0); assert.match(r.err,/dev-local-owner|serve flag/); }
|
||||
});
|
||||
test('M3 host config and data directories belong to the selected uid and both units use it', t => {
|
||||
const h = hub(t); const ok = unit(h); assert.equal(ok.code,0,ok.err);
|
||||
for (const file of [busFile(h),webuiFile(h)]) { assert.deepEqual(lines(read(file),'User'),['mosaic']); assert.deepEqual(lines(read(file),'Group'),['mosaic']); }
|
||||
const wrong = hub(t,{uid:UID+1}); const r = unit(wrong); assert.notEqual(r.code,0); assert.match(r.err,/owned by.*uid|directory.*owner/);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// M3 executable boundary: copies and instances are fixtures only, no live ports.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { cpSync, mkdirSync, mkdtempSync, writeFileSync, chmodSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fixture } from './helpers.mjs';
|
||||
const REPO = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
const SHA = 'a'.repeat(40);
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'db-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const copy = join(root, SHA); mkdirSync(copy);
|
||||
for (const dir of ['packages', 'scripts', 'roles', 'adapters']) cpSync(join(REPO, dir), join(copy, dir), { recursive: true, filter: p => !p.split('/').some(x => ['tests', 'node_modules'].includes(x)) });
|
||||
writeFileSync(join(copy, 'package.json'), '{"type":"module"}');
|
||||
const stamp = join(copy, '.stack-build.json'); writeFileSync(stamp, JSON.stringify({ commit: SHA }), { mode: 0o444 }); chmodSync(stamp, 0o444);
|
||||
const f = fixture(join(root, 'instance')); const home = join(root, 'home'); const dir = join(home, '.config/mosaic-test'); mkdirSync(dir, { recursive: true });
|
||||
const config = join(dir, 'config.json'); cpSync(f.env.MOSAIC_CONFIG, config);
|
||||
writeFileSync(join(f.dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'test' }));
|
||||
const env = { ...process.env, HOME: home, MOSAIC_CONFIG: config };
|
||||
const run = (args, from = copy) => spawnSync(process.execPath, [join(from, args[0]), ...args.slice(1)], { env, encoding: 'utf8', timeout: 5000 });
|
||||
return { root, copy, stamp, f, env, config, dir, run };
|
||||
}
|
||||
const serve = flags => ['packages/webui/src/cli.mjs', 'serve', '--port', '0', ...flags];
|
||||
test('stamped serve refuses dev owner, LAN flags, missing --sso and missing .deployed before listening', t => {
|
||||
const s = setup(t);
|
||||
for (const flags of [['--dev-local-owner'], ['--dev-insecure-bind','0.0.0.0'], ['--dev-allow-host','test.example'], []]) {
|
||||
const r = s.run(serve(flags)); assert.equal(r.status, 2); assert.match(r.stderr, /stamped build/); assert.doesNotMatch(r.stdout, /Mosaic Console:/);
|
||||
}
|
||||
const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /\.deployed/);
|
||||
const bus = s.run(['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']); assert.notEqual(bus.status, 0); assert.match(bus.stderr, /stamped build/);
|
||||
});
|
||||
test('writable, linked, malformed, wrong-commit and directory stamps refuse every start', t => {
|
||||
const s = setup(t);
|
||||
for (const kind of ['writable','symlink','wrong','malformed','directory']) {
|
||||
rmSync(s.stamp, { force: true, recursive: true });
|
||||
if (kind === 'symlink') { const other = join(s.root, 'stamp'); writeFileSync(other, JSON.stringify({ commit: SHA }), { mode: 0o444 }); symlinkSync(other, s.stamp); }
|
||||
else if (kind === 'directory') mkdirSync(s.stamp);
|
||||
else { writeFileSync(s.stamp, kind === 'malformed' ? '{' : JSON.stringify({ commit: kind === 'wrong' ? 'b'.repeat(40) : SHA })); chmodSync(s.stamp, kind === 'writable' ? 0o644 : 0o444); }
|
||||
for (const args of [serve([]), serve(['--sso']), ['packages/cli/src/cli.mjs','bus','start','acme']]) { const r = s.run(args); assert.notEqual(r.status, 0, kind); assert.match(r.stderr, /build stamp/, `${kind}: ${r.stderr}`); }
|
||||
}
|
||||
});
|
||||
test('stamped serve requires 0444 metadata naming its instance and a loadable accounts file', t => {
|
||||
const s = setup(t), file = join(s.f.dataRoot, '.deployed');
|
||||
for (const [mode, instance] of [[0o644,'test'], [0o444,'other']]) {
|
||||
writeFileSync(file, JSON.stringify({ instance })); chmodSync(file, mode);
|
||||
const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /\.deployed/);
|
||||
}
|
||||
chmodSync(file, 0o644); writeFileSync(file, JSON.stringify({ instance: 'test' })); chmodSync(file, 0o444);
|
||||
const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /accounts/);
|
||||
});
|
||||
test('checkout dev owner refuses accounts, SSO or deployment presence, including dangling links', t => {
|
||||
const s = setup(t);
|
||||
for (const name of ['accounts.json', '.deployed']) {
|
||||
const file = join(name === 'accounts.json' ? s.dir : s.f.dataRoot, name); symlinkSync(join(s.root, 'missing'), file);
|
||||
for (const args of [serve(['--dev-local-owner']), ['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']]) { const r = s.run(args, REPO); assert.notEqual(r.status, 0); assert.match(r.stderr, /dev-local-owner.*refuses/); }
|
||||
rmSync(file);
|
||||
}
|
||||
const config = JSON.parse((awaitRead(s.config))); config.console = { sso: { publicOrigin: 'https://console.test', providers: [{ id:'pocket', issuer:'https://id.test',clientId:'test',clientSecret:{slot:'test'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}] }] } };
|
||||
writeFileSync(s.config, JSON.stringify(config));
|
||||
for (const args of [serve(['--dev-local-owner']), ['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']]) { const r = s.run(args, REPO); assert.notEqual(r.status, 0); assert.match(r.stderr, /dev-local-owner.*refuses.*console.sso/); }
|
||||
});
|
||||
import { readFileSync as awaitRead } from 'node:fs';
|
||||
|
||||
test('deployment writer preserves matching metadata and refuses linked or foreign destinations', t => {
|
||||
const s = setup(t), system = { dataRoot: s.f.dataRoot }, file = join(s.f.dataRoot, '.deployed');
|
||||
rmSync(system.dataRoot, { recursive: true, force: true });
|
||||
writeDeployment(system, s.dir); assert.deepEqual(JSON.parse(awaitRead(file)), { instance: 'test' });
|
||||
assert.equal(statSync(system.dataRoot).mode & 0o777, 0o700);
|
||||
assert.equal(statSync(file).mode & 0o777, 0o444); const inode = statSync(file).ino; writeDeployment(system, s.dir); assert.equal(statSync(file).ino, inode);
|
||||
rmSync(file); symlinkSync(join(s.root, 'absent'), file); assert.throws(() => writeDeployment(system, s.dir), /\.deployed/); rmSync(file);
|
||||
writeFileSync(file, JSON.stringify({ instance: 'other' }), { mode: 0o444 }); assert.throws(() => writeDeployment(system, s.dir), /\.deployed/);
|
||||
});
|
||||
import { statSync } from 'node:fs';
|
||||
import { writeDeployment } from '../src/deployment.mjs';
|
||||
|
||||
test('valid stamped accounts Console starts with --sso and answers health on its own port zero', async t => {
|
||||
const { spawn } = await import('node:child_process');
|
||||
const { once } = await import('node:events');
|
||||
const { fakeOidc } = await import('../../webui/tests/helpers/fake-oidc.mjs');
|
||||
const s = setup(t), op = await fakeOidc(); t.after(() => op.close()); const iss = op.issuer('a');
|
||||
symlinkSync(join(REPO, 'node_modules'), join(s.copy, 'node_modules'));
|
||||
const config = JSON.parse(awaitRead(s.config)); config.console = { sso: { publicOrigin: 'https://console.test', providers: [{ id:'a',issuer:iss.issuer,clientId:iss.clientId,clientSecret:{slot:'a'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}] }] } };
|
||||
writeFileSync(s.config, JSON.stringify(config)); mkdirSync(join(s.dir, 'secrets/mosaic-stack'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(s.dir, 'secrets/mosaic-stack/a'), iss.clientSecret, { mode: 0o600 });
|
||||
writeFileSync(join(s.dir, 'accounts.json'), JSON.stringify({ accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'owner'}],name:'Owner',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}], memberships:[],invites:[] }), { mode: 0o600 });
|
||||
writeDeployment({ dataRoot: s.f.dataRoot }, s.dir); const ca = join(s.root, 'ca.pem'); writeFileSync(ca, op.cert);
|
||||
const child = spawn(process.execPath, [join(s.copy,'packages/webui/src/cli.mjs'), 'serve','--port','0','--sso','--board','http://127.0.0.1:9'], { env: { ...s.env, NODE_EXTRA_CA_CERTS: ca } });
|
||||
let out = '', err = ''; child.stderr.on('data', c => err += c);
|
||||
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { const done = once(child, 'close'); child.kill(); await done; } });
|
||||
await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('start timeout: '+err)), 10000); child.stdout.on('data', c => { out += c; if(out.includes('Ctrl-C')) {clearTimeout(timer);resolve();} }); child.on('exit', () => {clearTimeout(timer);reject(new Error(err));}); });
|
||||
const port = Number(out.match(/127\.0\.0\.1:(\d+)\//)[1]); assert.ok(port); assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status, 200);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
|
||||
function safe(label, Type, props = {}, omitted = secret) { return error => {
|
||||
assert.ok(error instanceof Type, error.stack);
|
||||
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
assert.ok(!error.message.includes(omitted), error.message);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
}; }
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fixture } from './helpers.mjs';
|
||||
import { runConfigCommand } from '../../organization/src/config-command.mjs';
|
||||
const repo = join(import.meta.dirname, '../../..');
|
||||
const task = () => ({ taskVersion: 1, id: 'valid', prompt: 'hello' });
|
||||
for (const [field, edit] of [
|
||||
['task id', d => { d.id = secret; }],
|
||||
['taskVersion', d => { d.taskVersion = secret; }],
|
||||
['tool', d => { d.capabilities = { tools: [secret] }; }],
|
||||
['mission id', (d, root) => { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: 1, id: secret, objective: 'hello' })); }],
|
||||
['missionVersion', (d, root) => { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: secret, id: 'valid', objective: 'hello' })); }],
|
||||
['mission tool', (d, root) => { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: 1, id: 'valid', objective: 'hello', capabilities: { tools: [secret] } })); }],
|
||||
]) test(`task validation ${field} omits file value and keeps exit 2`, t => {
|
||||
const root = scratch(t), d = task(); edit(d, root); const file = join(root, 'task.json'); writeFileSync(file, JSON.stringify(d));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2, r.stderr); assert.ok(r.stderr.includes(field), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
const configCases = [
|
||||
...['configVersion', 'environment', 'dataRoot'].map(k => [k, d => { d[k] = secret; }]),
|
||||
...['backend', 'provider', 'model', 'adapter'].map(k => [`execution.${k}`, d => { d.execution[k] = secret + '\n'; }]),
|
||||
['federation.self', d => { d.federation = { self: secret, peers: [] }; }],
|
||||
['federation.peers[0].id', d => { d.federation = { self: 'self', peers: [{ id: secret, origin: 'https://peer.example', label: 'Peer' }] }; }],
|
||||
['federation.peers[0].origin', d => { d.federation = { self: 'self', peers: [{ id: 'peer', origin: secret, label: 'Peer' }] }; }],
|
||||
];
|
||||
for (const [field, edit] of configCases) test(`config ${field} omits the configured value`, t => {
|
||||
const root = scratch(t), f = fixture(root), d = JSON.parse(readFileSync(f.config, 'utf8')); edit(d); writeFileSync(f.config, JSON.stringify(d));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-config.mjs'), 'validate'], { encoding: 'utf8', env: { ...f.env, PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2, r.stderr); assert.ok(r.stderr.includes(field), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
test('config-command refuses system config without relaying stderr file values', t => {
|
||||
const root = scratch(t), f = fixture(root), d = JSON.parse(readFileSync(f.config, 'utf8')); d.execution.model = secret + '\n'; writeFileSync(f.config, JSON.stringify(d));
|
||||
let err = ''; const status = runConfigCommand(['validate', 'acme'], { env: f.env, stdout: { write() {} }, stderr: { write(s) { err += s; } } });
|
||||
assert.equal(status, 3); assert.match(err, /system config problem \(mosaic-config exit 2\)/); assert.ok(err.includes(f.config), err); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/); assert.match(err, /execution.model/); assert.match(err, /expected a 1-200 character name/);
|
||||
});
|
||||
test('project id mismatch omits the file value in config-command', t => {
|
||||
const root = scratch(t), f = fixture(root), file = join(f.doc.projects.stack.root, '.mosaic', 'project.json');
|
||||
mkdirSync(join(f.doc.projects.stack.root, '.mosaic'));
|
||||
writeFileSync(file, JSON.stringify({ projectVersion: 1, id: secret.toLowerCase() }));
|
||||
let err = ''; const status = runConfigCommand(['validate', 'acme'], { env: f.env, stdout: { write() {} }, stderr: { write(s) { err += s; } } });
|
||||
assert.equal(status, 2); assert.ok(err.includes(file), err); assert.match(err, /id/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
for (const [field, edit] of [
|
||||
['dataRoot', d => { d.dataRoot = `/x/${secret}/../y`; }],
|
||||
['federation.peers[0].id', d => { d.federation = { self: secret.toLowerCase(), peers: [{ id: secret.toLowerCase(), origin: 'https://peer.example', label: 'Peer' }] }; }],
|
||||
]) test(`config ${field} repeated/canonical value ${field.endsWith(".id") ? "names the checked id" : "is omitted"}`, t => {
|
||||
const root = scratch(t), f = fixture(root), d = JSON.parse(readFileSync(f.config, 'utf8')); edit(d); writeFileSync(f.config, JSON.stringify(d));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-config.mjs'), 'validate'], { encoding: 'utf8', env: { ...f.env, PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2); assert.ok(r.stderr.includes(field), r.stderr);
|
||||
if (field.endsWith(".id")) assert.ok(r.stderr.includes(secret.toLowerCase()), r.stderr);
|
||||
else assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
for (const mission of [false, true]) test(`duplicate ${mission ? 'mission' : 'task'} tool names the validated value`, t => {
|
||||
const root = scratch(t), d = task();
|
||||
if (mission) { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: 1, id: 'valid', objective: 'hello', capabilities: { tools: ['read', 'read'] } })); }
|
||||
else d.capabilities = { tools: ['read', 'read'] };
|
||||
const file = join(root, 'task.json'); writeFileSync(file, JSON.stringify(d));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2); assert.match(r.stderr, /duplicate tool in/); assert.match(r.stderr, /read/);
|
||||
});
|
||||
for (const childStderr of [secret, `mosaic-config: execution.model must match ${secret}`]) test(`config-command does not relay arbitrary child stderr${childStderr.startsWith('mosaic-config:') ? ' after a known field' : ''}`, t => {
|
||||
const root = scratch(t), file = join(root, 'config.json');
|
||||
const code = `import cp from 'node:child_process'; import { syncBuiltinESMExports } from 'node:module';
|
||||
cp.spawnSync = () => ({ status: 2, stderr: ${JSON.stringify(childStderr)}, stdout: '' }); syncBuiltinESMExports();
|
||||
const { runConfigCommand } = await import(${JSON.stringify(new URL('../../organization/src/config-command.mjs', import.meta.url).href)});
|
||||
process.exitCode = runConfigCommand(['validate', 'acme'], { env: { MOSAIC_CONFIG: ${JSON.stringify(file)} } });`;
|
||||
const r = spawnSync(process.execPath, ['--input-type=module', '-e', code], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 3, r.stderr); assert.ok(r.stderr.includes(file), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
|
||||
import { bootConfig, loadSystem } from '../src/config.mjs';
|
||||
import { CliError } from '../src/errors.mjs';
|
||||
test('CLI boot project id mismatch omits the configured value and keeps exit 3', t => {
|
||||
const root = scratch(t), f = fixture(root), file = join(f.doc.projects.stack.root, '.mosaic', 'project.json');
|
||||
mkdirSync(join(f.doc.projects.stack.root, '.mosaic'));
|
||||
writeFileSync(file, JSON.stringify({ projectVersion: 1, id: secret.toLowerCase() }));
|
||||
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env }), safe(file, CliError, { exitCode: 3 }, secret.toLowerCase()));
|
||||
});
|
||||
|
||||
for (const mission of [false, true]) test(`duplicate ${mission ? 'mission' : 'task'} tool rejects an unknown value before naming it`, t => {
|
||||
const root = scratch(t), d = task();
|
||||
if (mission) { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: 1, id: 'valid', objective: 'hello', capabilities: { tools: [secret, secret] } })); }
|
||||
else d.capabilities = { tools: [secret, secret] };
|
||||
const file = join(root, 'task.json'); writeFileSync(file, JSON.stringify(d));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2); assert.match(r.stderr, /unsupported.*tool/); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
@@ -369,8 +369,8 @@ test("a launched pm's request_staff reaches the chief of staff; no other session
|
||||
const bad = await call("pm", `TOOL request_staff ${JSON.stringify({ task_ref: "vikunja:1/2", roles: [{ role: "coder", count: 0 }] })}`);
|
||||
assert.match(bad.body, /invalid-request/);
|
||||
// The chief of staff has no request_staff, and a pm has no launch.
|
||||
assert.equal((await call("chief", 'TOOL request_staff {"task_ref":"vikunja:1/2","roles":[{"role":"coder","count":1}]}')).body, "refused: unknown-tool");
|
||||
assert.equal((await call("pm", 'TOOL launch {"instance":"coder"}')).body, "refused: unknown-tool");
|
||||
assert.equal((await call("chief", 'TOOL request_staff {"task_ref":"vikunja:1/2","roles":[{"role":"coder","count":1}]}')).body.split("\n")[0], "refused: unknown-tool");
|
||||
assert.equal((await call("pm", 'TOOL launch {"instance":"coder"}')).body.split("\n")[0], "refused: unknown-tool");
|
||||
// Nothing was launched by asking: the chief of staff's limits are the same, and sonnet has one place left.
|
||||
assert.deepEqual(readSessions(f.dataRoot).map((s) => s.instance).sort(), ["chief", "pm"]);
|
||||
const launched = await call("chief", 'TOOL launch {"instance":"coder"}');
|
||||
|
||||
@@ -867,7 +867,7 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it
|
||||
assert.equal(listed.launched, true);
|
||||
// Hire vars set only model and limits.* (N3). harness and thinking are agent-layer route vars the
|
||||
// human sets, so until then the launch refuses no-route.
|
||||
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: no-route");
|
||||
assert.equal((await ask("launch", { instance: "sec-2" })).split("\n")[0], "refused: no-route");
|
||||
const route = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-2", op: "change", vars: route });
|
||||
const launched = JSON.parse(await ask("launch", { instance: "sec-2" }));
|
||||
@@ -875,13 +875,13 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it
|
||||
assert.deepEqual(s.running("sec-2").map((x) => x.run), [launched.run]);
|
||||
assert.equal(readSessions(p.f.dataRoot).find((x) => x.run === launched.run).launchedBy, "chief");
|
||||
// The schema's pattern guides the model. The host refuses any name its file doesn't list.
|
||||
assert.equal(await ask("launch", { instance: "NOT A NAME" }), "refused: instance-not-listed");
|
||||
assert.equal(await ask("launch", { instance: "reviewer" }), "refused: instance-not-listed");
|
||||
assert.equal((await ask("launch", { instance: "NOT A NAME" })).split("\n")[0], "refused: instance-not-listed");
|
||||
assert.equal((await ask("launch", { instance: "reviewer" })).split("\n")[0], "refused: instance-not-listed");
|
||||
await s.claimed(launched.run);
|
||||
const fired = JSON.parse(await ask("fire", { name: "sec-2", reason: "the review is done" }));
|
||||
assert.ok(fired.transaction, JSON.stringify(fired));
|
||||
await until(() => s.running("sec-2").length === 0, 30000);
|
||||
// A fired instance stays in roles, off the listing, so the chief can't launch it again.
|
||||
assert.equal(s.call("instances").instances["sec-2"].launched, false);
|
||||
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: instance-not-listed");
|
||||
assert.equal((await ask("launch", { instance: "sec-2" })).split("\n")[0], "refused: instance-not-listed");
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text, { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fixture } from './helpers.mjs';
|
||||
const repo = join(import.meta.dirname, '../../..');
|
||||
test('task file JSON refusal preserves exit 2 and file without parser bytes', t => {
|
||||
const file = write(join(scratch(t), 'task.json'));
|
||||
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2, r.stderr);
|
||||
assert.ok(r.stderr.includes(file), r.stderr);
|
||||
assert.match(r.stderr, /task file is not valid JSON/);
|
||||
assert.doesNotMatch(r.stdout + r.stderr, /sk-test|Unexpected token/);
|
||||
});
|
||||
test('task file read refusal retains exit 2, file and filesystem code', t => {
|
||||
const file = write(join(scratch(t), 'task.json'), '{}');
|
||||
// A directory is refused by lstat before the read. Inject a read failure
|
||||
// after that unchanged regular-file gate, including on root-run CI hosts.
|
||||
const preload = `import fs from 'node:fs';
|
||||
const read = fs.readFileSync;
|
||||
fs.readFileSync = function(path, ...args) {
|
||||
if (path === ${JSON.stringify(file)}) throw Object.assign(new Error(${JSON.stringify(secret)}), { code: 'EIO' });
|
||||
return read.call(this, path, ...args);
|
||||
};`;
|
||||
const r = spawnSync(process.execPath, ['--import', 'data:text/javascript,' + encodeURIComponent(preload), join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 2, r.stderr);
|
||||
assert.ok(r.stderr.includes(file), r.stderr);
|
||||
assert.match(r.stderr, /task file is not valid JSON.*\(EIO\)/);
|
||||
assert.doesNotMatch(r.stderr, /sk-test/);
|
||||
});
|
||||
test('case2 precheck business helper omits parser bytes in its BUSINESS fact', t => {
|
||||
const root = scratch(t), f = fixture(root);
|
||||
const file = write(join(root, 'config/businesses/acme.json'));
|
||||
// Execute the production facts helper, without host or credential checks.
|
||||
const shell = readFileSync(join(repo, 'scripts/case2-precheck.sh'), 'utf8');
|
||||
const start = shell.indexOf('facts="$(cd "$REPO" && node --input-type=module -e \'') + 'facts="$(cd "$REPO" && node --input-type=module -e \''.length;
|
||||
assert.ok(start > 100);
|
||||
const end = shell.indexOf("' \"$REPO\" \"$BUSINESS\"", start);
|
||||
assert.ok(end > start);
|
||||
const r = spawnSync(process.execPath, ['--input-type=module', '-e', shell.slice(start, end), repo, 'acme'], { encoding: 'utf8', env: { ...f.env, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, PATH: process.env.PATH } });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.ok(r.stdout.includes(`BUSINESS\tbad\tcannot read ${file}:`), r.stdout);
|
||||
assert.doesNotMatch(r.stdout + r.stderr, /sk-test|Unexpected token/);
|
||||
});
|
||||
@@ -147,7 +147,7 @@ test("rename r2 B2: org config prints the stage A golden v1 bytes, preimage and
|
||||
}
|
||||
const normalized = (text) => text.split(root).join("<ROOT>").replace(/[a-f0-9]{64}/g, "<DIGEST>");
|
||||
const preimageText = `${normalized(canonicalJson(preimage))}\n`;
|
||||
assert.equal(sha256(preimageText.slice(0, -1)), "5fc9d6258f4725b0e260d99e5cf81058a05d21b8ff298bb53653efbbc8013da9");
|
||||
assert.equal(sha256(preimageText.slice(0, -1)), "0a12b35f55d033b91bd89745859028d252be52b05ca4083964b778a27ec20fd6");
|
||||
assert.equal(normalized(validate), golden("installed-validate.json"));
|
||||
assert.equal(normalized(resolved), golden("installed-resolve.json"));
|
||||
assert.equal(preimageText, golden("installed-preimage.json"));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ownerAccounts } from '../../webui/tests/helpers/owner-accounts.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fork } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
@@ -119,7 +120,7 @@ export async function lifecycleFixture(t) {
|
||||
const issuer = op.issuer('fixture');
|
||||
sso = await createSso({ publicOrigin: ORIGIN, providers: [{ id: 'fixture', issuer: issuer.issuer, clientId: issuer.clientId,
|
||||
clientSecret: { slot: 'fixture' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'jason' }] }] },
|
||||
{ secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} });
|
||||
{ accounts: ownerAccounts({ providers: [{ id: 'fixture', subjects: [{ sub: 'owner', name: 'jason' }] }] }), secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} });
|
||||
const transport = async (verb, args, ctx) => {
|
||||
try { return await rpc('console', { verb, args, subject: ctx.subject }); }
|
||||
catch (e) { appendFileSync(join(root, 'console-errors.jsonl'), JSON.stringify({ verb, code: e.code, detail: e.detail })+'\n'); throw new BusReadError(e.code ?? 'outcome-unknown', 503); }
|
||||
|
||||
@@ -12,6 +12,8 @@ import {createOwnedRunner} from './fixtures/owned-child.mjs';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
import {fixture,notifyConfig,BUSINESSES} from './helpers.mjs';
|
||||
import {reserveOrg,activateOrg} from '../../business/src/org-registry.mjs';
|
||||
import {writeDeployment} from '../src/deployment.mjs';
|
||||
import {fakeOidc} from '../../webui/tests/helpers/fake-oidc.mjs';
|
||||
const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,'');
|
||||
const git=(cwd,...args)=>execFileSync('git',['-c','user.name=fixture','-c','user.email=fixture@invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','-C',cwd,...args],{encoding:'utf8'}).trim();
|
||||
export function setup(t, realPin=false){
|
||||
@@ -58,6 +60,7 @@ test('a copy carries every file the harness modules read from it',async t=>{
|
||||
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',async t=>{
|
||||
const s=setup(t),r=(await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]));assert.equal(r.status,0,r.stderr);
|
||||
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(copy,'.stack-build.json'),'utf8')), {commit:s.id}); assert.equal(statSync(join(copy,'.stack-build.json')).mode&0o222,0);
|
||||
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/library/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs','packages/processing-control/src/state.mjs','packages/processing-control/src/containers.mjs','packages/processing-control/src/effects.mjs'])assert.ok(existsSync(join(copy,f)),f);
|
||||
const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755);
|
||||
const existing=(await s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]));assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm);
|
||||
@@ -139,7 +142,7 @@ async function childResult(command,args,env,input){
|
||||
const timer=setTimeout(()=>p.kill('SIGKILL'),15000);
|
||||
try{const [status]=await once(p,'close');return {status,stdout,stderr};}finally{clearTimeout(timer)}
|
||||
}
|
||||
test('real pinned install loads bus and Console; same-copy mosaic talk passes proof, other trees refuse', {timeout:180000},async t=>{
|
||||
test('real pinned install loads bus and Console; the Console starts only with --sso and its deployed prerequisites; same-copy mosaic talk passes proof, other trees refuse', {timeout:180000},async t=>{
|
||||
const s=setup(t,true);
|
||||
const copied=(await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]));assert.equal(copied.status,0,copied.stderr);
|
||||
const root=join(s.copies,s.id),url=f=>pathToFileURL(join(root,f)).href;
|
||||
@@ -156,7 +159,24 @@ test('real pinned install loads bus and Console; same-copy mosaic talk passes pr
|
||||
host.stdout.on('data',c=>hostOut+=c);host.stderr.on('data',c=>hostErr+=c);
|
||||
try{await until(()=>hostOut.includes('\"org\":\"acme\"')||host.exitCode!==null);assert.match(hostOut,/\"org\":\"acme\"/,hostErr);}
|
||||
finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');const stop=spawnSync(join(root,'scripts/mosaic'),['bus','stop','acme'],{env,encoding:'utf8'});assert.equal(stop.status,0,stop.stderr);await done;}}
|
||||
const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
|
||||
// The copy is stamped (M3): its Console starts only with --sso, console.sso and its slot, .deployed and accounts.json.
|
||||
const serveArgs=sso=>[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9',...(sso?['--sso']:[])];
|
||||
const op=await fakeOidc();t.after(()=>op.close());const iss=op.issuer('a');
|
||||
const ca=join(s.root,'ca.pem');writeFileSync(ca,op.cert);const webEnv={...env,NODE_EXTRA_CA_CERTS:ca};
|
||||
const startRefused=async(sso,why)=>{const r=await childResult(process.execPath,serveArgs(sso),webEnv,'');assert.equal(r.status,2,r.stderr);assert.match(r.stderr,why);assert.doesNotMatch(r.stdout,/Mosaic Console:/);};
|
||||
await startRefused(false,/stamped build serve requires --sso/);
|
||||
await startRefused(true,/\.deployed is missing or invalid/);
|
||||
writeDeployment({dataRoot:f.dataRoot},configDir);
|
||||
await startRefused(true,/accounts file not found/);
|
||||
writeFileSync(join(configDir,'accounts.json'),JSON.stringify({accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'owner'}],name:'Owner',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}],memberships:[],invites:[]}),{mode:0o600});
|
||||
await startRefused(true,/must be a configured provider/);
|
||||
const config=JSON.parse(readFileSync(env.MOSAIC_CONFIG,'utf8'));
|
||||
config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'a',issuer:iss.issuer,clientId:iss.clientId,clientSecret:{slot:'a'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}};
|
||||
writeFileSync(env.MOSAIC_CONFIG,JSON.stringify(config));
|
||||
await startRefused(true,/client secret slot is missing/);
|
||||
mkdirSync(join(configDir,'secrets','mosaic-stack'),{recursive:true,mode:0o700});
|
||||
const slot=join(configDir,'secrets','mosaic-stack','a');writeFileSync(slot,iss.clientSecret,{mode:0o600});chmodSync(slot,0o600);
|
||||
const web=spawn(process.execPath,serveArgs(true),{env:webEnv,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
|
||||
web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c);
|
||||
try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);}
|
||||
finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}}
|
||||
|
||||
@@ -1555,51 +1555,110 @@ function setEdit(set,name,from,to,sums=true){
|
||||
if(sums){const s=join(set,'SHA256SUMS');chmodSync(s,0o600);writeFileSync(s,readFileSync(s,'utf8').replace(new RegExp(`^[0-9a-f]{64}( \\./${name.replace(/[.@]/g,'\\$&')})$`,'m'),`${sha(f)}$1`))}
|
||||
}
|
||||
|
||||
// M3 (#1873): console.sso marks an accounts instance, which runs system units, so a user-unit run
|
||||
// with it refuses accounts-user-unit before anything stops. An --sso pointer now comes two ways:
|
||||
// - a pre-M3 host's user-unit run. LEGACY is the fixture repo with the M3 base's stack-release.sh,
|
||||
// webui-service.sh and stack-copy.sh, so legacy(e) builds the state such a host left behind;
|
||||
// - the system path: root stops the units, stack-release stages with --units external, root
|
||||
// installs the units and starts them, and confirm pins the pointer to root's Console flags.
|
||||
const M3_BASE='c3c192677defa518eabeb324aac82f0757e398c3';
|
||||
const LEGACY=join(base,'legacy-repo');
|
||||
cpSync(repo,LEGACY,{recursive:true});
|
||||
for(const f of ['scripts/stack-release.sh','scripts/webui-service.sh','scripts/stack-copy.sh'])writeFileSync(join(LEGACY,f),execFileSync('git',['-C',ROOT,'show',`${M3_BASE}:${f}`]),{mode:0o755});
|
||||
const legacy=(e)=>({...e,sr:join(LEGACY,'scripts','stack-release.sh')});
|
||||
const X=(e)=>({...e,flags:[...e.flags,'--units','external']});
|
||||
const AUU=(e,op)=>`${e.cfg} holds console.sso; an accounts instance runs system units, so root renders them with case2-unit.sh and the ${op} runs with --units external`;
|
||||
// Root's part. rootUp installs the bus unit for COPY and writes the Console unit case2-unit.sh
|
||||
// renders for COPY with FLAGS (webui-service.sh refuses an accounts instance), then starts both.
|
||||
const rootDown=(e)=>{sysctl(e,'stop',e.con);sysctl(e,'stop',e.bus)};
|
||||
function rootUp(e,copy,flags){
|
||||
execFileSync('bash',[join(repo,'scripts','bus-service.sh'),'install',...e.flags,'--copy',copy,'--copies',COPIES,'--claude',e.claude],{env:e.env,stdio:'ignore'});
|
||||
const f=join(e.units,e.con),s=readFileSync(f,'utf8'),node=/^ExecStart=(\S+) /m.exec(s)[1],dir=join(COPIES,copy);
|
||||
writeFileSync(f,s.replace(/^WorkingDirectory=.*$/m,`WorkingDirectory=${dir}`).replace(/^Documentation=.*$/m,`Documentation=file://${dir}/packages/webui/README.md`)
|
||||
.replace(/^ExecStart=.*$/m,`ExecStart=${node} ${dir}/packages/webui/src/cli.mjs serve --checkout ${repo} ${flags.join(' ')}`));
|
||||
execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'0'}});sysctl(e,'start',e.con);
|
||||
}
|
||||
// One system-path run: OP (activate TO, or rollback --to TO) planned and staged from the pointer's
|
||||
// copy, root's units for TO with FLAGS, confirm. The plan, the staged set and both log lines are this
|
||||
// script's output; the unit flags are root's render (case2-unit.test.mjs pins that render), so the
|
||||
// pointer pinning them is confirm's read of root's unit, not a conversion of this script's.
|
||||
async function viaSystem(e,op,to,flags,...more){
|
||||
const x=X(e),from=pointer(e).copy,p0=readFileSync(e.pointer,'utf8'),w0=sha(join(e.bin,e.wrapper));
|
||||
const args=op==='activate'?['activate',to,'acme']:['rollback','acme','--to',to],inst=e.flags.length?`${e.flags.join(' ')} `:'';
|
||||
rootDown(e);
|
||||
const con0=sha(join(e.units,e.con));
|
||||
const out=await planOnly(x,0,...args,...more),plan=out.join('\n');
|
||||
assert.deepEqual([out.find((l)=>l.startsWith('plan: ')),out.find((l)=>l.startsWith('from: ')),out.find((l)=>l.startsWith('to: '))],[`plan: ${op} for acme`,`from: ${from}`,`to: ${to}`],plan);
|
||||
assert.ok(out.includes(`units: external; root installs the units for ${to} and starts them, then confirm gates them`),plan);
|
||||
assert.ok(out.includes(`CLI: scripts/stack-copy.sh wrapper ${to} ${inst}--copies ${COPIES} --bin ${e.bin}`),plan);
|
||||
// Root renders the units, so the plan names no unit, Console flag or refusal of this script's.
|
||||
assert.deepEqual(out.filter((l)=>/^(bus|pm|proof|send|sso|Console):|^(drop|refuses) /.test(l)),[],plan);
|
||||
let r=await prun(x,...args,...more);assert.equal(r.code,0,r.out+r.err);
|
||||
const st=logLines(e).at(-1),act=st.activation,rs=setOf(e,act);
|
||||
assert.deepEqual([st.op,st.from,st.to,st.result,st.units],[op,from,to,'started','external']);
|
||||
assert.equal(r.out.split('\n').at(-2),`stack-release: ${op} staged ${to} for acme as ${act}; root installs and starts the units, then runs confirm ${to} acme --units external`);
|
||||
// Staged, not live: the pointer stays; the set holds the pointer, the wrapper and the Console unit root stopped.
|
||||
assert.equal(readFileSync(e.pointer,'utf8'),p0);
|
||||
assert.equal(readFileSync(join(rs,'AWAITING-UNITS'),'utf8'),`${to}\n`);
|
||||
assert.equal(readFileSync(join(rs,'pointer'),'utf8'),p0);assert.equal(sha(join(rs,'wrapper')),w0);
|
||||
assert.ok(readFileSync(join(rs,'UNITS.sha256'),'utf8').includes(`${con0} ${join(e.units,e.con)}\n`));
|
||||
assert.ok(readFileSync(join(e.bin,e.wrapper),'utf8').includes(join(COPIES,to)),'the wrapper names the target copy');
|
||||
rootUp(e,to,flags);
|
||||
r=await prun(x,'confirm',to,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(r.out.split('\n').at(-2),`stack-release: ${op} ok: ${to} is live for acme`);
|
||||
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result,l.units,l.activation],[op,from,to,'ok','external',act]);
|
||||
assert.ok(!existsSync(join(rs,'AWAITING-UNITS')));
|
||||
assert.deepEqual([pointer(e).copy,pointer(e).console],[to,flags]);
|
||||
return act;
|
||||
}
|
||||
|
||||
// --plan with an accounts sign: exit 3 with the refusal alone; nothing written, started or stopped, and no log line.
|
||||
async function planRefused(e,args,code,msg){
|
||||
const before=fp(e),data=tree(e.data),n=calls(e).length,logs=logLines(e).length;
|
||||
const r=await prun(e,...args,'--plan');assert.equal(r.code,3,r.out+r.err);assert.equal(r.out,'');
|
||||
assert.equal(r.err,`stack-release: refusing ${code}: ${msg}\n`);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(tree(e.data),data);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
}
|
||||
// The user-unit run and its --plan both refuse accounts-user-unit, before anything stops.
|
||||
async function auu(e,args,op,msg=AUU(e,op)){await refused(e,args,'accounts-user-unit',msg);await planRefused(e,args,'accounts-user-unit',msg)}
|
||||
const rootStart=(e)=>{execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'0'}});sysctl(e,'start',e.con)};
|
||||
const shown=(e,act,show=SHOW)=>readFileSync(join(setOf(e,act),show),'utf8');
|
||||
|
||||
test('T57 row 200: activate --sso, its refusals before any stop, and the rollback to a Console without --sso',async(t)=>{
|
||||
await t.test('item 1: a pre-SSO activation drops legacy send; --sso then drops preview and proof before the tester port',async(t)=>{
|
||||
await t.test('item 1 (M3): a pre-SSO activation drops legacy send; with console.sso a user-unit --sso refuses accounts-user-unit, and the system path pins root\'s --sso Console',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),f=await proofFile(e),pre=noSend(base);
|
||||
let r=await prun(e,'activate',B,'acme','--tester-port','7333',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--tester-port','7333']);
|
||||
await ssoConfig(e);
|
||||
await auu(e,['activate',C1,'acme','--sso'],'activate');
|
||||
// Root renders the stamped Console with --sso and no preview or proof flags (case2-unit.test.mjs pins that render).
|
||||
const want=['--port',e.port,'--business','acme','--sso','--tester-port','7333'];
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--sso');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: dropped; --sso drops header x-console-proof','send: off',
|
||||
`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --tester-port 7333`]);
|
||||
assert.equal(out[out.findIndex((l)=>l.startsWith('sso:'))+1],`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
|
||||
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
const act=await viaSystem(e,'activate',C1,want);
|
||||
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
|
||||
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
const steps=stepText(e);
|
||||
assert.ok(steps.includes(`Z drop --proof-header x-console-proof --proof-file ${f}; --sso was given\n`),steps);
|
||||
assert.ok(steps.includes(`Z turn on --sso; drop ${DROPS}\n`),steps);
|
||||
// The activation's set holds the proxy-preview Console it replaced, pair and tester port included.
|
||||
assert.ok(shown(e,act).includes(` ${[...pre,...pair(f),'--tester-port','7333'].join(' ')}`),shown(e,act));
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('item 1: --sso may re-pin the live copy once; later activates carry it; --no-send still drops --send',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
let out=await planOnly(e,0,'activate',C1,'acme','--sso');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: off','send: off',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme`]);
|
||||
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
|
||||
assert.doesNotMatch(stepText(e),/proof/);
|
||||
await refused(e,['activate',C1,'acme','--sso'],'already-active',`${C1} is the pointer's copy`);
|
||||
await refused(e,['activate',C1,'acme'],'already-active',`${C1} is the pointer's copy`);
|
||||
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
|
||||
out=await planOnly(e,0,'activate',C,'acme');assert.deepEqual(ssoLines(out),['sso: carried']);
|
||||
r=await prun(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.out+r.err);
|
||||
// Carried, not turned on: no stamped step names sso (the install output's serve line still shows --sso).
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);assert.doesNotMatch(stepText(e),/^\S+Z .*sso/m);
|
||||
assert.match(stepText(e),/^serve: .* --sso --send$/m);
|
||||
r=await prun(e,'activate',C,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await t.test('item 1 (M3): while console.sso is set every user-unit activate refuses accounts-user-unit; the system path carries root\'s --sso --send Console to another copy',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--send']);await ssoConfig(e);
|
||||
for(const args of [['activate',C1,'acme'],['activate',C1,'acme','--sso'],['activate',C1,'acme','--send'],['activate',C1,'acme','--sso','--no-send'],['activate',C,'acme','--migrate']])await auu(e,args,'activate');
|
||||
const sso=['--port',e.port,'--business','acme','--sso','--send'];
|
||||
await viaSystem(e,'activate',C1,sso);
|
||||
assert.deepEqual(pointer(e).console,sso);
|
||||
await auu(e,['activate',C1,'acme','--no-send'],'activate');
|
||||
await auu(e,['rollback','acme'],'rollback');
|
||||
const act=await viaSystem(e,'activate',C,sso,'--migrate');
|
||||
assert.equal(pointer(e).copy,C);assert.deepEqual(pointer(e).console,sso);
|
||||
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${sso.join(' ')}`),conExec(e));
|
||||
assert.match(shown(e,act),/ --sso --send /);
|
||||
let r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
// Root's unit without --send: the pointer pins what root rendered.
|
||||
const g=await mk(t);await seedPreview(g,undefined,['--send']);await ssoConfig(g);
|
||||
r=await prun(g,'activate',C1,'acme','--sso','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
await viaSystem(g,'activate',C1,['--port',g.port,'--business','acme','--sso']);
|
||||
assert.deepEqual(pointer(g).console,['--port',g.port,'--business','acme','--sso']);
|
||||
assert.ok(stepText(g).includes('Z drop --send; --no-send was given\n'));
|
||||
r=await prun(g,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
for(const x of [e,g])await noLeak(x);
|
||||
});
|
||||
await t.test('item 1: --sso with a pair or --no-proof, or on any verb but activate, is a usage error',async(t)=>{
|
||||
@@ -1613,53 +1672,49 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('item 2: a target without --sso, a missing or bad console.sso, and each slot problem refuse before any stop',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);await ssoConfig(e,{slot:false});
|
||||
const slot=slotPath(e),put=(mode=0o600,text='x\n')=>{rmSync(slot,{force:true,recursive:true});writeFileSync(slot,text,{mode});chmodSync(slot,mode)};
|
||||
put();
|
||||
await t.test('item 2 (M3): without console.sso, a target without --sso and the missing key refuse before any stop; with console.sso, valid or not and whatever its slot, the user-unit run refuses accounts-user-unit first',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);
|
||||
await refused(e,['activate',B,'acme','--sso'],'sso-target',`${B}'s Console does not support --sso`);
|
||||
setKey(e,undefined);
|
||||
// N4: from another copy, the refusal names the re-pin without --sso as the first step.
|
||||
await refused(e,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C1} without --sso first, then write console.sso, then run activate ${C1} --sso`);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
await refused(e,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config`);
|
||||
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
|
||||
r=await prun(e,'activate',C1,'acme','--sso','--plan');assert.equal(r.code,0,r.out+r.err);
|
||||
await refused(e,['activate',C,'acme','--sso'],'sso-config',`${C}'s Console would refuse its --sso start: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`);
|
||||
setKey(e,SSO_KEY);
|
||||
const bad=(why)=>`console.sso provider a client secret slot ${slot} ${why}`;
|
||||
rmSync(slot);
|
||||
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is missing'));
|
||||
const elsewhere=join(e.root,'elsewhere');writeFileSync(elsewhere,'x\n',{mode:0o600});symlinkSync(elsewhere,slot);
|
||||
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is a symbolic link'));
|
||||
rmSync(slot);mkdirSync(slot,{mode:0o700});
|
||||
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is not a regular file'));
|
||||
for(const mode of [0o644,0o640,0o604]){put(mode);await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad(`has mode ${mode.toString(8)}, which gives group or other permission bits`))}
|
||||
put(0o600,'');
|
||||
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is empty'));
|
||||
// The slot directory resolving into the repository: the file is no link, but its real path is.
|
||||
const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
|
||||
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
|
||||
const secrets=dirname(slot);rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets);
|
||||
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is inside the repository or the copies'));
|
||||
// --plan lists the sso refusal with the rest and writes nothing.
|
||||
const out=await planOnly(e,3,'activate',C,'acme','--sso','--tester-port','80');
|
||||
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-invalid: --tester-port 80 is not a whole port from 1024 to 65535 other than 7332 and the Console's --port ${e.port}`,`refuses sso-slot: ${bad('is inside the repository or the copies')}`]);
|
||||
rmSync(secrets);mkdirSync(secrets,{mode:0o700});put();
|
||||
r=await prun(e,'activate',C,'acme','--sso','--migrate');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-invalid: --tester-port 80 is not a whole port from 1024 to 65535 other than 7332 and the Console's --port ${e.port}`,`refuses sso-config: ${C}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C} without --sso first, then write console.sso, then run activate ${C} --sso`]);
|
||||
// With console.sso the slot is the Console start's check (packages/webui/tests/sso.test.mjs, readSlot);
|
||||
// no slot state and no bad key gets a user-unit run past accounts-user-unit.
|
||||
await ssoConfig(e,{slot:false});
|
||||
const slot=slotPath(e),secrets=dirname(slot),put=(mode=0o600,text='x\n')=>{rmSync(slot,{force:true,recursive:true});writeFileSync(slot,text,{mode});chmodSync(slot,mode)};
|
||||
const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
|
||||
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
|
||||
const elsewhere=join(e.root,'elsewhere');writeFileSync(elsewhere,'x\n',{mode:0o600});
|
||||
const states=[()=>{},()=>symlinkSync(elsewhere,slot),()=>{rmSync(slot);mkdirSync(slot,{mode:0o700})},()=>put(0o644),()=>put(0o640),()=>put(0o604),()=>put(0o600,''),
|
||||
()=>{rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets)},()=>{rmSync(secrets);mkdirSync(secrets,{mode:0o700});put()}];
|
||||
// A bad tester port is a usage error outside --plan (row 180), so that case runs --plan only:
|
||||
// accounts-user-unit stops the plan before it lists tester-port-invalid.
|
||||
for(const s of states){s();for(const args of [['activate',C,'acme','--sso'],['activate',B,'acme','--sso']])await auu(e,args,'activate');await planRefused(e,['activate',C,'acme','--sso','--tester-port','80'],'accounts-user-unit',AUU(e,'activate'))}
|
||||
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
|
||||
await auu(e,['activate',C,'acme','--sso'],'activate');
|
||||
setKey(e,undefined);
|
||||
r=await prun(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.out+r.err);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('item 3: a config whose console key the target loader refuses stops an activate or a rollback before any stop, naming the first step',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
|
||||
await t.test('item 3 (M3): a config whose console key the target loader refuses stops an external activate or rollback before any stop, naming the first step; a user-unit run refuses accounts-user-unit first',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
const why='mosaic-config: unsupported configuration key: "console"';
|
||||
await refused(e,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why));
|
||||
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback',why));
|
||||
const out=await planOnly(e,3,'rollback','acme');
|
||||
await ssoConfig(e);
|
||||
await auu(e,['activate',B,'acme'],'activate');await auu(e,['rollback','acme'],'rollback');
|
||||
// Root stopped the units; the staged run checks the config before it writes anything.
|
||||
rootDown(e);
|
||||
const x=X(e),why='mosaic-config: unsupported configuration key: "console"';
|
||||
await refused(x,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why));
|
||||
await refused(x,['rollback','acme','--to',B],'config-console-key',KEY_MSG(e,B,'rollback',why));
|
||||
const out=await planOnly(x,3,'rollback','acme','--to',B);
|
||||
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses config-console-key: ${KEY_MSG(e,B,'rollback',why)}`]);
|
||||
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
|
||||
await refused(e,['activate',C,'acme'],'config-console-key',KEY_MSG(e,C,'activate',`mosaic-config: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`));
|
||||
setKey(e,undefined);
|
||||
await refused(x,['activate',C,'acme'],'config-console-key',KEY_MSG(e,C,'activate',`mosaic-config: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`));
|
||||
setKey(e,undefined);rootStart(e);
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
||||
await noLeak(e);
|
||||
});
|
||||
@@ -1676,17 +1731,17 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('item 3: a rollback from --sso to a Console without it takes the flags from the set of the activation onto the copy, past a same-copy re-pin (sage-1389)',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
|
||||
await t.test('item 3 (M3): from a pre-M3 --sso pointer the user-unit rollback refuses accounts-user-unit; without console.sso it takes the flags from the set of the activation onto the copy, past a same-copy re-pin (sage-1389)',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
const set=arrivalSet(e,C1);
|
||||
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
// A pre-M3 host turned --sso on in place, then re-pinned twice.
|
||||
await ssoConfig(e);
|
||||
for(const f of ['--sso','--send','--no-send']){r=await prun(legacy(e),'activate',C1,'acme',f);assert.equal(r.code,0,r.out+r.err)}
|
||||
// The latest set holds the --sso Console; the arrival set holds B's.
|
||||
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
|
||||
assert.match(shown(e,pointer(e).activation),/ --sso /);
|
||||
assert.doesNotMatch(readFileSync(join(set,SHOW),'utf8'),/ --sso /);
|
||||
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback','mosaic-config: unsupported configuration key: "console"'));
|
||||
await auu(e,['rollback','acme'],'rollback');
|
||||
setKey(e,undefined);
|
||||
const want=[...noSend(base.slice(0,-2)),'--tester-port','7333'];
|
||||
const out=await planOnly(e,0,'rollback','acme');
|
||||
@@ -1701,15 +1756,17 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
const g=await mk(t),gbase=await seedPreview(g,undefined,['--send']),f=await proofFile(g);
|
||||
r=await prun(g,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
||||
await ssoConfig(g);
|
||||
r=await prun(g,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(legacy(g),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
await auu(g,['rollback','acme'],'rollback');
|
||||
setKey(g,undefined);
|
||||
r=await prun(g,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(g).console,[...noSend(gbase),...pair(f)]);
|
||||
for(const x of [e,g])await noLeak(x);
|
||||
});
|
||||
await t.test('item 3: a rollback set that is missing, fails its sums, holds no argv or already ran --sso refuses sso-rollback-flags',async(t)=>{
|
||||
await t.test('item 3 (M3): from a pre-M3 --sso pointer, a rollback set that is missing, fails its sums, holds no argv or already ran --sso refuses sso-rollback-flags',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
await auu(e,['rollback','acme'],'rollback');
|
||||
setKey(e,undefined);
|
||||
const set=arrivalSet(e,C1),show=join(set,SHOW),text=readFileSync(show,'utf8');
|
||||
setEdit(set,SHOW,' --public-host ',' --sso --public-host ');
|
||||
@@ -1731,17 +1788,18 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('round 2 (webby-0028 N1): run through a symlinked repository path, a slot or a proof file inside the repository still refuses',async(t)=>{
|
||||
await t.test('round 2 (webby-0028 N1, M3): run through a symlinked repository path, a proof file inside the repository still refuses, and console.sso with a slot inside the repository refuses accounts-user-unit',async(t)=>{
|
||||
const link=join(base,'repo-link.200');if(!existsSync(link))symlinkSync(repo,link);
|
||||
const e=await mk(t);e.sr=join(link,'scripts','stack-release.sh');await seedPreview(e);
|
||||
const f=await proofFile(e,'proof.200',0o600,mkdtempSync(join(repo,'t57-proof-')));t.after(()=>rmSync(dirname(f),{recursive:true,force:true}));
|
||||
await refused(e,['activate',C1,'acme',...pair(f)],'proof-file-invalid',`--proof-file ${f} is inside the repository or the copies`);
|
||||
await ssoConfig(e,{slot:false});
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
await ssoConfig(e,{slot:false});
|
||||
const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
|
||||
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
|
||||
const secrets=dirname(slotPath(e));rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets);
|
||||
await refused(e,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(e)} is inside the repository or the copies`);
|
||||
// The Console's start refuses the slot (sso.test.mjs: inside the repository or a stack copy).
|
||||
await auu(e,['activate',C1,'acme','--sso'],'activate');
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('round 2 (webby-0028 N2): a config.json that does not parse stops the run before anything is read or stopped',async(t)=>{
|
||||
@@ -1758,24 +1816,21 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
const r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('round 3 (sage-1556): a named instance turns --sso on with its own config, slot and Console unit, and rolls back from its own set',async(t)=>{
|
||||
await t.test('round 3 (sage-1556, M3): a named instance refuses a user-unit --sso with its own config, turns --sso on through the system path on its own Console unit, and rolls back from its own set',async(t)=>{
|
||||
const e=await mk(t),a=await named(e,'alpha');await seed(a);
|
||||
const show=`show-${a.con}.txt`,unit=()=>readFileSync(join(a.units,a.con),'utf8').split('\n').find((l)=>l.startsWith('ExecStart='));
|
||||
assert.ok(slotPath(a).startsWith(join(a.env.HOME,'.config','mosaic-alpha','secrets','mosaic-stack')+'/'),slotPath(a));
|
||||
await ssoConfig(a,{slot:false});
|
||||
await refused(a,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(a)} is missing`);
|
||||
await proofFile(a,SLOT,0o600,dirname(slotPath(a)));
|
||||
await ssoConfig(a);
|
||||
await auu(a,['activate',C1,'acme','--sso'],'activate');
|
||||
const want=['--port',a.port,'--business','acme','--sso'];
|
||||
const out=await planOnly(a,0,'activate',C1,'acme','--sso');
|
||||
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --instance alpha --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
|
||||
let r=await prun(a,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
const act=await viaSystem(a,'activate',C1,want);
|
||||
assert.equal(pointer(a).copy,C1);assert.deepEqual(pointer(a).console,want);
|
||||
assert.ok(unit().endsWith(` ${want.join(' ')}`),unit());
|
||||
assert.ok(readFileSync(join(a.units,a.con),'utf8').includes(`\nEnvironment=MOSAIC_CONFIG=${a.cfg}\n`));
|
||||
assert.ok(!existsSync(join(e.units,CON)),'the default Console unit was written');
|
||||
r=await prun(a,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
let r=await prun(a,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
setKey(a,undefined);
|
||||
const set=arrivalSet(a,C1);assert.ok(existsSync(join(set,show)),show);
|
||||
const set=setOf(a,act);assert.equal(arrivalSet(a,C1),set);assert.ok(existsSync(join(set,show)),show);
|
||||
assert.deepEqual(ssoLines(await planOnly(a,0,'rollback','acme')),[`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${show}, with --tester-port as the pointer has it`]);
|
||||
r=await prun(a,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(a).copy,B);assert.deepEqual(pointer(a).console,want.slice(0,-1));
|
||||
@@ -1794,44 +1849,35 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
|
||||
// Row 273 (#1778, mos-1246 F39): activate --send turns send on only beside --sso, given or carried, and
|
||||
// activate --no-sso turns --sso off with the Console flags from the set of the activation that turned
|
||||
// it on, dropping --send. The tester follows the pointer.
|
||||
// it on, dropping --send. The tester follows the pointer. M3 (#1873): with console.sso these are
|
||||
// accounts-user-unit refusals; a pre-M3 --sso pointer comes from legacy(e), and --no-sso runs once
|
||||
// console.sso is gone.
|
||||
const SEND_MSG="--send needs --sso in the run's Console flags, given or carried; under forward auth any same-uid process could send (F39)";
|
||||
const NOT_ON_MSG="the pointer's Console flags hold no --sso, so --no-sso has nothing to turn off";
|
||||
const OFF_MSG=(why)=>`the Console flags from before --sso cannot be read: ${why}`;
|
||||
const OFF_PLAN=(set,restores)=>`sso: off; Console flags from ${set}/${SHOW}; restores ${restores}`;
|
||||
const NOTE=(to)=>`note: ${to} is the pointer's copy; the run re-pins it and only the Console flags change`;
|
||||
test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
await t.test('T1: --sso --send turns send on after --sso and before the tester port, and the plan says so',async(t)=>{
|
||||
await t.test('T1 (M3): a user-unit --sso --send refuses accounts-user-unit; the system path pins root\'s --sso --send before the tester port',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--tester-port','7333']);await ssoConfig(e);
|
||||
await auu(e,['activate',C1,'acme','--sso','--send'],'activate');
|
||||
const want=['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333'];
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--sso','--send');
|
||||
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: on; --send was given',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send --tester-port 7333`]);
|
||||
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
await viaSystem(e,'activate',C1,want);
|
||||
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
|
||||
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
||||
const steps=stepText(e);
|
||||
assert.ok(steps.includes('Z turn on --send; --send was given\n'),steps);assert.ok(steps.includes(`Z turn on --sso; drop ${DROPS}\n`),steps);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
// The same copy with send on again is already active.
|
||||
await refused(e,['activate',C1,'acme','--send'],'already-active',`${C1} is the pointer's copy`);
|
||||
const r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await auu(e,['activate',C1,'acme','--send'],'activate');
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T1: --send beside a carried --sso re-pins the live copy once, and the plan names the carried --sso',async(t)=>{
|
||||
await t.test('T1 (M3): beside a pre-M3 carried --sso, a user-unit --send refuses accounts-user-unit; the system path carries root\'s --sso --send to another copy',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--send');
|
||||
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: on; --send was given','sso: carried']);
|
||||
assert.ok(out.includes(NOTE(C1)),out.join('\n'));
|
||||
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
|
||||
await auu(e,['activate',C1,'acme','--send'],'activate');
|
||||
await auu(e,['activate',C,'acme','--send','--migrate'],'activate');
|
||||
const want=['--port',e.port,'--business','acme','--sso','--send'];
|
||||
assert.deepEqual(pointer(e).console,want);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
||||
assert.ok(stepText(e).includes('Z turn on --send; --send was given\n'));assert.doesNotMatch(stepText(e),/^\S+Z .*sso/m);
|
||||
await refused(e,['activate',C1,'acme','--send'],'already-active',`${C1} is the pointer's copy`);
|
||||
// Another copy carries both.
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C,'acme','--send')),['send: carried']);
|
||||
await viaSystem(e,'activate',C,want,'--migrate');
|
||||
assert.equal(pointer(e).copy,C);assert.deepEqual(pointer(e).console,want);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
@@ -1848,11 +1894,12 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
await refused(g,['activate',B,'acme','--send'],'already-active',`${B} is the pointer's copy`);
|
||||
for(const x of [e,g])await noLeak(x);
|
||||
});
|
||||
await t.test('T2 (sage-2315 B1): a rollback from an --sso --send pointer to a Console without --sso ends with neither flag',async(t)=>{
|
||||
await t.test('T2 (sage-2315 B1, M3): from a pre-M3 --sso --send pointer the user-unit rollback refuses accounts-user-unit; without console.sso the rollback to a Console without --sso ends with neither flag',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
let r=await prun(legacy(e),'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
|
||||
const set=arrivalSet(e,C1);
|
||||
await auu(e,['rollback','acme'],'rollback');
|
||||
setKey(e,undefined);
|
||||
const out=await planOnly(e,0,'rollback','acme');
|
||||
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: dropped; the run drops --sso, and --send needs it (F39)',ROLLBACK_PLAN(set)]);
|
||||
@@ -1866,7 +1913,7 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
});
|
||||
await t.test('T2 and T4: --send with --no-send, --no-sso with --sso, --send, a pair or --no-proof, and either flag on another verb, is a usage error',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);await ssoConfig(e);const f=await proofFile(e);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
let r=await prun(legacy(e),'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
|
||||
for(const args of [['activate',C1,'acme','--send','--no-send'],['activate',C1,'acme','--no-send','--send','--plan'],['activate',C1,'acme','--sso','--send','--no-send'],
|
||||
['activate',C1,'acme','--no-sso','--sso'],['activate',C1,'acme','--sso','--no-sso','--plan'],['activate',C1,'acme','--no-sso','--send'],
|
||||
@@ -1879,15 +1926,18 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T3: --no-sso on the same copy returns the unit and the pointer to the flags from before --sso, without --send',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
|
||||
await t.test('T3 (M3): --no-sso refuses accounts-user-unit while console.sso is set; without it, on the same copy it returns the unit and the pointer to the flags from before --sso, without --send',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
const pre=conExec(e),want=noSend(base);
|
||||
assert.ok(pre.endsWith(` ${want.join(' ')}`),pre);
|
||||
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
await ssoConfig(e);
|
||||
r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
const set=setOf(e,pointer(e).activation);
|
||||
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(legacy(e),'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333']);
|
||||
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
|
||||
setKey(e,undefined);
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: off','send: dropped; --no-sso drops --send (F39)',OFF_PLAN(set,want.slice(0,-2).join(' '))]);
|
||||
assert.ok(out.includes(NOTE(C1)),out.join('\n'));
|
||||
@@ -1904,14 +1954,16 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
await refused(e,['activate',C1,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T3: the set walk passes later re-pins that ran with --sso, and the tester follows the pointer',async(t)=>{
|
||||
await t.test('T3 (M3): from pre-M3 re-pins, the set walk passes later re-pins that ran with --sso, and the tester follows the pointer',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
|
||||
// The activation that turns --sso on also moves the tester to 7334, so the set and the pointer differ.
|
||||
let r=await prun(e,'activate',C1,'acme','--sso','--tester-port','7334');assert.equal(r.code,0,r.out+r.err);
|
||||
let r=await prun(legacy(e),'activate',C1,'acme','--sso','--tester-port','7334');assert.equal(r.code,0,r.out+r.err);
|
||||
const set=setOf(e,pointer(e).activation);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
|
||||
r=await prun(legacy(e),'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.match(shown(e,pointer(e).activation),/ --sso /);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--tester-port','7334']);
|
||||
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
|
||||
setKey(e,undefined);
|
||||
const restores=base.slice(0,-3),want=[...restores,'--tester-port','7334'];
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
|
||||
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: off; --no-sso was given and the pointer holds no --send',OFF_PLAN(set,restores.join(' '))]);
|
||||
@@ -1921,17 +1973,20 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T3 (sage-2305 a): after --sso, two later re-pins that carry it, then --no-sso restores the flags from before --sso byte for byte',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--tester-port','7333']);await ssoConfig(e);
|
||||
await t.test('T3 (sage-2305 a, M3): after a pre-M3 --sso and two re-pins that carry it, --no-sso without console.sso restores the flags from before --sso byte for byte',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--tester-port','7333']);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
const pre=conExec(e);assert.ok(pre.endsWith(` ${base.join(' ')}`),pre);
|
||||
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
await ssoConfig(e);
|
||||
r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
const set=setOf(e,pointer(e).activation);
|
||||
// Two same-copy re-pins carry --sso, so the two newest sets both hold a Console with it.
|
||||
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(legacy(e),'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(legacy(e),'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
const acts=logLines(e).filter((l)=>l.result==='ok'&&l.op==='activate').map((l)=>l.activation).slice(-2);
|
||||
for(const a of acts)assert.match(readFileSync(join(setOf(e,a),SHOW),'utf8'),/ --sso /);
|
||||
for(const a of acts)assert.match(shown(e,a),/ --sso /);
|
||||
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
|
||||
setKey(e,undefined);
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
|
||||
assert.deepEqual(ssoLines(out),[OFF_PLAN(set,base.slice(0,-2).join(' '))]);
|
||||
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
|
||||
@@ -1939,27 +1994,31 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T4 (sage-2305 b): a log in which every set holds a Console with --sso refuses before any unit stops',async(t)=>{
|
||||
await t.test('T4 (sage-2305 b, M3): a log in which every set holds a Console with --sso refuses before any unit stops',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--send']);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
const first=setOf(e,pointer(e).activation);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
|
||||
r=await prun(legacy(e),'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.match(shown(e,pointer(e).activation),/ --sso /);
|
||||
// The set of the activation that turned --sso on now claims its Console ran with it too, sums intact.
|
||||
setEdit(first,SHOW,' --public-host ',' --sso --public-host ');
|
||||
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
|
||||
setKey(e,undefined);
|
||||
const why=OFF_MSG(`the Console in ${join(first,SHOW)} already ran with --sso, and the log names no earlier activation`);
|
||||
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',why);
|
||||
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-off-flags: ${why}`]);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T4: --no-sso refuses before any stop on a pointer without --sso, a tampered or missing set, a set that ran with --sso, an ok line with no activation, and a log with no set',async(t)=>{
|
||||
await t.test('T4 (M3): --no-sso refuses before any stop on a pointer without --sso, a tampered or missing set, a set that ran with --sso, an ok line with no activation, and a log with no set',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--send']);
|
||||
await refused(e,['activate',B,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
|
||||
await refused(e,['activate',C1,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
|
||||
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-not-on: ${NOT_ON_MSG}`]);
|
||||
// One set, from the activation that turned --sso on.
|
||||
// One set, from the pre-M3 activation that turned --sso on.
|
||||
await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
|
||||
setKey(e,undefined);
|
||||
const set=setOf(e,pointer(e).activation),show=join(set,SHOW),text=readFileSync(show,'utf8');
|
||||
// An ok line with no activation stops the walk (sage-2315 N1); skipping it would reach the set above.
|
||||
const logText=readFileSync(e.log,'utf8');
|
||||
@@ -1985,6 +2044,62 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
});
|
||||
});
|
||||
|
||||
// M3 (#1873) item 2: each accounts sign refuses a user-unit activate, rollback or pm-on before
|
||||
// anything stops; without them the user path runs, and with them the system path runs.
|
||||
const AUU_TAIL=(op)=>`; an accounts instance runs system units, so root renders them with case2-unit.sh and the ${op} runs with --units external`;
|
||||
test('M3 accounts-user-unit: each accounts sign refuses a user-unit run before any stop; the user path without them and the system path with them both run',async(t)=>{
|
||||
await t.test('a stamped target, accounts.json, console.sso and .deployed each refuse activate and rollback, run and --plan',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
const accounts=join(dirname(e.cfg),'accounts.json'),deployed=join(e.data,'.deployed');
|
||||
const stamp=(copy)=>{const d=join(COPIES,copy),f=join(d,'.stack-build.json'),m=statSync(d).mode&0o777;chmodSync(d,0o755);writeFileSync(f,'{}\n');chmodSync(d,m);
|
||||
return ()=>{chmodSync(d,0o755);rmSync(f);chmodSync(d,m)}};
|
||||
const signs=[
|
||||
[()=>stamp(C),()=>stamp(B),`${join(COPIES,C)} is a stamped copy`,`${join(COPIES,B)} is a stamped copy`],
|
||||
[()=>{writeFileSync(accounts,'{}\n',{mode:0o600});return ()=>rmSync(accounts)},null,`${accounts} is present`],
|
||||
[()=>{setKey(e,SSO_KEY);return ()=>setKey(e,undefined)},null,`${e.cfg} holds console.sso`],
|
||||
[()=>{writeFileSync(deployed,'{}\n');return ()=>rmSync(deployed)},null,`${deployed} is present`],
|
||||
];
|
||||
for(const [on,onRb,why,whyRb] of signs){
|
||||
let off=on();t.after(()=>{try{off()}catch{}});
|
||||
await auu(e,['activate',C,'acme','--migrate'],'activate',why+AUU_TAIL('activate'));
|
||||
if(onRb){off();off=onRb()}
|
||||
await auu(e,['rollback','acme'],'rollback',(whyRb??why)+AUU_TAIL('rollback'));
|
||||
off();
|
||||
}
|
||||
// No sign left: the user path runs both ways.
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
||||
r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,C1);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('pm-on on an accounts instance refuses before it captures or stops anything; without the sign it runs',async(t)=>{
|
||||
const e=await mk(t);await pending(e);biz(e,HARNESS);
|
||||
const accounts=join(dirname(e.cfg),'accounts.json');writeFileSync(accounts,'{}\n',{mode:0o600});
|
||||
// The harness is set, so only the accounts sign stands between pm-on and the user bus unit.
|
||||
await refused(e,['pm-on','acme'],'accounts-user-unit',`${accounts} is present; an accounts instance runs system units, so root renders them with case2-unit.sh, and the PM is a flag in root's bus unit`);
|
||||
rmSync(accounts);
|
||||
const r=await run(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.ok(r.out.endsWith(`stack-release: pm-on ok: the PM is live on ${C1} for acme\n`),r.out);
|
||||
});
|
||||
await t.test('with accounts.json and .deployed the system path activates, confirms and rolls back, and the user path still refuses',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e);
|
||||
writeFileSync(join(dirname(e.cfg),'accounts.json'),'{}\n',{mode:0o600});writeFileSync(join(e.data,'.deployed'),'{}\n');
|
||||
await ssoConfig(e);
|
||||
const sso=['--port',e.port,'--business','acme','--sso','--send'];
|
||||
await viaSystem(e,'activate',C1,sso);
|
||||
assert.deepEqual([pointer(e).copy,pointer(e).console],[C1,sso]);
|
||||
let r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
// B's loader refuses the console key, so a rollback onto it drops console.sso first; the other signs stay.
|
||||
setKey(e,undefined);
|
||||
await auu(e,['rollback','acme'],'rollback',`${join(dirname(e.cfg),'accounts.json')} is present`+AUU_TAIL('rollback'));
|
||||
await viaSystem(e,'rollback',B,base);
|
||||
assert.deepEqual([pointer(e).copy,pointer(e).console],[B,base]);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
});
|
||||
|
||||
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
|
||||
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
|
||||
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
|
||||
@@ -2009,6 +2124,7 @@ function rHost(){
|
||||
}
|
||||
if(a[0]!=='bus'||a[1]!=='start'||!a.slice(3).every((f)=>flags.includes(f))){console.error(`fixture host: ${a.join(' ')}`);process.exit(4)}
|
||||
fs.appendFileSync(path.join(home,'host-args.log'),`${a.join(' ')}\n`);
|
||||
fs.appendFileSync(path.join(home,'host-ca.log'),`${process.env.NODE_EXTRA_CA_CERTS??'none'}\n`);
|
||||
if(fault(`host-fails-${id}`)){console.error('fixture host: refused');process.exit(1)}
|
||||
const b=a[2],off=a.includes('--no-launch'),lock=path.join(data,'bus','writer.lock'),at=new Date().toISOString();
|
||||
fs.writeFileSync(lock,String(process.pid),{flag:'wx'});
|
||||
@@ -2029,14 +2145,39 @@ function rHost(){
|
||||
}
|
||||
|
||||
// The Console: serve on the given loopback port; /healthz answers 200 unless healthz-<copy> is set.
|
||||
function rConsole(){
|
||||
// M3 (#1873): under --sso it runs its copy's real createSso on the scratch console.sso, with the
|
||||
// scratch slot placeholders, as the real Console does, and /healthz carries sso.health(). Faults:
|
||||
// console-hold waits for $HOME/release before it starts; console-no-sso reports every provider
|
||||
// ready without asking any; console-asks-token POSTs to provider a's token endpoint, then asks its
|
||||
// authorize endpoint with a query, once each.
|
||||
async function rConsole(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),http=process.getBuiltinModule('node:http');
|
||||
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
|
||||
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
|
||||
const c=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8'));
|
||||
fs.appendFileSync(path.join(home,'console-args.log'),`${a.join(' ')}\n`);
|
||||
fs.appendFileSync(path.join(home,'console-context.jsonl'),JSON.stringify({argv:a,dataRoot:JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot})+'\n');
|
||||
fs.appendFileSync(path.join(home,'console-context.jsonl'),JSON.stringify({argv:a,dataRoot:c.dataRoot,ca:process.env.NODE_EXTRA_CA_CERTS??null})+'\n');
|
||||
if(a[0]!=='serve'){console.error('fixture Console: refused');process.exit(1)}
|
||||
const s=http.createServer((q,r)=>{r.writeHead(q.url==='/healthz'&&!fault(`healthz-${id}`)?200:500);r.end()});
|
||||
while(fault('console-hold')&&!fs.existsSync(path.join(home,'release')))await new Promise((r)=>setTimeout(r,50));
|
||||
let health=null;
|
||||
if(a.includes('--sso')){
|
||||
const ps=c.console.sso.providers;
|
||||
if(fault('console-no-sso'))health=()=>Object.fromEntries(ps.map((p)=>[p.id,'ready']));
|
||||
else{
|
||||
const {createSso}=await import(path.join(import.meta.dirname,'sso.mjs'));
|
||||
const secrets=new Map(ps.map((p)=>[p.id,fs.readFileSync(path.join(path.dirname(process.env.MOSAIC_CONFIG),'secrets','mosaic-stack',p.clientSecret.slot),'utf8')]));
|
||||
let sso;try{sso=await createSso(c.console.sso,{secrets,log:()=>{}})}catch(err){console.error(`fixture Console: ${err.message}`);process.exit(2)}
|
||||
health=()=>sso.health();
|
||||
}
|
||||
if(fault('console-asks-token')){
|
||||
await fetch(`${ps[0].issuer}token`,{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:'grant_type=authorization_code&code=x'}).catch(()=>{});
|
||||
await fetch(`${ps[0].issuer}authorize?client_id=rehearsal&response_type=code`).catch(()=>{});
|
||||
}
|
||||
}
|
||||
const s=http.createServer((q,r)=>{
|
||||
const ok=q.url==='/healthz'&&!fault(`healthz-${id}`);r.writeHead(ok?200:500,{'content-type':'application/json'});
|
||||
r.end(ok&&health?JSON.stringify({ok:true,sso:health()}):'');
|
||||
});
|
||||
s.listen(Number(a[a.indexOf('--port')+1]),'127.0.0.1',()=>console.log(`Mosaic Console: http://127.0.0.1:${s.address().port}/\nBoard: fixture`));
|
||||
process.on('SIGTERM',()=>process.exit(0));
|
||||
}
|
||||
@@ -2079,6 +2220,24 @@ for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],
|
||||
for(const [f,text] of [['packages/cli/src/cli.mjs',id===A?'// fixture host from before row 163\nprocess.exit(1);\n':script(rHost)],['packages/webui/src/cli.mjs',script(rConsole)],['packages/bus/src/store.mjs',rStore(v,froms)],['packages/bus/src/migrate-schema.mjs',script(rMigrate)],['packages/bus/src/broker.mjs','export class BusError extends Error { constructor(code) { super(code); this.code = code; } }\nexport function requestArchived() { return false; }\n']]){mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text)}
|
||||
execFileSync('chmod',['-R','a-w',d]);
|
||||
}
|
||||
// M3 (#1873): SCOPIES holds B as a stamped copy: RCOPIES's B with a read-only .stack-build.json
|
||||
// naming its commit, the Console's sso-config.mjs the config loader needs for a console key, its
|
||||
// sso.mjs the stand-in Console runs, and the deployment.mjs whose writeDeployment the rehearsal runs.
|
||||
const SCOPIES=join(base,'scopies');mkdirSync(SCOPIES);
|
||||
{
|
||||
const d=join(SCOPIES,B);cpSync(join(RCOPIES,B),d,{recursive:true});execFileSync('chmod',['-R','u+w',d]);
|
||||
for(const p of ['packages/webui/src/sso-config.mjs','packages/webui/src/sso.mjs','packages/cli/src/deployment.mjs'])cpSync(join(repo,p),join(d,p));
|
||||
// sso.mjs imports oauth4webapi.
|
||||
symlinkSync(join(ROOT,'node_modules'),join(d,'node_modules'));
|
||||
writeFileSync(join(d,'.stack-build.json'),JSON.stringify({commit:B}),{mode:0o444});
|
||||
execFileSync('chmod',['-R','a-w',d]);
|
||||
}
|
||||
// UCOPIES holds B unstamped but able to load a console key: SCOPIES's B without .stack-build.json.
|
||||
const UCOPIES=join(base,'ucopies');mkdirSync(UCOPIES);
|
||||
{
|
||||
const d=join(UCOPIES,B);cpSync(join(SCOPIES,B),d,{recursive:true,verbatimSymlinks:true});execFileSync('chmod',['-R','u+w',d]);
|
||||
rmSync(join(d,'.stack-build.json'));execFileSync('chmod',['-R','a-w',d]);
|
||||
}
|
||||
|
||||
const TESTER='7499';
|
||||
// Vikunja #782: this leases a port, closes it and hands the number to `stack-release.sh rehearse`, which
|
||||
@@ -2091,12 +2250,13 @@ const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync
|
||||
|
||||
// A root for one rehearsal: a stopped live store at `store`, a pointer by hand on `copy` with the
|
||||
// Console on e.port and the tester listener on TESTER, and a backup as backup_store writes one.
|
||||
// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG, are killed after the test.
|
||||
// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG or a rehearsal path in their command
|
||||
// line (the rehearsal provider), are killed after the test.
|
||||
async function rmk(t,{copy=B,store='3g'}={}){
|
||||
const e=await mk(t);
|
||||
t.after(()=>{
|
||||
for(const pid of readdirSync('/proc').filter((n)=>/^\d+$/.test(n)))
|
||||
try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{}
|
||||
try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`)||readFileSync(`/proc/${pid}/cmdline`,'utf8').includes(`${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{}
|
||||
});
|
||||
const db=new DatabaseSync(e.store);
|
||||
db.exec('PRAGMA journal_mode=WAL');
|
||||
@@ -2121,7 +2281,7 @@ async function rmk(t,{copy=B,store='3g'}={}){
|
||||
const untouched=(e)=>JSON.stringify([...[e.store,e.cfg,e.biz,e.pointer].map((f)=>[f,sha(f)]),existsSync(e.log),...readdirSync(e.backup,{recursive:true}).sort().map((f)=>join(e.backup,f)).filter((f)=>statSync(f).isFile()).map((f)=>[f,sha(f)])]);
|
||||
|
||||
async function rehearse(e,id,...args){
|
||||
const ch=spawn('bash',[e.sr??SR,'rehearse',id,'acme',...args,'--copies',RCOPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
|
||||
const ch=spawn('bash',[e.sr??SR,'rehearse',id,'acme',...args,'--copies',e.copies??RCOPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
|
||||
let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d});
|
||||
e.during?.(ch);
|
||||
const timer=setTimeout(()=>ch.kill('SIGKILL'),120000);
|
||||
@@ -2137,7 +2297,7 @@ function ownScript(e,from,to){
|
||||
const s=readFileSync(e.sr,'utf8');assert.ok(s.includes(from),`${e.sr} has no ${from}`);writeFileSync(e.sr,s.replace(from,()=>to));
|
||||
}
|
||||
const rDirs=(e)=>existsSync(join(e.data,'rehearsals'))?readdirSync(join(e.data,'rehearsals')):[];
|
||||
const PRE=['PASS copy','PASS port','PASS backup','PASS scratch-root','PASS data-root-live','PASS sessions-live','PASS data-root-scratch','PASS notify-off','PASS tracker-configured','PASS credential-ref-live'];
|
||||
const PRE=['PASS copy','PASS port','PASS backup','PASS scratch-root','PASS data-root-live','PASS sessions-live','PASS data-root-scratch','PASS notify-off','PASS tracker-configured','PASS credential-ref-live','PASS sso-isolated'];
|
||||
const POST=['PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged'];
|
||||
|
||||
test('R1 rehearse passes on the same schema; the scratch root holds its files and the live files stay byte-identical',async(t)=>{
|
||||
@@ -2374,6 +2534,229 @@ test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops t
|
||||
}
|
||||
});
|
||||
|
||||
// --- M3 (#1873): rehearsing a stamped copy (the brief's item 1) ---
|
||||
// A stamped copy serves only with --sso, console.sso, accounts.json and a .deployed naming its
|
||||
// instance. The rehearsal gives its scratch Console all four: the live console.sso with its origin
|
||||
// on rehearsal.invalid and each issuer at the rehearsal's own provider, a placeholder per slot, a
|
||||
// copy of the live accounts.json, and a .deployed from the copy's writeDeployment. The live slot
|
||||
// has a read watch: it is never opened.
|
||||
const ACCOUNTS={accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'subject-1'}],name:'Jason',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}],memberships:[],invites:[]};
|
||||
async function smk(t,{sso=true,accounts=true}={}){
|
||||
const e=await rmk(t);e.copies=SCOPIES;
|
||||
if(sso)await ssoConfig(e);
|
||||
e.accounts=join(dirname(e.cfg),'accounts.json');
|
||||
if(accounts)writeFileSync(e.accounts,JSON.stringify(ACCOUNTS),{mode:0o600});
|
||||
return e;
|
||||
}
|
||||
const SPRE=[...PRE.slice(0,3),'PASS provider-start',PRE[3],'PASS deployment',...PRE.slice(4)];
|
||||
const SPOST=['PASS console-stopped','PASS host-stopped','PASS provider-stopped',...POST.slice(2)];
|
||||
// The rehearsal provider's origin and pid, from its provider-start line.
|
||||
const provOf=(out)=>{const m=/^PASS provider-start: pid (\d+) at (https:\/\/127\.0\.0\.1:\d+): /m.exec(out);return m?{pid:Number(m[1]),origin:m[2]}:null};
|
||||
// A real stamped copy of this checkout, as deployment-boundary.test.mjs builds one.
|
||||
function stampedCopy(t){
|
||||
const sha40='a'.repeat(40),root=mkdtempSync(join(tmpdir(),'rs-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
const copy=join(root,sha40);mkdirSync(copy);
|
||||
for(const dir of ['packages','scripts','roles','adapters'])cpSync(join(ROOT,dir),join(copy,dir),{recursive:true,filter:(q)=>!q.split('/').some((x)=>['tests','node_modules'].includes(x))});
|
||||
writeFileSync(join(copy,'package.json'),'{"type":"module"}');
|
||||
writeFileSync(join(copy,'.stack-build.json'),JSON.stringify({commit:sha40}),{mode:0o444});chmodSync(join(copy,'.stack-build.json'),0o444);
|
||||
symlinkSync(join(ROOT,'node_modules'),join(copy,'node_modules'));
|
||||
return copy;
|
||||
}
|
||||
// Starts the copy's real Console on port 0 against the rehearsal's scratch config. Resolves with
|
||||
// {code,out,err} on exit, or with the bound port once it prints its address line.
|
||||
async function realServe(t,copy,e,dir,more={}){
|
||||
const ch=spawn(process.execPath,[join(copy,'packages/webui/src/cli.mjs'),'serve','--port','0','--sso','--board','http://127.0.0.1:9'],{env:{HOME:e.env.HOME,PATH:e.env.PATH,TMPDIR:e.env.TMPDIR,MOSAIC_CONFIG:join(dir,'config','config.json'),...more},stdio:['ignore','pipe','pipe']});
|
||||
t.after(async()=>{if(ch.exitCode===null&&ch.signalCode===null){const done=once(ch,'close');ch.kill('SIGKILL');await done}});
|
||||
let out='',err='';ch.stderr.on('data',(d)=>{err+=d});
|
||||
return new Promise((res)=>{
|
||||
const timer=setTimeout(()=>{ch.kill('SIGKILL');res({code:'timeout',out,err})},20000);
|
||||
ch.stdout.on('data',(d)=>{out+=d;const m=/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//.exec(out);if(m&&out.includes('Ctrl-C')){clearTimeout(timer);res({port:Number(m[1]),out,err,ch})}});
|
||||
ch.on('close',(code)=>{clearTimeout(timer);res({code,out,err})});
|
||||
});
|
||||
}
|
||||
|
||||
test('M3 R16 a stamped copy rehearses with --sso against its own provider, a scratch accounts copy and its own .deployed; the live files stay as they were',async(t)=>{
|
||||
const st=(f)=>{const x=statSync(f);return [x.ino,x.size,x.mtimeMs,x.mode]};
|
||||
const e=await smk(t),before=untouched(e),accounts=sha(e.accounts),slot=st(slotPath(e)),port=await freePort();
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',port,'--timeout','10');
|
||||
assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(r.names,[...SPRE,'PASS migration','PASS gate','PASS no-session','PASS provider-discovery',...SPOST]);
|
||||
const R=r.dir,prov=provOf(r.out);assert.ok(prov,r.out);
|
||||
assert.ok(r.out.includes(`PASS provider-start: pid ${prov.pid} at ${prov.origin}: discovery and a JWKS from keys made for this run, no code and no token; its certificate ${join(R,'provider','cert.pem')} is the trust the scratch Console alone gets\n`),r.out);
|
||||
assert.ok(r.out.includes(`PASS deployment: ${B} is stamped: ${join(R,'data','.deployed')} from its writeDeployment, the live accounts.json copied to ${join(R,'config')}, the Console starts with --sso\n`),r.out);
|
||||
assert.ok(r.out.includes(`PASS sso-isolated: 1 provider(s), each at the rehearsal provider ${prov.origin} with a placeholder secret\n`),r.out);
|
||||
assert.ok(r.out.includes(`PASS gate: host-up, bus-status, console-up, healthz, sso-ready and copy-match on ${B}\n`),r.out);
|
||||
assert.ok(r.out.includes(`PASS provider-discovery: the Console fetched each provider's discovery and JWKS from ${prov.origin} and asked for no code or token\n`),r.out);
|
||||
// The Console started with --sso, only on the scratch config, and alone trusts the provider's
|
||||
// certificate; the host gets no added trust.
|
||||
assert.deepEqual(lines(rh(e,'console-args.log')),[`serve --port ${port} --business acme --sso`]);
|
||||
assert.deepEqual(lines(rh(e,'console-context.jsonl')).map((l)=>JSON.parse(l).ca),[join(R,'provider','cert.pem')]);
|
||||
assert.deepEqual(lines(rh(e,'host-ca.log')),['none']);
|
||||
// The provider served discovery and the JWKS, got no other request, kept no TLS key and is gone.
|
||||
assert.deepEqual(lines(join(R,'provider','requests.log')).sort(),['GET /a/.well-known/openid-configuration 200','GET /a/jwks 200']);
|
||||
assert.deepEqual(readdirSync(join(R,'provider')).sort(),['cert.pem','origin','provider.log','requests.log']);
|
||||
assert.equal(statSync(join(R,'provider')).mode&0o777,0o700);
|
||||
assert.equal(existsSync(`/proc/${prov.pid}`),false,`provider pid ${prov.pid} is still there`);
|
||||
// The scratch config: the live one with dataRoot, origin and issuer moved; provider id, client id,
|
||||
// algorithms, slot name and subjects as they were. No subject is added.
|
||||
const live=JSON.parse(readFileSync(e.cfg,'utf8')),sc=JSON.parse(readFileSync(join(R,'config','config.json'),'utf8'));
|
||||
const want=structuredClone(live);want.dataRoot=join(R,'data');want.console.sso.publicOrigin='https://rehearsal.invalid';want.console.sso.providers[0].issuer=`${prov.origin}/a/`;
|
||||
assert.deepEqual(sc,want);assert.deepEqual(sc.console.sso.providers[0].subjects,SSO_KEY.sso.providers[0].subjects);
|
||||
// The slot is a scratch placeholder, 0600 in a 0700 directory.
|
||||
const ph=join(R,'config','secrets','mosaic-stack',SLOT);
|
||||
assert.equal(readFileSync(ph,'utf8'),'rehearsal-placeholder-secret\n');assert.equal(statSync(ph).mode&0o777,0o600);
|
||||
assert.equal(statSync(dirname(ph)).mode&0o777,0o700);assert.deepEqual(readdirSync(dirname(ph)),[SLOT]);
|
||||
// accounts.json is the live file's bytes; .deployed names the instance the scratch config dir gives.
|
||||
assert.equal(readFileSync(join(R,'config','accounts.json'),'utf8'),readFileSync(e.accounts,'utf8'));
|
||||
const dep=join(R,'data','.deployed');
|
||||
assert.deepEqual(JSON.parse(readFileSync(dep,'utf8')),{instance:'dev'});assert.equal(statSync(dep).mode&0o777,0o444);
|
||||
// The live store, config, business, pointer, backup, accounts and slot are as they were; the run
|
||||
// never opened the live slot and its sentinel is nowhere under the data root.
|
||||
assert.equal(untouched(e),before);assert.equal(sha(e.accounts),accounts);assert.deepEqual(st(slotPath(e)),slot);
|
||||
assert.ok(readFileSync(join(R,'live.before'),'utf8').includes(e.accounts),'the end check sums the live accounts.json');
|
||||
await noLeak(e);
|
||||
});
|
||||
|
||||
test('M3 R17 the real stamped Console finds the rehearsal provider ready with the scratch prerequisites, starts degraded once it is gone, and refuses without .deployed or accounts.json',async(t)=>{
|
||||
const e=await smk(t);rfault(e,'console-hold');
|
||||
const copy=stampedCopy(t);
|
||||
// While the stand-in Console holds, the rehearsal provider is up: the real Console of a stamped
|
||||
// copy passes every startup check (stamp, --sso, .deployed, accounts, console.sso and its slot)
|
||||
// and, trusting only the provider's certificate, finds provider a ready.
|
||||
let during=null;
|
||||
e.during=async()=>{
|
||||
try{
|
||||
for(let i=0;i<400&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));
|
||||
const dir=join(e.data,'rehearsals',rDirs(e)[0]);
|
||||
const up=await realServe(t,copy,e,dir,{NODE_EXTRA_CA_CERTS:join(dir,'provider','cert.pem')});
|
||||
during={up:up.port?await (await fetch(`http://127.0.0.1:${up.port}/healthz`)).json():null,why:`${up.code} ${up.out} ${up.err}`};
|
||||
if(up.ch){const done=once(up.ch,'close');up.ch.kill('SIGTERM');await done}
|
||||
}finally{writeFileSync(rh(e,'release'),'')}
|
||||
};
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30');
|
||||
assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(during?.up,{ok:true,sso:{a:'ready'}},during?.why);
|
||||
// After the rehearsal its provider is gone: the same Console serves degraded and /healthz says so.
|
||||
const up=await realServe(t,copy,e,r.dir);
|
||||
assert.ok(up.port,`${up.code} ${up.out} ${up.err}`);
|
||||
const h=await fetch(`http://127.0.0.1:${up.port}/healthz`);
|
||||
assert.equal(h.status,200);assert.deepEqual(await h.json(),{ok:true,sso:{a:'unreachable'}});
|
||||
const done=once(up.ch,'close');up.ch.kill('SIGTERM');await done;
|
||||
// The same start without each prerequisite refuses before it listens.
|
||||
const dep=join(r.dir,'data','.deployed'),acc=join(r.dir,'config','accounts.json'),keep=readFileSync(dep);
|
||||
rmSync(dep);
|
||||
let q=await realServe(t,copy,e,r.dir);assert.equal(q.code,2,q.out+q.err);assert.match(q.err,/\.deployed is missing or invalid/);assert.doesNotMatch(q.out,/Mosaic Console:/);
|
||||
writeFileSync(dep,keep,{mode:0o444});rmSync(acc);
|
||||
q=await realServe(t,copy,e,r.dir);assert.equal(q.code,2,q.out+q.err);assert.match(q.err,/accounts/);assert.doesNotMatch(q.out,/Mosaic Console:/);
|
||||
});
|
||||
|
||||
test('M3 R18 a stamped copy refuses sso-not-prepared without a live console.sso and accounts-not-initialized without a live accounts.json, before any rehearsal directory',async(t)=>{
|
||||
for(const [opts,code,msg] of [
|
||||
[{sso:false},'sso-not-prepared',(e)=>`${B} is a stamped copy, which serves only with --sso, and ${e.cfg} has no console.sso; prepare SSO for this instance (run sheet) before re-pinning to it`],
|
||||
[{accounts:false},'accounts-not-initialized',(e)=>`${B} is a stamped copy, which needs an accounts file, and there is no ${e.accounts}; a human runs mosaic accounts init (run sheet) before re-pinning to it`],
|
||||
]){
|
||||
const e=await smk(t,opts),before=untouched(e);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,code+r.out+r.err);
|
||||
assert.ok(r.out.includes(`FAIL ${code}: ${msg(e)}\nREHEARSAL_FAIL\n`),r.out);
|
||||
assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),[`FAIL ${code}`]);
|
||||
assert.deepEqual(rDirs(e),[]);assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
assert.equal(existsSync(e.accounts),code!=='accounts-not-initialized');
|
||||
assert.equal(untouched(e),before);
|
||||
}
|
||||
// An unstamped copy needs neither: B from RCOPIES rehearses without console.sso or accounts.json,
|
||||
// and its Console gets no --sso.
|
||||
const u=await rmk(t),port=await freePort();
|
||||
const r=await rehearse(u,B,'--backup',u.act,'--port',port,'--timeout','10');
|
||||
assert.equal(r.code,0,r.out+r.err);assert.ok(!r.names.includes('PASS deployment'));
|
||||
assert.ok(r.out.includes('PASS sso-isolated: no console.sso\n'),r.out);
|
||||
assert.deepEqual(lines(rh(u,'console-args.log')),[`serve --port ${port} --business acme`]);
|
||||
assert.equal(existsSync(join(r.dir,'data','.deployed')),false);
|
||||
});
|
||||
|
||||
test('M3 R19 sso-isolated fails before the host starts when the scratch origin, an issuer or a slot is not the rehearsal\'s',async(t)=>{
|
||||
// stamped: the issuer must be the rehearsal provider's; unstamped (no provider): on rehearsal.invalid.
|
||||
for(const [stamped,from,to,why] of [
|
||||
[true,'sso.publicOrigin="https://rehearsal.invalid";','','console.sso.publicOrigin is https://console.example.test, not https://rehearsal.invalid'],
|
||||
[true,'p.issuer=`${op||','p.issuer=p.issuer||`${op||','console.sso provider a issuer is https://op.example.test/, not ORIGIN/a/ at the rehearsal provider'],
|
||||
[false,'p.issuer=`${op||"https://rehearsal.invalid"}','p.issuer=`${op||"https://op.example.test"}','console.sso provider a issuer is https://op.example.test/a, not on rehearsal.invalid'],
|
||||
[true,'"rehearsal-placeholder-secret\\n",{mode:0o600,flag:"wx"}','"other\\n",{mode:0o600,flag:"wx"}','console.sso provider a has no scratch placeholder secret'],
|
||||
]){
|
||||
let e;if(stamped)e=await smk(t);else{e=await rmk(t);e.copies=UCOPIES;await ssoConfig(e)}
|
||||
ownScript(e,from,to);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,why+r.out+r.err);
|
||||
const prov=provOf(r.out);assert.equal(!!prov,stamped,r.out);
|
||||
assert.ok(r.out.includes(`FAIL sso-isolated: ${why.replace('ORIGIN',prov?.origin)}\n`),r.out);
|
||||
assert.deepEqual(r.names.slice(stamped?-4:-3),['FAIL sso-isolated',...(stamped?['PASS provider-stopped']:[]),'PASS backup-unchanged','PASS live-config-unchanged']);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
if(prov)assert.equal(existsSync(`/proc/${prov.pid}`),false);
|
||||
await noLeak(e);
|
||||
}
|
||||
});
|
||||
|
||||
test('M3 R20 the rehearsal provider\'s failure controls: no start, no trust, no discovery, a token request, a signal and a SIGKILL each fail and stop it',async(t)=>{
|
||||
const ends=['PASS backup-unchanged','PASS live-config-unchanged'];
|
||||
// The provider cannot start, leaves its TLS key, offers a non-loopback origin or never writes one:
|
||||
// provider-start fails before the scratch build, and no host or Console starts.
|
||||
for(const [from,to,why] of [
|
||||
['spawnSync("openssl"','spawnSync("openssl-missing-for-r20"','the rehearsal provider exited: rehearsal provider: cannot make the certificate: openssl exit ENOENT'],
|
||||
['finally{fs.rmSync(tls,{force:true})}','finally{}','the rehearsal provider left its TLS key at RDIR/provider/tls-key.pem'],
|
||||
['origin=`https://127.0.0.1:${','origin=`https://localhost:${','the rehearsal provider\'s origin https://localhost:PORT is not https on 127.0.0.1'],
|
||||
['fs.renameSync(path.join(dir,"origin.tmp"),path.join(dir,"origin"));','','the rehearsal provider wrote no origin within 3s'],
|
||||
]){
|
||||
const e=await smk(t);ownScript(e,from,to);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,why+r.out+r.err);assert.ok(r.dir,r.out+r.err);
|
||||
const line=r.out.split('\n').find((l)=>l.startsWith('FAIL provider-start: '))??'';
|
||||
const re=new RegExp(`^FAIL provider-start: ${why.replace(/[.*+?^${}()|[\]\\]/g,'\\$&').replace('RDIR',r.dir.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).replace('PORT','\\d+')}$`);
|
||||
assert.match(line,re,r.out);
|
||||
assert.deepEqual(r.names,[...PRE.slice(0,3),'FAIL provider-start',...(why.includes('exited')?[]:['PASS provider-stopped']),...ends],r.out);
|
||||
assert.equal(existsSync(join(r.dir,'config')),false);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
}
|
||||
// The Console cannot reach the provider (its discovery drops the connection, or the Console has
|
||||
// no trust for its certificate): it starts degraded and the gate fails sso-ready.
|
||||
for(const [from,to] of [
|
||||
[' send(200,{issuer,authorization_endpoint',' return q.socket.destroy();send(200,{issuer,authorization_endpoint'],
|
||||
['r_ca=(NODE_EXTRA_CA_CERTS="$R/provider/cert.pem")','r_ca=()'],
|
||||
]){
|
||||
const e=await smk(t);ownScript(e,from,to);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','5');
|
||||
assert.equal(r.code,1,from+r.out+r.err);
|
||||
assert.ok(r.out.includes('FAIL sso-ready: provider a is unreachable, not ready\n'),r.out);
|
||||
assert.deepEqual(r.names,[...SPRE,'PASS migration','FAIL sso-ready',...SPOST],r.out);
|
||||
assert.equal(existsSync(`/proc/${provOf(r.out).pid}`),false);
|
||||
}
|
||||
// A Console that reports ready without asking the provider, or asks it for a token: the gate
|
||||
// passes and provider-discovery fails.
|
||||
for(const [f,why] of [
|
||||
['console-no-sso','no GET /a/.well-known/openid-configuration 200; no GET /a/jwks 200'],
|
||||
['console-asks-token','a request for POST /a/token 403; a request for GET /a/authorize?client_id=rehearsal&response_type=code 403'],
|
||||
]){
|
||||
const e=await smk(t);rfault(e,f);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','10');
|
||||
assert.equal(r.code,1,f+r.out+r.err);
|
||||
assert.ok(r.out.includes(`FAIL provider-discovery: ${why}; see ${join(r.dir,'provider','requests.log')}\n`),r.out);
|
||||
assert.deepEqual(r.names,[...SPRE,'PASS migration','PASS gate','PASS no-session','FAIL provider-discovery',...SPOST],r.out);
|
||||
}
|
||||
// A signal while the gate waits stops the Console, host and provider.
|
||||
const e=await smk(t);rfault(e,`healthz-${B}`);
|
||||
e.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill('SIGTERM')};
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.deepEqual(r.names,[...SPRE,'PASS migration','FAIL signal',...SPOST],r.out);
|
||||
assert.equal(existsSync(`/proc/${provOf(r.out).pid}`),false);
|
||||
// SIGKILL leaves no trap to run: the provider sees its parent gone and exits by itself.
|
||||
const k=await smk(t);rfault(k,`healthz-${B}`);
|
||||
k.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(k,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill('SIGKILL')};
|
||||
const rk=await rehearse(k,B,'--backup',k.act,'--port',await freePort(),'--timeout','30');
|
||||
assert.equal(rk.code,null,rk.out+rk.err);
|
||||
const pk=provOf(rk.out);assert.ok(pk,rk.out);
|
||||
for(let i=0;i<60&&existsSync(`/proc/${pk.pid}`);i++)await new Promise((r)=>setTimeout(r,100));
|
||||
assert.equal(existsSync(`/proc/${pk.pid}`),false,`provider pid ${pk.pid} outlived the killed rehearsal`);
|
||||
});
|
||||
|
||||
// --- Row 233 (#1735): --backup last, the newest backup the log records (sage-1650, sage-1653) ---
|
||||
|
||||
// The log's lines, as the runs write them, with only the fields from_log reads set.
|
||||
@@ -2601,7 +2984,8 @@ test('row196 explicit socket conversion refuses inherited TCP before stop and ro
|
||||
assert.equal(r.code,3,r.out+r.err);assert.match(r.err,/tester-socket-path/);assert.deepEqual(startsStops(long,before),[]);
|
||||
});
|
||||
|
||||
// Row196 merge guard: without --send, SSO insertion must still precede the socket flag.
|
||||
// Row196 merge guard, M3 (#1873): a user-unit --sso --tester-socket refuses accounts-user-unit; root's
|
||||
// unit for the copy carries --sso before the socket flag, and the pointer and status pin that order.
|
||||
test('row196 SSO socket conversion pins pointer and unit argv order without send',async t=>{
|
||||
const e=await mk(t);await seedPreview(e,{copy:B});await ssoConfig(e);
|
||||
const file=join(COPIES,C1,'packages/webui/src/cli.mjs'),original=readFileSync(file,'utf8');
|
||||
@@ -2611,8 +2995,9 @@ test('row196 SSO socket conversion pins pointer and unit argv order without send
|
||||
chmodSync(dirname(module),0o755);
|
||||
writeFileSync(module,'export const inspectInterpreter=()=>({path:"/usr/bin/python3",sha256:"fixture"});export const verifyInterpreterReceipt=()=>true;\n');chmodSync(module,0o444);chmodSync(dirname(module),0o555);
|
||||
t.after(()=>{chmodSync(dirname(module),0o755);rmSync(module);chmodSync(dirname(module),0o555);});
|
||||
const r=await prun(e,'activate',C1,'acme','--sso','--tester-socket');assert.equal(r.code,0,r.out+r.err);
|
||||
await auu(e,['activate',C1,'acme','--sso','--tester-socket'],'activate');
|
||||
const want=['--port',e.port,'--business','acme','--sso','--tester-socket'];
|
||||
await viaSystem(e,'activate',C1,want);
|
||||
assert.deepEqual(pointer(e).console,want);
|
||||
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
||||
assert.doesNotMatch(conExec(e),/ --send(?: |$)|--tester-port|--public-host|--proof-header/);
|
||||
|
||||
@@ -47,12 +47,19 @@ function fakeSudo(K){
|
||||
|
||||
// case2-unit.sh: logs its argv, then installs the copy's units as the fixture's user units (its
|
||||
// WorkingDirectory under the stack's copies) and starts the optional proxy, bus, then Console,
|
||||
// as case2-unit.sh does. The business is --business's value, or --org's when that is the spelling. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
|
||||
// as case2-unit.sh does. The business is --business's value, or --org's when that is the spelling. A first
|
||||
// --check or --check-new (#1873) logs its argv and exits 1 under faults/check-fails-<copy>, else 0,
|
||||
// writing nothing. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
|
||||
// probe.sh, a second driver, and records its exit and stderr.
|
||||
function fakeRender(K){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process');
|
||||
const a=process.argv.slice(2),sd=process.env.SD_STATE,f=(n)=>path.join(sd,'faults',n);
|
||||
fs.appendFileSync(path.join(sd,'render.log'),JSON.stringify(a)+'\n');
|
||||
if(a[0]==='--check'||a[0]==='--check-new'){
|
||||
const c=a[a.indexOf('--copy')+1];
|
||||
if(fs.existsSync(f(`check-fails-${c}`))){console.error(`fixture render: ${a[0]} ${c} fails`);process.exit(1)}
|
||||
process.exit(0);
|
||||
}
|
||||
const cut=a.indexOf('--'),pre=a.slice(0,cut),serve=a.slice(cut+1),get=(k)=>pre[pre.indexOf(k)+1];
|
||||
const copy=get('--copy'),copies=path.join(get('--home'),'.local','share','mosaic-stack','copies');
|
||||
if(fs.existsSync(f(`render-probe-${copy}`))){
|
||||
@@ -147,7 +154,7 @@ const arm=(e,key,text)=>writeFileSync(join(e.sd,'arm',key),text);
|
||||
|
||||
// The step log: every line `<time> <step> rc=N ...` or the result line, root:root 0640 (here the
|
||||
// test user's), and nothing the stack's user printed. Returns the lines without the time.
|
||||
const STEP=/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ (start|fetch|ff|copy|hostcheck|units|plan|rehearse|rehearse-store|rehearsal-skipped|stop-console|stop-bus|stop-proxy|wait-down|stage|render|confirm|args|revert|render-old|confirm-rollback|gate|start-proxy|start-bus|start-console|stop-units) rc=\d+ |^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ result exit=[0-35] [a-z-]+ /;
|
||||
const STEP=/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ (start|fetch|ff|copy|hostcheck|units|plan|rehearse|rehearse-store|rehearsal-skipped|render-old-check|render-check|stop-console|stop-bus|stop-proxy|wait-down|units-aside|units-back|daemon-reload|next-args|stage|render|confirm|args|revert|render-old|confirm-rollback|gate|start-proxy|start-bus|start-console|stop-units) rc=\d+ |^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ result exit=[0-35] [a-z-]+ /;
|
||||
function slog(e){
|
||||
const l=lines(e.slog);
|
||||
for(const x of l){assert.match(x,STEP);assert.ok(!/stack-release:|fixture rehearse|PASS |FAIL |REHEARSAL/.test(x),x)}
|
||||
@@ -165,7 +172,7 @@ const live=(e)=>[units(e)[e.bus].state,units(e)[e.con].state,units(e)[e.bus].wd.
|
||||
// Nothing the run did reached the args file: the same bytes and mode, and no temporary file left.
|
||||
const argsKept=(e)=>{assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.equal(statSync(e.args).mode&0o777,0o640);assert.deepEqual(readdirSync(e.etc),['dev.args'])};
|
||||
const argsCopy=(e)=>{const l=readFileSync(e.args,'utf8').split('\n');return l[l.indexOf('--copy')+1]};
|
||||
const PRE=['start','fetch','ff','copy','hostcheck','units','plan','rehearse','rehearse-store'];
|
||||
const PRE=['start','fetch','ff','copy','hostcheck','units','plan','render-old-check','render-check','rehearse','rehearse-store'];
|
||||
const A2=['stop-console','stop-bus','wait-down','wait-down'];
|
||||
const R=[...A2,'revert','render-old','confirm-rollback'];
|
||||
|
||||
@@ -394,7 +401,7 @@ test('row 233: sequence 7: two upgrades and a rollback --to the previous copy wi
|
||||
const rb=await drive(e,C,'rollback','--instance','dev','--to',C1,'--restore',a2,...TO);assert.equal(rb.code,0,rb.all);
|
||||
assert.equal(rb.out.split('\n').at(-2),`stack-upgrade: rollback ok: ${C1} is live for acme`);
|
||||
// A rollback fetches, copies and rehearses nothing.
|
||||
assert.deepEqual(names(e).slice(n),['start','hostcheck','units','plan',...A2,'stage','render','confirm','args','result']);
|
||||
assert.deepEqual(names(e).slice(n),['start','hostcheck','units','plan','render-old-check','render-check',...A2,'stage','render','confirm','args','result']);
|
||||
const ra=lastAct(e),l3=final(e,ra);assert.deepEqual([l3.op,l3.result,l3.from,l3.to,l3.backup],['rollback','ok',C,C1,a2]);
|
||||
assert.deepEqual([pointer(e).copy,pointer(e).schema],[C1,'3g']);assert.equal(storeVersion(e.store,true),'3g');
|
||||
assert.equal(argsCopy(e),C1);assert.deepEqual(live(e),['active','active',C1,C1]);
|
||||
@@ -632,7 +639,9 @@ test('rename r2: an upgrade from an args file that spells both selectors --org k
|
||||
const after=e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`);assert.notEqual(after,e.argsText);
|
||||
assert.equal(readFileSync(e.args,'utf8'),after);
|
||||
assert.equal(statSync(e.args).mode&0o777,0o640);assert.deepEqual(readdirSync(e.etc),['dev.args']);
|
||||
assert.deepEqual(lines(join(e.sd,'render.log')).map((x)=>JSON.parse(x)),[after.trimEnd().split('\n')]);
|
||||
// #1873: before anything stops, --check renders the installed copy, then the target, from the same lines.
|
||||
const al=after.trimEnd().split('\n');
|
||||
assert.deepEqual(lines(join(e.sd,'render.log')).map((x)=>JSON.parse(x)),[['--check',...e.argsText.trimEnd().split('\n')],['--check',...al],al]);
|
||||
// The stack's user's steps get the business as a word, never a spelling.
|
||||
for(const k of ['activate-plan','rehearse','activate','confirm'])assert.ok(JSON.parse(lines(join(e.sd,'sudo.log')).find((x)=>JSON.parse(x).key===k)).cmd.includes('acme'),k);
|
||||
assert.ok(!lines(join(e.sd,'sudo.log')).some((x)=>JSON.parse(x).cmd.includes('--org')));
|
||||
@@ -718,12 +727,25 @@ const baseCase2=()=>{
|
||||
// read. args(copy, extra) is a library args file's lines without --send; legacy() renders base's pair into dir.
|
||||
function case2Host(e,dir){
|
||||
const home=e.env.HOME,secrets=join(home,'.config','mosaic-dev','secrets');mkdirSync(secrets,{recursive:true,mode:0o700});
|
||||
copyFileSync(e.cfg,join(home,'.config','mosaic-dev','config.json'));
|
||||
const proof=join(secrets,'proxy-proof');writeFileSync(proof,'made-up-proof\n');chmodSync(proof,0o600);
|
||||
const c2=join(e.root,'case2'),bin=join(c2,'bin'),log=join(c2,'systemctl.log');mkdirSync(bin,{recursive:true});mkdirSync(dir,{recursive:true});
|
||||
const uid=process.getuid(),gid=process.getgid();
|
||||
writeFileSync(join(bin,'getent'),`#!/bin/sh\ncase "$1 $2" in\n "passwd fixture") echo "fixture:x:${uid}:${gid}::${home}:/bin/bash" ;;\n "group ${gid}") echo "fixture:x:${gid}:" ;;\n *) exit 2 ;;\nesac\n`,{mode:0o755});
|
||||
writeFileSync(join(bin,'getent'),`#!/bin/sh\ncase "$1 $2" in\n "passwd fixture"|"passwd ") echo "fixture:x:${uid}:${gid}::${home}:/usr/sbin/nologin" ;;\n "group ${gid}") echo "fixture:x:${gid}:" ;;\n *) exit 2 ;;\nesac\n`,{mode:0o755});
|
||||
writeFileSync(join(bin,'systemctl'),`#!/bin/sh\necho "$*" >> '${log}'\n[ "$1" != is-active ] || exit 3\n`,{mode:0o755});
|
||||
const env={CASE2_UNIT_DIR:dir,CASE2_GETENT:join(bin,'getent'),CASE2_SYSTEMCTL:join(bin,'systemctl')};
|
||||
// M3: the current renderer queries the config under the selected uid for every
|
||||
// profile. These copies deliberately stub the CLI; give that query its own
|
||||
// fixture loader and sudo rather than invoking the machine's sudo.
|
||||
for(const id of [B,C1,C]){
|
||||
const d=join(COPIES,id),packages=join(d,'packages'),cli=join(packages,'cli'),src=join(cli,'src');
|
||||
if(!existsSync(join(src,'config.mjs'))){
|
||||
chmodSync(d,0o755);chmodSync(packages,0o755);mkdirSync(src,{recursive:true});
|
||||
writeFileSync(join(src,'config.mjs'),`import fs from 'node:fs';import os from 'node:os';import path from 'node:path';export const loadSystem=()=>JSON.parse(fs.readFileSync(path.join(os.homedir(),'.config/mosaic-dev/config.json'),'utf8'));`);
|
||||
chmodSync(join(src,'config.mjs'),0o444);chmodSync(src,0o555);chmodSync(cli,0o555);chmodSync(packages,0o555);chmodSync(d,0o555);
|
||||
}
|
||||
}
|
||||
writeFileSync(join(bin,'sudo'),'#!/bin/sh\n[ "$1" = -u ] && [ "$2" = fixture ] && [ "$3" = -H ] || exit 97\nshift 3\nexec "$@"\n',{mode:0o755});
|
||||
const env={PATH:`${bin}:${e.env.PATH}`,CASE2_UNIT_DIR:dir,CASE2_GETENT:join(bin,'getent'),CASE2_SYSTEMCTL:join(bin,'systemctl')};
|
||||
const serve=['--port','443','--business','acme','--public-host','books.example','--auth-header','X-Auth-User','--allow-user','melanie','--proof-header','X-Proxy-Proof','--proof-file',proof];
|
||||
const args=(copy=B,extra=[])=>['--user','fixture','--home',home,'--business','acme','--copy',copy,'--node',RENDER_NODE,...extra,'--',...serve];
|
||||
const render=(script,a)=>{const r=spawnSync('bash',[script,...a],{env:{PATH:e.env.PATH,...env},encoding:'utf8'});return {code:r.status,out:r.stdout,err:r.stderr}};
|
||||
@@ -829,3 +851,101 @@ test('row 233: usage exits 4 before any log or lock',async(t)=>{
|
||||
}
|
||||
assert.ok(!existsSync(dirname(e.slog)));assert.ok(!existsSync(e.run));
|
||||
});
|
||||
|
||||
// --- #1873 M3: render checks before anything stops, and --next-args ---
|
||||
|
||||
// The installed units' bytes, for the byte-for-byte checks across a moved-aside render.
|
||||
const unitBytes=(e)=>Object.fromEntries([e.tmpl,e.con].map((u)=>[u,readFileSync(join(e.units,u),'utf8')]));
|
||||
const renders=(e)=>lines(join(e.sd,'render.log')).map((l)=>JSON.parse(l));
|
||||
const copyOf=(a)=>a[a.indexOf('--copy')+1];
|
||||
// The next args: the args file's lines with the serve flags in another order, root's 0640.
|
||||
const nextText=(e)=>e.argsText.replace(`--\n--port\n${e.port}\n--business\nacme\n`,`--\n--business\nacme\n--port\n${e.port}\n`);
|
||||
const writeNext=(e,text=nextText(e),mode=0o640)=>{const f=join(e.etc,'dev.args.next');writeFileSync(f,text);chmodSync(f,mode);return f};
|
||||
const replacedDirs=(e)=>readdirSync(e.etc).filter((n)=>/^dev\.replaced\.[A-Za-z0-9]{6}$/.test(n)).sort();
|
||||
|
||||
test('#1873: a target whose render check fails refuses render-refused, naming --next-args, before anything stops',async(t)=>{
|
||||
const e=await useed(t);
|
||||
fault(e,`check-fails-${C1}`);
|
||||
const r=await upgrade(e,C1);assert.equal(r.code,3,r.all);
|
||||
assert.deepEqual(names(e),['start','fetch','ff','copy','hostcheck','units','plan','render-old-check','render-check','result']);
|
||||
assert.match(result(e),new RegExp(`^result exit=3 render-refused case2-unit.sh --check refuses ${C1}'s units from .*dev\\.args; .*takes --next-args .*\\(nothing was stopped\\)$`));
|
||||
assert.deepEqual(renders(e).map((a)=>[a[0],copyOf(a)]),[['--check',B],['--check',C1]]);
|
||||
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);argsKept(e);
|
||||
});
|
||||
|
||||
test('#1873: an installed copy whose render check fails refuses at render-old-check, before the target\'s check',async(t)=>{
|
||||
const e=await useed(t);
|
||||
fault(e,`check-fails-${B}`);
|
||||
const r=await upgrade(e,C1);assert.equal(r.code,3,r.all);
|
||||
assert.deepEqual(names(e),['start','fetch','ff','copy','hostcheck','units','plan','render-old-check','result']);
|
||||
assert.match(result(e),new RegExp(`^result exit=3 render-refused case2-unit.sh --check refuses ${B}'s units from .*dev\\.args, so a rollback could not render them`));
|
||||
assert.deepEqual(renders(e).map((a)=>[a[0],copyOf(a)]),[['--check',B]]);
|
||||
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);argsKept(e);
|
||||
});
|
||||
|
||||
test('#1873: --next-args moves the old units aside, renders the new flags as a first install, keeps the old units and args, and rolls back the same way',async(t)=>{
|
||||
const e=await useed(t);
|
||||
const before=unitBytes(e),next=nextText(e);writeNext(e);
|
||||
const r=await upgrade(e,C1,'--next-args');assert.equal(r.code,0,r.all);
|
||||
assert.deepEqual(names(e),[...PRE,...A2,'stage','units-aside','render','confirm','next-args','args','result']);
|
||||
const [d]=replacedDirs(e);assert.ok(d,readdirSync(e.etc).join(' '));
|
||||
assert.equal(result(e),`result exit=0 ok upgrade ok: ${C1} is live for acme; the replaced units and args file are in ${join(e.etc,d)}`);
|
||||
assert.deepEqual(readdirSync(e.etc).sort(),['dev.args',d]);
|
||||
assert.equal(readFileSync(e.args,'utf8'),next.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`));assert.equal(statSync(e.args).mode&0o777,0o640);
|
||||
const rd=join(e.etc,d);assert.equal(statSync(rd).mode&0o777,0o700);
|
||||
assert.deepEqual(readdirSync(rd).sort(),['dev.args',e.tmpl,e.con].sort());
|
||||
for(const [u,text] of Object.entries(before))assert.equal(readFileSync(join(rd,u),'utf8'),text,u);
|
||||
assert.equal(readFileSync(join(rd,'dev.args'),'utf8'),e.argsText);assert.equal(statSync(join(rd,'dev.args')).mode&0o777,0o640);
|
||||
const serve=(a)=>a.slice(a.indexOf('--')+1);
|
||||
const rl=renders(e);assert.deepEqual(rl.map((a)=>[a[0],copyOf(a)]),[['--check',B],['--check-new',C1],['--user',C1]]);
|
||||
assert.deepEqual(serve(rl[0]),['--port',String(e.port),'--business','acme']);
|
||||
assert.deepEqual(serve(rl[1]),['--business','acme','--port',String(e.port)]);assert.deepEqual(serve(rl[2]),serve(rl[1]));
|
||||
assert.deepEqual(live(e),['active','active',C1,C1]);
|
||||
// The run sheet's rollback: the replaced args file as the next args, then rollback --next-args.
|
||||
copyFileSync(join(rd,'dev.args'),join(e.etc,'dev.args.next'));chmodSync(join(e.etc,'dev.args.next'),0o640);
|
||||
const after=unitBytes(e),m=slog(e).length,n=renders(e).length;
|
||||
const rb=await drive(e,C1,'rollback','--instance','dev','--to',B,'--next-args',...TO);assert.equal(rb.code,0,rb.all);
|
||||
assert.deepEqual(names(e).slice(m),['start','hostcheck','units','plan','render-old-check','render-check',...A2,'stage','units-aside','render','confirm','next-args','args','result']);
|
||||
assert.deepEqual(renders(e).slice(n).map((a)=>[a[0],copyOf(a)]),[['--check',C1],['--check-new',B],['--user',B]]);
|
||||
assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.deepEqual(live(e),['active','active',B,B]);
|
||||
const d2=replacedDirs(e).find((x)=>x!==d);assert.ok(d2);assert.deepEqual(readdirSync(e.etc).sort(),['dev.args',d,d2].sort());
|
||||
for(const [u,text] of Object.entries(after))assert.equal(readFileSync(join(e.etc,d2,u),'utf8'),text,u);
|
||||
assert.equal(readFileSync(join(e.etc,d2,'dev.args'),'utf8'),next.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`));
|
||||
});
|
||||
|
||||
test('#1873: a failed render under --next-args moves the old units back unchanged and restarts them, with no render of the old copy: exit 1',async(t)=>{
|
||||
const e=await useed(t);
|
||||
const before=unitBytes(e),next=nextText(e);writeNext(e);
|
||||
fault(e,`render-fails-${C1}`);
|
||||
const r=await upgrade(e,C1,'--next-args');assert.equal(r.code,1,r.all);
|
||||
assert.deepEqual(names(e),[...PRE,...A2,'stage','units-aside','render',...A2,'revert','units-back','daemon-reload','start-bus','start-console','confirm-rollback','result']);
|
||||
assert.match(result(e),new RegExp(`^result exit=1 failed A4 failed; rolled back to ${B}`));
|
||||
assert.deepEqual(renders(e).map((a)=>[a[0],copyOf(a)]),[['--check',B],['--check-new',C1],['--user',C1]]);
|
||||
for(const [u,text] of Object.entries(before))assert.equal(readFileSync(join(e.units,u),'utf8'),text,u);
|
||||
assert.deepEqual(readdirSync(e.etc).sort(),['dev.args','dev.args.next']);
|
||||
assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.equal(readFileSync(join(e.etc,'dev.args.next'),'utf8'),next);
|
||||
assert.equal(final(e,lastAct(e)).rollback,'ok');assert.deepEqual(live(e),['active','active',B,B]);
|
||||
});
|
||||
|
||||
test('#1873: the next args must be root\'s, equal before -- but for --copy, differ after it, and render as a first install',async(t)=>{
|
||||
const e=await useed(t);
|
||||
const tryNext=async(text,mode,code,why,at='plan')=>{
|
||||
if(text===null)rmSync(join(e.etc,'dev.args.next'),{force:true});else writeNext(e,text,mode);
|
||||
const n=existsSync(e.slog)?slog(e).length:0;
|
||||
const r=await upgrade(e,C1,'--next-args');assert.equal(r.code,3,`${why}: ${r.all}`);
|
||||
assert.match(slog(e).at(-1),new RegExp(`^result exit=3 ${code} `),why);
|
||||
const ran=names(e).slice(n);assert.ok(!ran.some((x)=>/^(stop|stage|units-aside|render$|confirm)/.test(x)),`${why}: ${ran}`);
|
||||
if(at)assert.equal(ran.at(-2),at,`${why}: ${ran}`);
|
||||
};
|
||||
await tryNext(null,0,'args-unreadable','no next file','start');
|
||||
await tryNext(nextText(e),0o660,'args-unreadable','group write','start');
|
||||
await tryNext(nextText(e).replace('--home\n','--pm\n--home\n'),0o640,'next-args-differ','a flag before --','start');
|
||||
await tryNext(nextText(e).replace('--user\nfixture\n','--user\nother\n'),0o640,'next-args-differ','another user','start');
|
||||
await tryNext(e.argsText,0o640,'next-args-same','the same lines','start');
|
||||
await tryNext(e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C}\n`),0o640,'next-args-same','only --copy differs','start');
|
||||
fault(e,`check-fails-${C1}`);
|
||||
await tryNext(nextText(e),0o640,'render-refused','the first-install check','render-check');
|
||||
assert.match(result(e),new RegExp(`^result exit=3 render-refused case2-unit.sh --check-new refuses ${C1}'s units from .*dev\\.args\\.next`));
|
||||
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
|
||||
assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.deepEqual(replacedDirs(e),[]);
|
||||
});
|
||||
@@ -13,6 +13,7 @@
|
||||
// exception is <boardDir>/seen.json, which holds Jason's "seen" marks and is
|
||||
// only changed when he clicks; a scan reads it and never rewrites it.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
|
||||
import { join, basename, isAbsolute, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
@@ -35,9 +36,9 @@ export function loadConfig(path = defaultConfigPath()) {
|
||||
if (!existsSync(path)) throw new ConfigError(`config not found: ${path}`);
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new ConfigError(`config is not valid JSON: ${path} (${err.message})`);
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new ConfigError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new ConfigError(`config is not an object: ${path}`);
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) {
|
||||
@@ -227,9 +228,9 @@ export function loadSeen(boardDir) {
|
||||
if (!existsSync(path)) return {};
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new ConfigError(`seen marks file is not valid JSON: ${path} (${err.message})`);
|
||||
parsed = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new ConfigError(`seen marks file is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ConfigError(`seen marks file must be a JSON object: ${path}`);
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text, { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
function safe(label, ErrorType, properties = {}) {
|
||||
return error => {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
import { ConfigError, loadConfig, loadSeen } from '../src/scan.mjs';
|
||||
for (const kind of ['config', 'seen']) test(`${kind} JSON refusal names only the file`, t => {
|
||||
const root = scratch(t), file = write(join(root, kind === 'config' ? 'config.json' : 'seen.json'));
|
||||
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), safe(file, ConfigError));
|
||||
});
|
||||
for (const kind of ['config', 'seen']) test(`${kind} read refusal retains the filesystem code`, t => {
|
||||
const root = scratch(t), file = join(root, kind === 'config' ? 'config.json' : 'seen.json');
|
||||
mkdirSync(file);
|
||||
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), error => {
|
||||
safe(file, ConfigError)(error);
|
||||
assert.match(error.message, /\(EISDIR\)$/);
|
||||
assert.doesNotMatch(error.message, /illegal operation/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
// The per-message budget resets on agent_start, the event pi emits once per
|
||||
// prompt run, so a follow-up message gets a fresh budget.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { Type } from "typebox";
|
||||
import { TOOLS_ENV, TOOL_DESCRIPTIONS, READ_MAX_LINES, loadToolsConfig, createToolSet, enabledToolNames } from "../src/tools.mjs";
|
||||
import { COMMIT_MESSAGE_MAX, COMMIT_PATHS_MAX, VAULT_PREFIXES } from "../src/git.mjs";
|
||||
@@ -141,9 +142,9 @@ export default function (pi) {
|
||||
if (typeof raw !== "string" || raw.length === 0) throw new Error(`${TOOLS_ENV} is not set; the connector sets it from the binding's tools key`);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`${TOOLS_ENV} is not valid JSON: ${err.message}`);
|
||||
parsed = parseJsonFileText(raw, TOOLS_ENV);
|
||||
} catch {
|
||||
throw new Error(`${TOOLS_ENV} is not valid JSON`);
|
||||
}
|
||||
const config = loadToolsConfig(parsed);
|
||||
const tools = createToolSet(config);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
// Ids in this file are private to the binding's data directory (0600),
|
||||
// like every other journal.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
@@ -104,9 +105,9 @@ export function readApprovals(dir) {
|
||||
for (const [i, line] of readFileSync(path, "utf8").split("\n").entries()) {
|
||||
if (line.length === 0) continue;
|
||||
try {
|
||||
out.push(JSON.parse(line));
|
||||
} catch (err) {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON (${err.message})`);
|
||||
out.push(parseJsonFileText(line, `${path}:${i + 1}`));
|
||||
} catch {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
// Absent means no tools and a launch exactly as before. It is a fixed
|
||||
// key: the extension reads it at pi start.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import { loadWebConfig } from "./web.mjs";
|
||||
import { loadSetsparkConfig } from "./setspark.mjs";
|
||||
@@ -59,9 +60,9 @@ export function loadDataRoot(path = defaultConfigPath()) {
|
||||
if (!existsSync(path)) throw new DiscordError(`config not found: ${path}`);
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new DiscordError(`config is not valid JSON: ${path} (${err.message})`);
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new DiscordError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DiscordError(`config is not an object: ${path}`);
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new DiscordError(`config.dataRoot must be an absolute path: ${path}`);
|
||||
@@ -95,7 +96,7 @@ function onlyKeys(obj, allowed, where) {
|
||||
function requireString(obj, key, where, pattern, what) {
|
||||
const v = obj[key];
|
||||
if (typeof v !== "string" || v.length === 0) throw new DiscordError(`${where}: ${key} must be a non-empty string`);
|
||||
if (pattern && !pattern.test(v)) throw new DiscordError(`${where}: ${key} is not ${what}: ${JSON.stringify(v)}`);
|
||||
if (pattern && !pattern.test(v)) throw new DiscordError(`${where}: ${key} is not ${what}`);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -147,7 +148,7 @@ export function validateBinding(raw, where = "binding") {
|
||||
if (!Array.isArray(u.channels) || u.channels.length === 0) throw new DiscordError(`${w}: channels must be a non-empty array of listed channel ids`);
|
||||
allowed = u.channels.map((cid, j) => {
|
||||
if (typeof cid !== "string" || !SNOWFLAKE.test(cid)) throw new DiscordError(`${w}.channels[${j}]: not a Discord snowflake id`);
|
||||
if (!channels.some((c) => c.id === cid)) throw new DiscordError(`${w}.channels[${j}]: ${cid} is not a listed channel`);
|
||||
if (!channels.some((c) => c.id === cid)) throw new DiscordError(`${w}.channels[${j}]: is not a listed channel`);
|
||||
return cid;
|
||||
});
|
||||
if (new Set(allowed).size !== allowed.length) throw new DiscordError(`${w}: duplicate channel id`);
|
||||
@@ -203,7 +204,7 @@ export function validateBinding(raw, where = "binding") {
|
||||
const rname = requireString(r, "name", w, ROOT_NAME, "a root name");
|
||||
const rpath = requireString(r, "path", w);
|
||||
if (!isAbsolute(rpath) || rpath.includes("\0")) throw new DiscordError(`${w}: path must be an absolute path`);
|
||||
if (rpath.split(sep).some((seg) => seg.startsWith(".") && seg.length > 0)) throw new DiscordError(`${w}: path must not have a dot-prefixed segment (${rpath})`);
|
||||
if (rpath.split(sep).some((seg) => seg.startsWith(".") && seg.length > 0)) throw new DiscordError(`${w}: path must not have a dot-prefixed segment`);
|
||||
if (resolve(rpath) === sep || resolve(rpath) === homedir()) throw new DiscordError(`${w}: path must not be the filesystem root or the home directory`);
|
||||
if (r.write !== undefined && r.write !== true && r.write !== false) throw new DiscordError(`${w}: write must be true or false`);
|
||||
let git = null;
|
||||
@@ -292,9 +293,9 @@ export function loadBinding(path) {
|
||||
checkPrivateFile(path, "binding");
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new DiscordError(`binding is not valid JSON: ${path} (${err.message})`);
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new DiscordError(`binding is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
return validateBinding(raw, `binding ${path}`);
|
||||
}
|
||||
@@ -342,7 +343,7 @@ function setsparkConfig(raw, where, users) {
|
||||
const approvers = {};
|
||||
for (const u of users) {
|
||||
const name = u.name.trim().toLowerCase();
|
||||
if (Object.hasOwn(approvers, name)) throw new DiscordError(`${where}: two users named ${name}; approver names must be distinct`);
|
||||
if (Object.hasOwn(approvers, name)) throw new DiscordError(`${where}: two users share a name; approver names must be distinct (case-insensitive)`);
|
||||
approvers[name] = u.id;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -46,7 +46,7 @@ export function createGateway({
|
||||
random = Math.random,
|
||||
properties = { os: process.platform, browser: "mosaic-stack", device: "mosaic-stack" },
|
||||
} = {}) {
|
||||
if (typeof url !== "string" || !url.startsWith("wss://")) throw new DiscordError(`gateway: url must be wss://, got ${JSON.stringify(url)}`, 1);
|
||||
if (typeof url !== "string" || !url.startsWith("wss://")) throw new DiscordError(`gateway: url must be wss://`, 1);
|
||||
if (typeof token !== "string" || token.length === 0) throw new DiscordError("gateway: token required", 1);
|
||||
if (typeof WebSocketImpl !== "function") throw new DiscordError("gateway: WebSocket implementation required", 1);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
// supervised form for a service unit's pre-start
|
||||
// Directories are 0700, files 0600. Lines are appended, never rewritten.
|
||||
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import {
|
||||
appendFileSync, closeSync, constants, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmdirSync, rmSync,
|
||||
unlinkSync, writeFileSync, writeSync,
|
||||
@@ -47,9 +48,9 @@ function readLines(path) {
|
||||
for (const [i, line] of text.split("\n").entries()) {
|
||||
if (line.length === 0) continue;
|
||||
try {
|
||||
out.push(JSON.parse(line));
|
||||
} catch (err) {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON (${err.message})`);
|
||||
out.push(parseJsonFileText(line, `${path}:${i + 1}`));
|
||||
} catch {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -130,7 +131,8 @@ export function writeTurn(dir, id, record) {
|
||||
}
|
||||
|
||||
export function readTurn(dir, id) {
|
||||
return JSON.parse(readFileSync(turnPath(dir, id), "utf8"));
|
||||
const path = turnPath(dir, id);
|
||||
return parseJsonFileText(readFileSync(path, "utf8"), path, "turn record");
|
||||
}
|
||||
|
||||
export function listTurns(dir) {
|
||||
@@ -138,7 +140,7 @@ export function listTurns(dir) {
|
||||
if (!existsSync(turns)) return [];
|
||||
return readdirSync(turns)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => JSON.parse(readFileSync(join(turns, f), "utf8")));
|
||||
.map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), "turn record"));
|
||||
}
|
||||
|
||||
// The daily ceiling counts admissions on the current UTC date. An admission
|
||||
|
||||
@@ -96,7 +96,7 @@ export function loadSetsparkConfig(raw, where = "setspark") {
|
||||
function loadApprovers(raw, where) {
|
||||
if (!isObject(raw) || Object.keys(raw).length > 64) throw new Error(`${where}: must be an object of at most 64 names`);
|
||||
for (const [name, id] of Object.entries(raw)) {
|
||||
if (name.length === 0 || name.length > 64 || name !== name.trim().toLowerCase() || /[\u0000-\u001f\u007f]/.test(name)) throw new Error(`${where}: ${JSON.stringify(name.slice(0, 64))} must be a trimmed lower-case name`);
|
||||
if (name.length === 0 || name.length > 64 || name !== name.trim().toLowerCase() || /[\u0000-\u001f\u007f]/.test(name)) throw new Error(`${where}: an approver name must be trimmed, lower-case, 1-64 characters, with no control characters`);
|
||||
if (typeof id !== "string" || !SNOWFLAKE.test(id)) throw new Error(`${where}.${name}: must be a Discord user id`);
|
||||
}
|
||||
if (new Set(Object.values(raw)).size !== Object.keys(raw).length) throw new Error(`${where}: one Discord user id under two names`);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
|
||||
function safe(label, Type, props = {}, omitted = secret) { return error => {
|
||||
assert.ok(error instanceof Type, error.stack);
|
||||
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
assert.ok(!error.message.includes(omitted), error.message);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
}; }
|
||||
import { validateBinding } from '../src/binding.mjs';
|
||||
import { createGateway } from '../src/gateway.mjs';
|
||||
import { readTurn, listTurns } from '../src/journal.mjs';
|
||||
import { DiscordError } from '../src/errors.mjs';
|
||||
import { rawBinding } from './helpers.mjs';
|
||||
for (const [field, edit] of [
|
||||
['guildId', d => { d.guildId = secret; }],
|
||||
['name', d => { d.name = secret + '/'; }],
|
||||
['path', d => { d.tools = { roots: [{ name: 'root', path: `/x/.${secret}`, write: false }] }; }],
|
||||
]) test(`binding ${field} refusal omits its configured value`, () => {
|
||||
const d = rawBinding(); edit(d); assert.throws(() => validateBinding(d), safe(field, DiscordError, { exitCode: 2 }));
|
||||
});
|
||||
test('gateway url refusal omits its value', () => assert.throws(() => createGateway({ url: secret, token: 'fake' }), safe('url', DiscordError, { exitCode: 1 })));
|
||||
for (const mode of ['read', 'list']) test(`turn ${mode} parser omits file bytes and keeps SyntaxError`, t => {
|
||||
const root = scratch(t); mkdirSync(join(root, 'turns'));
|
||||
const file = join(root, 'turns', 'turn-1.json'); writeFileSync(file, secret);
|
||||
assert.throws(() => mode === 'read' ? readTurn(root, 'turn-1') : listTurns(root), safe(file, SyntaxError));
|
||||
});
|
||||
|
||||
for (const duplicate of [true, false]) test(`binding approver ${duplicate ? 'duplicate' : 'invalid'} name omits user values`, t => {
|
||||
const root = scratch(t), keyFile = join(root, 'fixture-key'); writeFileSync(keyFile, 'fixture\n', { mode: 0o600 });
|
||||
const name = secret.toLowerCase() + (duplicate ? '' : '\u0001');
|
||||
const users = [{ id: '100000000000000100', name }];
|
||||
if (duplicate) users.push({ id: '100000000000000101', name });
|
||||
const raw = rawBinding({ users, tools: { roots: [{ name: 'docs', path: root }], setspark: { baseUrl: 'https://api.setspark.io', keyFile, principal: 'sage' } } });
|
||||
assert.throws(() => validateBinding(raw), safe(duplicate ? 'users' : 'approvers', DiscordError, { exitCode: 2 }, secret.toLowerCase()));
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text, { mode: 0o600 });
|
||||
return file;
|
||||
}
|
||||
function safe(label, ErrorType, properties = {}) {
|
||||
return error => {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
import { DiscordError } from '../src/errors.mjs';
|
||||
import { loadDataRoot, loadBinding } from '../src/binding.mjs';
|
||||
import { readApprovals } from '../src/approvals.mjs';
|
||||
import { readInboxIds } from '../src/journal.mjs';
|
||||
import extension from '../extension/tools.mjs';
|
||||
import { TOOLS_ENV } from '../src/tools.mjs';
|
||||
for (const [name, read, relative] of [
|
||||
['config', (file) => loadDataRoot(file), 'config.json'],
|
||||
['binding', (file) => loadBinding(file), 'binding.json'],
|
||||
['approvals', (_file, root) => readApprovals(root), 'approvals.jsonl'],
|
||||
['journal', (_file, root) => readInboxIds(root), 'inbox.jsonl'],
|
||||
]) test(`${name} JSON refusal omits parser bytes`, t => {
|
||||
const root = scratch(t), ledger = name === 'approvals' || name === 'journal';
|
||||
const file = write(join(root, relative), ledger ? '{}\n' + malformed : malformed);
|
||||
assert.throws(() => read(file, root), safe(ledger ? `${file}:2` : file, DiscordError, { exitCode: 2 }));
|
||||
});
|
||||
test('tools environment JSON refusal names the config key without its bytes', () => {
|
||||
const previous = process.env[TOOLS_ENV];
|
||||
process.env[TOOLS_ENV] = malformed;
|
||||
try { assert.throws(() => extension({}), safe(TOOLS_ENV, Error)); }
|
||||
finally { if (previous === undefined) delete process.env[TOOLS_ENV]; else process.env[TOOLS_ENV] = previous; }
|
||||
});
|
||||
test('config read refusal retains the filesystem code', t => {
|
||||
const file = scratch(t);
|
||||
assert.throws(() => loadDataRoot(file), error => {
|
||||
safe(file, DiscordError, { exitCode: 2 })(error);
|
||||
assert.match(error.message, /\(EISDIR\)$/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -157,7 +157,7 @@ test("setspark config: approvers come from the binding's users, never from the b
|
||||
const b = validateBinding(rawBinding({ users, tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
||||
assert.deepEqual(b.tools.setspark.approvers, { jason: "100000000000000100", carmen: "100000000000000101" });
|
||||
assert.throws(() => validateBinding(rawBinding({ users, tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", approvers: { mallory: "100000000000000199" } } } })), /approvers come from users/);
|
||||
assert.throws(() => validateBinding(rawBinding({ users: [{ id: "100000000000000100", name: "Jason" }, { id: "100000000000000101", name: "jason" }], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } })), /two users named jason/);
|
||||
assert.throws(() => validateBinding(rawBinding({ users: [{ id: "100000000000000100", name: "Jason" }, { id: "100000000000000101", name: "jason" }], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } })), /two users share a name/);
|
||||
const withCarmenOut = validateBinding(rawBinding({ users: [users[0]], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
||||
assert.throws(() => reloadDiff(b, withCarmenOut), /tools cannot change/, "pi's approvers are fixed at start, so a user change needs a restart");
|
||||
const channelsOnly = validateBinding(rawBinding({ users: [{ ...users[0], channels: [rawBinding().channels[0].id] }, users[1]], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createHash } from "node:crypto";
|
||||
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { railsText, ownRails } from "../../organization/src/rails.mjs";
|
||||
import { typedTools, untypedActions } from "./tools.mjs";
|
||||
import { route as defaultProxyRoute } from "../../plugins/model-provider/cliproxy/index.mjs";
|
||||
import { CREATIVE_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
|
||||
@@ -36,7 +37,7 @@ export function webPlan(resolved, harness, builtinWeb) {
|
||||
throw new BundleError(`instance ${resolved.instance}: a ${resolved.definition} role fetches only through the broker, not search-source "harness-builtin"`, "web-source-not-allowed");
|
||||
}
|
||||
if (granted && source === "harness-builtin" && !builtinWeb) {
|
||||
throw new BundleError(`instance ${resolved.instance}: search-source "harness-builtin" has no built-in web tool on ${harness}`);
|
||||
throw new BundleError(`instance ${resolved.instance}: search-source "harness-builtin" has no built-in web tool on the configured harness`);
|
||||
}
|
||||
return { source, builtin: granted && source === "harness-builtin" };
|
||||
}
|
||||
@@ -281,7 +282,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
|
||||
...checkoutLines(session.checkouts ?? []),
|
||||
// Row 89 (#1583): the names the harness gives its built-ins, as the manifest lists them.
|
||||
`- Built-in tools: ${builtins.join(", ") || "none"}.`,
|
||||
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`,
|
||||
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal carries its code, the rail it enforces and the next step (plus a reason when available).`,
|
||||
"- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.",
|
||||
"- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.",
|
||||
];
|
||||
@@ -306,6 +307,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
|
||||
if (named.has("request_staff"))
|
||||
lines.push("- Staff: you don't launch sessions. For intake or a task, request_staff takes exactly one of request or task_ref and names each role with how many. The chief of staff answers by message with the instances it launched, or with the refusal and its reason.");
|
||||
if (untyped.length) lines.push(`- Actions you hold that have no bus tool yet: ${untyped.join(", ")}. Say so instead of working around it.`);
|
||||
lines.push("", railsText(resolved.rails ?? ownRails({ instance: resolved.instance, authority: resolved.limits.authority, network: resolved.limits.network })), "");
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
// capability or the claim stopped working (ended, revoked), 23 the broker
|
||||
// stayed unreachable, 2 a bad run directory.
|
||||
|
||||
import { refusalGuidance } from "../../bus/src/refusals.mjs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, closeSync } from "node:fs";
|
||||
@@ -366,6 +367,7 @@ export function toolServer({ path, tools, client, local }) {
|
||||
answer({ ok: true, result });
|
||||
} catch (e) {
|
||||
answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed",
|
||||
...refusalGuidance(e.code),
|
||||
...(typeof e.reason === "string" && request.tool === "advance_request" && tool.verb === "request.advance" && e.code === "request-evidence-required"
|
||||
? { reason: e.reason.slice(0, 300) } : tool.verb === "record.write" && e.code === "project-record-invalid" && typeof e.reason === "string"
|
||||
? { reason: e.reason.slice(0, 300) } : {}) });
|
||||
@@ -523,14 +525,20 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
process.on("SIGINT", stop);
|
||||
|
||||
let session, cap, policy;
|
||||
let input = "session.json";
|
||||
try {
|
||||
session = { ...DEFAULTS, ...JSON.parse(readFileSync(join(runDir, "session.json"), "utf8")), runDir,
|
||||
proxyFirstTurn: existsSync(join(runDir, "proxy-first-turn-enabled")) };
|
||||
input = "stdin capability";
|
||||
cap = JSON.parse(await readLine(stdin)).cap;
|
||||
if (typeof cap !== "string" || !/^[0-9a-f]{64}$/.test(cap)) throw new Error("no capability on stdin");
|
||||
input = "policy.json";
|
||||
policy = JSON.parse(readFileSync(session.bundle.policy, "utf8"));
|
||||
} catch (e) {
|
||||
log(`runner: ${e.message}`);
|
||||
const reason = e.message === "stdin too large" ? "stdin too large (expected at most 4096 characters)"
|
||||
: e.message === "no capability on stdin" ? "no capability on stdin (expected cap with 64 lowercase hex characters)"
|
||||
: `${input} must be readable JSON with the expected session, capability or policy fields`;
|
||||
log(`runner: ${e.code ? `${e.code}: ` : ""}${reason}`);
|
||||
return EXIT.usage;
|
||||
}
|
||||
if (!landingOk(session.landing)) {
|
||||
@@ -567,7 +575,13 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
}
|
||||
log(`runner: ${session.business}/${session.instance} claimed by run ${session.run}`);
|
||||
|
||||
const tools = JSON.parse(readFileSync(session.bundle.tools, "utf8"));
|
||||
let tools;
|
||||
try {
|
||||
tools = JSON.parse(readFileSync(session.bundle.tools, "utf8"));
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) throw new SyntaxError("tools.json must be valid JSON");
|
||||
throw error;
|
||||
}
|
||||
const server = await toolServer({
|
||||
path: session.toolSocket,
|
||||
tools,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// socket (MOSAIC_TOOL_SOCKET) with `{tool, args}`; the runner checks the
|
||||
// name against this list and forwards the call with its own capability.
|
||||
|
||||
import { refusalGuidance } from "../../bus/src/refusals.mjs";
|
||||
import { connect } from "node:net";
|
||||
import { ACTIONS, ID_PATTERN, THREAD_DEFINITION, CREATIVE_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
|
||||
|
||||
@@ -372,10 +373,11 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
requirement_snapshot: str("Exact approved snapshot ID; omit to read the active snapshot"),
|
||||
}));
|
||||
}
|
||||
add("my_rails", "my_rails", null, "Read only your current actions, routes, resolve levels, network, credential service/scope names and messaging limits. No values or other agents' rails.", obj({}));
|
||||
// mos-1308 / M3: no generic spec, evidence, tracker, decision or write tools. The
|
||||
// C2-08 web grant still supplies broker web reads to companion and research only.
|
||||
if (CREATIVE_DEFINITIONS.includes(resolved.definition))
|
||||
return tools.filter(tool => tool.name === "send_message" || tool.name.startsWith("library_") ||
|
||||
return tools.filter(tool => tool.name === "my_rails" || tool.name === "send_message" || tool.name.startsWith("library_") ||
|
||||
tool.name === "web_search" || tool.name === "web_fetch");
|
||||
return tools;
|
||||
}
|
||||
@@ -429,7 +431,13 @@ export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) {
|
||||
if (!input.includes("\n")) return;
|
||||
try {
|
||||
const r = JSON.parse(input);
|
||||
r.ok ? end(null, r.result) : end(new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: target instance is already running and will receive its waiting messages when it claims; you must not launch it again or relay the notice" : `refused: ${r.error}${typeof r.reason === "string" && tool === "advance_request" && r.error === "request-evidence-required" ? `: ${r.reason.slice(0, 300)}` : tool === "write_record" && r.error === "project-record-invalid" && typeof r.reason === "string" ? `: ${r.reason.slice(0, 300)}` : ""}`));
|
||||
if (r.ok) return end(null, r.result);
|
||||
const guidance = refusalGuidance(r.error);
|
||||
const error = new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: target instance is already running and will receive its waiting messages when it claims; you must not launch it again or relay the notice" : `refused: ${r.error}${typeof r.reason === "string" && tool === "advance_request" && r.error === "request-evidence-required" ? `: ${r.reason.slice(0, 300)}` : tool === "write_record" && r.error === "project-record-invalid" && typeof r.reason === "string" ? `: ${r.reason.slice(0, 300)}` : ""}`);
|
||||
error.code = r.error;
|
||||
Object.assign(error, guidance);
|
||||
error.message += `\nRail: ${guidance.rail}\nNext step: ${guidance.nextStep}`;
|
||||
end(error);
|
||||
} catch {
|
||||
end(unknown("tool reply isn't JSON"));
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ test("a chief-of-staff bundle lists end_run, change_organization and set_name as
|
||||
const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" }));
|
||||
const b = build(t, r);
|
||||
const prefix = harness === "pi" ? "" : "mcp__mosaic__";
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"].map((n) => prefix + n), harness);
|
||||
assert.deepEqual(b.manifest.untyped, ["decision.resolve.technical", "decision.resolve.delivery"], harness);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/);
|
||||
@@ -440,7 +440,7 @@ test("search-source harness-builtin: claude-code gets WebSearch and WebFetch and
|
||||
|
||||
test("search-source harness-builtin is refused for a granted pi role, which has no built-in web tool", (t) => {
|
||||
const r = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "pi", model: "claude-sonnet-5-5" }));
|
||||
assert.throws(() => build(t, withSource(granted(r), "harness-builtin")), (e) => e instanceof BundleError && /no built-in web tool on pi/.test(e.message));
|
||||
assert.throws(() => build(t, withSource(granted(r), "harness-builtin")), (e) => e instanceof BundleError && /no built-in web tool on the configured harness/.test(e.message));
|
||||
// Without the grant the source doesn't matter on pi.
|
||||
assert.ok(!build(t, withSource(r, "harness-builtin")).manifest.builtins.some((x) => /web/i.test(x)));
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
|
||||
function safe(label, Type, props = {}, omitted = secret) { return error => {
|
||||
assert.ok(error instanceof Type, error.stack);
|
||||
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
assert.ok(!error.message.includes(omitted), error.message);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
}; }
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { EXIT } from '../src/runner.mjs';
|
||||
const runner = join(import.meta.dirname, '../src/runner.mjs');
|
||||
for (const at of ['session.json', 'stdin', 'policy.json']) test(`runner ${at} failure omits parser bytes and keeps usage exit`, t => {
|
||||
const root = scratch(t), policy = join(root, 'policy.json');
|
||||
writeFileSync(join(root, 'session.json'), at === 'session.json' ? secret : JSON.stringify({ bundle: { policy } }));
|
||||
writeFileSync(policy, at === 'policy.json' ? secret : '{}');
|
||||
const r = spawnSync(process.execPath, [runner, root], { encoding: 'utf8', input: at === 'stdin' ? secret + '\n' : JSON.stringify({ cap: 'a'.repeat(64) }) + '\n', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, EXIT.usage, r.stderr);
|
||||
assert.ok(r.stderr.includes(at), r.stderr);
|
||||
assert.doesNotMatch(r.stdout + r.stderr, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
});
|
||||
test('runner keeps a read errno without relaying its message', t => {
|
||||
const root = scratch(t); mkdirSync(join(root, 'session.json'));
|
||||
const r = spawnSync(process.execPath, [runner, root], { encoding: 'utf8', input: '', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, EXIT.usage); assert.match(r.stderr, /session.json/); assert.match(r.stderr, /runner: EISDIR:/);
|
||||
});
|
||||
test('runner tools parser keeps SyntaxError without file bytes', t => {
|
||||
const root = scratch(t), policy = join(root, 'policy.json'), tools = join(root, 'tools.json');
|
||||
writeFileSync(join(root, 'session.json'), JSON.stringify({ bundle: { policy, tools }, turnEnd: 'exit', harnessEnv: {} }));
|
||||
writeFileSync(policy, '{"needs":[]}'); writeFileSync(tools, secret);
|
||||
// Isolate the claim boundary: this test exercises the real post-claim file parser,
|
||||
// and intentionally never starts a broker, adapter or tool server.
|
||||
const code = `import { Readable } from 'node:stream';
|
||||
import { Client } from ${JSON.stringify(new URL('../../bus/src/client.mjs', import.meta.url).href)};
|
||||
import { main } from ${JSON.stringify(new URL('../src/runner.mjs', import.meta.url).href)};
|
||||
Client.prototype.call = async () => ({});
|
||||
try { await main(${JSON.stringify(root)}, { stdin: Readable.from([JSON.stringify({ cap: 'a'.repeat(64) }) + '\\n']), env: {}, log() {} }); }
|
||||
catch (e) { if (!(e instanceof SyntaxError)) throw e; console.error(e.stack); process.exitCode = 9; }`;
|
||||
const r = spawnSync(process.execPath, ['--input-type=module', '-e', code], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 9, r.stderr); assert.match(r.stderr, /tools.json/); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET|Unexpected token/);
|
||||
});
|
||||
@@ -14,9 +14,9 @@ const role = (t, definition, harness = 'pi') => resolvedFor(t, 'bean', d => {
|
||||
d.roles.bean = { definition, credentials: { gitea: { ...d.roles.coder.credentials.gitea } }, vars: { harness, model: 'claude-sonnet-5-5', thinking: 'high' } };
|
||||
});
|
||||
const expected = definition => ['send_message', ...(['companion', 'research'].includes(definition)
|
||||
? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete'];
|
||||
? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete', 'my_rails'];
|
||||
|
||||
test('M3: per-role snapshots contain eight names, or ten with the C2-08 web grant', t => {
|
||||
test('M3/TI: per-role snapshots contain nine names, or eleven with the C2-08 web grant', t => {
|
||||
for (const definition of CREATIVE) {
|
||||
const tools = typedTools({ resolved: role(t, definition), library: true, tracker: true, arbiter: true });
|
||||
assert.deepEqual(tools.map(t => t.name), expected(definition), definition);
|
||||
@@ -72,7 +72,7 @@ test('bundle flag reaches both harness tool manifests and creative prompt withou
|
||||
|
||||
test('C2-05c scope schemas expand only find/search/get, keeping manuscript and tool names unchanged', t => {
|
||||
const tools = typedTools({ resolved: role(t, 'ideas'), library: true });
|
||||
assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete']);
|
||||
assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete', 'my_rails']);
|
||||
for (const name of ['library_find', 'library_search', 'library_get']) {
|
||||
const tool = tools.find(t => t.name === name);
|
||||
assert.deepEqual(tool.parameters.properties.scope.enum, ['canon', 'playground', 'supporting']);
|
||||
|
||||
@@ -11,12 +11,12 @@ const role = (t, definition) => resolvedFor(t, 'bean', doc => {
|
||||
doc.roles.bean = { definition, credentials: { gitea: { ...doc.roles.coder.credentials.gitea } }, vars: { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' } };
|
||||
});
|
||||
|
||||
test('C2-02 M3: each creative role has eight tools, or ten with its web grant', t => {
|
||||
test('C2-02 M3/TI: each creative role has nine tools, or eleven with its web grant', t => {
|
||||
for (const definition of CREATIVE) {
|
||||
const resolved = role(t, definition), tools = typedTools({ resolved, library: true, tracker: true, arbiter: true });
|
||||
const expected = ['send_message', ...(['companion', 'research'].includes(definition) ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, ...WRITES];
|
||||
const expected = ['send_message', ...(['companion', 'research'].includes(definition) ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, ...WRITES, 'my_rails'];
|
||||
assert.deepEqual(tools.map(tool => tool.name), expected, definition);
|
||||
assert.equal(tools.length, ['companion', 'research'].includes(definition) ? 10 : 8);
|
||||
assert.equal(tools.length, ['companion', 'research'].includes(definition) ? 11 : 9);
|
||||
assert.equal(untypedActions(resolved, tools).includes('library.write'), false);
|
||||
}
|
||||
});
|
||||
@@ -56,5 +56,5 @@ test('C2-02 tools: no library, noncreative or no within-role write grant exposes
|
||||
const narrowed = structuredClone(creative);
|
||||
narrowed.limits.authority.withinRole = narrowed.limits.authority.withinRole.filter(action => action !== 'library.write');
|
||||
narrowed.limits.authority.crossRole.push('library.write');
|
||||
assert.deepEqual(typedTools({ resolved: narrowed, library: true }).map(tool => tool.name), ['send_message', ...LOOKUPS]);
|
||||
assert.deepEqual(typedTools({ resolved: narrowed, library: true }).map(tool => tool.name), ['send_message', ...LOOKUPS, 'my_rails']);
|
||||
});
|
||||
@@ -68,7 +68,7 @@ test("tools/call goes through the tool socket; a refusal is an isError result",
|
||||
assert.deepEqual(JSON.parse(ok.content[0].text), { tool: "list_agents", args: { scope: "all" } });
|
||||
const refused = (await server.send("tools/call", { name: "launch", arguments: { instance: "coder" } })).result;
|
||||
assert.equal(refused.isError, true);
|
||||
assert.equal(refused.content[0].text, "refused: capacity-full");
|
||||
assert.match(refused.content[0].text, /^refused: capacity-full\nRail: .+\nNext step: .+/);
|
||||
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { buildBundle } from '../src/bundle.mjs';
|
||||
import { typedTools } from '../src/tools.mjs';
|
||||
import { resolvedFor, scratch, HARNESS, REPO_SKILLS } from './helpers.mjs';
|
||||
import { ownRails, railsText, decisionAutonomy } from '../../organization/src/rails.mjs';
|
||||
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
test('coder prompt lists every held action with its route and only service/scope names', t => {
|
||||
const resolved = resolvedFor(t, 'coder');
|
||||
const root = scratch(t), workspace = join(root, 'ws'); mkdirSync(workspace);
|
||||
const bundle = buildBundle({ dir: join(root, 'bundle'), resolved,
|
||||
contract: '# Contract', harness: HARNESS.pi, skillsDir: REPO_SKILLS,
|
||||
session: { run: 'r1', workspace, toolSocket: join(root, 'tool.sock'), turnMarker: join(root, 'turn.done') } });
|
||||
const prompt = readFileSync(bundle.files.prompt, 'utf8');
|
||||
assert.ok(prompt.includes(railsText(resolved.rails)));
|
||||
for (const action of resolved.limits.authority.withinRole) assert.ok(prompt.includes(`- ${action}: alone.`));
|
||||
assert.match(prompt, /task.scope.change: delivery \(default\): arbiter instance "pm"; technical: arbiter instance "cto"/);
|
||||
assert.match(prompt, /Network: none/);
|
||||
assert.match(prompt, /Credential service gitea; scopes:.*write:repository/);
|
||||
assert.ok(!railsText(resolved.rails).includes(resolved.credentials.gitea.file));
|
||||
assert.ok(!railsText(resolved.rails).includes(SECRET));
|
||||
assert.match(prompt, /a refusal carries its code, the rail it enforces and the next step/);
|
||||
});
|
||||
|
||||
test('all roles including creative roles receive a parameterless caller-only my_rails tool', t => {
|
||||
for (const instance of ['coder', 'chief', 'reviewer']) {
|
||||
const tool = typedTools({ resolved: resolvedFor(t, instance) }).find(t => t.name === 'my_rails');
|
||||
assert.equal(tool.verb, 'my_rails');
|
||||
assert.deepEqual(tool.parameters, { type: 'object', properties: {}, required: [], additionalProperties: false });
|
||||
}
|
||||
const creative = typedTools({ resolved: { ...resolvedFor(t, 'coder'), definition: 'companion' } });
|
||||
assert.ok(creative.some(t => t.name === 'my_rails'));
|
||||
});
|
||||
|
||||
test('resolve rights name their classes and fixed levels through the AU extension point', t => {
|
||||
const { rails } = resolvedFor(t, 'cto');
|
||||
assert.deepEqual(rails.resolve, [{ right: 'decision.resolve.technical', classes: [
|
||||
{ name: 'cross-role', level: decisionAutonomy('cross-role') }, { name: 'merge', level: decisionAutonomy('merge') },
|
||||
] }]);
|
||||
assert.match(railsText(rails), /merge: out of the loop/);
|
||||
});
|
||||
|
||||
test('fixed domains, arbiter self-raises and hard waits describe existing routes only', () => {
|
||||
const rails = ownRails({ instance: 'cto', authority: { withinRole: ['deploy'],
|
||||
crossRole: ['git.merge.protected', 'policy.change'] }, arbiters: { technical: 'cto', delivery: 'pm' } });
|
||||
assert.deepEqual(rails.actions, [
|
||||
{ action: 'deploy', routes: [{ to: 'human' }] },
|
||||
{ action: 'git.merge.protected', routes: [{ domain: 'technical', to: 'human' }] },
|
||||
{ action: 'policy.change', routes: [{ domain: 'delivery', to: 'pm' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('hire and fire are unavailable to a chief that is not the configured launcher', t => {
|
||||
const { rails } = resolvedFor(t, 'chief', doc => { delete doc.launch; });
|
||||
for (const action of ['role.hire', 'role.fire']) {
|
||||
assert.deepEqual(rails.actions.find(a => a.action === action).routes, [{ to: 'unavailable' }]);
|
||||
}
|
||||
assert.match(railsText(rails), /role.hire: unavailable here; only the configured chief can hire or fire/);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
function safe(label, ErrorType, properties = {}) {
|
||||
return error => {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
import { BundleError, webPlan } from '../src/bundle.mjs';
|
||||
test('harness web refusal names the harness field without the configured value', () => {
|
||||
assert.throws(() => webPlan({ instance: 'builder', definition: 'coder', webSource: 'harness-builtin', limits: { tools: ['web'] } }, secret, false), safe('harness', BundleError, { code: 'bundle-refused' }));
|
||||
});
|
||||
@@ -392,7 +392,12 @@ test('advance_request exposes the named task evidence refusal to the PM tool', a
|
||||
const { Client } = await import('../../bus/src/client.mjs');
|
||||
const server = await runner.toolServer({ path, tools: [{ name: 'advance_request', verb: 'request.advance' }, { name: 'other_tool', verb: 'request.advance' }], local: {}, client: new Client({ path: busPath, cap: 'fixture' }) });
|
||||
t.after(() => new Promise(resolve => server.close(resolve)));
|
||||
await assert.rejects(callTool(path, 'advance_request', {}), { message: `refused: request-evidence-required: ${reason.slice(0, 300)}` });
|
||||
await assert.rejects(callTool(path, 'advance_request', { other: true }), { message: 'refused: project-record-invalid' });
|
||||
await assert.rejects(callTool(path, 'other_tool', {}), { message: 'refused: request-evidence-required' });
|
||||
const refusal = message => error => {
|
||||
assert.equal(error.message.split('\n')[0], message);
|
||||
assert.match(error.message, /\nRail: .+\nNext step: .+/);
|
||||
return true;
|
||||
};
|
||||
await assert.rejects(callTool(path, 'advance_request', {}), refusal(`refused: request-evidence-required: ${reason.slice(0, 300)}`));
|
||||
await assert.rejects(callTool(path, 'advance_request', { other: true }), refusal('refused: project-record-invalid'));
|
||||
await assert.rejects(callTool(path, 'other_tool', {}), refusal('refused: request-evidence-required'));
|
||||
});
|
||||
@@ -477,10 +477,23 @@ test("typed tools carry the runner's capability; launch goes to the host's launc
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL resolve_decision {"id":"x"}' });
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL send_message {"to":"nobody","body":"x"}' });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 4);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body), ["refused: capacity-full", '{"run":"new-run"}', "refused: unknown-tool", "refused: unknown-role"]);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body.split("\n")[0]), ["refused: capacity-full", '{"run":"new-run"}', "refused: unknown-tool", "refused: unknown-role"]);
|
||||
assert.deepEqual(ctx.launches, [{ cap: ctx.cap, instance: "coder" }, { cap: ctx.cap, instance: "reviewer" }]);
|
||||
});
|
||||
|
||||
test("my_rails reaches the live broker with the runner's own capability and refuses an identity selector", async t => {
|
||||
const ctx = await setup(t, { tools: typedTools({ resolved: resolvedFor(t, "coder") }) });
|
||||
ctx.inbox = [];
|
||||
const r = startRunner(ctx.runDir, ctx.cap);
|
||||
t.after(() => r.child.kill("SIGKILL"));
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL my_rails {}' });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 1);
|
||||
assert.deepEqual(JSON.parse(ctx.inbox[0].body), ctx.call(ctx.cap, "my_rails", {}));
|
||||
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL my_rails {"role":"cto"}' });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
|
||||
assert.match(ctx.inbox[1].body, /^refused: invalid-request\nRail: .+\nNext step: .+/);
|
||||
});
|
||||
|
||||
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1, REQ-LIMIT-1): the chief's two tools reach the broker by their verbs
|
||||
// with the arguments the session gave, and a refusal comes back with the broker's code.
|
||||
// Row 134 (#1669): organization_revision reaches the broker's organization.revision and answers the revision alone.
|
||||
@@ -509,7 +522,7 @@ test("end_run, organization_revision and change_organization reach the broker wi
|
||||
send("change_organization", { ...change, surprise: 1 });
|
||||
send("change_organization", { ...change, revision: "e".repeat(64) });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 8);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body), [
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body.split("\n")[0]), [
|
||||
'{"revoked":true}',
|
||||
"refused: role-end-refused",
|
||||
"refused: not-held",
|
||||
@@ -541,7 +554,7 @@ test("set_name reaches the broker's name.set with the tool's arguments, and refu
|
||||
send({ instance: "coder", name: "rex" });
|
||||
send({ instance: "coder", name: "pm" });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body), [
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body.split("\n")[0]), [
|
||||
'{"instance":"coder","name":"rex"}',
|
||||
"refused: name-is-instance",
|
||||
]);
|
||||
|
||||
@@ -18,7 +18,7 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => {
|
||||
"intake_status", "intake_projects", "intake_open", "intake_question", "intake_propose", "deliver_request", "advance_request", "submit_prd",
|
||||
"task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change",
|
||||
"task_update_assigned", "task_close",
|
||||
"read_spec", "write_record", "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements",
|
||||
"read_spec", "write_record", "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements", "my_rails",
|
||||
]);
|
||||
// Row 119 (#1613): only the chief of staff launches (REQ-COS-1).
|
||||
assert.equal(pm.launch, null);
|
||||
@@ -47,7 +47,7 @@ test("send_message offers the BLOCK class, beside the classes it already had", (
|
||||
test("the chief of staff gets launch, send_message, end_run, hire, fire, change_organization and set_name, and no task verbs", (t) => {
|
||||
const chief = resolvedFor(t, "chief");
|
||||
const tools = typedTools({ resolved: chief, tracker: true });
|
||||
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]);
|
||||
const launch = tools.find((x) => x.name === "launch");
|
||||
assert.equal(launch.verb, "launch");
|
||||
assert.equal(launch.action, "role.launch");
|
||||
@@ -225,7 +225,7 @@ test("no role's tools send instances or instances.set, and change_organization t
|
||||
test("the assistant gets send_message, the reads and the web tools (it holds the web grant), no launch", (t) => {
|
||||
const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" }));
|
||||
const tools = typedTools({ resolved: assistant, tracker: true });
|
||||
assert.deepEqual(names(tools), ["send_message", "web_search", "web_fetch", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(tools), ["send_message", "web_search", "web_fetch", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]);
|
||||
assert.equal(assistant.launch, null);
|
||||
assert.deepEqual(untypedActions(assistant, tools), []);
|
||||
});
|
||||
@@ -260,7 +260,7 @@ test("list_requirements reads the requirement ids, for a task.create holder with
|
||||
test("a coder gets no launch, no resolve_decision, and no task tools without a tracker", (t) => {
|
||||
const coder = resolvedFor(t, "coder");
|
||||
const tools = typedTools({ resolved: coder });
|
||||
assert.deepEqual(names(tools), ["send_message", "request_review", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail"]);
|
||||
assert.deepEqual(names(tools), ["send_message", "request_review", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "my_rails"]);
|
||||
// Held actions without a tool are listed, so the manifest shows them. With
|
||||
// no clone to push from, push_working is one of them.
|
||||
assert.deepEqual(untypedActions(coder, tools), ["task.update.assigned", "git.push.working", "task.scope.change"]);
|
||||
@@ -270,7 +270,7 @@ test("a coder with a tracker gets its own task tools and no task_reassign", (t)
|
||||
const coder = resolvedFor(t, "coder");
|
||||
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), [
|
||||
"send_message", "task_scope_change", "task_update_assigned", "request_review",
|
||||
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
|
||||
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -283,7 +283,7 @@ test("launch only when the business's launch block names the instance as launche
|
||||
|
||||
test("an action outside the instance's authority has no tool", (t) => {
|
||||
const coder = resolvedFor(t, "coder", (d) => (d.roles.coder.vars["limits.authority"] = ["review.request"]));
|
||||
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })), ["request_review", "read_spec", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })), ["request_review", "read_spec", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]);
|
||||
});
|
||||
|
||||
// Row 93 (#1587): the review loop's tools, by authority, and the git ones only with a clone.
|
||||
@@ -292,7 +292,7 @@ test("the review loop: push_working and request_review for the coder, fetch_revi
|
||||
const tools = typedTools({ resolved: coder, tracker: true, projects: ["stack", "site"] });
|
||||
assert.deepEqual(names(tools), [
|
||||
"send_message", "task_scope_change", "task_update_assigned", "push_working", "request_review",
|
||||
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
|
||||
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails",
|
||||
]);
|
||||
assert.deepEqual(untypedActions(coder, tools), []);
|
||||
const push = tools.find((x) => x.name === "push_working");
|
||||
@@ -309,7 +309,7 @@ test("the review loop: push_working and request_review for the coder, fetch_revi
|
||||
|
||||
const reviewer = resolvedFor(t, "reviewer");
|
||||
const rtools = typedTools({ resolved: reviewer, tracker: true, projects: ["stack"] });
|
||||
assert.deepEqual(names(rtools), ["send_message", "task_update_assigned", "fetch_review", "review_verdict", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(rtools), ["send_message", "task_update_assigned", "fetch_review", "review_verdict", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]);
|
||||
const fetch = rtools.find((x) => x.name === "fetch_review");
|
||||
assert.deepEqual([fetch.verb, fetch.action], ["working.fetch", null]);
|
||||
assert.deepEqual(fetch.parameters.required, ["task_ref", "request"]);
|
||||
|
||||
@@ -62,14 +62,20 @@ for (const [name, value, detail] of [
|
||||
});
|
||||
}
|
||||
|
||||
test('agent sees validation detail while other refusal codes remain code-only', async t => {
|
||||
test('agent sees validation detail and static guidance without unrelated refusal reasons', async t => {
|
||||
const f = await setup(t);
|
||||
await assert.rejects(f.write('requirements/REQ-1.json', { ...requirement, title: '' }),
|
||||
/refused: project-record-invalid: requirement\.title: too short/);
|
||||
await assert.rejects(f.write('spec/secret-marker.md', '# text'), { message: 'refused: project-spec-human-only' });
|
||||
const refusal = message => error => {
|
||||
assert.equal(error.message.split('\n')[0], message);
|
||||
assert.match(error.message, /\nRail: .+\nNext step: .+/);
|
||||
assert.doesNotMatch(error.message, /secret-marker/);
|
||||
return true;
|
||||
};
|
||||
await assert.rejects(f.write('spec/secret-marker.md', '# text'), refusal('refused: project-spec-human-only'));
|
||||
const path = join(f.root, 'other-tools.sock');
|
||||
const server = await toolServer({ path, tools: [{ name: 'write_record', verb: 'record.write' }], local: {},
|
||||
client: { call() { throw Object.assign(new Error('hidden'), { code: 'project-busy', reason: 'secret-marker' }); } } });
|
||||
t.after(() => server.close());
|
||||
await assert.rejects(callTool(path, 'write_record', {}), { message: 'refused: project-busy' });
|
||||
await assert.rejects(callTool(path, 'write_record', {}), refusal('refused: project-busy'));
|
||||
});
|
||||
@@ -65,6 +65,9 @@ function fixture(t) {
|
||||
cpSync(source, path.join(root, 'packages/ledger/src'), { recursive: true });
|
||||
// The queue section imports the queue validator and the seat registry.
|
||||
for (const pkg of ['queue', 'seat']) cpSync(path.join(source, '../..', pkg, 'src'), path.join(root, 'packages', pkg, 'src'), { recursive: true });
|
||||
mkdirSync(path.join(root, 'packages/organization/src'), { recursive: true });
|
||||
for (const file of ['util.mjs', 'errors.mjs', 'vocabulary.mjs'])
|
||||
cpSync(path.join(source, '../../organization/src', file), path.join(root, 'packages/organization/src', file));
|
||||
// The fake serves ISSUES for every page, or page N of the ISSUE_PAGES array
|
||||
// (an empty page past its end). API_FAIL fails every call, or only FAIL_PAGE.
|
||||
const api = put('bin/gitea-api.sh', `#!/usr/bin/env node
|
||||
|
||||
@@ -337,6 +337,9 @@ function cliFixture(t, rows = MAP) {
|
||||
const repo = scratchRepo(t, { map: mapText(rows, { retired: [], highWater: Math.max(...rows.map(r => r.id)) }) });
|
||||
genesisCommitted(repo);
|
||||
for (const pkg of ['ledger', 'seat']) cpSync(path.join(PACKAGES, pkg, 'src'), path.join(repo.root, 'packages', pkg, 'src'), { recursive: true });
|
||||
mkdirSync(path.join(repo.root, 'packages/organization/src'), { recursive: true });
|
||||
for (const file of ['util.mjs', 'errors.mjs', 'vocabulary.mjs'])
|
||||
cpSync(path.join(PACKAGES, 'organization/src', file), path.join(repo.root, 'packages/organization/src', file));
|
||||
const dataRoot = path.join(repo.base, 'data');
|
||||
const config = path.join(repo.base, 'config.json');
|
||||
writeFileSync(config, JSON.stringify({ dataRoot }));
|
||||
|
||||
@@ -75,7 +75,7 @@ function checkUser(user, at, ctx, humans, seenSubjects) {
|
||||
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
|
||||
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
|
||||
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
|
||||
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)} (got ${JSON.stringify(user.home)})`);
|
||||
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)}`);
|
||||
}
|
||||
const subjects = requireArray(user.subjects, `${at}.subjects`);
|
||||
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
|
||||
@@ -84,7 +84,7 @@ function checkUser(user, at, ctx, humans, seenSubjects) {
|
||||
requireObject(s, sat);
|
||||
rejectUnknownKeys(s, ["provider", "sub"], sat);
|
||||
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
|
||||
refuse(`${sat}.provider must be a configured provider (got ${JSON.stringify(s.provider)})`);
|
||||
refuse(`${sat}.provider must be a configured provider`);
|
||||
}
|
||||
requireText(s.sub, `${sat}.sub`);
|
||||
const key = subjectKey(s.provider, s.sub);
|
||||
@@ -112,10 +112,10 @@ function checkScope(value, where, check) {
|
||||
|
||||
function checkExpires(value, where) {
|
||||
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
|
||||
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
|
||||
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z`);
|
||||
}
|
||||
const iso = new Date(value).toISOString();
|
||||
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
|
||||
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time`);
|
||||
}
|
||||
|
||||
function checkGrant(grant, at, ctx, users, seenGrants) {
|
||||
@@ -124,18 +124,18 @@ function checkGrant(grant, at, ctx, users, seenGrants) {
|
||||
requireId(grant.id, `${at}.id`);
|
||||
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
|
||||
seenGrants.add(grant.id);
|
||||
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file (got ${JSON.stringify(grant.user)})`);
|
||||
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file`);
|
||||
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
|
||||
refuse(`${at}.business must name a declared business (got ${JSON.stringify(grant.business)})`);
|
||||
refuse(`${at}.business must name a declared business`);
|
||||
}
|
||||
const business = ctx.businesses.get(grant.business);
|
||||
checkScope(grant.projects, `${at}.projects`, (p, where) => {
|
||||
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
|
||||
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID`);
|
||||
});
|
||||
checkScope(grant.instances, `${at}.instances`, (x, where) => {
|
||||
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
|
||||
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of the grant's business`);
|
||||
});
|
||||
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
|
||||
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")}`);
|
||||
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export function checkAccess(document, context) {
|
||||
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
|
||||
if (!isPlainObject(document)) refuse("access file must be a JSON object");
|
||||
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
|
||||
if (document.accessVersion !== 1) refuse(`accessVersion must be 1 (got ${JSON.stringify(document.accessVersion)})`);
|
||||
if (document.accessVersion !== 1) refuse(`accessVersion must be 1`);
|
||||
requirePositiveInt(document.revision, "revision");
|
||||
const users = new Set();
|
||||
const seenSubjects = new Set();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Phase M2 (#1873): instance accounts, independent of Console sessions and bus authority.
|
||||
import { constants, lstatSync } from 'node:fs';
|
||||
import { constants } from 'node:fs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { dirname, join, parse, resolve } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { OrgError, readRegistryLocked } from './org-registry.mjs';
|
||||
import { assertTrustedConfigDir } from './config-dir-trust.mjs';
|
||||
import { readJsonFile, requireObject, rejectUnknownKeys, requireString, requirePositiveInt, requireId, requireDistinctList, deepFreeze, canonicalJson } from './util.mjs';
|
||||
import { RIGHTS, ROLE_RIGHTS, normalizeRights } from './org-rights.mjs';
|
||||
|
||||
@@ -128,23 +129,16 @@ export function accountsRepairs(doc) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check the host boundary before any read or lock. Every ancestor must be a real
|
||||
// directory; configDir itself belongs to this uid and is not publicly writable.
|
||||
// Check the host boundary before any read or lock: every ancestor a real directory
|
||||
// that only root or this uid can change, and configDir this uid's and not publicly
|
||||
// writable (config-dir-trust.mjs). Refusals name the rule and uid, never the path.
|
||||
export function assertAccountsOwner(configDir, { uid = process.geteuid() } = {}) {
|
||||
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('accounts-unsafe', 'config directory must be an absolute normalized path');
|
||||
let current = parse(configDir).root;
|
||||
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
|
||||
current = join(current, part); const st = lstatSync(current);
|
||||
if (!st.isDirectory() || st.isSymbolicLink()) fail('accounts-unsafe', 'config path contains a symlink or non-directory');
|
||||
}
|
||||
const st = lstatSync(configDir);
|
||||
if (st.uid !== uid) fail('accounts-unsafe', `config directory must belong to uid ${uid}: ${configDir}`);
|
||||
if (st.mode & 0o022) fail('accounts-unsafe', 'config directory must not be writable by group or other');
|
||||
assertTrustedConfigDir(configDir, { uid, code: 'accounts-unsafe' });
|
||||
}
|
||||
export function loadAccounts(file, context, { uid = process.geteuid() } = {}) {
|
||||
assertAccountsOwner(dirname(file), { uid });
|
||||
const doc = readJsonFile(file, 'accounts file', st => {
|
||||
if (st.uid !== uid) fail('accounts-unsafe', `accounts file must belong to uid ${uid}: ${file}`);
|
||||
if (st.uid !== uid) fail('accounts-unsafe', `accounts file must belong to uid ${uid}`);
|
||||
if (st.mode & 0o022) fail('accounts-unsafe', 'accounts file must not be writable by group or other');
|
||||
});
|
||||
return checkAccounts(doc, context);
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// Root and project agent copies (#1873 Phase A, slice A3; design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 4 and 9 item 2).
|
||||
//
|
||||
// When an organization's bus boots it reads the root file once, through loadRootAgents, and its
|
||||
// own organization file through its own writer. Each root agent the organization does not exclude,
|
||||
// and each agent a project defines, becomes a copy. The snapshot this module returns is plain data
|
||||
// and is fixed until the next boot: nothing here, or anything that holds the snapshot, reads the
|
||||
// root file again.
|
||||
//
|
||||
// A copy runs as an ordinary role instance of the organization: withCopies adds it to the
|
||||
// organization document's `roles` and validates the whole document, so every copy goes through the
|
||||
// same checkRoles an org instance takes, with the organization's credential map. A root or project
|
||||
// definition carries no credential (agent-levels.mjs refuses one), and A3 has no way to bind one,
|
||||
// so a copy whose role needs a credential service or a tracker bot is unbound: it is not added, and
|
||||
// a launch refuses it by name, naming the missing services and never a value.
|
||||
//
|
||||
// Nothing in a copy's view carries a var value, a credential reference, a path or file bytes.
|
||||
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { HOST_SERVICE } from "./vocabulary.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { resolveAgentLevels } from "./agent-levels.mjs";
|
||||
import { mergeVars } from "./vars.mjs";
|
||||
import { businessFilePath, loadBusiness, validateBusinessDocument } from "./organization-config.mjs";
|
||||
import { organizationWriter } from "./organization.mjs";
|
||||
import { deepFreeze } from "./util.mjs";
|
||||
|
||||
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
// How a refusal names one claimant of an instance name.
|
||||
const claimLabel = ({ name, project }) => (project === null ? `root agent ${name}` : `project ${project}'s ${name}`);
|
||||
const collision = (key, claimants) => `the instance name ${key} is claimed by ${claimants.map(claimLabel).join(" and ")}`;
|
||||
// The root assistant is shown, not copied (A6); agent-levels.mjs names it the same way.
|
||||
const ASSISTANT = "assistant";
|
||||
export const COPY_STATES = Object.freeze(["instance", "unbound", "refused"]);
|
||||
|
||||
// The bus instance name of a copy. A root copy keeps its name. A project agent is
|
||||
// `<name>.<project>`, so two projects may define the same name and a project may define a name an
|
||||
// organization or root agent already has, each as its own instance. Null when it isn't a valid id.
|
||||
export function copyInstanceName(name, project = null) {
|
||||
const instance = project === null ? name : `${name}.${project}`;
|
||||
return ID.test(instance) ? instance : null;
|
||||
}
|
||||
|
||||
// The credential services a copy of `definition` would need bound in the organization, and whether
|
||||
// it needs a tracker bot. The host key comes from a grant record at bus start, never from the file,
|
||||
// so it is not one (organization-config.mjs, checkRoles).
|
||||
function needs(definition, rolesDir, cache) {
|
||||
if (!cache.has(definition)) {
|
||||
const role = loadRole(rolesDir, definition);
|
||||
const services = role.credentials.map((c) => c.service).filter((s) => s !== HOST_SERVICE);
|
||||
cache.set(definition, [...new Set(services)].sort());
|
||||
}
|
||||
return cache.get(definition);
|
||||
}
|
||||
|
||||
// The copies of one organization, from its raw document (as its own writer read it) and the root
|
||||
// agents loadRootAgents returned. `root` is null when the root file refused: the root level is then
|
||||
// hidden everywhere (design section 6), so no root copy is made, and project agents still are.
|
||||
// Returns a frozen plain-data snapshot { org, root, rootAgents, copies: { instance: copy } } where
|
||||
// each copy is { name, level, project, definition, vars, state, missing?, reason? }. `rootAgents`
|
||||
// holds the root agents this organization takes (not excluded, not the root assistant), so a later
|
||||
// read of the organization file recomputes each project's union without reading the root file.
|
||||
export function materializeCopies({ orgId, doc, root, rolesDir }) {
|
||||
const rootState = root === null ? "refused" : root.state;
|
||||
const levels = resolveAgentLevels({ orgId, doc, root: root ?? { agents: {} }, rolesDir });
|
||||
const rootAgents = {};
|
||||
for (const [name, entry] of Object.entries(root?.agents ?? {})) {
|
||||
if (entry.definition === ASSISTANT || entry.excludeOrgs.includes(orgId)) continue;
|
||||
rootAgents[name] = { definition: entry.definition, vars: entry.vars ?? {}, excludeOrgs: [] };
|
||||
}
|
||||
const cache = new Map();
|
||||
const copies = {};
|
||||
const add = (agent, project, refusal = null) => {
|
||||
const instance = copyInstanceName(agent.name, project);
|
||||
// An over-long project agent name has no instance; it refuses under its own name.
|
||||
const key = instance ?? `${agent.name}.${project}`;
|
||||
const base = { name: agent.name, level: project === null ? "root" : "project", project, definition: agent.definition, vars: agent.vars };
|
||||
// Two agents whose instance names come out the same (root checker.stack and project stack's
|
||||
// checker, or two dotted names) refuse both, naming each; neither overwrites the other. The
|
||||
// entry lists every claimant, so a reader finds each agent's copy by its name and project.
|
||||
if (Object.hasOwn(copies, key)) {
|
||||
const claimants = [...(copies[key].claims ?? [{ name: copies[key].name, project: copies[key].project }]), { name: agent.name, project }];
|
||||
copies[key] = { ...copies[key], state: "refused", reason: collision(key, claimants), claims: claimants };
|
||||
delete copies[key].missing;
|
||||
return;
|
||||
}
|
||||
// The organization file's own instance of that name wins: no copy is made (design section 3).
|
||||
if (instance !== null && Object.hasOwn(doc?.roles ?? {}, instance)) return;
|
||||
if (refusal) { copies[key] = { ...base, state: "refused", reason: refusal }; return; }
|
||||
if (instance === null) { copies[key] = { ...base, state: "refused", reason: `project ${project}: ${agent.name}.${project} is longer than an instance name may be` }; return; }
|
||||
const missing = needs(agent.definition, rolesDir, cache);
|
||||
copies[key] = missing.length ? { ...base, state: "unbound", missing } : { ...base, state: "instance" };
|
||||
};
|
||||
// Root copies: the base union's root-level agents. It already leaves out the root assistant (A6),
|
||||
// the ones this organization is excluded from and the ones an org instance shadows by name.
|
||||
for (const agent of levels.base.agents) if (agent.level === "root") add(agent, null);
|
||||
// Project agents: each project's own define entries. When the project's union refuses, every
|
||||
// agent it defines refuses too, naming the project and the reason.
|
||||
for (const [project, entries] of Object.entries(levels.projects)) {
|
||||
const union = levels.unions[project];
|
||||
for (const [name, entry] of Object.entries(entries)) {
|
||||
if (entry.form !== "define") continue;
|
||||
const agent = { name, definition: entry.definition, vars: entry.vars };
|
||||
add(agent, project, union.ok ? null : `project ${project} refuses its agents: ${union.reason}`);
|
||||
}
|
||||
}
|
||||
return deepFreeze({ org: orgId, root: rootState, rootAgents, copies });
|
||||
}
|
||||
|
||||
// The view of a snapshot that leaves the boot: names, levels, definitions, states, the missing
|
||||
// service names and refusal reasons. No vars.
|
||||
export function copiesView(copies) {
|
||||
return Object.fromEntries(Object.entries(copies ?? {}).map(([instance, c]) => [instance, {
|
||||
name: c.name, level: c.level, project: c.project, definition: c.definition, state: c.state,
|
||||
...(c.missing ? { missing: [...c.missing] } : {}), ...(c.reason ? { reason: c.reason } : {}),
|
||||
...(c.claims ? { claims: c.claims.map(({ name, project }) => ({ name, project })) } : {}),
|
||||
}]));
|
||||
}
|
||||
|
||||
// Every string the document or the copy holds as a var or a credential reference, longest first.
|
||||
function valuesOf(document, copy) {
|
||||
const out = new Set();
|
||||
const walk = (x) => {
|
||||
if (typeof x === "string") { if (x.length >= 3) out.add(x); }
|
||||
else if (x && typeof x === "object") for (const v of Object.values(x)) walk(v);
|
||||
};
|
||||
walk(copy.vars);
|
||||
walk(document.vars);
|
||||
for (const role of Object.values(document.roles ?? {})) { walk(role?.vars); walk(role?.credentials); }
|
||||
for (const project of Object.values(document.projects ?? {})) walk(project?.vars);
|
||||
return [...out].sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
// A validation refusal for a copy, said without the host path, a quoted "got" value, or any string
|
||||
// the document or the copy holds as a var or a credential reference.
|
||||
function reasonOf(error, file, orgId, document, copy) {
|
||||
let message = error.message.replaceAll(file, `${orgId}.json`).replace(/; got [^)]*\)/g, ")").replace(/ \(got [\s\S]*$/, "");
|
||||
for (const value of valuesOf(document, copy)) message = message.replaceAll(value, "<value>");
|
||||
return message.slice(0, 400);
|
||||
}
|
||||
|
||||
const entryOf = (copy, vars = copy.vars) => ({ definition: copy.definition, ...(Object.keys(vars ?? {}).length ? { vars: structuredClone(vars) } : {}) });
|
||||
|
||||
// Whether any code project of the raw document has its own agent entries.
|
||||
const hasProjectAgents = (document) => Object.values(document?.projects ?? {}).some((p) => p?.kind !== "library" && p?.agents !== undefined);
|
||||
|
||||
// `vars` narrowed by each project entry in `narrows`, the way projectUnion narrows one: mergeVars
|
||||
// applies the intersect rule, and only the keys either side set are kept.
|
||||
function narrowVars(level, vars, narrows) {
|
||||
return narrows.reduce((current, entry) => {
|
||||
const merged = mergeVars([{ source: level, vars: current }, { source: "project", vars: entry }]).vars;
|
||||
const keys = new Set([...Object.keys(current), ...Object.keys(entry)]);
|
||||
return Object.fromEntries(Object.entries(merged).filter(([key]) => keys.has(key)));
|
||||
}, vars ?? {});
|
||||
}
|
||||
|
||||
// The instance name each union agent runs as: a project's own agent is its copy, every other agent
|
||||
// is the instance of its own name.
|
||||
const unionInstance = (agent, project) => (agent.level === "project" ? copyInstanceName(agent.name, project) ?? `${agent.name}.${project}` : agent.name);
|
||||
|
||||
// Why a project copy can't join this document, or null when it can: its project is still a declared
|
||||
// code project, the project still defines it with the definition and vars the bus started with, and
|
||||
// the project's union, recomputed from this document, still accepts it. A copy is made at boot, so
|
||||
// a changed definition or vars waits for the next start; until then the copy refuses rather than
|
||||
// launch with settings the project no longer gives it.
|
||||
function projectCopyRefusal(copy, document, levels) {
|
||||
const p = document.projects?.[copy.project];
|
||||
if (!p || p.kind === "library") return `project ${copy.project} is no longer declared`;
|
||||
const union = levels.unions[copy.project];
|
||||
if (!union.ok) return `project ${copy.project} refuses its agents: ${union.reason}`;
|
||||
const entry = levels.projects[copy.project]?.[copy.name];
|
||||
if (entry?.form !== "define" || entry.definition !== copy.definition) return `project ${copy.project} no longer defines ${copy.name} as ${copy.definition}`;
|
||||
if (!isDeepStrictEqual(entry.vars ?? {}, copy.vars ?? {})) return `project ${copy.project} changed ${copy.name}'s vars since the bus started; the change takes effect at the next start`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// The organization document with the snapshot's bound copies added to `roles`, validated whole by
|
||||
// validateBusinessDocument, the loader every org instance goes through. Each project's union is
|
||||
// recomputed from this document and the snapshot's root agents (never the root file), so:
|
||||
// - the business carries `unions`: for each code project with agents, { ok: true, agents: [the
|
||||
// instance names available there] } or { ok: false, reason };
|
||||
// - an instance a project narrows is narrowed in `roles` itself, so its tools, network and actions
|
||||
// are the intersection over every project that narrows it (a narrow in one project narrows the
|
||||
// instance in every launch: fail closed, since one launch mounts every project it may work in);
|
||||
// - a project copy whose project stops defining it, or whose union now refuses, is refused.
|
||||
// A copy the document refuses (a model var the organization doesn't list, say) is left out and
|
||||
// reported refused; the rest still join. A copy whose name the organization file now declares is
|
||||
// dropped: the file's own instance wins. Returns a frozen business whose `copies` key is the
|
||||
// snapshot's view, with each copy's state as it is in this document; a business with no copies and
|
||||
// no project agents is plain validateBusinessDocument.
|
||||
export function withCopies(document, file, { rolesDir, snapshot }) {
|
||||
const copies = snapshot?.copies ?? {};
|
||||
if (Object.keys(copies).length === 0 && !hasProjectAgents(document)) return validateBusinessDocument(document, file, { rolesDir });
|
||||
// The document's own refusals come first, whole.
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
const orgId = snapshot?.org ?? document.id;
|
||||
const levels = resolveAgentLevels({ orgId, doc: document, root: { agents: snapshot?.rootAgents ?? {} }, rolesDir });
|
||||
const unions = {};
|
||||
const narrows = {};
|
||||
for (const [project, union] of Object.entries(levels.unions)) {
|
||||
if (!union.ok) { unions[project] = { ok: false, reason: union.reason }; continue; }
|
||||
unions[project] = { ok: true, agents: union.agents.map((a) => unionInstance(a, project)).sort() };
|
||||
for (const agent of union.agents) {
|
||||
if (agent.level !== "project" && agent.narrowed.length) (narrows[agent.name] ??= []).push(levels.projects[project][agent.name].vars);
|
||||
}
|
||||
}
|
||||
const roles = { ...document.roles };
|
||||
for (const [name, list] of Object.entries(narrows)) {
|
||||
if (Object.hasOwn(roles, name)) roles[name] = { ...roles[name], vars: narrowVars("org", roles[name].vars, list) };
|
||||
}
|
||||
const narrowed = { ...document, roles };
|
||||
const view = copiesView(copies);
|
||||
// Who claims each copy's instance name now: the boot's root copies and the projects' define
|
||||
// entries as this document has them. A project that comes to define an agent whose name collides
|
||||
// with a copy refuses that copy, naming every claimant, as the boot does.
|
||||
const claimants = {};
|
||||
const claim = (key, who) => { if (!(claimants[key] ??= []).some((c) => c.name === who.name && c.project === who.project)) claimants[key].push(who); };
|
||||
for (const [instance, copy] of Object.entries(copies)) for (const c of copy.claims ?? [{ name: copy.name, project: copy.project }]) if (c.project === null) claim(instance, c);
|
||||
for (const [project, entries] of Object.entries(levels.projects)) {
|
||||
for (const [name, entry] of Object.entries(entries)) if (entry.form === "define") claim(copyInstanceName(name, project) ?? `${name}.${project}`, { name, project });
|
||||
}
|
||||
const join = {};
|
||||
for (const [instance, copy] of Object.entries(copies)) {
|
||||
// The organization file's own instance of this name wins; the copy is gone, not refused.
|
||||
if (Object.hasOwn(roles, instance)) { delete view[instance]; continue; }
|
||||
if ((claimants[instance]?.length ?? 0) > 1) {
|
||||
const all = claimants[instance].map(({ name, project }) => ({ name, project }));
|
||||
const { missing: _, ...rest } = view[instance];
|
||||
view[instance] = { ...rest, state: "refused", reason: collision(instance, all), claims: all };
|
||||
continue;
|
||||
}
|
||||
if (copy.state !== "instance") continue;
|
||||
const refused = copy.project === null ? null : projectCopyRefusal(copy, document, levels);
|
||||
if (refused) { view[instance] = { ...view[instance], state: "refused", reason: refused }; continue; }
|
||||
const entry = entryOf(copy, copy.project === null && narrows[copy.name] ? narrowVars("root", copy.vars, narrows[copy.name]) : copy.vars);
|
||||
try {
|
||||
validateBusinessDocument({ ...narrowed, roles: { ...roles, [instance]: entry } }, file, { rolesDir });
|
||||
join[instance] = entry;
|
||||
} catch (error) {
|
||||
if (error?.name !== "BusinessError") throw error;
|
||||
view[instance] = { ...view[instance], state: "refused", reason: reasonOf(error, file, orgId, document, copy) };
|
||||
}
|
||||
}
|
||||
const business = validateBusinessDocument({ ...narrowed, roles: { ...roles, ...join } }, file, { rolesDir });
|
||||
return deepFreeze({ ...business, ...(Object.keys(copies).length ? { copies: view } : {}), unions });
|
||||
}
|
||||
|
||||
// The organization with its copies, as a bus or launcher loads it. The plain load keeps
|
||||
// loadBusiness's file checks; when there are copies or project agents, the document is read again
|
||||
// through the organization's own writer and validated with them, and only that second read is used.
|
||||
export function loadWithCopies(id, { dir, rolesDir, snapshot }) {
|
||||
const plain = loadBusiness(id, { dir, rolesDir });
|
||||
const { document } = organizationWriter({ dir, rolesDir }).read(id);
|
||||
if ((!snapshot || Object.keys(snapshot.copies ?? {}).length === 0) && !hasProjectAgents(document)) return plain;
|
||||
return withCopies(document, businessFilePath(id, dir), { rolesDir, snapshot });
|
||||
}
|
||||
|
||||
// Why `instance` can't work in code project `project` of this business, as { code, message }, or
|
||||
// null when it can. A project copy works only in its own project; an instance a project excludes,
|
||||
// or any instance in a project whose union refuses, doesn't work there. The message names the
|
||||
// instance, the project and the reason, never a value.
|
||||
export function projectRefusal(business, instance, project) {
|
||||
const copy = copyOf(business, instance);
|
||||
if (copy?.project != null && copy.project !== project) {
|
||||
return { code: "project-agent-elsewhere", message: `${instance} works only in project ${copy.project}` };
|
||||
}
|
||||
const union = project == null ? undefined : business?.unions?.[project];
|
||||
if (!union) return null;
|
||||
if (!union.ok) return { code: "project-refused", message: `${instance} can't work in project ${project}: project ${project} refuses its agents: ${union.reason}` };
|
||||
if (!union.agents.includes(instance)) return { code: "project-refused", message: `${instance} can't work in project ${project}: the project excludes it` };
|
||||
return null;
|
||||
}
|
||||
|
||||
// The copy a launch names, or null for an org instance. `business` is one withCopies returned.
|
||||
export function copyOf(business, instance) {
|
||||
return business?.copies && Object.hasOwn(business.copies, instance) ? business.copies[instance] : null;
|
||||
}
|
||||
|
||||
// Why a copy can't launch, as { code, message }, or null when it can. The message names the agent,
|
||||
// its project and the missing services or the refusal, and never a value.
|
||||
export function copyRefusal(business, instance) {
|
||||
const copy = copyOf(business, instance);
|
||||
if (!copy || copy.state === "instance") return null;
|
||||
if (copy.state === "unbound") {
|
||||
return { code: "credential-unbound", message: `${instance} has no binding in ${business.id} for ${copy.missing.join(", ")}` };
|
||||
}
|
||||
return { code: copy.project === null ? "copy-refused" : "project-refused", message: `${instance} can't launch in ${business.id}: ${copy.reason}` };
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// #1873 Phase A slice A2: the root agent file's writer (design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
|
||||
//
|
||||
// One instance-wide file, <configDir>/agents.json. Only the signed-in owner writes it: the caller
|
||||
// passes the actor and the owner principal its own trusted context established (the CLI's local
|
||||
// owner, the Console's owner session), and anything else refuses `human-required`. The write
|
||||
// follows M2's locked store (accounts.mjs): a lock file, a re-read and revision check under it, the
|
||||
// whole new document checked with A1's checkRootAgents, a 0600 temp file, a re-check, then rename
|
||||
// (or link for a new file), a directory sync and one receipt line. Nothing here launches an agent.
|
||||
//
|
||||
// No refusal and no receipt carries a value from the file or the request: a var value can be a
|
||||
// secret typed into the wrong place. Messages name the file (agents.json) and the key, never a
|
||||
// host path.
|
||||
import { constants } from 'node:fs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { OrgError } from './org-registry.mjs';
|
||||
import { assertTrustedConfigDir } from './config-dir-trust.mjs';
|
||||
import { ROOT_FILE, checkProjectAgents, checkRootAgents, loadRootAgents, projectUnion, rootAgentsPath } from './agent-levels.mjs';
|
||||
import { canonicalJson, isPlainObject } from './util.mjs';
|
||||
import { ID_PATTERN } from './vocabulary.mjs';
|
||||
import { REGISTRY } from './vars.mjs';
|
||||
|
||||
export const ROOT_LOCK = 'agents.lock';
|
||||
export const ROOT_RECEIPTS = 'agents-receipts.jsonl';
|
||||
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
||||
// The revision of a root file that does not exist yet: the digest of no bytes.
|
||||
export const ABSENT_REVISION = sha256('');
|
||||
const REVISION = /^[a-f0-9]{64}$/;
|
||||
const PRINCIPAL = ['provider', 'iss', 'sub'];
|
||||
const fail = (code, message = code) => { throw new OrgError(code, message); };
|
||||
|
||||
// The only actor that writes: the owner principal the caller's trusted context names. The tester
|
||||
// identity never counts as the owner, whatever a context says.
|
||||
export function requireRootOwner(actor, owner) {
|
||||
const ok = p => isPlainObject(p) && PRINCIPAL.every(k => typeof p[k] === 'string' && p[k] !== '');
|
||||
if (!ok(actor) || !ok(owner) || PRINCIPAL.some(k => actor[k] !== owner[k]) || actor.sub === 'tester' || actor.provider === 'tester')
|
||||
fail('human-required', 'only the signed-in owner writes the root agent file');
|
||||
}
|
||||
|
||||
// The shared config directory check (config-dir-trust.mjs): every ancestor root's or this uid's and
|
||||
// not writable by others without the sticky bit, configDir this uid's and not group or other writable.
|
||||
const assertConfigDir = (configDir, uid) => assertTrustedConfigDir(configDir, { uid, code: 'agents-unsafe' });
|
||||
|
||||
// The reader's descriptor checks (loadRootAgents), plus one link: a regular file, no symbolic link,
|
||||
// this uid, no group or other write. Missing is an empty root level at ABSENT_REVISION.
|
||||
async function readCurrent(configDir, uid, statFile = fd => fd.stat()) {
|
||||
const file = rootAgentsPath(configDir);
|
||||
let fd;
|
||||
try { fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
||||
catch (error) {
|
||||
if (error.code === 'ENOENT') return { exists: false, revision: ABSENT_REVISION, document: { agentsVersion: 1, agents: {} } };
|
||||
fail('agents-unsafe', `root agent file must be a regular file, not a symbolic link (${ROOT_FILE})`);
|
||||
}
|
||||
let bytes;
|
||||
try {
|
||||
const st = await statFile(fd);
|
||||
if (!st.isFile()) fail('agents-unsafe', `root agent file must be a regular file, not a symbolic link (${ROOT_FILE})`);
|
||||
if (st.uid !== uid) fail('agents-unsafe', `root agent file must belong to uid ${uid} (${ROOT_FILE})`);
|
||||
if (st.mode & 0o022) fail('agents-unsafe', `root agent file must not be writable by group or other (${ROOT_FILE})`);
|
||||
if (st.nlink !== 1) fail('agents-unsafe', `root agent file must have exactly one link (${ROOT_FILE})`);
|
||||
bytes = await fd.readFile();
|
||||
} finally { await fd.close(); }
|
||||
let document;
|
||||
// The parser's text can quote the file's bytes: say only that it is not JSON.
|
||||
try { document = JSON.parse(bytes.toString('utf8')); } catch { fail('agents-invalid', `root agent file is not valid JSON (${ROOT_FILE})`); }
|
||||
return { exists: true, revision: sha256(bytes), document };
|
||||
}
|
||||
|
||||
// The file's document and revision, for a form or a CLI list. A file that is not a root agent
|
||||
// document at all refuses; one with a bad entry reads, so the owner can fix or remove that entry.
|
||||
// statFile is a test seam for the descriptor stat; it grants nothing a caller's uid does not.
|
||||
export async function readRootAgentsFile(configDir, { uid = process.geteuid(), statFile } = {}) {
|
||||
assertConfigDir(configDir, uid);
|
||||
const current = await readCurrent(configDir, uid, statFile);
|
||||
shape(current.document);
|
||||
return current;
|
||||
}
|
||||
|
||||
function shape(document) {
|
||||
if (!isPlainObject(document) || document.agentsVersion !== 1 || !isPlainObject(document.agents))
|
||||
fail('agents-invalid', `root agent file must be {"agentsVersion": 1, "agents": {...}} (${ROOT_FILE})`);
|
||||
}
|
||||
|
||||
// Every string a request put in an entry, as a message could quote it bare or as JSON.
|
||||
// What a refusal must not show. Field names, var names, role ids and org ids may appear; every
|
||||
// var value, every unknown key and any other string that isn't an id is caller text.
|
||||
const FIELDS = new Set(['definition', 'vars', 'excludeOrgs', 'exclude', 'credentials', 'holder', 'tracker', 'identity', 'publish', 'hosts']);
|
||||
function all(value, out) {
|
||||
if (typeof value === 'string') { if (value.length >= 3) out.push(value, JSON.stringify(value)); }
|
||||
else if (Array.isArray(value)) value.forEach(v => all(v, out));
|
||||
else if (isPlainObject(value)) for (const [key, v] of Object.entries(value)) { all(key, out); all(v, out); }
|
||||
return out;
|
||||
}
|
||||
const unlessId = (value, out) => (typeof value === 'string' && ID_PATTERN.test(value) ? out : all(value, out));
|
||||
function strings(entry, out = []) {
|
||||
if (!isPlainObject(entry)) return all(entry, out);
|
||||
for (const [key, value] of Object.entries(entry)) {
|
||||
if (!FIELDS.has(key)) all(key, out);
|
||||
if (key === 'definition') unlessId(value, out);
|
||||
else if (key === 'excludeOrgs' && Array.isArray(value)) value.forEach(v => unlessId(v, out));
|
||||
else if (key === 'exclude' && typeof value === 'boolean') continue;
|
||||
else if (key === 'vars' && isPlainObject(value)) for (const [name, v] of Object.entries(value)) { if (!Object.hasOwn(REGISTRY, name)) all(name, out); all(v, out); }
|
||||
else all(value, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A refusal's message with its key path and without any value: an id check's "(got ...)" is cut,
|
||||
// and a message that still holds a string from the entry is replaced by one naming the key only.
|
||||
export function withoutValues(message, entry, prefix) {
|
||||
const cut = String(message).replace(/ \(got [\s\S]*\)$/, '');
|
||||
if (!strings(entry).some(text => cut.includes(text))) return cut;
|
||||
const at = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\.[^\\s:]+)*`).exec(cut)?.[0] ?? prefix;
|
||||
return `${at}: refused (the value is not shown)`;
|
||||
}
|
||||
|
||||
// A1's checks on the whole new document.
|
||||
function validate(document, rolesDir, entry) {
|
||||
try { return checkRootAgents(document, { rolesDir }); }
|
||||
catch (error) {
|
||||
if (error?.name !== 'BusinessError') throw error;
|
||||
fail('agents-invalid', withoutValues(error.message, entry, 'root agents'));
|
||||
}
|
||||
}
|
||||
|
||||
// What changed in one entry, by key name only.
|
||||
function changedKeys(before, after) {
|
||||
if (!before && !after) return [];
|
||||
if (!before) return ['added'];
|
||||
if (!after) return ['removed'];
|
||||
const out = [];
|
||||
if (before.definition !== after.definition) out.push('definition');
|
||||
const bv = isPlainObject(before.vars) ? before.vars : {}, av = isPlainObject(after.vars) ? after.vars : {};
|
||||
for (const key of [...new Set([...Object.keys(bv), ...Object.keys(av)])].sort()) {
|
||||
if (canonicalJson(bv[key]) !== canonicalJson(av[key])) out.push(`vars.${key}`);
|
||||
}
|
||||
if (canonicalJson(before.excludeOrgs ?? []) !== canonicalJson(after.excludeOrgs ?? [])) out.push('excludeOrgs');
|
||||
return out;
|
||||
}
|
||||
|
||||
// One entry changed by a patch: `definition` replaces, `vars` sets each key it names (null removes
|
||||
// that key), `excludeOrgs` replaces the list ([] clears it). Keys the patch leaves out are kept.
|
||||
export function patchRootEntry(old, { definition, vars, excludeOrgs } = {}) {
|
||||
const next = old ? structuredClone(old) : {};
|
||||
if (definition !== undefined) next.definition = definition;
|
||||
if (vars !== undefined) {
|
||||
if (!isPlainObject(vars)) fail('agents-invalid', 'vars must be an object of keys');
|
||||
const merged = isPlainObject(next.vars) ? { ...next.vars } : {};
|
||||
for (const [key, value] of Object.entries(vars)) { if (value === null) delete merged[key]; else merged[key] = value; }
|
||||
if (Object.keys(merged).length) next.vars = merged; else delete next.vars;
|
||||
}
|
||||
if (excludeOrgs !== undefined) { if (Array.isArray(excludeOrgs) && excludeOrgs.length === 0) delete next.excludeOrgs; else next.excludeOrgs = excludeOrgs; }
|
||||
return next;
|
||||
}
|
||||
|
||||
async function syncDirectory(dir) {
|
||||
const fd = await fs.open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
try { await fd.sync(); } finally { await fd.close(); }
|
||||
}
|
||||
|
||||
// Change one root agent. `entry` is the new entry, null to remove it, or a function of the old
|
||||
// entry (undefined when there is none) returning either. Returns the receipt. An error after the
|
||||
// rename carries committed: true, so a caller never retries a write that landed.
|
||||
export async function changeRootAgent(configDir, { revision, name, entry }, { actor, owner, rolesDir, uid = process.geteuid(), beforeReplace, statFile, syncDirectory: syncDir = syncDirectory } = {}) {
|
||||
requireRootOwner(actor, owner);
|
||||
if (typeof name !== 'string' || !ID_PATTERN.test(name)) fail('agents-invalid', 'agent name must be an id: lowercase letters, digits, dot, dash or underscore, at most 64');
|
||||
if (typeof revision !== 'string' || !REVISION.test(revision)) fail('revision-conflict', 'a root agent change needs the revision it was read at');
|
||||
assertConfigDir(configDir, uid);
|
||||
const lock = join(configDir, ROOT_LOCK);
|
||||
let lockFd;
|
||||
for (let n = 0; n < 200; n++) {
|
||||
try { lockFd = await fs.open(lock, 'wx', 0o600); break; }
|
||||
catch (error) { if (error.code !== 'EEXIST') throw error; await new Promise(r => setTimeout(r, 10)); }
|
||||
}
|
||||
if (!lockFd) fail('agents-locked', 'the root agent file is locked by another write; inspect agents.lock before retrying');
|
||||
const file = rootAgentsPath(configDir);
|
||||
let temp, receiptFd, committed = false;
|
||||
try {
|
||||
assertConfigDir(configDir, uid);
|
||||
const before = await readCurrent(configDir, uid, statFile);
|
||||
if (before.revision !== revision) fail('revision-conflict', 'the root agent file changed since it was read; read it again');
|
||||
shape(before.document);
|
||||
const old = Object.hasOwn(before.document.agents, name) ? before.document.agents[name] : undefined;
|
||||
const value = typeof entry === 'function' ? entry(old === undefined ? undefined : structuredClone(old)) : entry;
|
||||
if (value === null && old === undefined) fail('agent-not-found', `root agents.${name} does not exist`);
|
||||
if (value !== null && !isPlainObject(value)) fail('agents-invalid', `root agents.${name} must be an object`);
|
||||
const next = structuredClone(before.document);
|
||||
if (value === null) delete next.agents[name]; else next.agents[name] = value;
|
||||
const changed = changedKeys(old, value ?? undefined);
|
||||
if (changed.length === 0) fail('no-change', `root agents.${name} is already that`);
|
||||
validate(next, rolesDir, value);
|
||||
const bytes = JSON.stringify(next, null, 2) + '\n';
|
||||
// Check what lands on disk, not only the object built: JSON can drop or reshape a value.
|
||||
validate(JSON.parse(bytes), rolesDir, value);
|
||||
const after = sha256(bytes);
|
||||
const line = {
|
||||
at: new Date().toISOString(), uid: process.geteuid(), actor: 'owner',
|
||||
verb: value === null ? 'root.agent.remove' : 'root.agent.set', agent: name,
|
||||
...(value === null ? {} : { definition: value.definition }),
|
||||
revisionBefore: before.revision, revisionAfter: after, changed,
|
||||
};
|
||||
// Refuse an unsafe receipt destination before publishing the change.
|
||||
receiptFd = await fs.open(join(configDir, ROOT_RECEIPTS), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
||||
const st = await receiptFd.stat();
|
||||
if (!st.isFile() || st.uid !== uid || (st.mode & 0o022) || st.nlink !== 1) fail('agents-unsafe', `unsafe root agent receipt file (${ROOT_RECEIPTS})`);
|
||||
temp = join(configDir, `.agents-${randomBytes(12).toString('hex')}`);
|
||||
const fd = await fs.open(temp, 'wx', 0o600);
|
||||
try { await fd.writeFile(bytes); await fd.sync(); } finally { await fd.close(); }
|
||||
await beforeReplace?.();
|
||||
// A writer that ignores the lock still loses: the bytes must be the ones read.
|
||||
const again = await readCurrent(configDir, uid, statFile);
|
||||
if (again.exists !== before.exists || again.revision !== before.revision) fail('revision-conflict', 'the root agent file changed before the write; read it again');
|
||||
// A new file is published with link, which fails rather than replace a file made meanwhile.
|
||||
if (before.exists) await fs.rename(temp, file);
|
||||
else await fs.link(temp, file);
|
||||
committed = true;
|
||||
if (!before.exists) await fs.unlink(temp);
|
||||
await syncDir(configDir);
|
||||
await receiptFd.writeFile(JSON.stringify(line) + '\n'); await receiptFd.sync();
|
||||
return line;
|
||||
} catch (error) { if (committed && error instanceof Error) error.committed = true; throw error; }
|
||||
finally {
|
||||
if (temp) await fs.rm(temp, { force: true });
|
||||
if (receiptFd) await receiptFd.close();
|
||||
await lockFd.close(); await fs.unlink(lock);
|
||||
}
|
||||
}
|
||||
|
||||
// The entries as a form or a list shows them: names, definition, the model, the other var keys and
|
||||
// the opted-out organizations. No var value other than the model.
|
||||
export function rootAgentSummary(document) {
|
||||
return Object.entries(isPlainObject(document?.agents) ? document.agents : {}).map(([name, entry]) => {
|
||||
const vars = isPlainObject(entry?.vars) ? entry.vars : {};
|
||||
return {
|
||||
name, definition: typeof entry?.definition === 'string' ? entry.definition : null,
|
||||
model: typeof vars.model === 'string' ? vars.model : null,
|
||||
varKeys: Object.keys(vars).filter(k => k !== 'model').sort(),
|
||||
excludeOrgs: Array.isArray(entry?.excludeOrgs) ? entry.excludeOrgs.filter(x => typeof x === 'string') : [],
|
||||
};
|
||||
}).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// One row per entry: its form and what it names, never a var value.
|
||||
export function projectAgentSummary(agents) {
|
||||
if (!isPlainObject(agents)) return [];
|
||||
return Object.keys(agents).sort().map(name => {
|
||||
const entry = isPlainObject(agents[name]) ? agents[name] : {};
|
||||
if (entry.exclude === true) return { name, form: 'exclude' };
|
||||
if (entry.definition !== undefined) return { name, form: 'define', definition: typeof entry.definition === 'string' ? entry.definition : null, varKeys: Object.keys(isPlainObject(entry.vars) ? entry.vars : {}).sort() };
|
||||
return { name, form: 'narrow', varKeys: Object.keys(isPlainObject(entry.vars) ? entry.vars : {}).sort() };
|
||||
});
|
||||
}
|
||||
|
||||
// The project-agent verb's guard (design section 7, the projects fence's one exception for agents):
|
||||
// `next` may differ from `old` only in projects.<project>.agents.<name>, the project must exist and
|
||||
// not be a library, and the project's entries and its union must pass A1's checks. Returns
|
||||
// [code, message] or null. `root` is the root level as loadRootAgents reads it.
|
||||
export function projectAgentProblem(old, next, { orgId, project, name, entry, rolesDir, root }) {
|
||||
const target = old?.projects?.[project];
|
||||
if (!isPlainObject(target)) return ['project-not-found', `projects.${project} does not exist`];
|
||||
if (target.kind === 'library') return ['project-agents-library', `projects.${project} is a library project; it takes no agents`];
|
||||
const without = doc => {
|
||||
const copy = structuredClone(doc);
|
||||
const p = copy?.projects?.[project];
|
||||
if (isPlainObject(p) && isPlainObject(p.agents)) {
|
||||
delete p.agents[name];
|
||||
if (Object.keys(p.agents).length === 0) delete p.agents;
|
||||
}
|
||||
return canonicalJson(copy);
|
||||
};
|
||||
if (without(old) !== without(next)) return ['organization-projects-protected', `only projects.${project}.agents.${name} may change`];
|
||||
const after = next.projects[project].agents?.[name];
|
||||
if (canonicalJson(after ?? null) !== canonicalJson(entry ?? null)) return ['organization-projects-protected', `projects.${project}.agents.${name} is not the entry asked for`];
|
||||
if (canonicalJson(after ?? null) === canonicalJson(target.agents?.[name] ?? null)) return ['no-change', `projects.${project}.agents.${name} is already that`];
|
||||
const where = `projects.${project}.agents`;
|
||||
let entries;
|
||||
try { entries = checkProjectAgents(next.projects[project].agents ?? {}, { rolesDir, where }); }
|
||||
catch (error) { if (error?.name !== 'BusinessError') throw error; return ['project-agents-invalid', withoutValues(error.message, entry, where)]; }
|
||||
const org = Object.fromEntries(Object.entries(old.roles ?? {}).map(([n, r]) => [n, { definition: r?.definition, vars: r?.vars ?? {} }]));
|
||||
try { projectUnion({ orgId, root, org, project: entries, projectId: project }); }
|
||||
catch (error) { if (error?.name !== 'BusinessError') throw error; return ['project-agents-invalid', withoutValues(error.message, entry, where)]; }
|
||||
return null;
|
||||
}
|
||||
|
||||
// The root level a project union is checked against. A root file that refuses refuses the project
|
||||
// write too: an exclusion or narrowing checked against a root level that did not parse could be wrong.
|
||||
export function rootForProjectCheck(configDir, rolesDir) {
|
||||
try { return loadRootAgents(configDir, { rolesDir }); }
|
||||
catch (error) { if (error?.name !== 'BusinessError') throw error; fail('project-agents-invalid', 'the root agent file refuses; fix it before changing project agents'); }
|
||||
}
|
||||
|
||||
// A project entry's form, for the change log and the receipt.
|
||||
export const entryForm = entry => entry === null ? 'remove' : Object.hasOwn(entry, 'exclude') ? 'exclude' : Object.hasOwn(entry, 'definition') ? 'define' : 'narrow';
|
||||
@@ -18,10 +18,10 @@ export function checkAppearance(value, where) {
|
||||
requireObject(value, where);
|
||||
rejectUnknownKeys(value, ["theme", "mode"], where);
|
||||
if (Object.hasOwn(value, "theme") && (typeof value.theme !== "string" || value.theme.length > 64 || !NAME.test(value.theme))) {
|
||||
refuse(`${where}.theme must be an installed theme's name, matching ${NAME} (got ${JSON.stringify(value.theme)})`);
|
||||
refuse(`${where}.theme must be an installed theme's name, matching ${NAME}`);
|
||||
}
|
||||
if (Object.hasOwn(value, "mode") && !APPEARANCE_MODES.includes(value.mode)) {
|
||||
refuse(`${where}.mode must be one of ${APPEARANCE_MODES.join(", ")} (got ${JSON.stringify(value.mode)})`);
|
||||
refuse(`${where}.mode must be one of ${APPEARANCE_MODES.join(", ")}`);
|
||||
}
|
||||
return Object.freeze({
|
||||
...(Object.hasOwn(value, "theme") ? { theme: value.theme } : {}),
|
||||
|
||||
@@ -67,7 +67,20 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
|
||||
maxBuffer: 1024 * 1024,
|
||||
env,
|
||||
});
|
||||
if (proc.status !== 0) fail(3, `system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`);
|
||||
// Render known key paths with fixed constraints; never relay child stderr.
|
||||
if (proc.status !== 0) {
|
||||
const problem = proc.stderr.trim();
|
||||
const field = problem.startsWith('mosaic-config: execution "dataRoot"') ? "dataRoot"
|
||||
: /^mosaic-config: (?:unsupported )?"?((?:configVersion|environment|dataRoot|execution|console|federation|compactionReadBytes|compactionStreams)(?:\.[A-Za-z][A-Za-z0-9]*|\[\d+\])*)"?(?=[: ])/u.exec(problem)?.[1];
|
||||
const name = "a 1-200 character name starting with a letter or digit and containing only letters, digits, . _ : / -";
|
||||
const expected = { configVersion: "1", environment: "development or production", dataRoot: "a canonical absolute instance data path",
|
||||
"execution.backend": "docker", "execution.adapter": "pi or mock", "execution.provider": name, "execution.model": name };
|
||||
const detail = problem.startsWith("mosaic-config: configuration is not valid JSON (")
|
||||
? "configuration is not valid JSON; expected readable JSON: "
|
||||
: field ? `${field}: expected ${expected[field] ?? "a value satisfying the system configuration schema"}: `
|
||||
: "expected valid system configuration: ";
|
||||
fail(3, `system config problem (mosaic-config exit ${proc.status}): ${detail}${env.MOSAIC_CONFIG || join(env.HOME || "~", ".config", "mosaic-dev", "config.json")}${proc.error?.code ? ` (${proc.error.code})` : ""}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(proc.stdout);
|
||||
} catch {
|
||||
@@ -106,7 +119,7 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
|
||||
continue;
|
||||
}
|
||||
const project = loadProject(entry.root);
|
||||
if (project.id !== id) fail(2, `project file ${file} has id ${project.id}, but business ${organization.id} declares it as ${id}`);
|
||||
if (project.id !== id) fail(2, `project file ${file} has id that does not match the declared project key`);
|
||||
for (const instance of Object.keys(organization.roles)) resolveInstance({ system, business: organization, project, instance });
|
||||
projects[id] = "valid";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// #1873: the config directory check shared by the accounts store (accounts.mjs) and the root agent
|
||||
// file's writer (agent-levels-write.mjs). Both open, lock, write and rename files by path under
|
||||
// configDir, so the path must stay this uid's from the check to the last write.
|
||||
//
|
||||
// Every ancestor is a real directory that only root or this uid can change: owned by one of them, and
|
||||
// group or other writable only with the sticky bit (as /tmp is). Otherwise another user could rename
|
||||
// configDir out from under the check and put their own file in its place. configDir itself belongs to
|
||||
// this uid and is not group or other writable.
|
||||
//
|
||||
// Each caller keeps its own refusal code. No message quotes a path: a refusal names the rule and the
|
||||
// uid only (the redaction rule, PR #1916).
|
||||
import { lstatSync } from 'node:fs';
|
||||
import { join, parse, resolve } from 'node:path';
|
||||
import { OrgError } from './org-registry.mjs';
|
||||
|
||||
export function assertTrustedConfigDir(configDir, { uid, code }) {
|
||||
const fail = message => { throw new OrgError(code, message); };
|
||||
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('config directory must be an absolute normalized path');
|
||||
const ancestor = `a config directory ancestor must belong to root or uid ${uid} and not be writable by others without the sticky bit`;
|
||||
const trusted = st => (st.uid === 0 || st.uid === uid) && (!(st.mode & 0o022) || (st.mode & 0o1000));
|
||||
const stat = path => { try { return lstatSync(path); } catch { return fail('config directory is missing'); } };
|
||||
let current = parse(configDir).root;
|
||||
if (!trusted(stat(current))) fail(ancestor);
|
||||
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
|
||||
current = join(current, part);
|
||||
const st = stat(current);
|
||||
if (!st.isDirectory() || st.isSymbolicLink()) fail('config path contains a symbolic link or non-directory');
|
||||
if (current !== configDir && !trusted(st)) fail(ancestor);
|
||||
}
|
||||
const st = stat(configDir);
|
||||
if (st.uid !== uid) fail(`config directory must belong to uid ${uid}`);
|
||||
if (st.mode & 0o022) fail('config directory must not be writable by group or other');
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { checkReplyLimits } from "../../library/src/limits.mjs";
|
||||
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
|
||||
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
|
||||
import { withRegistryWriter } from "./org-registry.mjs";
|
||||
import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
|
||||
|
||||
export class OrganizationError extends Error {
|
||||
constructor(code, message = code) {
|
||||
@@ -272,7 +273,9 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
// The one commit path. `guard(old, document)` is what a caller may do to `projects`: write() keeps
|
||||
// them whole (C2-04 M9) and addProject() allows one exact addition. `document` may be a function of
|
||||
// the old document, for a writer that builds what it publishes from what it read under the lock.
|
||||
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard }) {
|
||||
// #1873 A2: `pathsOnly` logs each change's path and presence without its values, for a verb whose
|
||||
// values can hold a var a person typed (a secret pasted in the wrong place must not reach evidence).
|
||||
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard, pathsOnly = false }) {
|
||||
requireId(actor, "authenticated actor");
|
||||
requireId(id, "organization id");
|
||||
let revoked = false;
|
||||
@@ -320,7 +323,8 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
syncDir(tx);
|
||||
const event = {
|
||||
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
|
||||
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
|
||||
changes: pathsOnly ? changes(old, written).map(({ path, oldPresent, newPresent }) => ({ path, oldPresent, newPresent })) : changes(old, written),
|
||||
...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
|
||||
};
|
||||
log(id, { ...event, state: "prepared" });
|
||||
syncDir(home);
|
||||
@@ -382,6 +386,36 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
}, { revision, actor, guard, detail: { verb: "project.remote.set", project } });
|
||||
}
|
||||
|
||||
// #1873 A2 (design section 7): the only writer of `projects.<p>.agents`, one entry per call. `entry`
|
||||
// is a define ({definition, vars?}), a narrow ({vars}), an exclude ({exclude: true}) or null to
|
||||
// remove the entry. The human owner only: every agent actor, the tester included, refuses
|
||||
// human-required, and so does any actor that is not the organization's human. The guard checks the
|
||||
// candidate and the serialized bytes: nothing but that entry changes, the project exists and is not
|
||||
// a library, and the project's entries and union pass A1's checks against the current root level.
|
||||
// The change log names paths only; the detail names the project, the agent and the form.
|
||||
function setProjectAgent(id, { revision, project, name, entry }, actor) {
|
||||
requireId(id, "organization id");
|
||||
requireId(project, "project id");
|
||||
requireId(name, "agent name");
|
||||
if (entry !== null && (entry === undefined || typeof entry !== "object" || Array.isArray(entry))) fail("project-agents-invalid", "an entry is an object, or null to remove it");
|
||||
const root = rootForProjectCheck(dir, rolesDir);
|
||||
const guard = (old, document) => {
|
||||
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
|
||||
if (actor !== old.human || actor === "tester" || agents.includes(actor)) fail("human-required");
|
||||
if (entry === null && old.projects?.[project]?.agents?.[name] === undefined) fail("agent-not-found", `projects.${project}.agents.${name} does not exist`);
|
||||
const problem = projectAgentProblem(old, document, { orgId: id, project, name, entry, rolesDir, root });
|
||||
if (problem) fail(...problem);
|
||||
};
|
||||
return commit(id, old => {
|
||||
const target = old.projects?.[project];
|
||||
if (target === null || typeof target !== "object" || Array.isArray(target)) return old;
|
||||
const agents = { ...(target.agents ?? {}) };
|
||||
if (entry === null) delete agents[name]; else agents[name] = entry;
|
||||
const { agents: _drop, ...rest } = target;
|
||||
return { ...old, projects: { ...old.projects, [project]: Object.keys(agents).length ? { ...rest, agents } : rest } };
|
||||
}, { revision, actor, guard, pathsOnly: true, detail: { verb: "project.agents.set", project, agent: name, form: entryForm(entry) } });
|
||||
}
|
||||
|
||||
// Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may
|
||||
// write this business meanwhile. `fn` must be synchronous: the lock is a directory held for its
|
||||
// duration, and an awaited call would leave a half-checked world to whoever finds the lock gone.
|
||||
@@ -449,5 +483,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function validateProjectDocument(document, file) {
|
||||
}
|
||||
|
||||
export function loadProject(root) {
|
||||
if (typeof root !== "string" || !isAbsolute(root) || normalize(root) !== root) refuse(`project root must be a normalized absolute path (got ${JSON.stringify(root)})`);
|
||||
if (typeof root !== "string" || !isAbsolute(root) || normalize(root) !== root) refuse(`project root must be a normalized absolute path`);
|
||||
const file = projectFilePath(root);
|
||||
return validateProjectDocument(readJsonFile(file, "project file"), file);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Caller-only descriptions, shared by launch and live bus reads. Inputs are validated policy,
|
||||
// never credential references, vars, call arguments or file contents.
|
||||
import { GATED_ONLY } from './vocabulary.mjs';
|
||||
|
||||
const DOMAINS = Object.freeze({ 'git.merge.protected': 'technical', 'policy.change': 'delivery' });
|
||||
|
||||
// TI extension point for AU: today's fixed levels, without changing any routing.
|
||||
export function decisionAutonomy(decisionClass) {
|
||||
return ['cross-role', 'merge', 'policy'].includes(decisionClass) ? 'out of the loop' : 'in the loop';
|
||||
}
|
||||
|
||||
export function ownRails({ instance, authority, network, credentials = [], messaging = null, arbiters = {}, launcher = null }) {
|
||||
const actions = [];
|
||||
for (const [kind, held] of Object.entries(authority ?? {})) {
|
||||
for (const action of held) {
|
||||
const chiefOnly = ['role.hire', 'role.fire'].includes(action) && launcher !== instance;
|
||||
const hard = GATED_ONLY.includes(action) || (action === 'role.launch' && launcher !== instance);
|
||||
const domains = DOMAINS[action] ? [DOMAINS[action]] : ['delivery', 'technical'];
|
||||
const routes = chiefOnly ? [{ to: 'unavailable' }] : hard ? [{ to: 'human' }] : kind === 'withinRole' ? [{ to: 'alone' }]
|
||||
: domains.map(domain => ({ domain, to: arbiters[domain] === instance ? 'human' : arbiters[domain] ?? 'unconfigured' }));
|
||||
actions.push({ action, routes });
|
||||
}
|
||||
}
|
||||
const resolve = [...(authority?.withinRole ?? []), ...(authority?.crossRole ?? [])]
|
||||
.filter(action => action.startsWith('decision.resolve.')).map(right => {
|
||||
const domain = right.slice('decision.resolve.'.length);
|
||||
const classes = domain === 'technical' ? ['cross-role', 'merge'] : domain === 'delivery' ? ['cross-role', 'policy'] : [];
|
||||
return { right, classes: classes.map(name => ({ name, level: decisionAutonomy(name) })) };
|
||||
});
|
||||
return {
|
||||
actions, resolve, network: network ?? 'unknown',
|
||||
credentials: credentials.map(({ service, scopes }) => ({ service, ...(scopes === undefined ? {} : { scopes: structuredClone(scopes) }) })),
|
||||
messaging: messaging ? { sendTo: [...messaging.sendTo], receiveFrom: [...messaging.receiveFrom] } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function railsText(rails) {
|
||||
const lines = ['## Your rails', '', 'These are your resolved rails. Call my_rails for the current rails after a narrowing.'];
|
||||
for (const { action, routes } of rails.actions) {
|
||||
const route = routes.map(({ domain, to }) => `${domain ? `${domain}${domain === 'delivery' && routes.length > 1 ? ' (default)' : ''}: ` : ''}${to === 'human' ? 'a person' : to === 'alone' ? 'alone' : to === 'unavailable' ? 'unavailable here; only the configured chief can hire or fire' : to === 'unconfigured' ? 'unconfigured; report missing arbiter' : `arbiter instance ${JSON.stringify(to)}`}`).join('; ');
|
||||
lines.push(`- ${action}: ${route}.`);
|
||||
}
|
||||
if (!rails.actions.length) lines.push('- Held actions: none.');
|
||||
for (const { right, classes } of rails.resolve) lines.push(`- ${right} levels: ${classes.map(c => `${c.name}: ${c.level}`).join('; ') || 'unconfigured'}.`);
|
||||
lines.push('- A resolve right must be held withinRole, and the decision must route to you. PRD approval also follows the request mode and author checks.');
|
||||
lines.push('- Unheld actions are gated. Hard waits always go to a person; raise with blocking false when there is no task. A current spec citation can route other gated work to its arbiter.');
|
||||
lines.push('- Revoking a protected instance is a hard wait; ending it follows the chief/person protection checks. Messaging also checks the recipient\'s policy.');
|
||||
lines.push(`- Network: ${rails.network}.`);
|
||||
for (const credential of rails.credentials) lines.push(`- Credential service ${credential.service}; scopes: ${credential.scopes === undefined ? 'none declared' : JSON.stringify(credential.scopes)}.`);
|
||||
if (!rails.credentials.length) lines.push('- Credential services: none.');
|
||||
lines.push(`- Messaging sendTo: ${rails.messaging ? rails.messaging.sendTo.join(', ') || 'none' : 'unrestricted by your definition'}.`);
|
||||
lines.push(`- Messaging receiveFrom: ${rails.messaging ? rails.messaging.receiveFrom.join(', ') || 'none' : 'unrestricted by your definition'}.`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// the record a launcher or the broker uses. Plain values: the most specific
|
||||
// layer wins. Limits: every layer narrows, nothing widens.
|
||||
|
||||
import { ownRails } from "./rails.mjs";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { ACTIONS, NETWORKS, isHostKind } from "./vocabulary.mjs";
|
||||
import { checkVars, mergeVars } from "./vars.mjs";
|
||||
@@ -89,6 +90,7 @@ export function resolveInstance({ system, business, project = null, instance })
|
||||
limits: { tools, network, authority: { withinRole, crossRole } },
|
||||
// Row 210 (#1707, design D5): a host key is used only by the bus child, never by the session,
|
||||
// so it stays out of the session's credentials and the harness's needs.
|
||||
rails: ownRails({ instance, authority: { withinRole, crossRole }, network, credentials: definition.credentials, messaging: definition.messaging, arbiters: business.arbiters, launcher: business.launch?.by }),
|
||||
credentials: Object.fromEntries(Object.entries(entry.credentials).filter(([kind]) => !isHostKind(kind))),
|
||||
tracker: entry.tracker,
|
||||
launch,
|
||||
|
||||
@@ -24,7 +24,7 @@ const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
|
||||
function checkTools(tools) {
|
||||
if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names');
|
||||
return requireDistinctList(tools, "role tools", (tool) => {
|
||||
if (!TOOLS.includes(tool)) refuse(`unsupported tool: ${JSON.stringify(tool)} (supported: ${TOOLS.join(", ")})`);
|
||||
if (!TOOLS.includes(tool)) refuse(`unsupported tool (supported: ${TOOLS.join(", ")})`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ function checkAuthority(authority) {
|
||||
const lists = {};
|
||||
for (const key of ["withinRole", "crossRole"]) {
|
||||
lists[key] = requireDistinctList(authority[key], `role authority.${key}`, (action) => {
|
||||
if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}: ${JSON.stringify(action)}`);
|
||||
if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}; expected an action from the role vocabulary`);
|
||||
if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists ${action}, which is always gated`);
|
||||
});
|
||||
}
|
||||
@@ -71,7 +71,7 @@ function checkMessaging(messaging) {
|
||||
function checkSkills(skills) {
|
||||
return requireDistinctList(skills, "role skills", (name) => {
|
||||
if (typeof name !== "string" || !SKILL_NAME.test(name) || name === "." || name === "..") {
|
||||
refuse(`role skill must be a skill directory name matching ${SKILL_NAME} (got ${JSON.stringify(name)})`);
|
||||
refuse(`role skill must be a skill directory name matching ${SKILL_NAME}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -89,11 +89,11 @@ function checkHireable(hireable, file) {
|
||||
requireId(name, "role hireable entry");
|
||||
if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names ${name}, a creative definition only the human adds`);
|
||||
const path = join(dir, `${name}.json`);
|
||||
if (!existsSync(path)) refuse(`hireable names ${name}, which has no role file`);
|
||||
if (!existsSync(path)) refuse(`hireable names a definition which has no role file`);
|
||||
const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false });
|
||||
if (named.roleVersion !== 2) refuse(`hireable names ${name}, which is role version 1`);
|
||||
if (named.roleVersion !== 2) refuse(`hireable names a definition which is role version 1`);
|
||||
const outside = unhireableAction(named);
|
||||
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside}`);
|
||||
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside} outside the hireable ceiling`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ function checkGiteaScopes(scopes) {
|
||||
const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
|
||||
const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null;
|
||||
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) {
|
||||
refuse(`unsupported gitea scope: ${JSON.stringify(scope)} (expected read:<category> or write:<category>; categories: ${GITEA_SCOPE_CATEGORIES.join(", ")})`);
|
||||
refuse(`unsupported gitea scope (expected read:<category> or write:<category>; categories: ${GITEA_SCOPE_CATEGORIES.join(", ")})`);
|
||||
}
|
||||
}, { nonEmpty: true });
|
||||
const categories = list.map((s) => s.split(":")[1]);
|
||||
@@ -116,9 +116,9 @@ function checkVikunjaScopes(scopes) {
|
||||
const out = {};
|
||||
for (const [group, verbs] of Object.entries(scopes)) {
|
||||
const allowed = VIKUNJA_GRANTABLE[group];
|
||||
if (!allowed) refuse(`vikunja route group not grantable to a role: ${JSON.stringify(group)}`);
|
||||
if (!allowed) refuse(`vikunja route group not grantable to a role`);
|
||||
out[group] = requireDistinctList(verbs, `vikunja scopes.${group}`, (verb) => {
|
||||
if (!allowed.includes(verb)) refuse(`vikunja verb not grantable to a role: ${group}.${JSON.stringify(verb)}`);
|
||||
if (!allowed.includes(verb)) refuse(`vikunja verb not grantable to a role`);
|
||||
}, { nonEmpty: true });
|
||||
}
|
||||
return out;
|
||||
@@ -145,7 +145,7 @@ function checkCredentials(credentials) {
|
||||
|
||||
function checkContract(contract, file) {
|
||||
if (typeof contract !== "string" || !CONTRACT_NAME.test(contract)) {
|
||||
refuse(`role "contract" must be a Markdown file name in the role's directory, matching ${CONTRACT_NAME} (got ${JSON.stringify(contract)})`);
|
||||
refuse(`role "contract" must be a Markdown file name in the role's directory, matching ${CONTRACT_NAME}`);
|
||||
}
|
||||
const path = join(dirname(file), contract);
|
||||
let stat;
|
||||
@@ -193,7 +193,7 @@ export function validateRoleDocument(document, file, options = {}) {
|
||||
rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role");
|
||||
requireId(document.name, "role name");
|
||||
const base = basename(file).replace(/\.json$/, "");
|
||||
if (document.name !== base) refuse(`role "name" (${document.name}) must match its filename (${base}.json)`);
|
||||
if (document.name !== base) refuse(`role "name" must match its filename (${base}.json)`);
|
||||
const tools = checkTools(document.tools);
|
||||
if (document.roleVersion === 1) {
|
||||
if (CREATIVE_DEFINITIONS.includes(document.name)) refuse(`creative role ${document.name} must be version 2`);
|
||||
|
||||
@@ -20,7 +20,7 @@ export function rejectUnknownKeys(object, allowed, where) {
|
||||
|
||||
export function requireId(value, where) {
|
||||
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
|
||||
refuse(`${where} must match ${ID_PATTERN} (got ${JSON.stringify(value)})`);
|
||||
refuse(`${where} must match ${ID_PATTERN}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function requireString(value, where, { max = 200 } = {}) {
|
||||
}
|
||||
|
||||
export function requirePositiveInt(value, where) {
|
||||
if (!Number.isSafeInteger(value) || value < 1) refuse(`${where} must be a positive integer (got ${JSON.stringify(value)})`);
|
||||
if (!Number.isSafeInteger(value) || value < 1) refuse(`${where} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function requireDistinctList(value, where, check, { nonEmpty = false } =
|
||||
const seen = new Set();
|
||||
for (const item of value) {
|
||||
check(item);
|
||||
if (seen.has(item)) refuse(`duplicate entry in ${where}: ${JSON.stringify(item)}`);
|
||||
if (seen.has(item)) refuse(`duplicate entry in ${where}`);
|
||||
seen.add(item);
|
||||
}
|
||||
return [...seen];
|
||||
@@ -52,9 +52,9 @@ export function requireDistinctList(value, where, check, { nonEmpty = false } =
|
||||
|
||||
// Calendar date YYYY-MM-DD that exists (2026-02-30 refuses).
|
||||
export function requireDate(value, where) {
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) refuse(`${where} must be a date YYYY-MM-DD (got ${JSON.stringify(value)})`);
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) refuse(`${where} must be a date YYYY-MM-DD`);
|
||||
const d = new Date(`${value}T00:00:00Z`);
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) refuse(`${where} is not a real date: ${value}`);
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value) refuse(`${where} is not a real date`);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -295,7 +295,7 @@ export const GRANT_ALL = "*";
|
||||
// and a viewer grant never covers it. An expired grant covers nothing.
|
||||
export function covers(grant, { business, project, instance }, now, { level } = {}) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
|
||||
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")} (got ${JSON.stringify(level)})`);
|
||||
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")}`);
|
||||
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
|
||||
if (grant.business !== business) return false;
|
||||
if (!(grant.projects[0] === GRANT_ALL || grant.projects.includes(project))) return false;
|
||||
|
||||
@@ -104,19 +104,19 @@ test("users: unknown keys and the id rule", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].role = "x"; }), context()), /unsupported users\[1\] key: "role"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0] = "bean"; }), context()), /users\[0\] must be a JSON object/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "Bean"; }), context()), /users\[0\]\.id must match/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id: bean/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id/);
|
||||
});
|
||||
|
||||
test("users: human, tester and any business's human are refused", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human \("melanie"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human", a bus principal/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester", a bus principal/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human/);
|
||||
// The human of a business the grant doesn't name is refused too.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human \("jason"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human/);
|
||||
});
|
||||
|
||||
test("users: home is a given peer or local", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local" \(got "elsewhere"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local"/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.users[1].home; }), context()), /users\[1\]\.home must be a configured peer/);
|
||||
refuses(() => checkAccess(doc(), context({ peers: [] })), /users\[0\]\.home must be a configured peer/);
|
||||
});
|
||||
@@ -125,7 +125,7 @@ test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects = []; }), context()), /users\[0\]\.subjects must not be empty/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.users[0].subjects; }), context()), /users\[0\]\.subjects must be an array/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].extra = 1; }), context()), /unsupported users\[0\]\.subjects\[0\] key: "extra"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider \(got "google"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = ""; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = 7; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
|
||||
// A pair twice in one user, and across two users.
|
||||
@@ -140,15 +140,15 @@ test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
|
||||
|
||||
test("grants: unknown keys, id, user and business", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].role = "x"; }), context()), /unsupported grants\[0\] key: "role"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id: g-1/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].id = "G 1"; }), context()), /grants\[0\]\.id must match/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file \(got "nobody"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business \(got "other"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "constructor"; }), context()), /grants\[1\]\.business must name a declared business/);
|
||||
});
|
||||
|
||||
test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID \(got "stack"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1.toUpperCase()]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, 7]; }), context()), /grants\[1\]\.projects\[1\] must be "\*" or a bus project id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", P1]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
@@ -161,7 +161,7 @@ test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
|
||||
});
|
||||
|
||||
test("grants: instances is [\"*\"] or distinct declared instances of that business", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of creative-hub \(got "coder"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of the grant's business/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["*", "writer"]; }), context()), /grants\[0\]\.instances must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["writer", "writer"]; }), context()), /duplicate entry in grants\[0\]\.instances/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = []; }), context()), /grants\[0\]\.instances must not be empty/);
|
||||
@@ -172,7 +172,7 @@ test("grants: any level other than collaborator or viewer refuses, naming grants
|
||||
for (const bad of ["owner", "Collaborator", "COLLABORATOR", "Viewer", "", null, 1]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].level = bad; }), context()), /grants\[1\]\.level must be one of collaborator, viewer/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator, viewer \(got undefined\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator, viewer/);
|
||||
});
|
||||
|
||||
test("grants: expires is optional ISO time, and a past expiry is not refused", () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, unlinkSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, unlinkSync, existsSync, readdirSync, rmSync, statSync, lstatSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { checkAccounts, loadAccounts, accountsRepairs, withAccounts, withAccountsLock, withAccountsLocked } from '../src/accounts.mjs';
|
||||
import { checkAccounts, loadAccounts, accountsRepairs, assertAccountsOwner, withAccounts, withAccountsLock, withAccountsLocked } from '../src/accounts.mjs';
|
||||
import { templateRender } from '../src/org-service.mjs';
|
||||
import { loadBusiness } from '../src/organization-config.mjs';
|
||||
import { REPO_ROLES } from './helpers.mjs';
|
||||
@@ -178,3 +178,75 @@ test('review P3-6: a real business file and accounts file load with the same hum
|
||||
const s = setup(t), doc = await templateRender('acme'); doc.human = OP; mkdirSync(join(s.configDir, 'businesses')); writeFileSync(join(s.configDir, 'businesses', 'acme.json'), JSON.stringify(doc), { mode: 0o600 });
|
||||
assert.equal(loadBusiness('acme', { dir: s.configDir, rolesDir: REPO_ROLES }).human, OP); assert.equal(loadAccounts(s.file, s).users[0].id, OP);
|
||||
});
|
||||
|
||||
// org-acct-anchor (#1873, A2 review P2-1): every ancestor of the config directory must belong to root
|
||||
// or this uid, and be writable by others only with the sticky bit. Otherwise another user could rename
|
||||
// the directory away and put their own accounts.json in its place. No refusal quotes the path.
|
||||
const SECRETISH = 'sk-test-NOT-A-SECRET-1234';
|
||||
function nested(t) {
|
||||
const top = mkdtempSync(join(tmpdir(), 'a-')); t.after(() => { chmodSync(top, 0o700); rmSync(top, { recursive: true, force: true }); });
|
||||
const parent = join(top, SECRETISH), configDir = join(parent, 'cfg'); mkdirSync(configDir, { recursive: true, mode: 0o700 }); chmodSync(parent, 0o700);
|
||||
const accountsFile = join(configDir, 'accounts.json'); writeFileSync(accountsFile, JSON.stringify(document()), { mode: 0o600 });
|
||||
return { top, parent, configDir, instance: 'test', ...context, file: accountsFile };
|
||||
}
|
||||
const unsafe = rule => e => e.code === 'accounts-unsafe' && rule.test(e.message) && !e.message.includes(SECRETISH) && !e.message.includes('/');
|
||||
const ANCESTOR = /ancestor must belong to root or uid \d+ and not be writable by others without the sticky bit/;
|
||||
|
||||
test('org-acct-anchor: a 0777 ancestor refuses load, init and the lock; 1777 passes', async t => {
|
||||
const s = nested(t), initDir = join(s.parent, 'new'); mkdirSync(initDir, { mode: 0o700 });
|
||||
const init = { ...s, configDir: initDir, file: join(initDir, 'accounts.json') };
|
||||
for (const dir of [s.parent, s.top]) {
|
||||
chmodSync(dir, 0o777);
|
||||
try {
|
||||
assert.throws(() => loadAccounts(s.file, s), unsafe(ANCESTOR));
|
||||
await assert.rejects(withAccounts(init, { revision: 0, verb: 'init', init: true }, d => d.users.push(user())), unsafe(ANCESTOR));
|
||||
let entered = false;
|
||||
await assert.rejects(withAccountsLock(s, async () => { entered = true; }), unsafe(ANCESTOR));
|
||||
assert.equal(entered, false);
|
||||
for (const c of [s, init]) for (const name of ['accounts.lock', 'accounts-receipts.jsonl']) assert.equal(existsSync(join(c.configDir, name)), false, name);
|
||||
assert.equal(existsSync(init.file), false);
|
||||
chmodSync(dir, 0o1777);
|
||||
assert.equal(loadAccounts(s.file, s).revision, 1);
|
||||
await withAccountsLock(s, async () => { entered = true; }); assert.equal(entered, true);
|
||||
await withAccounts(init, { revision: 0, verb: 'init', init: true }, d => d.users.push(user()));
|
||||
assert.equal(loadAccounts(init.file, init).revision, 1);
|
||||
rmSync(init.file); rmSync(join(init.configDir, 'accounts-receipts.jsonl'));
|
||||
} finally { chmodSync(dir, 0o700); }
|
||||
}
|
||||
// A group-writable ancestor without the sticky bit refuses too.
|
||||
chmodSync(s.parent, 0o770);
|
||||
try { assert.throws(() => loadAccounts(s.file, s), unsafe(ANCESTOR)); } finally { chmodSync(s.parent, 0o700); }
|
||||
});
|
||||
|
||||
test('org-acct-anchor: an ancestor owned by another uid refuses; root-owned ancestors pass', t => {
|
||||
const s = nested(t), other = process.geteuid() + 1;
|
||||
// Simulated as the agent-levels tests do: as another uid, this uid's directories are foreign.
|
||||
assert.throws(() => assertAccountsOwner(s.configDir, { uid: other }), unsafe(new RegExp(`ancestor must belong to root or uid ${other} `)));
|
||||
assert.throws(() => loadAccounts(s.file, s, { uid: other }), unsafe(ANCESTOR));
|
||||
// The real ancestors above the scratch root include root-owned ones (at least /), which pass.
|
||||
assert.equal(lstatSync('/').uid, 0);
|
||||
assertAccountsOwner(s.configDir);
|
||||
assert.equal(loadAccounts(s.file, s).revision, 1);
|
||||
});
|
||||
|
||||
test('org-acct-anchor: a symlinked ancestor refuses, and leaf refusals name no path', async t => {
|
||||
const s = nested(t), link = join(s.top, 'link'); symlinkSync(s.parent, link);
|
||||
const via = { ...s, configDir: join(link, 'cfg'), file: join(link, 'cfg', 'accounts.json') };
|
||||
assert.throws(() => loadAccounts(via.file, via), unsafe(/symbolic link or non-directory/));
|
||||
await assert.rejects(withAccountsLock(via, async () => {}), unsafe(/symbolic link or non-directory/));
|
||||
assert.equal(existsSync(join(s.configDir, 'accounts.lock')), false);
|
||||
// The leaf checks are unchanged, but no longer quote the path.
|
||||
const other = process.geteuid() + 1;
|
||||
for (const mode of [0o720, 0o702]) {
|
||||
chmodSync(s.configDir, mode);
|
||||
try { assert.throws(() => loadAccounts(s.file, s), unsafe(/^config directory must not be writable by group or other$/)); } finally { chmodSync(s.configDir, 0o700); }
|
||||
}
|
||||
// As another uid the ancestors refuse first; the leaf's uid rule is the same no-path form (source check below).
|
||||
assert.throws(() => assertAccountsOwner(s.configDir, { uid: process.geteuid() + 1 }), e => !e.message.includes(SECRETISH) && !e.message.includes('/'));
|
||||
const source = readFileSync(new URL('../src/config-dir-trust.mjs', import.meta.url), 'utf8') + readFileSync(new URL('../src/accounts.mjs', import.meta.url), 'utf8');
|
||||
assert.match(source, /`config directory must belong to uid \$\{uid\}`/);
|
||||
assert.match(source, /`accounts file must belong to uid \$\{uid\}`/);
|
||||
assert.doesNotMatch(source, /must belong to uid \$\{uid\}: \$\{/);
|
||||
assert.throws(() => loadAccounts(join(s.configDir, 'missing', 'accounts.json'), s), unsafe(/^config directory is missing$/));
|
||||
assert.throws(() => assertAccountsOwner(`${s.configDir}/../cfg`), unsafe(/absolute normalized/));
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
// #1873 Phase A slice A3: root and project agent copies (design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 4 and 9 item 2).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { checkRootAgents, loadRootAgents } from "../src/agent-levels.mjs";
|
||||
import {
|
||||
copiesView, copyInstanceName, copyRefusal, loadWithCopies, materializeCopies, projectRefusal, withCopies,
|
||||
} from "../src/agent-copies.mjs";
|
||||
import { businessFilePath, validateBusinessDocument } from "../src/organization-config.mjs";
|
||||
import { resolveInstance } from "../src/resolve.mjs";
|
||||
import { REPO, REPO_ROLES, businessDoc, systemFor, tmp, tokenFile, writeJson } from "./helpers.mjs";
|
||||
|
||||
const rolesDir = REPO_ROLES;
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
const root = (agents) => ({ state: "ok", ...checkRootAgents({ agentsVersion: 1, agents }, { rolesDir }) });
|
||||
|
||||
// An organization `id` under `dir`, written where loadBusiness and the writer read it.
|
||||
function org(dir, id, edit = (d) => d) {
|
||||
const doc = edit(businessDoc(dir, id));
|
||||
writeJson(businessFilePath(id, dir), doc);
|
||||
return doc;
|
||||
}
|
||||
const boot = (dir, id, doc, r) => {
|
||||
const snapshot = materializeCopies({ orgId: id, doc, root: r, rolesDir });
|
||||
return { snapshot, business: withCopies(doc, businessFilePath(id, dir), { rolesDir, snapshot }) };
|
||||
};
|
||||
|
||||
test("A3: two root agents, one excluded: the other becomes an instance in each organization, each its own", () => {
|
||||
const dir = tmp();
|
||||
const r = root({ gate: { definition: "merge-gate" }, sec: { definition: "security-reviewer", excludeOrgs: ["acme"] }, assistant: { definition: "assistant" } });
|
||||
const acme = boot(dir, "acme", org(dir, "acme"), r);
|
||||
assert.deepEqual(Object.keys(acme.snapshot.copies), ["gate"]);
|
||||
assert.equal(acme.business.roles.gate.definition, "merge-gate");
|
||||
assert.equal(Object.hasOwn(acme.business.roles, "sec"), false, "the excluded root agent is no instance");
|
||||
// The root assistant stays shown, not launched (A6): never a copy.
|
||||
assert.equal(Object.hasOwn(acme.business.roles, "assistant"), false);
|
||||
assert.equal(acme.business.copies.gate.state, "instance");
|
||||
// A second organization makes its own copies from the same root agents.
|
||||
const beta = boot(dir, "beta", org(dir, "beta"), r);
|
||||
assert.deepEqual(Object.keys(beta.snapshot.copies).sort(), ["gate", "sec"]);
|
||||
assert.equal(beta.snapshot.org, "beta");
|
||||
assert.notEqual(beta.snapshot.copies.gate, acme.snapshot.copies.gate);
|
||||
assert.ok(Object.isFrozen(acme.snapshot.copies.gate), "the boot snapshot can't be changed in place");
|
||||
// Each copy carries only its own organization's entry: the credential map is the organization's
|
||||
// own, and a copy holds no credential.
|
||||
assert.deepEqual(acme.business.roles.gate.credentials, {});
|
||||
assert.deepEqual(beta.business.roles.gate.credentials, {});
|
||||
});
|
||||
|
||||
test("A3: an unbound copy refuses by name and binding, and no value reaches the refusal or the view", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
// The secret is bound elsewhere in this organization: the coder's gitea token file is named it
|
||||
// and holds it.
|
||||
const file = tokenFile(join(dir, "secrets"), SECRET);
|
||||
writeFileSync(file, SECRET);
|
||||
d.roles.coder.credentials.gitea = { file, rotateBy: "2099-01-01" };
|
||||
return d;
|
||||
});
|
||||
const r = root({ researcher: { definition: "research" }, reviewer2: { definition: "reviewer" } });
|
||||
const { snapshot, business } = boot(dir, "acme", doc, r);
|
||||
assert.equal(snapshot.copies.researcher.state, "unbound");
|
||||
assert.deepEqual(snapshot.copies.researcher.missing, ["gitea"]);
|
||||
assert.deepEqual(snapshot.copies.reviewer2.missing, ["gitea", "vikunja"]);
|
||||
assert.equal(Object.hasOwn(business.roles, "researcher"), false, "an unbound copy is no instance");
|
||||
const refusal = copyRefusal(business, "researcher");
|
||||
assert.equal(refusal.code, "credential-unbound");
|
||||
assert.match(refusal.message, /researcher/);
|
||||
assert.match(refusal.message, /gitea/);
|
||||
assert.equal(copyRefusal(business, "coder"), null, "an org instance is no copy");
|
||||
for (const text of [refusal.message, copyRefusal(business, "reviewer2").message, JSON.stringify(business.copies), JSON.stringify(copiesView(snapshot.copies))]) {
|
||||
assert.equal(text.includes(SECRET), false);
|
||||
assert.equal(text.includes(dir), false, "no host path");
|
||||
}
|
||||
});
|
||||
|
||||
test("A3: a copy whose var the organization refuses is refused alone, and its value stays out of the reason", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme");
|
||||
const r = root({ gate: { definition: "merge-gate" }, wide: { definition: "merge-gate", vars: { model: SECRET } } });
|
||||
const { business } = boot(dir, "acme", doc, r);
|
||||
assert.equal(business.roles.gate.definition, "merge-gate", "the other copy still joins");
|
||||
assert.equal(Object.hasOwn(business.roles, "wide"), false);
|
||||
assert.equal(business.copies.wide.state, "refused");
|
||||
const refusal = copyRefusal(business, "wide");
|
||||
assert.equal(refusal.code, "copy-refused");
|
||||
assert.match(refusal.message, /^wide can't launch in acme: /);
|
||||
assert.equal(refusal.message.includes(SECRET), false);
|
||||
assert.equal(JSON.stringify(business.copies).includes(SECRET), false);
|
||||
});
|
||||
|
||||
test("A3: a copy's tools and actions equal an org instance's of the same role; a copy that skips checkRoles fails this", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme", (d) => ({ ...d, roles: { ...d.roles, "gate-org": { definition: "merge-gate", vars: { model: "glm-5.3-flash" } } } }));
|
||||
const r = root({ gate: { definition: "merge-gate", vars: { model: "glm-5.3-flash" } }, wide: { definition: "merge-gate", vars: { model: "not-a-listed-model" } } });
|
||||
const snapshot = materializeCopies({ orgId: "acme", doc, root: r, rolesDir });
|
||||
const file = businessFilePath("acme", dir);
|
||||
const system = systemFor(dir);
|
||||
const same = (business) => {
|
||||
assert.deepEqual(business.roles.gate, business.roles["gate-org"]);
|
||||
const copy = resolveInstance({ system, business, instance: "gate" });
|
||||
const own = resolveInstance({ system, business, instance: "gate-org" });
|
||||
assert.deepEqual(copy.limits.tools, own.limits.tools);
|
||||
assert.deepEqual(copy.limits.authority, own.limits.authority);
|
||||
assert.deepEqual(copy.credentials, own.credentials);
|
||||
// A copy the organization's own checks refuse never joins.
|
||||
assert.equal(Object.hasOwn(business.roles, "wide"), false);
|
||||
};
|
||||
same(withCopies(doc, file, { rolesDir, snapshot }));
|
||||
// The counterfactual: the copies joined as their raw entries, with no checkRoles.
|
||||
const plain = validateBusinessDocument(doc, file, { rolesDir });
|
||||
const skipped = { ...plain, roles: { ...plain.roles, gate: { definition: "merge-gate", vars: { model: "glm-5.3-flash" } }, wide: { definition: "merge-gate", vars: { model: "not-a-listed-model" } } } };
|
||||
assert.throws(() => same(skipped), assert.AssertionError);
|
||||
});
|
||||
|
||||
test("A3: a root file change after boot changes nothing until the next boot", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme");
|
||||
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { gate: { definition: "merge-gate" } } });
|
||||
const snapshot = materializeCopies({ orgId: "acme", doc, root: loadRootAgents(dir, { rolesDir }), rolesDir });
|
||||
const first = loadWithCopies("acme", { dir, rolesDir, snapshot });
|
||||
assert.equal(first.roles.gate.definition, "merge-gate");
|
||||
// The root file now drops gate, adds one and excludes acme; the bus holds its snapshot.
|
||||
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { sec: { definition: "security-reviewer" }, gate: { definition: "merge-gate", excludeOrgs: ["acme"] } } });
|
||||
const later = loadWithCopies("acme", { dir, rolesDir, snapshot });
|
||||
assert.deepEqual(Object.keys(later.copies), ["gate"]);
|
||||
assert.equal(later.roles.gate.definition, "merge-gate");
|
||||
assert.equal(Object.hasOwn(later.roles, "sec"), false);
|
||||
// A broken root file is no different: nothing after boot reads it.
|
||||
writeFileSync(join(dir, "agents.json"), "{ not json");
|
||||
assert.equal(loadWithCopies("acme", { dir, rolesDir, snapshot }).roles.gate.definition, "merge-gate");
|
||||
// The next boot reads it again.
|
||||
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { sec: { definition: "security-reviewer" } } });
|
||||
const next = materializeCopies({ orgId: "acme", doc, root: loadRootAgents(dir, { rolesDir }), rolesDir });
|
||||
assert.deepEqual(Object.keys(next.copies), ["sec"]);
|
||||
// Nothing that runs after boot reads the root file: only the cli's boot step names loadRootAgents.
|
||||
for (const src of ["packages/bus/src/runtime.mjs", "packages/bus/src/broker.mjs", "packages/cli/src/launcher.mjs", "packages/organization/src/agent-copies.mjs"])
|
||||
assert.equal(/loadRootAgents\s*\(/.test(readFileSync(join(REPO, src), "utf8")), false, src);
|
||||
});
|
||||
|
||||
test("A3: project agents copy into their own project, and a refused union refuses every agent it defines", () => {
|
||||
const dir = tmp();
|
||||
mkdirSync(join(dir, "web"), { recursive: true });
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
|
||||
// web excludes an agent nothing defines, so its union refuses.
|
||||
d.projects.web = { root: join(dir, "web"), agents: { checker: { definition: "merge-gate" }, ghost: { exclude: true } } };
|
||||
return d;
|
||||
});
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({}));
|
||||
assert.equal(copyInstanceName("checker", "stack"), "checker.stack");
|
||||
assert.deepEqual({ ...snapshot.copies["checker.stack"], vars: undefined }, { name: "checker", level: "project", project: "stack", definition: "merge-gate", vars: undefined, state: "instance" });
|
||||
assert.equal(business.roles["checker.stack"].definition, "merge-gate");
|
||||
assert.equal(Object.hasOwn(business.roles, "checker.web"), false);
|
||||
const refusal = copyRefusal(business, "checker.web");
|
||||
assert.equal(refusal.code, "project-refused");
|
||||
assert.match(refusal.message, /^checker\.web can't launch in acme: project web refuses its agents: /);
|
||||
assert.match(refusal.message, /ghost/);
|
||||
// A project that stops being declared after boot takes its copy with it.
|
||||
const { stack, ...rest } = doc.projects;
|
||||
const later = withCopies({ ...doc, projects: rest }, businessFilePath("acme", dir), { rolesDir, snapshot });
|
||||
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
|
||||
assert.equal(copyRefusal(later, "checker.stack").code, "project-refused");
|
||||
});
|
||||
|
||||
test("A3: an organization instance of the same name wins over a root or project copy, at boot and after it, and the copy is gone, not refused", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
|
||||
// A raw org instance already named like the project copy wins at boot.
|
||||
d.roles["checker.stack"] = { definition: "security-reviewer" };
|
||||
return d;
|
||||
});
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
|
||||
assert.equal(business.copies.gate.state, "instance");
|
||||
assert.equal(Object.hasOwn(snapshot.copies, "checker.stack"), false);
|
||||
assert.equal(business.roles["checker.stack"].definition, "security-reviewer");
|
||||
assert.equal(copyRefusal(business, "checker.stack"), null);
|
||||
// An org instance added after boot replaces the root copy: the copy leaves the lookup, so nothing
|
||||
// refuses the organization's own instance.
|
||||
const later = withCopies({ ...doc, roles: { ...doc.roles, gate: { definition: "security-reviewer" } } }, businessFilePath("acme", dir), { rolesDir, snapshot });
|
||||
assert.equal(later.roles.gate.definition, "security-reviewer", "the organization file's own instance");
|
||||
assert.equal(Object.hasOwn(later.copies, "gate"), false);
|
||||
assert.equal(copyRefusal(later, "gate"), null);
|
||||
assert.equal(projectRefusal(later, "gate", "stack"), null);
|
||||
});
|
||||
|
||||
test("A3: two agents whose instance names come out the same both refuse, naming each; neither overwrites the other", () => {
|
||||
const dir = tmp();
|
||||
mkdirSync(join(dir, "a.b"), { recursive: true });
|
||||
mkdirSync(join(dir, "b"), { recursive: true });
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
|
||||
// Project a.b's c and project b's c.a both come out c.a.b.
|
||||
d.projects["a.b"] = { root: join(dir, "a.b"), agents: { c: { definition: "merge-gate" } } };
|
||||
d.projects.b = { root: join(dir, "b"), agents: { "c.a": { definition: "merge-gate" } } };
|
||||
return d;
|
||||
});
|
||||
// Root checker.stack is a different agent from project stack's checker.
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({ "checker.stack": { definition: "security-reviewer" } }));
|
||||
for (const [instance, claims] of [["checker.stack", /root agent checker\.stack and project stack's checker/], ["c.a.b", /project a\.b's c and project b's c\.a/]]) {
|
||||
assert.equal(snapshot.copies[instance].state, "refused", instance);
|
||||
assert.match(snapshot.copies[instance].reason, claims);
|
||||
assert.equal(Object.hasOwn(business.roles, instance), false, instance);
|
||||
assert.ok(copyRefusal(business, instance), instance);
|
||||
}
|
||||
// The view lists every claimant, so a reader finds each agent's copy by its name and project.
|
||||
assert.deepEqual(business.copies["checker.stack"].claims, [{ name: "checker.stack", project: null }, { name: "checker", project: "stack" }]);
|
||||
assert.deepEqual(business.copies["c.a.b"].claims, [{ name: "c", project: "a.b" }, { name: "c.a", project: "b" }]);
|
||||
});
|
||||
|
||||
test("A3: a collision the current file brings in after boot refuses every claimant it reaches, and lifts when the file drops it", () => {
|
||||
const dir = tmp();
|
||||
mkdirSync(join(dir, "a.b"), { recursive: true });
|
||||
mkdirSync(join(dir, "b"), { recursive: true });
|
||||
const file = businessFilePath("acme", dir);
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects["a.b"] = { root: join(dir, "a.b"), agents: { c: { definition: "merge-gate" } } };
|
||||
d.projects.b = { root: join(dir, "b") };
|
||||
return d;
|
||||
});
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({ "new.stack": { definition: "security-reviewer" } }));
|
||||
assert.equal(business.copies["c.a.b"].state, "instance");
|
||||
assert.equal(business.copies["new.stack"].state, "instance");
|
||||
// Project b's c.a and project stack's new now come out as the names of copies made at boot.
|
||||
const edited = structuredClone(doc);
|
||||
edited.projects.b.agents = { "c.a": { definition: "merge-gate" } };
|
||||
edited.projects.stack.agents = { new: { definition: "merge-gate" } };
|
||||
const later = withCopies(edited, file, { rolesDir, snapshot });
|
||||
for (const [instance, claims, reason] of [
|
||||
["c.a.b", [{ name: "c", project: "a.b" }, { name: "c.a", project: "b" }], "the instance name c.a.b is claimed by project a.b's c and project b's c.a"],
|
||||
["new.stack", [{ name: "new.stack", project: null }, { name: "new", project: "stack" }], "the instance name new.stack is claimed by root agent new.stack and project stack's new"],
|
||||
]) {
|
||||
assert.equal(later.copies[instance].state, "refused", instance);
|
||||
assert.deepEqual(later.copies[instance].claims, claims, instance);
|
||||
assert.equal(later.copies[instance].reason, reason, instance);
|
||||
assert.equal(Object.hasOwn(later.roles, instance), false, instance);
|
||||
assert.ok(copyRefusal(later, instance), instance);
|
||||
}
|
||||
// Without the new defines, both copies are instances again.
|
||||
const back = withCopies(doc, file, { rolesDir, snapshot });
|
||||
assert.equal(back.copies["c.a.b"].state, "instance");
|
||||
assert.equal(back.copies["new.stack"].state, "instance");
|
||||
});
|
||||
|
||||
test("A3: a project define whose vars change after boot refuses its copy until the next boot", () => {
|
||||
const dir = tmp();
|
||||
const file = businessFilePath("acme", dir);
|
||||
const doc = org(dir, "acme", (d) => { d.projects.stack.agents = { checker: { definition: "merge-gate" } }; return d; });
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({}));
|
||||
assert.equal(business.copies["checker.stack"].state, "instance");
|
||||
const edited = structuredClone(doc);
|
||||
edited.projects.stack.agents.checker.vars = { "limits.tools": ["read"] };
|
||||
const later = withCopies(edited, file, { rolesDir, snapshot });
|
||||
const r = copyRefusal(later, "checker.stack");
|
||||
assert.equal(r.code, "project-refused");
|
||||
assert.match(r.message, /project stack changed checker's vars since the bus started; the change takes effect at the next start$/);
|
||||
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
|
||||
// The next boot makes the copy from the new vars.
|
||||
const next = boot(dir, "acme", edited, root({})).business;
|
||||
assert.equal(next.copies["checker.stack"].state, "instance");
|
||||
assert.deepEqual(next.roles["checker.stack"].vars["limits.tools"], ["read"]);
|
||||
});
|
||||
|
||||
test("A3: each project's union is recomputed from the current file: a refused union refuses its own copy and every agent in it; exclude and narrow reach the instance", () => {
|
||||
const dir = tmp();
|
||||
mkdirSync(join(dir, "web"), { recursive: true });
|
||||
const file = businessFilePath("acme", dir);
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
|
||||
d.projects.web = { root: join(dir, "web") };
|
||||
d.roles["gate-org"] = { definition: "merge-gate" };
|
||||
return d;
|
||||
});
|
||||
const { snapshot, business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
|
||||
assert.deepEqual(business.unions.stack, { ok: true, agents: [...Object.keys(doc.roles), "checker.stack", "gate"].sort() });
|
||||
assert.equal(projectRefusal(business, "checker.stack", "web").code, "project-agent-elsewhere");
|
||||
assert.equal(projectRefusal(business, "gate", "web"), null);
|
||||
const system = systemFor(dir);
|
||||
const tools = (b, instance) => resolveInstance({ system, business: b, instance }).limits.tools;
|
||||
assert.ok(tools(business, "gate").includes("grep"));
|
||||
// After boot: stack excludes something nothing defines, so its union refuses; web excludes gate and
|
||||
// narrows gate-org's tools.
|
||||
const edited = structuredClone(doc);
|
||||
edited.projects.stack.agents.ghost = { exclude: true };
|
||||
edited.projects.web.agents = { gate: { exclude: true }, "gate-org": { vars: { "limits.tools": ["read"] } } };
|
||||
const later = withCopies(edited, file, { rolesDir, snapshot });
|
||||
assert.equal(later.unions.stack.ok, false);
|
||||
assert.match(later.unions.stack.reason, /ghost/);
|
||||
// stack's own copy refuses, naming the project and why.
|
||||
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
|
||||
const own = copyRefusal(later, "checker.stack");
|
||||
assert.equal(own.code, "project-refused");
|
||||
assert.match(own.message, /^checker\.stack can't launch in acme: project stack refuses its agents: .*ghost/);
|
||||
// Every inherited agent refuses there too.
|
||||
for (const instance of ["gate", "coder", "gate-org"]) {
|
||||
const r = projectRefusal(later, instance, "stack");
|
||||
assert.equal(r.code, "project-refused", instance);
|
||||
assert.match(r.message, new RegExp(`^${instance} can't work in project stack: project stack refuses its agents: .*ghost`));
|
||||
}
|
||||
assert.match(projectRefusal(later, "gate", "web").message, /the project excludes it$/);
|
||||
assert.equal(projectRefusal(later, "coder", "web"), null);
|
||||
// The narrow reaches the instance itself, so its resolved tools and actions are narrowed.
|
||||
assert.deepEqual(later.roles["gate-org"].vars["limits.tools"], ["read"]);
|
||||
assert.deepEqual(tools(later, "gate-org"), ["read"]);
|
||||
// A project that stops defining its agent takes the copy with it.
|
||||
const dropped = structuredClone(doc);
|
||||
dropped.projects.stack.agents = { other: { definition: "merge-gate" } };
|
||||
assert.match(copyRefusal(withCopies(dropped, file, { rolesDir, snapshot }), "checker.stack").message, /project stack no longer defines checker as merge-gate$/);
|
||||
});
|
||||
|
||||
test("A3: a project narrowing a root copy narrows that copy; an organization with only project entries still gets its unions", () => {
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme", (d) => {
|
||||
d.projects.stack.agents = { gate: { vars: { "limits.tools": ["read", "ls"] } } };
|
||||
return d;
|
||||
});
|
||||
const { business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
|
||||
assert.deepEqual(resolveInstance({ system: systemFor(dir), business, instance: "gate" }).limits.tools, ["read", "ls"]);
|
||||
// No root agents and no copy: the project's entries still shape the business.
|
||||
const only = org(dir, "beta", (d) => { d.projects.stack.agents = { coder: { vars: { "limits.tools": ["read"] } } }; return d; });
|
||||
const plain = loadWithCopies("beta", { dir, rolesDir, snapshot: null });
|
||||
assert.deepEqual(plain.unions.stack.agents, Object.keys(only.roles).sort());
|
||||
assert.deepEqual(plain.roles.coder.vars["limits.tools"], ["read"]);
|
||||
assert.equal(Object.hasOwn(plain, "copies"), false);
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
// #1873 Phase A slice A2: the root agent file's writer and the project-agent writer verb (design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { loadRootAgents } from "../src/agent-levels.mjs";
|
||||
import {
|
||||
ABSENT_REVISION, ROOT_LOCK, ROOT_RECEIPTS, changeRootAgent, patchRootEntry, projectAgentProblem, readRootAgentsFile, rootAgentSummary,
|
||||
} from "../src/agent-levels-write.mjs";
|
||||
import * as oldPath from "../../business/src/agent-levels-write.mjs";
|
||||
import { organizationWriter } from "../src/organization.mjs";
|
||||
import { businessDoc, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const rolesDir = REPO_ROLES;
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
const OWNER = Object.freeze({ provider: "local-dev", iss: "local-dev", sub: "local-dev-owner" });
|
||||
const as = (actor = OWNER) => ({ actor, owner: OWNER, rolesDir });
|
||||
|
||||
function config() {
|
||||
const dir = join(tmp(), "config");
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
chmodSync(dir, 0o700);
|
||||
return dir;
|
||||
}
|
||||
const file = dir => join(dir, "agents.json");
|
||||
const bytes = dir => existsSync(file(dir)) ? readFileSync(file(dir), "utf8") : null;
|
||||
const receipts = dir => existsSync(join(dir, ROOT_RECEIPTS))
|
||||
? readFileSync(join(dir, ROOT_RECEIPTS), "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line)) : [];
|
||||
const seeded = async dir => {
|
||||
await changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research", vars: { model: "claude-opus-5-5" } } }, as());
|
||||
return readRootAgentsFile(dir);
|
||||
};
|
||||
const refuses = (code, check = () => true) => error => error.code === code && check(error);
|
||||
|
||||
test("the old business path re-exports the writer", () => {
|
||||
assert.equal(oldPath.changeRootAgent, changeRootAgent);
|
||||
});
|
||||
|
||||
test("root writer: an owner write lands, reads back through A1's loader, and its receipt names the change", async () => {
|
||||
const dir = config();
|
||||
const receipt = await changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research", vars: { model: "claude-opus-5-5" } } }, as());
|
||||
assert.equal(receipt.verb, "root.agent.set");
|
||||
assert.equal(receipt.agent, "researcher");
|
||||
assert.equal(receipt.definition, "research");
|
||||
assert.deepEqual(receipt.changed, ["added"]);
|
||||
assert.equal(receipt.revisionBefore, ABSENT_REVISION);
|
||||
const st = lstatSync(file(dir));
|
||||
assert.equal(st.mode & 0o777, 0o600);
|
||||
assert.equal(st.nlink, 1);
|
||||
const loaded = loadRootAgents(dir, { rolesDir });
|
||||
assert.equal(loaded.agents.researcher.vars.model, "claude-opus-5-5");
|
||||
const read = await readRootAgentsFile(dir);
|
||||
assert.equal(read.revision, receipt.revisionAfter);
|
||||
// A patch keeps the keys it leaves out and names, by key, what it changed.
|
||||
const second = await changeRootAgent(dir, { revision: read.revision, name: "researcher", entry: old => patchRootEntry(old, { vars: { "limits.network": "api-only" }, excludeOrgs: ["home"] }) }, as());
|
||||
assert.deepEqual(second.changed, ["vars.limits.network", "excludeOrgs"]);
|
||||
assert.deepEqual(rootAgentSummary((await readRootAgentsFile(dir)).document), [{ name: "researcher", definition: "research", model: "claude-opus-5-5", varKeys: ["limits.network"], excludeOrgs: ["home"] }]);
|
||||
const removed = await changeRootAgent(dir, { revision: second.revisionAfter, name: "researcher", entry: null }, as());
|
||||
assert.equal(removed.verb, "root.agent.remove");
|
||||
assert.deepEqual(removed.changed, ["removed"]);
|
||||
assert.deepEqual(loadRootAgents(dir, { rolesDir }).agents, {});
|
||||
const lines = receipts(dir);
|
||||
assert.equal(lines.length, 3);
|
||||
// Receipts carry names and key paths only: no var value, no host path.
|
||||
const text = readFileSync(join(dir, ROOT_RECEIPTS), "utf8");
|
||||
assert.ok(!text.includes("claude-opus-5-5") && !text.includes("api-only") && !text.includes(dir));
|
||||
assert.ok(lines.every(line => line.actor === "owner" && line.uid === process.geteuid()));
|
||||
assert.equal(lstatSync(join(dir, ROOT_RECEIPTS)).mode & 0o777, 0o600);
|
||||
assert.equal(existsSync(join(dir, "agents.lock")), false);
|
||||
assert.deepEqual(readdirSync(dir).filter(n => n.startsWith(".agents-")), []);
|
||||
});
|
||||
|
||||
test("root writer: every actor but the owner refuses human-required and nothing is written", async () => {
|
||||
const dir = config();
|
||||
const others = [
|
||||
{ provider: "local-dev", iss: "local-dev", sub: "chief" },
|
||||
{ provider: "local-dev", iss: "local-dev", sub: "chief-of-staff.chief" },
|
||||
{ provider: "local-dev", iss: "local-dev", sub: "tester" },
|
||||
{ ...OWNER, provider: "tester" },
|
||||
{ provider: "https-idp", iss: "https://idp.example", sub: "local-dev-owner" },
|
||||
"local-dev-owner", "pm", null, undefined, {},
|
||||
];
|
||||
for (const actor of others) {
|
||||
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor, owner: OWNER, rolesDir }), refuses("human-required"), JSON.stringify(actor ?? null));
|
||||
}
|
||||
// An owner context that names the tester is not an owner either.
|
||||
const tester = { provider: "local-dev", iss: "local-dev", sub: "tester" };
|
||||
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor: tester, owner: tester, rolesDir }), refuses("human-required"));
|
||||
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor: OWNER, rolesDir }), refuses("human-required"));
|
||||
assert.equal(bytes(dir), null);
|
||||
assert.deepEqual(receipts(dir), []);
|
||||
});
|
||||
|
||||
test("root writer: a stale revision refuses and the file is unchanged", async () => {
|
||||
const dir = config();
|
||||
const first = await seeded(dir);
|
||||
const before = bytes(dir);
|
||||
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "reviewer", entry: { definition: "reviewer" } }, as()), refuses("revision-conflict"));
|
||||
await changeRootAgent(dir, { revision: first.revision, name: "reviewer", entry: { definition: "reviewer" } }, as());
|
||||
const after = bytes(dir);
|
||||
// The first revision is now stale too.
|
||||
await assert.rejects(changeRootAgent(dir, { revision: first.revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("revision-conflict"));
|
||||
assert.notEqual(before, after);
|
||||
assert.equal(bytes(dir), after);
|
||||
await assert.rejects(changeRootAgent(dir, { revision: "nope", name: "pm", entry: { definition: "pm" } }, as()), refuses("revision-conflict"));
|
||||
});
|
||||
|
||||
test("root writer: a symbolic link, a group-writable file and a foreign uid refuse and the file is unchanged", async () => {
|
||||
const dir = config();
|
||||
const { revision } = await seeded(dir);
|
||||
const original = bytes(dir);
|
||||
// A symbolic link in place of the file: the target is never followed or written.
|
||||
const target = join(dir, "elsewhere.json");
|
||||
writeFileSync(target, original); chmodSync(target, 0o600);
|
||||
const real = join(dir, "real.json");
|
||||
writeFileSync(real, original); chmodSync(real, 0o600);
|
||||
const moved = readFileSync(file(dir));
|
||||
writeFileSync(real, moved);
|
||||
const { renameSync } = await import("node:fs");
|
||||
renameSync(file(dir), join(dir, "kept.json"));
|
||||
symlinkSync(target, file(dir));
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => !e.message.includes(dir)));
|
||||
assert.equal(readFileSync(target, "utf8"), original);
|
||||
assert.ok(lstatSync(file(dir)).isSymbolicLink());
|
||||
await assert.rejects(readRootAgentsFile(dir), refuses("agents-unsafe"));
|
||||
// Restore, then make it group-writable.
|
||||
const { unlinkSync } = await import("node:fs");
|
||||
unlinkSync(file(dir)); renameSync(join(dir, "kept.json"), file(dir));
|
||||
chmodSync(file(dir), 0o620);
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /group or other/.test(e.message)));
|
||||
assert.equal(bytes(dir), original);
|
||||
assert.equal(lstatSync(file(dir)).mode & 0o777, 0o620);
|
||||
chmodSync(file(dir), 0o600);
|
||||
// A second link to the file refuses: a write through one name would not be the file another reads.
|
||||
const { linkSync } = await import("node:fs");
|
||||
linkSync(file(dir), join(dir, "alias.json"));
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /one link/.test(e.message)));
|
||||
unlinkSync(join(dir, "alias.json"));
|
||||
// Not this process's uid: the directory checks refuse first (an ancestor this uid owns is foreign).
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, { ...as(), uid: process.geteuid() + 1 }), refuses("agents-unsafe", e => /config directory/.test(e.message) && !/root agent file/.test(e.message)));
|
||||
await assert.rejects(readRootAgentsFile(dir, { uid: process.geteuid() + 1 }), refuses("agents-unsafe", e => /config directory/.test(e.message) && !/root agent file/.test(e.message)));
|
||||
assert.equal(bytes(dir), original);
|
||||
// A file another uid owns, in a directory that is this uid's: the file's own owner check refuses.
|
||||
// Changing a file's owner needs root, so the descriptor stat reports a foreign uid through the seam.
|
||||
const foreign = async fd => { const st = await fd.stat(); return Object.assign(Object.create(Object.getPrototypeOf(st)), st, { uid: st.uid + 1 }); };
|
||||
const receiptsBefore = existsSync(join(dir, ROOT_RECEIPTS)) ? readFileSync(join(dir, ROOT_RECEIPTS), "utf8") : null;
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, { ...as(), statFile: foreign }), refuses("agents-unsafe", e => /root agent file must belong to uid/.test(e.message)));
|
||||
await assert.rejects(readRootAgentsFile(dir, { statFile: foreign }), refuses("agents-unsafe", e => /root agent file must belong to uid/.test(e.message)));
|
||||
assert.equal(bytes(dir), original);
|
||||
assert.equal(lstatSync(file(dir)).mode & 0o777, 0o600);
|
||||
assert.equal(existsSync(join(dir, ROOT_RECEIPTS)) ? readFileSync(join(dir, ROOT_RECEIPTS), "utf8") : null, receiptsBefore);
|
||||
assert.equal(existsSync(join(dir, ROOT_LOCK)), false, "the lock is released");
|
||||
// A group-writable config directory refuses before any lock or read.
|
||||
chmodSync(dir, 0o770);
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /config directory/.test(e.message)));
|
||||
chmodSync(dir, 0o700);
|
||||
// An ancestor another user can write without the sticky bit refuses: that user could rename the
|
||||
// config directory between the checks and the write. With the sticky bit, as /tmp has, it can't.
|
||||
const parent = dirname(dir);
|
||||
const mode = lstatSync(parent).mode & 0o7777;
|
||||
chmodSync(parent, 0o777);
|
||||
try {
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /ancestor/.test(e.message) && !e.message.includes(dir)));
|
||||
await assert.rejects(readRootAgentsFile(dir), refuses("agents-unsafe", e => /ancestor/.test(e.message)));
|
||||
chmodSync(parent, 0o1777);
|
||||
assert.equal((await readRootAgentsFile(dir)).revision, revision);
|
||||
} finally { chmodSync(parent, mode); }
|
||||
assert.equal(bytes(dir), original);
|
||||
assert.equal(receipts(dir).length, 1);
|
||||
assert.equal(existsSync(join(dir, "agents.lock")), false);
|
||||
});
|
||||
|
||||
test("root writer: an invalid document refuses with the key named and the value absent", async () => {
|
||||
const dir = config();
|
||||
const { revision } = await seeded(dir);
|
||||
const original = bytes(dir);
|
||||
const cases = [
|
||||
[{ definition: "research", vars: { model: SECRET.repeat(10) } }, /variable model/],
|
||||
[{ definition: "research", vars: { "limits.network": SECRET } }, /variable limits\.network/],
|
||||
[{ definition: SECRET }, /root agents\.leaky\.definition/],
|
||||
[{ definition: "research", excludeOrgs: [SECRET.toUpperCase()] }, /root agents\.leaky\.excludeOrgs/],
|
||||
[{ definition: "research", vars: { [SECRET]: "x" } }, /root agents\.leaky\.vars/],
|
||||
[{ definition: "research", credentials: { gitea: SECRET } }, /root agents\.leaky\.credentials/],
|
||||
[{ definition: "chief-of-staff" }, /chief-of-staff can't be a root agent/],
|
||||
[{ definition: "assistant" }, /assistant definition is the root agent named assistant only/],
|
||||
];
|
||||
for (const [entry, pattern] of cases) {
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "leaky", entry }, as()),
|
||||
refuses("agents-invalid", e => pattern.test(e.message) && !e.message.includes(SECRET) && !e.message.includes(SECRET.toUpperCase()) && !e.message.includes(dir)),
|
||||
JSON.stringify(Object.keys(entry)));
|
||||
}
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: SECRET.toUpperCase(), entry: { definition: "research" } }, as()), refuses("agents-invalid", e => !e.message.includes(SECRET.toUpperCase())));
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "absent", entry: null }, as()), refuses("agent-not-found"));
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "researcher", entry: { definition: "research", vars: { model: "claude-opus-5-5" } } }, as()), refuses("no-change"));
|
||||
assert.equal(bytes(dir), original);
|
||||
assert.equal(receipts(dir).length, 1);
|
||||
});
|
||||
|
||||
test("root writer: a crash between the temp file and the rename leaves the old file whole", async () => {
|
||||
const dir = config();
|
||||
const { revision } = await seeded(dir);
|
||||
const original = bytes(dir);
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "reviewer", entry: { definition: "reviewer" } }, { ...as(), beforeReplace: () => { throw new Error("crash"); } }), /crash/);
|
||||
assert.equal(bytes(dir), original);
|
||||
assert.deepEqual(readdirSync(dir).filter(n => n.startsWith(".agents-")), []);
|
||||
assert.equal(existsSync(join(dir, "agents.lock")), false);
|
||||
assert.equal(receipts(dir).length, 1);
|
||||
// A writer that ignores the lock and changes the file between the temp and the rename loses.
|
||||
await assert.rejects(changeRootAgent(dir, { revision, name: "reviewer", entry: { definition: "reviewer" } }, { ...as(), beforeReplace: () => writeFileSync(file(dir), original.replace("research\"", "research\" ")) }), refuses("revision-conflict"));
|
||||
// A new file is never published over one created meanwhile.
|
||||
const fresh = config();
|
||||
await assert.rejects(changeRootAgent(fresh, { revision: ABSENT_REVISION, name: "pm", entry: { definition: "pm" } }, { ...as(), beforeReplace: () => writeJson(file(fresh), { agentsVersion: 1, agents: {} }) }), refuses("revision-conflict"));
|
||||
assert.deepEqual(JSON.parse(bytes(fresh)), { agentsVersion: 1, agents: {} });
|
||||
// A failure after the rename says the change landed.
|
||||
const landed = config();
|
||||
await assert.rejects(changeRootAgent(landed, { revision: ABSENT_REVISION, name: "pm", entry: { definition: "pm" } }, { ...as(), syncDirectory: async () => { throw new Error("disk"); } }), e => e.committed === true);
|
||||
assert.ok(loadRootAgents(landed, { rolesDir }).agents.pm);
|
||||
});
|
||||
|
||||
test("root writer: a held lock refuses agents-locked", async () => {
|
||||
const dir = config();
|
||||
writeFileSync(join(dir, "agents.lock"), "");
|
||||
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-locked"));
|
||||
assert.equal(bytes(dir), null);
|
||||
});
|
||||
|
||||
// The project-agent verb: the organization file's projects.<p>.agents, one entry per call.
|
||||
function org(mutate = () => {}) {
|
||||
const root = tmp();
|
||||
const doc = businessDoc(root);
|
||||
mutate(doc);
|
||||
const dir = join(root, "config");
|
||||
const orgFile = writeJson(join(dir, "businesses/acme.json"), doc);
|
||||
chmodSync(dir, 0o700);
|
||||
const roles = rolesCopy(root);
|
||||
return { dir, orgFile, roles, writer: organizationWriter({ dir, rolesDir: roles }) };
|
||||
}
|
||||
const agentsOf = (writer, project = "stack") => writer.read("acme").document.projects[project].agents;
|
||||
const history = (writer) => readFileSync(writer.logFile("acme"), "utf8");
|
||||
|
||||
test("project verb: each entry form lands, the log names paths only, and remove drops the key", () => {
|
||||
const { writer } = org();
|
||||
let { revision } = writer.read("acme");
|
||||
const step = (name, entry) => ({ revision } = writer.setProjectAgent("acme", { revision, project: "stack", name, entry }, "jason"));
|
||||
step("checker", { definition: "fact-checker", vars: { model: "claude-sonnet-5-5" } });
|
||||
assert.deepEqual(agentsOf(writer), { checker: { definition: "fact-checker", vars: { model: "claude-sonnet-5-5" } } });
|
||||
step("coder", { vars: { "limits.network": "none" } });
|
||||
step("reviewer", { exclude: true });
|
||||
assert.deepEqual(Object.keys(agentsOf(writer)).sort(), ["checker", "coder", "reviewer"]);
|
||||
step("checker", null);
|
||||
step("coder", null);
|
||||
step("reviewer", null);
|
||||
assert.equal(writer.read("acme").document.projects.stack.agents, undefined);
|
||||
const events = history(writer).trim().split("\n").map(line => JSON.parse(line)).filter(e => e.state === "committed");
|
||||
assert.deepEqual(events.map(e => e.detail.form), ["define", "narrow", "exclude", "remove", "remove", "remove"]);
|
||||
assert.ok(events.every(e => e.detail.verb === "project.agents.set" && e.detail.project === "stack" && e.actor === "jason"));
|
||||
assert.ok(events.every(e => e.changes.every(c => !("old" in c) && !("new" in c))));
|
||||
assert.ok(!history(writer).includes("claude-sonnet-5-5"));
|
||||
});
|
||||
|
||||
test("project verb: a narrow that widens refuses, and refusals never quote a value", () => {
|
||||
const { writer, orgFile } = org();
|
||||
const { revision } = writer.read("acme");
|
||||
const before = readFileSync(orgFile, "utf8");
|
||||
const refusals = [
|
||||
[{ vars: { model: SECRET } }, "project-agents-invalid", /can only narrow/],
|
||||
[{ vars: { "limits.network": SECRET } }, "project-agents-invalid", /variable limits\.network/],
|
||||
[{ definition: "fact-checker", vars: { model: SECRET.repeat(10) } }, "project-agents-invalid", /variable model/],
|
||||
[{ definition: SECRET }, "project-agents-invalid", /projects\.stack\.agents\.coder\.definition/],
|
||||
[{ definition: "fact-checker", credentials: { gitea: SECRET } }, "project-agents-invalid", /credentials/],
|
||||
[{ exclude: false }, "project-agents-invalid", /exclude entry/],
|
||||
[{}, "project-agents-invalid", /defines \(definition\), narrows \(vars\) or excludes/],
|
||||
];
|
||||
for (const [entry, code, pattern] of refusals) {
|
||||
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "coder", entry }, "jason"),
|
||||
e => e.code === code && pattern.test(e.message) && !e.message.includes(SECRET), JSON.stringify(entry));
|
||||
}
|
||||
// A narrow or exclude that names nothing it could act on refuses (the union check).
|
||||
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "ghost", entry: { exclude: true } }, "jason"), e => e.code === "project-agents-invalid" && /names no root or organization agent/.test(e.message));
|
||||
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "ghost", entry: null }, "jason"), { code: "agent-not-found" });
|
||||
assert.equal(readFileSync(orgFile, "utf8"), before);
|
||||
});
|
||||
|
||||
test("project verb: the chief and the assistant refuse", () => {
|
||||
const { writer, orgFile } = org(doc => { doc.roles.assistant = { definition: "assistant" }; });
|
||||
const { document, revision } = writer.read("acme");
|
||||
assert.equal(document.roles.assistant.definition, "assistant", "the fixture has an organization assistant");
|
||||
assert.equal(document.roles.chief.definition, "chief-of-staff", "the fixture has a chief");
|
||||
const before = readFileSync(orgFile, "utf8");
|
||||
const protectedAgent = /a project can't change the organization's (chief-of-staff|assistant)/;
|
||||
const notProjectAgent = /can't be a project agent/;
|
||||
for (const [name, entry, reason] of [
|
||||
["chief", { exclude: true }, protectedAgent], ["chief", { vars: { "limits.network": "none" } }, protectedAgent], ["chief", { definition: "reviewer" }, protectedAgent],
|
||||
["assistant", { exclude: true }, protectedAgent], ["assistant", { vars: { "limits.network": "none" } }, protectedAgent], ["assistant", { definition: "reviewer" }, protectedAgent],
|
||||
["helper", { definition: "chief-of-staff" }, notProjectAgent], ["helper", { definition: "assistant" }, notProjectAgent],
|
||||
]) {
|
||||
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name, entry }, "jason"), e => e.code === "project-agents-invalid" && reason.test(e.message), `${name} ${JSON.stringify(entry)}`);
|
||||
}
|
||||
assert.equal(readFileSync(orgFile, "utf8"), before);
|
||||
});
|
||||
|
||||
test("project verb: every agent actor and every non-owner refuses human-required", () => {
|
||||
const { writer, orgFile } = org();
|
||||
const { revision } = writer.read("acme");
|
||||
const before = readFileSync(orgFile, "utf8");
|
||||
for (const actor of ["pm", "pm.pm", "chief", "chief-of-staff.chief", "coder", "tester", "local-dev-owner", "someone"]) {
|
||||
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "checker", entry: { definition: "fact-checker" } }, actor), { code: "human-required" }, actor);
|
||||
}
|
||||
assert.equal(readFileSync(orgFile, "utf8"), before);
|
||||
});
|
||||
|
||||
test("project verb: only projects.<p>.agents.<name> may change; the projects fence keeps every other writer out", () => {
|
||||
const { writer, orgFile, roles } = org();
|
||||
const { document, revision } = writer.read("acme");
|
||||
const before = readFileSync(orgFile, "utf8");
|
||||
// The general writer can't touch project agents, or anything else under projects.
|
||||
const withAgents = structuredClone(document);
|
||||
withAgents.projects.stack.agents = { checker: { definition: "fact-checker" } };
|
||||
assert.throws(() => writer.write("acme", withAgents, { revision, actor: "jason" }), { code: "organization-projects-protected" });
|
||||
// The verb's guard refuses any candidate that changes another projects key.
|
||||
const root = { state: "absent", agents: {}, assistant: null };
|
||||
const args = { orgId: "acme", project: "stack", name: "checker", entry: { definition: "fact-checker" }, rolesDir: roles, root };
|
||||
const good = structuredClone(document);
|
||||
good.projects.stack.agents = { checker: { definition: "fact-checker" } };
|
||||
assert.equal(projectAgentProblem(document, good, args), null);
|
||||
const mutations = [
|
||||
d => { d.projects.stack.root = "/elsewhere"; },
|
||||
d => { d.projects.other = { root: "/elsewhere" }; },
|
||||
d => { d.projects.stack.agents.extra = { definition: "reviewer" }; },
|
||||
d => { d.human = "someone"; },
|
||||
d => { d.projects.stack.agents.checker = { definition: "reviewer" }; },
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const next = structuredClone(good);
|
||||
mutate(next);
|
||||
assert.equal(projectAgentProblem(document, next, args)?.[0], "organization-projects-protected", mutate.toString());
|
||||
}
|
||||
// A missing project and a library project refuse.
|
||||
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "nope", name: "checker", entry: { definition: "fact-checker" } }, "jason"), { code: "project-not-found" });
|
||||
const library = structuredClone(document);
|
||||
library.projects.stack.kind = "library";
|
||||
assert.equal(projectAgentProblem(library, good, args)?.[0], "project-agents-library");
|
||||
// A stale revision refuses.
|
||||
writer.setProjectAgent("acme", { revision, project: "stack", name: "checker", entry: { definition: "fact-checker" } }, "jason");
|
||||
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "other", entry: { definition: "reviewer" } }, "jason"), { code: "revision-conflict" });
|
||||
assert.notEqual(readFileSync(orgFile, "utf8"), before);
|
||||
});
|
||||
|
||||
test("project verb: a root file that refuses refuses the project write", () => {
|
||||
const { writer, dir, orgFile } = org();
|
||||
writeJson(join(dir, "agents.json"), { agentsVersion: 2 });
|
||||
const { revision } = writer.read("acme");
|
||||
const before = readFileSync(orgFile, "utf8");
|
||||
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "checker", entry: { definition: "fact-checker" } }, "jason"), e => e.code === "project-agents-invalid" && /root agent file/.test(e.message));
|
||||
assert.equal(readFileSync(orgFile, "utf8"), before);
|
||||
});
|
||||
|
||||
test("project verb: a narrow of a root agent checks against the root level", async () => {
|
||||
const { writer, dir, roles } = org();
|
||||
await changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor: OWNER, owner: OWNER, rolesDir: roles });
|
||||
const { revision } = writer.read("acme");
|
||||
writer.setProjectAgent("acme", { revision, project: "stack", name: "researcher", entry: { vars: { "limits.network": "none" } } }, "jason");
|
||||
assert.deepEqual(agentsOf(writer).researcher, { vars: { "limits.network": "none" } });
|
||||
});
|
||||
@@ -59,7 +59,7 @@ test("row 325: the old mosaic-stack project key refuses against those same proje
|
||||
const r = x.validate();
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.equal(r.stdout, "");
|
||||
assert.match(r.stderr, /has id stack, but business mosaic-stack declares it as mosaic-stack/);
|
||||
assert.match(r.stderr, /has id that does not match the declared project key/);
|
||||
assert.ok(r.stderr.includes(x.projectFile));
|
||||
assert.doesNotMatch(r.stderr, /no project file/);
|
||||
});
|
||||
@@ -77,7 +77,7 @@ test("validate: project files", () => {
|
||||
assert.deepEqual(JSON.parse(r.out).projects, { stack: "valid" });
|
||||
|
||||
writeJson(file, { projectVersion: 1, id: "other" });
|
||||
assert.match(run(s, "validate", "acme").err, /has id other, but business acme declares it as stack/);
|
||||
assert.match(run(s, "validate", "acme").err, /has id that does not match the declared project key/);
|
||||
assert.equal(run(s, "validate", "acme").code, 2);
|
||||
writeJson(file, { projectVersion: 1, id: "stack", roles: { ghost: {} } });
|
||||
const ghost = run(s, "validate", "acme");
|
||||
|
||||
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user