Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9039682eb | ||
|
|
7686fd783e | ||
|
|
1c5705d6db | ||
|
|
d8bcf8066c | ||
|
|
eb4c756a14 | ||
|
|
f6b6a04b60 | ||
|
|
f41729178e | ||
|
|
305857c8f9 | ||
|
|
176a6bf4b9 | ||
|
|
5b8def2f14 | ||
|
|
4429a104f6 | ||
|
|
32b54ac3b0 | ||
|
|
6025ddd9cf | ||
|
|
ecbfd6deb6 | ||
|
|
0498c0613f | ||
|
|
49d1f3a231 | ||
|
|
628e2ca5cd | ||
|
|
088087ac96 | ||
|
|
a0fbefd48d | ||
|
|
f24f2cd40d |
@@ -0,0 +1,926 @@
|
||||
# Machine grant path, design draft r6 (Vikunja #436, row 210 follow-up)
|
||||
|
||||
Owner: Gizmoduck. Reviewer: Duckworth. Sizing and row assignment: Sage. Design only. Code starts when
|
||||
Duckworth approves the design and row 210 lands (sage-1527).
|
||||
|
||||
Sources: Q188 (DECISIONS.md:1642, jason-master-restart 8ecf252), mos-1115, mos-1120, mos-1125, mos-1127, mos-1131, mos-1133, mos-1139,
|
||||
sage-1527, Duckworth's r1 review (REVIEW-r1.md, duckworth/reviews d025ef492, corrected at 749080115), Duckworth's
|
||||
r3 and r4 review (REVIEW-r3.md, duckworth/reviews 9dceb2148, duckworth-0060), Duckworth's r5 diff check
|
||||
(REVIEW-r5.md, duckworth/reviews c3dc58f30, duckworth-0063),
|
||||
Honker's PRD 1.8 draft (honker/row194-b2 3c30f1036, REQ-TRUST-2 and REQ-GRANT-1 to -4), row 210 DESIGN r3
|
||||
(e021dfa26) and its approved candidate 455964f23. Code cites are `path:line` at origin/refactor f41729178
|
||||
unless they say "210:", which means the row 210 candidate 455964f23 (the same code as the branch head
|
||||
a2fd30390). Host facts come from ops-01's read-only check of .53 and .54 at 22:21Z (mos-1127).
|
||||
|
||||
## r6 changes (from r5 7686fd783)
|
||||
|
||||
Duckworth approved r5 with C1 and one note (duckworth-0063). The design is approved for code once row 210
|
||||
lands. r6 carries C1 and the note, for a diff check on those lines.
|
||||
|
||||
- C1: a revoke can't be undone by a commands-change undo. After step 5 commits, the executor ignores an
|
||||
abort (X5). Both undo paths that set a record back to `previous` only update a record that exists, never
|
||||
insert one, and do nothing when a `grant.revoked` is newer than the decision (X3). G28 and an upsert
|
||||
mutant cover it.
|
||||
- N1: a failed unlink at revoke step 2 no longer depends on the marker. At start, after the grant
|
||||
reconcile, the bus unlinks every `host.<id>` key file that has no `host_grants` record (section 6, G27).
|
||||
|
||||
## r5 changes (from r4 1c5705d6d)
|
||||
|
||||
Duckworth's review of r3 and r4 (duckworth-0060) asked for changes, B1 and B2, with notes N1 to N11. mos-1139
|
||||
says to fix both and rules on N8.
|
||||
|
||||
- B1: E2 step 1 is now one transaction: `#consumeGrant`, a check that the host isn't enrolled yet, then the
|
||||
insert. A second approved enrollment for the same `hostId` ends `grant.failed host-enrolled`. `hosts` gets
|
||||
the primary key (business, host id) in `3i-3j.sql`. G17 and two mutants cover it.
|
||||
- B2: X2 consumes first and claims second, in one turn of the event loop. A busy claim ends `grant.failed
|
||||
host-busy` with a message. At start, a grant decision resolved yes with no `action.allowed` ends
|
||||
`grant.failed interrupted` and never runs late (X3). `hosts.revoke` records `grant.revoked` even with no
|
||||
reference and no claim, so X5's first bullet now matches the code. G11 and G23 cover it.
|
||||
- N1: the `ID_PATTERN` cite is marked `210:`.
|
||||
- N2: X5 states the build rule that `issue` and its `commit` stay synchronous, with a mutant.
|
||||
- N3, N4: `hosts.revoke` is its own verb in `bus/src/host-grants.mjs`, not 210's `credential-verbs.mjs`. It
|
||||
writes a `pending` marker first, and the start reconcile finishes a revoke a restart cut short (section 6,
|
||||
G27).
|
||||
- N5: the writer also refuses `disk`, `libvirt` and `incus-admin` (E3).
|
||||
- N6: a first `add` creates the file, and a leftover regular tmp is unlinked under the `flock` (E3, G25).
|
||||
- N7: the installer's line is written out exactly (E3).
|
||||
- N8: a dev instance is a business whose `bus.tester` var is exactly true (mos-1139, section 6).
|
||||
- N9: the row a run sheet strips row 210's `hosts` keys before the `--migrate` re-pin (S1).
|
||||
- N10: the writer list in the r3 changes now includes `hosts.check`. ops-01 reads only the three key types the
|
||||
keyscan asks for (E1).
|
||||
- N11: Duckworth withdrew a PRD note. Section 6 step 1's record delete stays.
|
||||
- Row b grows by the revoke verb and its marker. My estimate goes from 1.5 days to 1.75 (section 12).
|
||||
|
||||
## r4 changes (from r3 d8bcf8066)
|
||||
|
||||
mos-1133 corrects S1's migration lines. r3 said the 3i-3j re-pin needs `--migrate` with Jason present (P19
|
||||
371). That was wrong. P19 371 predates Q173, and Q173 lets Mos, Sage or Darkwing re-pin, migrate, roll back
|
||||
and restore the mosaic-stack business on dragon-lin. S1 now says so and adds the .53 and .54 plan. Nothing
|
||||
else changed.
|
||||
|
||||
## r3 changes (from r2 eb4c756a1)
|
||||
|
||||
mos-1131 (from duckworth-0057 and mos-1129): under PRD 1.8 only an Approve creates a grant, and a hand edit
|
||||
of the business file makes nothing. So W1 is gone.
|
||||
|
||||
- Enrolled hosts and grants are records in the bus store, written only by the executor, revoke,
|
||||
`hosts.check` and `hosts.remove`, each in the same transaction as its event (section 5, S1). Nothing writes them into the
|
||||
business file.
|
||||
- Row 210's hand-edited `hosts` keys stay a test path until row a (#436a) moves `host.run` to the store
|
||||
records. After that the loader refuses those keys (S1).
|
||||
- B1 gets simpler. `host.run` reads the store at each call, so the broker needs no runtime apply. Only
|
||||
`Credentials.register` stays (X4).
|
||||
- `registrySha256` stays, now over the store's host record. It is what 1.8 calls "the target or its
|
||||
enrolled identity" (mos-1131 point 3).
|
||||
- Enrollment carries expected fingerprints, and the raise runs its own keyscan (E1, mos-1132 Q-M2).
|
||||
- Revokers follow mos-1124 d and mos-1132: the human, the assistant in its own organization (Q188's "Mos",
|
||||
Mos's reading for prd.approve), and the tester on a dev instance only (section 6).
|
||||
- ops-01's Q-O2 rules and writer points a to d are in E3 and section 9.
|
||||
- X5 (B2) is 1.8's N2: a revoke during creation stops it, and nothing it made stays usable (mos-1131 point
|
||||
4).
|
||||
- The store records need a new migration, `3i-3j.sql` (S1). That makes row a a schema change.
|
||||
|
||||
## r2 changes (from r1 f6b6a04b6)
|
||||
|
||||
- Enrollment is a request the chief raises and Jason approves, not a CLI Jason types (mos-1120 point 3).
|
||||
Section 3 is rewritten. `hosts.enroll` stays, as the verb the executor runs.
|
||||
- The action is `credential.mint` with a `grant` binding, not a new `host.grant` (sage-1527 Q-S3, Honker's
|
||||
REQ-GRANT-1). The hard-waits list doesn't change. R2 says why the binding still carries G1 to G10.
|
||||
- The binding adds a hash of the agent's role definition, because REQ-GRANT-2 voids an approval when the
|
||||
definition changes. It also carries the full commands list (N2).
|
||||
- The host side follows ops-01's answer to Q-O1 (mos-1125): lines in a root-owned
|
||||
`/etc/ssh/mosaic-keys/%u`, a dedicated install user, a forced command that only parses, and a root
|
||||
writer in this repo that checks everything again (E3). This answers B5.
|
||||
- A target user with sudo is enrolled by name, and Jason's Approve of the enrollment covers it (E1, E3,
|
||||
mos-1125's recommendation). Without this no `host.sudo` grant could be installed.
|
||||
- B1: a grant, a commands change, an enrollment and a revoke take effect in the running bus (X4).
|
||||
- B2: the executor holds an `#inflight` entry that revoke aborts, and a revoke works before any grant
|
||||
exists (X5).
|
||||
- B3: progress events before each step, and a start-time reconcile that undoes to the state before the
|
||||
decision (X3).
|
||||
- B4: the tag is a hash, recorded in events, and `list` and `sweep` only touch this business's recorded
|
||||
tags (X2, step 2).
|
||||
- N1 to N3 answered in X1, R4 and E4.
|
||||
- Section 9 adds ops-01's point that a host line has no `command=` (mos-1125).
|
||||
- Answers recorded: Q-S1 to Q-S4, Q-M1, Q-O1. New questions Q-M2, Q-S5 and Q-O2.
|
||||
|
||||
## 0. What Q188 asks for, and what this draft builds
|
||||
|
||||
Q188 says no agent creates a grant. A request goes up the chain, only the chief raises it to Jason's inbox,
|
||||
Jason clicks Approve or Deny, and on Approve protected stack code creates the grant. Revoke stays instant, by
|
||||
Jason, Mos or the tester. mos-1115 puts machines first: issue the key, install it on the host with
|
||||
`from="<stack host>",restrict`, pin the host fingerprint, and on revoke drop the key and cut the connection.
|
||||
This replaces row 210 section 4, where Jason hand-edits three layers, runs `issue`, and ops-01 pastes the
|
||||
`authorized_keys` line.
|
||||
|
||||
This draft covers:
|
||||
|
||||
- host enrollment, once per host (section 3)
|
||||
- the grant request and its decision (section 4)
|
||||
- the executor that runs on Approve (section 5)
|
||||
- revoke (section 6)
|
||||
- how row 210's per-call decision becomes manual mode (section 7)
|
||||
|
||||
Gitea and GitHub grants follow the same executor shape and are a later draft (section 11).
|
||||
|
||||
One thing up front, because it changes what Jason sees on 10-27. A grant made this way still doesn't let an
|
||||
agent run a command without a click. Under PRD 1.7 `host.sudo` is a hard wait, and every `host.run` needs its
|
||||
own decision (210 D4). "Runs without a click" needs REQ-CRED-4 in PRD 1.8 (Vikunja #434, Honker through Sage)
|
||||
and the reviewed commit that routes `host.sudo`. This path and that routing are separate pieces, and the
|
||||
first is useless for Q188's goal without the second.
|
||||
|
||||
## 1. Facts from the code
|
||||
|
||||
- F1. **No chain of command exists in the data.** A role definition has only `roleVersion, name, title,
|
||||
contract, tools, network, authority, credentials, messaging, skills` (`business/src/role.mjs:19-20`). There
|
||||
is no `reportsTo` or manager field. The one fixed rank is the chief, the definition named
|
||||
`chief-of-staff` (`bus/src/broker.mjs:157`), and the broker already checks an instance's definition against
|
||||
it (`broker.mjs:593`, `:1990`). Who may message whom is the only structure, `#checkMessaging`
|
||||
(`broker.mjs:617-627`).
|
||||
- F2. **Any agent may raise any action.** `decision.raise` stores `raised_by_role` and `raised_by_run`
|
||||
(`broker.mjs:1703-1708`). No rule limits which role raises which action, and no raise names another agent
|
||||
as its beneficiary.
|
||||
- F3. **A hard wait always routes to the human.** Any action outside `ROUTED` is in `GATED_ONLY`
|
||||
(`business/src/vocabulary.mjs:88`), classifies `gated` (`broker.mjs:597-609`) and routes to `human`
|
||||
(`broker.mjs:1677-1680`). A tester thread can't route to the human (`broker.mjs:1683`).
|
||||
- F4. **Resolving a decision runs nothing.** `decision.resolve` records a `decision_events` row and messages
|
||||
the raiser (`broker.mjs:1752-1828`). An effect happens later, when a verb consumes the decision through
|
||||
`#consumeAuthority`, which refuses `decision-consumed` if an `action.allowed` event other than the raise
|
||||
already cites it (`broker.mjs:808-831`). `#consumeAuthority` needs the raiser's own agent session: it
|
||||
checks `raised_by_role` and the raiser's thread against the caller (`broker.mjs:764`, `:786`). The
|
||||
landing path is the only place where stack code reacts to a resolution, and even there the agent's own
|
||||
`land_work` call does the work (`cli/src/launcher.mjs:555-600`).
|
||||
- F5. **The human proof.** `mosaic decide` resolves through `human-cli.mjs` (`cli/src/cli.mjs:142-175`,
|
||||
`cli/src/transport.mjs:55-75`). `verifyHuman` is a same-UID cooperative check (`bus/src/human.mjs:50-52`),
|
||||
and `#human` needs `via === 'cli'` and `outsideAgent` (`broker.mjs:375-378`).
|
||||
- F6. **The business file has one writer discipline.** `organizationWriter.write`
|
||||
(`business/src/organization.mjs:177-219`): a lock dir, a revision that is the sha256 of the current bytes,
|
||||
`previous.json`, tmp, fsync, rename, and receipts in `businesses/.history/<id>/changes.jsonl`. The bus child
|
||||
does the write (`cli/src/host.mjs:215`, `bus/src/process.mjs:1-3`). Agent-reachable keys are
|
||||
`ORGANIZATION_KEYS` and `INSTANCE_KEYS` (`broker.mjs:146-148`). Row 210's `hosts` keys sit outside both.
|
||||
- F7. **Row 210's pieces this path reuses:**
|
||||
- the agent key store at `<configDir>/secrets/agents/<business>/<agent>/host.<id>` (210:
|
||||
`bus/src/credentials.mjs:153-157`)
|
||||
- `issue`, which writes tmp, fsync, rename, then calls `commit`, then sets memory and clears the revoked
|
||||
mark (210: `credentials.mjs:243-280`, the clear at `:279`)
|
||||
- `revoke`, which drops memory, aborts the call in flight and unlinks the file (210:
|
||||
`credentials.mjs:284-298`, the abort at `:289`). It refuses `credential-unknown` for a service with no
|
||||
loaded reference (210: `credentials.mjs:235-238`).
|
||||
- the busy map `#inflight`, instance and service to an `AbortController` (210: `credentials.mjs:118`,
|
||||
`busy` at `:332-334`, used by `host-run.mjs:249`)
|
||||
- pinned `known_hosts` built from the registry's `hostKeys` with `StrictHostKeyChecking=yes` (210:
|
||||
`bus/src/host-run.mjs:36`, `:170`)
|
||||
- F8. **Nothing on refactor generates keys or contacts a host.** No `ssh-keygen` or ssh spawn exists in
|
||||
`packages/*/src`. Row 210 adds the ssh spawn, and row 210's tests already require `ssh-keygen` on the
|
||||
machine (210 test list, first test).
|
||||
- F9. **The Console can't approve today.** It shows decisions and copies the `mosaic decide` command
|
||||
(`webui/src/public/bus.js:3-5`). `decision.resolve` is not in `BUS_VERBS` (`webui/src/serve.mjs:62`).
|
||||
Vikunja #435 adds Approve after Q175 sign-in (#412).
|
||||
- F10. **`credential.mint` is already a hard wait with no executor** (`vocabulary.mjs:38`).
|
||||
- F11. **The running bus never reloads the business file, except the limits.** The broker keeps a clone
|
||||
made once (210: `broker.mjs:234`), and `host.run` reads `b.hosts` and `b.roles[role].hosts` from it (210:
|
||||
`broker.mjs:1731-1735`). `Credentials` loads its references once, in the constructor (210:
|
||||
`bus/src/runtime.mjs:85-98`, `credentials.mjs:125-147`). Only the limits read the file at each ask, and
|
||||
`instances.set` answers `restart: true` (`business/src/instance-settings.mjs:73`, `:155`).
|
||||
- F12. **The business file has no room for state on a host or a grant.** A registry entry takes only
|
||||
`address, port, user, hostKeys` (210: `business/src/hosts.mjs:75`), and a role's grant only `commands`
|
||||
(210: `hosts.mjs:123`). Under 1.8 the file holds no grant at all (mos-1131), so hosts, grants, state, tags
|
||||
and progress live in the bus store (S1).
|
||||
|
||||
## 2. Shape
|
||||
|
||||
```
|
||||
agent --message--> chief --decision.raise credential.mint + grant--> Jason's inbox
|
||||
|
|
||||
mosaic decide (later: Console Approve, #435)
|
||||
|
|
||||
bus child grant executor (on resolve, approve only)
|
||||
keygen -> install via host installer -> verify -> key file + store record + live refs
|
||||
```
|
||||
|
||||
Revoke runs the other way: memory, then the in-flight call, then the key file, then the store record, then
|
||||
the host line.
|
||||
|
||||
## 3. Host enrollment (once per host, a request like a grant)
|
||||
|
||||
Installing a key on a host needs something on the host that already trusts the stack. Nothing on refactor
|
||||
holds that trust, so each host gets one enrollment. This is the step that remains for ops-01, once per
|
||||
host, and it goes on the ops-01 run sheet in the installer's I3 row (#432, mos-1120). Per-agent installs no
|
||||
longer need ops-01.
|
||||
|
||||
A host here is a registry entry, so it has one user (F12). Two users on one machine are two host ids with the
|
||||
same address, each enrolled on its own.
|
||||
|
||||
### E1. The request
|
||||
|
||||
ops-01 reads the host's ed25519, ecdsa and rsa key fingerprints on the host, read-only
|
||||
(`/etc/ssh/ssh_host_{ed25519,ecdsa,rsa}_key.pub`). Those are the three types the keyscan asks for, so a
|
||||
fingerprint of any other type would make the raise refuse `host-key-mismatch` (N10). Host keys never travel
|
||||
by message (mos-1132). The request carries ordinary fields: address, port, the target user,
|
||||
whether that user has sudo, and the expected fingerprints. On the #433 VM, Mos or Sage passes them to the
|
||||
chief as the tester (Q173). On .53 and .54, Jason sends the request in the Console (Q118), with text Mos
|
||||
builds from ops-01's values. The chief raises a `credential.mint` decision with the binding (section 4, R4):
|
||||
|
||||
```
|
||||
grant: { "method": "host-enroll", "host": "<hostId>", "address": "A", "port": 22, "user": "U",
|
||||
"sudo": true, "fingerprints": ["SHA256:...", ...] }
|
||||
```
|
||||
|
||||
The raise checks are 210 D3 layer 2's: the address rules, refusing loopback and this machine's names, the
|
||||
port range, the user pattern and the key types. It also refuses a `hostId` that is already enrolled, a user
|
||||
named `root`, `localadmin` or `mosaic-installer`, and a `sudo` that isn't a boolean.
|
||||
|
||||
Before the raise transaction, the raise runs its own `ssh-keyscan -T 5 -p <port> -t ed25519,ecdsa,rsa
|
||||
<address>` from the stack host. The set of scanned fingerprints must equal the set in the request, or the
|
||||
raise refuses `host-key-mismatch` and nothing is stored. So two independent reads, ops-01's on the host and
|
||||
the stack's over the network, have to agree before Jason sees the binding (mos-1132). The scanned key lines
|
||||
go in the raise event body as `hostKeys`, and those are what E2 writes and E4 pins. The keyscan only reads
|
||||
the host's public keys. It logs in to nothing.
|
||||
|
||||
The rendered block shows each fingerprint and the key line, so Jason can compare them with ops-01's. With `"sudo": true` it adds one line in plain words: "U has sudo on this host.
|
||||
Approving lets later grants for U be installed, and section 9's first limit applies to U."
|
||||
|
||||
Jason approves with `mosaic decide` until #412 brings the Console inbox. No one pastes a host key. The keys
|
||||
come from the keyscan, checked against ops-01's fingerprints.
|
||||
|
||||
The pin is never taken on trust from one connection (210 section 4). It is the keyscan result, and only
|
||||
when it matches the fingerprints ops-01 read on the host. E4's check fails closed on a wrong pin too.
|
||||
|
||||
Not every host can be enrolled yet. jarvis on .53 and melanie on .54 wait for #423 steps 4 and 5
|
||||
(mos-1125 point 5).
|
||||
|
||||
### E2. What the executor does
|
||||
|
||||
On Approve the bus child runs `hosts.enroll`, with the same start rules as a grant (section 5, X1):
|
||||
|
||||
1. **Recheck and consume** in one store transaction (B1): `#consumeGrant(business, decision, 'host-enroll')`
|
||||
(X1), a check that no `hosts` record has this (business, `hostId`), then `host.enroll.step {decision,
|
||||
host, step: 'registry'}` and the `hosts` record (S1) with the bound `address, port, user, sudo,
|
||||
hostKeys`. If the host is already enrolled, the transaction writes only the spend and `grant.failed
|
||||
{code: 'host-enrolled'}`, and the human and the chief get a message. The primary key (S1) refuses a
|
||||
second insert as well. So of two approved enrollments for one `hostId`, the second never replaces the
|
||||
pin, the user or the sudo mark under live grants, and enrolling again still means `hosts.remove` first
|
||||
(E4). Enrollment needs no `#inflight` claim, because a second enrollment stops here, before anything
|
||||
else is written.
|
||||
2. generates an installer key pair with `ssh-keygen -t ed25519 -N '' -C mosaic-installer-<tag>`, where `<tag>`
|
||||
is the hash of section 5's X2 step 2 over (business, `@installer`, hostId), in a 0700 dir under
|
||||
`<configDir>/secrets/run`, the dir row 210 already sweeps at start
|
||||
3. moves the private half to `<configDir>/secrets/hosts/<business>/<hostId>/installer`, mode 0600 in 0700
|
||||
dirs, by the same tmp, fsync, rename steps as 210 `issue`. The path follows row 199 D1's
|
||||
`secrets/agents/<business>/...` (Q-S2, mos-1120, sage-1527)
|
||||
4. records `host.enrolled {decision, host, registrySha256, installerFingerprint, user, sudo}` and messages
|
||||
the human and the chief with what ops-01 installs (E3)
|
||||
|
||||
No private material goes out. A failure at step 2 or 3 deletes the host record again and ends
|
||||
`grant.failed`, as X3 does. A restart after step 1 does the same (X3).
|
||||
|
||||
The host's state is in the store record (S1):
|
||||
|
||||
- `pending-install`: set by step 1
|
||||
- `ready`: set by a passing `hosts.check` whose `registrySha256` and installer fingerprint match the record
|
||||
|
||||
### E3. The host side
|
||||
|
||||
ops-01 answered Q-O1 with six changes (mos-1125). The stack side doesn't change. The host side is four
|
||||
files in this repo under `tools/host/`, each reviewed like code, and Duckworth reviews the writer, the
|
||||
sudoers line and the sshd drop-in before .53 (mos-1125 point 6):
|
||||
|
||||
| File | Installed as | Does |
|
||||
|---|---|---|
|
||||
| `mosaic-keys` | `/usr/local/sbin/mosaic-keys`, root, 0755 | the installer key's forced command. Parses `SSH_ORIGINAL_COMMAND` against fixed patterns and runs `sudo -n /usr/local/sbin/mosaic-keys-write <request>`. Nothing else. |
|
||||
| `mosaic-keys-write` | `/usr/local/sbin/mosaic-keys-write`, root, 0755 | checks everything again and writes the line |
|
||||
| `mosaic-keys.sudoers` | `/etc/sudoers.d/mosaic-keys`, root, 0440 | `mosaic-installer ALL=(root) NOPASSWD: /usr/local/sbin/mosaic-keys-write` |
|
||||
| `mosaic-keys.sshd.conf` | `/etc/ssh/sshd_config.d/<nn>-mosaic-keys.conf` | `AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2 /etc/ssh/mosaic-keys/%u` |
|
||||
|
||||
The drop-in keeps both paths the hosts use today. On .53 and .54, `sshd_config` includes
|
||||
`sshd_config.d/*.conf` at line 12 and no file sets `AuthorizedKeysFile` (mos-1127 point 2). Root never
|
||||
writes into a user's home (mos-1125 point 1).
|
||||
|
||||
Host config, all root-owned and written by ops-01 from the approved enrollment message:
|
||||
|
||||
- `/etc/mosaic-keys/users`: the users the writer may touch
|
||||
- `/etc/mosaic-keys/privileged`: the users with sudo that Jason approved by name (E1 `"sudo": true`)
|
||||
- `/etc/mosaic-keys/from`: the `from=` value. The stack can't choose it.
|
||||
- `/etc/ssh/mosaic-keys/`: root, 0755. `mosaic-installer`'s own line lives here, in a root-owned
|
||||
`mosaic-installer` file that ops-01 writes by hand.
|
||||
|
||||
The installer's line is exactly this, with `<from>` the same value as `/etc/mosaic-keys/from` (N7):
|
||||
|
||||
```
|
||||
restrict,command="/usr/local/sbin/mosaic-keys",from="<from>" ssh-ed25519 <base64> mosaic-installer-<tag>
|
||||
```
|
||||
|
||||
E2 step 4's message carries the line with the public key filled in, and the I3 run sheet's host step quotes
|
||||
it. So a hand-written line can't drop `restrict` or `command=`.
|
||||
|
||||
`mosaic-installer` is a dedicated user, never `localadmin` and never an agent user (mos-1125 point 2). It
|
||||
needs no entry in any sshd list, because .53 and .54 have no `AllowUsers`, `AllowGroups`, `DenyUsers`,
|
||||
`DenyGroups`, `ForceCommand` or `Match` block and `pam_access` is off (mos-1127 point 1).
|
||||
|
||||
Requests:
|
||||
|
||||
| Request | Effect |
|
||||
|---|---|
|
||||
| `list <user>` | prints each line of `/etc/ssh/mosaic-keys/<user>` whose tag matches the tag pattern |
|
||||
| `add <user> <type> <base64> <tag>` | appends `from="<from>",restrict <type> <base64> <tag>` unless the tag exists |
|
||||
| `remove <user> <tag>` | drops the line with that exact tag |
|
||||
|
||||
`remove` of a tag that isn't there changes nothing and exits 0, so a revoke and an executor undo can both run
|
||||
it.
|
||||
|
||||
What `mosaic-keys-write` checks, at every request (mos-1125 points 3 and 4):
|
||||
|
||||
- the request matches the patterns again. Each tag matches `^mosaic-[0-9a-f]{32}$`, and each type and
|
||||
base64 match E1's patterns.
|
||||
- `<user>` matches `^[a-z_][a-z0-9_-]{0,31}$` before it is used in any path. `O_NOFOLLOW` covers only the
|
||||
last component, so the pattern is what stops a `../x` relative to the dir fd (ops-01 point a).
|
||||
- `<user>` exists, is in `users`, and isn't uid 0, uid under 1000, `mosaic-installer` or `localadmin`. These
|
||||
names and the uid rule are in the code, not in a file. `users` is always required.
|
||||
- `<user>` is in none of the groups `sudo`, `admin`, `wheel`, `docker`, `lxd`, `disk`, `libvirt` and
|
||||
`incus-admin`, and has no sudo rights, unless it is in `privileged`. A group the host doesn't have counts
|
||||
as no membership. `disk` reads and writes raw block devices, and `libvirt` and `incus-admin` start guests
|
||||
with host mounts, so each is root by another route (N5). `privileged` only lifts this check. Being in it alone allows nothing (ops-01 Q-O2
|
||||
point 1).
|
||||
- the sudo test is `LC_ALL=C sudo -l -U <user>`. Any output other than the exact line
|
||||
`User <user> is not allowed to run sudo on <host>.` counts as having sudo (ops-01 Q-O2 point 4).
|
||||
- it never touches a line whose tag doesn't match the pattern.
|
||||
|
||||
How it writes, so no path trick moves the write:
|
||||
|
||||
- opens `/etc/ssh/mosaic-keys` with `O_DIRECTORY | O_NOFOLLOW` and refuses unless it is root-owned and not
|
||||
group or world writable
|
||||
- takes `flock` on `.lock` in that dir, a name the user pattern can't match, so sshd never reads it and
|
||||
`list` never shows it (ops-01 point b)
|
||||
- opens the user's file relative to the dir fd with `O_NOFOLLOW`, and refuses one that isn't a regular
|
||||
root-owned file. A first `add` for a user finds no file. `ENOENT` there means an empty list, and the
|
||||
rename creates the file. `list` and `remove` on a missing file print nothing and change nothing (N6).
|
||||
- the tmp is `.<user>.tmp`, a name the user pattern can't match. Under the `flock`, a leftover tmp can only
|
||||
come from a writer that crashed, so a regular root-owned one is unlinked first. A tmp that is a symlink
|
||||
or not a regular file refuses, and nothing is written (N6).
|
||||
- writes the tmp with `O_CREAT | O_EXCL | O_NOFOLLOW`, sets mode 0644, `fsync`, `renameat` over the file,
|
||||
then `fsync` on the dir
|
||||
- logs to syslog the result, user, tag and key fingerprint. The fingerprint is sha256 over the decoded
|
||||
blob, so no `ssh-keygen` runs
|
||||
|
||||
sudo's `env_reset` drops `SSH_CONNECTION` on both hosts. So `mosaic-keys` logs `SSH_CONNECTION` and the request
|
||||
with `logger -t mosaic-keys` before it calls sudo, and the sudoers file stays one line with no `env_keep`
|
||||
(ops-01 point c). The hosts set `use_pty`. With `restrict` there is no pty, and sudo shouldn't allocate one
|
||||
when stdin isn't a tty. The #433 proof checks that `list` output and the exit code pass through unchanged
|
||||
(ops-01 point d).
|
||||
|
||||
The writer is python3, standard library only, with `#!/usr/bin/python3 -I`, because `os.open` takes `dir_fd`
|
||||
and `O_NOFOLLOW` and a shell script can't do that safely. Both hosts have Python 3.12.3 at `/usr/bin/python3`,
|
||||
root-owned, and `python3 -I` imports every module the writer needs (ops-01, 22:26Z).
|
||||
|
||||
When ops-01 writes `users` and `privileged` from an approved enrollment, ops-01 first checks the E1 `sudo`
|
||||
mark on the host. If the host disagrees, for example `sudo: false` for a user in `docker`, ops-01 writes
|
||||
neither file and reports back, and the enrollment needs a new request (ops-01 Q-O2 point 2).
|
||||
|
||||
sshd's `StrictModes yes` (on both hosts, mos-1127 point 3) also refuses a key file whose path is writable by
|
||||
anyone but root or the user.
|
||||
|
||||
The install proof runs on the #433 VM first (mos-1125 point 6), as ops-01 does it (mos-1127): `sshd -t`,
|
||||
`systemctl reload ssh`, then a new connection. An existing session survives the reload. Rollback removes
|
||||
the drop-in and reloads, which restores the old value. The run sheet's host step (#432, I3) cites these.
|
||||
|
||||
This still doesn't make the installer key safe to lose. A key that can add a line for a privileged user is
|
||||
root on that host. Section 9 says so.
|
||||
|
||||
### E4. Check
|
||||
|
||||
After ops-01 installs the files and the installer line, `hosts.check <hostId>` connects as `mosaic-installer`
|
||||
with the installer key, the pinned `known_hosts` and 210's ssh options, and runs `list <user>`. A wrong pin
|
||||
exits 255 and refuses `host-key-mismatch`, as 210 does, and the host stays `pending-install`. A pass records
|
||||
`host.checked {host, registrySha256, installerFingerprint}` and sets the record `ready` in the same
|
||||
transaction. Only a `ready` host can be named in a grant (R4).
|
||||
|
||||
The chief, the tester or the human may run `hosts.check`. It reads the host's lines and widens nothing past
|
||||
what Jason approved, so it needs no decision. `status` shows each host's state, pin fingerprints, installer
|
||||
fingerprint and last check time.
|
||||
|
||||
`hosts.remove <hostId>` is for the human or the tester, as a revoke is (REQ-GRANT-4), because it only takes
|
||||
access away. It refuses `host-in-use` while any grant names the host and `host-line-left` while any event's
|
||||
`hostLineLeft` names the host and no later `sweep` cleared it (N3). Then it deletes the installer key and the
|
||||
host record, and records `host.removed` in the same transaction. ops-01 removes the
|
||||
installer line on the host. Enrolling the host again takes a new request.
|
||||
|
||||
## 4. The request and the decision
|
||||
|
||||
### R1. Up the chain
|
||||
|
||||
An agent that needs host access sends the chief a message, as any request to the chief goes today
|
||||
(`#checkMessaging`, `broker.mjs:617-627`). There is no structured request verb (Q-S1). F1 gives the reason:
|
||||
nothing records who reports to whom, so the bus can't check a chain beyond "the chief raises it".
|
||||
|
||||
"Not every agent may request" (Q188) is enforced at the raise. The target agent's definition must declare
|
||||
`{"service": "host"}` in `credentials` (210 D3 layer 1, a reviewed commit). An agent whose definition lacks
|
||||
it can ask, but the chief's raise for it refuses `host-not-declared`. Which definitions declare `host` is
|
||||
the reviewed list of agents that may hold a host grant.
|
||||
|
||||
### R2. The action
|
||||
|
||||
The request is a `credential.mint` decision, as Honker's REQ-GRANT-1 says. `credential.mint` is already a
|
||||
hard wait (`vocabulary.mjs:38`, F10), so it classifies `gated` for every role and routes to the human (F3).
|
||||
No role file may list it (REQ-WAIT-2), so it can never be routed, and the hard-waits list doesn't change.
|
||||
|
||||
r1 used a new `host.grant` action because `credential.mint` names no host or argv. That reason doesn't
|
||||
hold. The binding lives in the raise's `grant` argument and the raise event body, not in the action name,
|
||||
so G1 to G10 test the same things either way. The executor starts only for a decision whose raise carries
|
||||
a `grant` binding.
|
||||
|
||||
A `credential.mint` raise without `grant` keeps today's behaviour. Any agent may raise one, Jason may
|
||||
answer it, and nothing runs. It is a question for a hand-made credential, as it is now.
|
||||
|
||||
### R3. Who may raise it
|
||||
|
||||
The broker refuses a raise that carries `grant` unless the raiser's definition is `chief-of-staff`
|
||||
(`b.roles[s.role].definition === STAFF_TO`, the check at `broker.mjs:593`). Refusal: `grant-chief-only`. A
|
||||
tester thread can't raise it, because it routes to the human (`broker.mjs:1683`). Any other action with a
|
||||
`grant` argument refuses `invalid-request`.
|
||||
|
||||
### R4. What the raise binds
|
||||
|
||||
New raise argument `grant`, allowed only with `action === 'credential.mint'`. For a host grant:
|
||||
|
||||
```
|
||||
grant: { "method": "host", "agent": "<instance>", "host": "<hostId>",
|
||||
"commands": [["systemctl", "status"], ...], "rotateBy": "YYYY-MM-DD" }
|
||||
```
|
||||
|
||||
`host-enroll` is E1's. A later draft adds `gitea` and `github`. Any other `method` refuses
|
||||
`invalid-request`.
|
||||
|
||||
Checks at raise, all inside the raise transaction:
|
||||
|
||||
- `agent` is a role instance of this business, and its definition declares `host` (R1)
|
||||
- `host` is `ready` (E2, E4), or the raise refuses `host-not-ready`
|
||||
- `commands` passes 210 D6's argv rules: no wildcard, no shell, no empty prefix, and the existing
|
||||
refusals for `bash` and the like
|
||||
- `rotateBy` is a date inside 210's rotation window
|
||||
|
||||
The broker appends a rendered block to the question, as 210 D4 does for `host.sudo`: agent, host id,
|
||||
address, user, whether the user has sudo (from `host.enrolled`), each argv prefix JSON-quoted, the rotate
|
||||
date, and whether this is a new key or a change to an existing grant's commands. The chief can't shape that
|
||||
block. If the question with the block exceeds the 4096-character cap (`broker.mjs:126`), the raise refuses
|
||||
`invalid-request`, as 210 D4 does (N2). The chief then splits the commands over two requests.
|
||||
|
||||
The raise event body gets `grant = {method, agent, host, commands, commandsSha256, rotateBy, registrySha256,
|
||||
definitionSha256}`. The full `commands` list is stored there, and the executor reads it from that event at
|
||||
X2 step 1 and checks it against `commandsSha256` (N2). `registrySha256` hashes the host record's identity (`address, port, user, sudo,
|
||||
hostKeys`) at raise time, which is 1.8's "the target or its enrolled identity" (mos-1131), and `definitionSha256` hashes the agent's role definition file. REQ-GRANT-2 voids an approval
|
||||
when the agent, its definition, the target or the rights change after the raise. If either hash differs at
|
||||
X2 step 1, the executor refuses `decision-mismatch` and the chief raises again.
|
||||
|
||||
### R5. Jason decides
|
||||
|
||||
`mosaic decide <id> yes|no` as today (F5). After #435, the Console's Approve calls the same
|
||||
`decision.resolve` through `human-cli.mjs` behind Q175 sign-in. The executor keys on the resolution, not on
|
||||
the client, so #435 needs no executor change.
|
||||
|
||||
## 5. The executor
|
||||
|
||||
### X1. When it runs
|
||||
|
||||
At resolve. Today a resolution runs nothing (F4), and the raiser comes back later to consume the decision.
|
||||
Here the raiser is the chief, and Q188 says no agent creates the grant, so the chief must not be the one
|
||||
whose call does it. The bus child starts the executor itself after the human's `decision.resolve` commits,
|
||||
when all of these hold: the decision's action is `credential.mint` and its raise carries `grant`, the route
|
||||
is `human`, the choice equals `approvalChoice`, and `via === 'cli'`. A resolve by an arbiter, the tester or
|
||||
an agent starts nothing (REQ-GRANT-2).
|
||||
|
||||
The resolve call returns at once with `{grant: "started"}`. It does not wait on ssh. Jason sees the outcome
|
||||
in `mosaic hosts status` and as a message in his inbox from `system` when the executor ends.
|
||||
|
||||
The executor can't use `#consumeAuthority`, because that needs the raiser's agent session (F4, N1). It gets
|
||||
its own path, `#consumeGrant(business, decisionId, method)`. In one transaction it checks the decision is
|
||||
resolved yes by the human via cli, checks `#spent` the same way (`broker.mjs:808-831`), and writes the
|
||||
`action.allowed` event with `operation: 'grant.<method>'` and actor `system/grant`. A second start finds it
|
||||
spent and refuses `decision-consumed`. It is a private method, and only the executor calls it.
|
||||
|
||||
### X2. Steps
|
||||
|
||||
1. **Recheck, consume, claim.** One store transaction: `#consumeGrant` (X1), the agent and its definition
|
||||
still declare `host`, the definition hashes to `definitionSha256`, the host record hashes to
|
||||
`registrySha256`, the host is `ready`, the `commands` in the raise event hash to `commandsSha256`, and no
|
||||
revoke of this (agent, host) is newer than the raise. Any failure is `grant.failed` with its code, and
|
||||
nothing remote has happened. Right after the commit, with no `await` between, the executor claims the
|
||||
(agent, `host.<hostId>`) entry in `#inflight` (X5). The store is `DatabaseSync` (`store.mjs:1`), so the
|
||||
transaction and the claim run in one turn of the event loop, and no revoke or `host.run` can run between
|
||||
them. A claim that finds an entry, usually a running `host.run` from the same agent (210:
|
||||
`credentials.mjs:355`), ends `grant.failed {code: 'host-busy'}` with a message to the human and the
|
||||
chief, and the chief raises again (B2). The decision is spent before the claim, so every Approve ends in
|
||||
`grant.applied` or `grant.failed`, never in silence.
|
||||
I chose failing over waiting for the running call. A wait could last as long as 210's timeout while
|
||||
Jason's inbox says "started", and a failure with a message is easier to see than a slow grant.
|
||||
2. **Key.** The tag is `mosaic-` and the first 32 hex digits of sha256 over business, agent and hostId
|
||||
joined with a NUL byte. Names match `ID_PATTERN` (210: `vocabulary.mjs:143`, `:127` at f41729178), which has no NUL, so the join is
|
||||
one to one, and the tag has a fixed length the host pattern accepts (B4). For a new grant,
|
||||
`ssh-keygen -t ed25519 -N '' -C <tag>` in a fresh 0700 dir under `<configDir>/secrets/run`. For a
|
||||
commands change with a live key, skip to step 5.
|
||||
3. **Install.** Write `grant.step {decision, agent, host, user, tag, step: 'install'}`. Connect as
|
||||
`mosaic-installer` with the installer key, pinned (210's options), and run `list <user>`. If the tag is
|
||||
there, `remove` it first: the tag is this agent's on this host, and step 2 only runs when the stack holds
|
||||
no live key for it. Then `add <user> <type> <base64> <tag>`.
|
||||
4. **Verify.** Connect as `<user>` with the new key through a short-lived ssh-agent (210 D7) and run `true`.
|
||||
The connection proves the line, the `from=` and the pin together. A failure here runs `remove <user> <tag>`
|
||||
and ends `grant.failed`.
|
||||
5. **Go live.** Write `grant.step {..., step: 'commit', previous}`, where `previous` is the grant's
|
||||
`commands` before this decision, or `null` for a new key. Then check the abort signal (X5), and with no
|
||||
`await` between, run 210 `issue(agent, "host.<hostId>", privateKey, rotateBy, commit)`. For a new key the
|
||||
reference is registered first (X4). `issue` writes the key file, then calls `commit`, which writes the
|
||||
`host_grants` record (S1) with the decided `commands`, `rotateBy`, tag and decision id. Then `issue`
|
||||
fills memory. For a commands change, step 5 is the abort check and one store transaction that updates
|
||||
`commands` and the decision id, with no `issue`. The tmp dir from step 2 is removed in a `finally`.
|
||||
6. **Event and message.** `grant.applied {decision, agent, host, user, tag, fingerprint, commandsSha256}`
|
||||
and a message to the human and the chief. The private key appears in no event, message or log (210 D8's
|
||||
`assertCleanKey` runs on every event body).
|
||||
|
||||
After step 6, or after any failure, the executor releases its `#inflight` entry in a `finally`.
|
||||
|
||||
The tag is recorded in `grant.step` and `grant.applied`, so `list` output and `sweep` can be matched to this
|
||||
business's grants without trusting a comment on the host (B4).
|
||||
|
||||
### X3. Failure and restart
|
||||
|
||||
The steps are ordered so a failure leaves nothing an agent can use:
|
||||
|
||||
| Fails at | State left | Cleanup |
|
||||
|---|---|---|
|
||||
| 1 | nothing | none |
|
||||
| 2 | tmp dir | `finally`, and the start sweep |
|
||||
| 3 | maybe a line with a key nobody holds | `remove <user> <tag>`, best effort |
|
||||
| 4 | a line, no grant | `remove`, best effort |
|
||||
| 5 before `commit` | a line, a key file, no grant record | unlink the key file, unregister (X4), `remove` |
|
||||
| 5 after `commit` | a line, a grant record, a key file | the record back to `previous` (deleted for a new key), 210 `revoke`, `remove` |
|
||||
|
||||
Setting a record back to `previous`, here and in the restart table below, only updates a record that
|
||||
exists. It never inserts one, and it does nothing when a `grant.revoked` for the (agent, host) is newer than
|
||||
the decision. So a revoke that deleted the record stays done, and the next start loads no reference for it
|
||||
(C1).
|
||||
|
||||
A best-effort `remove` that fails adds `hostLineLeft: <tag>` to `grant.failed`, and `status` shows it until
|
||||
`mosaic hosts sweep <hostId>` (human or tester) succeeds. `sweep` runs `list <user>` and removes each line
|
||||
whose tag is in this business's recorded `grant.step` tags and has no live grant. A line with any other tag
|
||||
is left alone, so a sweep can't touch another business's line on the same user (B4).
|
||||
|
||||
**Restart.** A bus child that dies mid-executor leaves a spent decision with no `grant.applied` or
|
||||
`grant.failed`. At start, before `Credentials` loads, the bus reconciles each one from its last
|
||||
`grant.step` (or `host.enroll.step`), back to the state before the decision (B3):
|
||||
|
||||
| Last step | Local undo, before load | Remote undo, after the bus is up |
|
||||
|---|---|---|
|
||||
| none | none (the tmp dir goes in 210's run-dir sweep) | none |
|
||||
| `install` | none | `remove <user> <tag>` |
|
||||
| `commit`, new key | unlink the key file if present, delete the grant record if present | `remove <user> <tag>` |
|
||||
| `commit`, commands change | set the record's `commands` back to `previous`, by the update-only rule above | none, the key and line didn't change |
|
||||
| enrollment `registry` | delete the installer key if present, delete the host record | none |
|
||||
|
||||
Then it records `grant.failed {code: 'interrupted', tag}` with the tag listed every time, and `hostLineLeft:
|
||||
<tag>` if the remote undo failed. Jason gets a message. The chief raises again, and Jason approves again. I
|
||||
chose a new decision over an automatic retry because Jason approved one attempt at one moment, and a retry
|
||||
after a restart might be hours later. The remote undo only takes access away, so it needs no decision.
|
||||
|
||||
A bus child can also die after the human's resolve commits and before step 1 runs. That leaves a grant
|
||||
decision resolved yes by the human via cli with no `action.allowed`. At start the reconcile spends each one
|
||||
through `#consumeGrant`, in the same transaction as `grant.failed {code: 'interrupted'}`, and Jason gets a
|
||||
message (B2). It never runs late, for the reason above, and a later start finds it spent. Enrollments get the
|
||||
same treatment.
|
||||
|
||||
### X4. Taking effect in the running bus (B1)
|
||||
|
||||
The running bus never reloads the business file (F11). The store records (S1) sidestep that:
|
||||
|
||||
- After row a, `host.run` reads the host record and the agent's grant record from the store inside its
|
||||
transaction at each call, and refuses `host-not-granted` unless the record's decision is a `credential.mint`
|
||||
resolved yes by the human via cli and spent by `#consumeGrant` with `operation: 'grant.host'`. That is the
|
||||
Approve record check. The record is read in place of `b.hosts` and `b.roles[role].hosts` (210: `broker.mjs:1731-1735`,
|
||||
`:1962-1968`). A grant, a commands change, a revoke and a `hosts.remove` are seen by the next call with no
|
||||
broker change.
|
||||
- `Credentials` still loads its references once (F11). It gets `register(instance, service, ref)`, which runs
|
||||
the constructor's reference checks for one entry (210: `credentials.mjs:128-143`, moved into a shared
|
||||
method) and sets `#refs`, and `unregister`, which drops an entry with no token. The executor registers
|
||||
before `issue`, because `issue` refuses an unknown service (210: `credentials.mjs:235-238`).
|
||||
- At start, the runtime adds a store reference for each `host_grants` record to the references it hands
|
||||
`Credentials` (210: `runtime.mjs:85-98`), so a restart finds the same keys.
|
||||
- Revoke deletes the grant record, and the reference left in memory reads as revoked with no file (199) until
|
||||
the next start drops it.
|
||||
|
||||
### X5. Revoke while the executor runs (B2)
|
||||
|
||||
The executor's `#inflight` entry is an `AbortController` (210: `credentials.mjs:118`), the same entry that
|
||||
`revoke`'s `abort()` reaches (210: `credentials.mjs:289`). A new `Credentials.claim(instance, service)` sets
|
||||
it and returns the signal, and `release` deletes it.
|
||||
|
||||
- A revoke before step 1 goes through `hosts.revoke` (section 6), which records `grant.revoked` for the
|
||||
(agent, host) even when `Credentials` holds no reference and no claim. Step 1's "no revoke newer than the
|
||||
raise" check finds it and ends `grant.failed {code: 'revoked'}` (B2). No revoke can fall between step 1's
|
||||
transaction and its claim (X2).
|
||||
- A revoke during steps 2 to 5 aborts the claim. `hosts.revoke` doesn't call 210's `revoke` when
|
||||
`Credentials` has no reference, because that refuses `credential-unknown` (210: `credentials.mjs:235-238`).
|
||||
It calls a new `Credentials.abortClaim(instance, service)`, which aborts a live claim and answers
|
||||
`{pending: true}`, and the broker records `grant.revoked {pending: true}`.
|
||||
- The executor checks `signal.aborted` after each `await`. At step 5 it checks right before `issue`, and
|
||||
nothing between the check and `issue` awaits, so no revoke can run in between. That matters, because
|
||||
`issue` clears the revoked mark (210: `credentials.mjs:279`).
|
||||
- Build rule (N2): `issue` and the `commit` it calls stay synchronous. `commit` is a store write and the
|
||||
store is `DatabaseSync` (`store.mjs:1`), so nothing in `issue` yields today. An async `commit` would let a
|
||||
revoke run between the key file write and the memory fill, and `issue` would then clear the revoked mark.
|
||||
A mutant covers it.
|
||||
- On abort the executor undoes as X3's failure table does at that step, then ends
|
||||
`grant.failed {code: 'revoked'}`.
|
||||
- A revoke after step 5 is an ordinary revoke (section 6). Once step 5 commits, the executor ignores an
|
||||
abort and goes on to step 6, so its undo never runs against a revoke that came after the commit (C1).
|
||||
|
||||
While the executor holds the claim, a `host.run` for the same agent and host refuses `host-busy`. That only
|
||||
matters for a commands change on a live key, and it lasts until the executor ends.
|
||||
|
||||
### S1. Grant records in the store
|
||||
|
||||
Two tables in the bus store, added by a new migration `3i-3j.sql` next to `3h-3i.sql`
|
||||
(`bus/src/migrations/`):
|
||||
|
||||
- `hosts`: business, host id, address, port, user, sudo, host keys, installer fingerprint, state
|
||||
(`pending-install` or `ready`), the enrolling decision id. Primary key (business, host id), so the store
|
||||
refuses a second enrollment of one host (B1).
|
||||
- `host_grants`: business, agent, host id, commands, rotate-by date, tag, the decision id that made or last
|
||||
changed it. Primary key (business, agent, host id).
|
||||
|
||||
The executor, revoke, `hosts.check` and `hosts.remove` are the only writers, each in the same transaction as
|
||||
its event. No broker verb takes these records from a request body. Every write cites a decision id or the
|
||||
revoker, as W1's receipts did.
|
||||
|
||||
The business file holds none of it (mos-1131 point 2). A hand edit makes nothing:
|
||||
|
||||
- Until row a lands, row 210's `hosts` keys and role grants in the business file stay as they are, a test
|
||||
path (mos-1131).
|
||||
- Row a moves `host.run` to the store records. From then the loader refuses `hosts` at the top level and
|
||||
`hosts` in a role (210: `hosts.mjs:75`, `:123`), so a hand-edited grant fails closed at load instead of
|
||||
doing nothing quietly. Row 210's tests move to store fixtures in the same row.
|
||||
- The credential reference for `host.<id>` comes from the grant record (X4), not from
|
||||
`roles.<agent>.credentials`.
|
||||
|
||||
The store file is writable by the stack's OS user, like the key files (199 D6). Section 9's same-user limit
|
||||
covers it.
|
||||
|
||||
A new store schema is a migration (mos-1133):
|
||||
|
||||
- On dragon-lin the 3i-3j re-pin is an agent-run `--migrate` window under Q173. Mos, Sage or Darkwing runs
|
||||
it as the tester, takes a backup first and sends a STATE report after. Jason doesn't need to be present.
|
||||
P19 371 predates Q173 and doesn't apply.
|
||||
- On .53 and .54 the plan is for row a to land before the installs (row a in review by 10-24, installs
|
||||
10-26). Both hosts then start at 3j and need no migration.
|
||||
- The row a run sheet removes row 210's `hosts` keys and role grants from the business file before the
|
||||
`--migrate` re-pin, because the loader refuses them from then on (G14). Any 210 grant in use stops there,
|
||||
and the agent needs a chief raise and an Approve to get it back (N9).
|
||||
- If row a slips past an install, a later round or the row a packet says so. That host then goes 3i to 3j through I3's
|
||||
upgrade driver (#432), and Mos rules on it at that point.
|
||||
|
||||
## 6. Revoke
|
||||
|
||||
Q188: instant, by Jason, Mos or the tester. `mosaic hosts revoke <agent> <hostId> --reason R`. Every revoke
|
||||
is logged with who made it (REQ-GRANT-4).
|
||||
|
||||
Who may revoke follows mos-1124 d and PRD 1.8 r3's REQ-GRANT-4 (d16ccdb91):
|
||||
|
||||
- the human, always
|
||||
- the assistant (`THREAD_DEFINITION`, `vocabulary.mjs:131`), for grants in its own organization only. The
|
||||
broker checks the grant's business against the session's.
|
||||
- the tester, with `--tester` (210 D9 already gives the tester `revoke`), on a dev instance only. On any
|
||||
other instance a tester revoke refuses `tester-dev-only`.
|
||||
|
||||
A dev instance is a business whose var `bus.tester` is exactly `true` (mos-1139, N8). `vars.mjs:125` defines
|
||||
the var for the business layer only, with a default of `false`, and `broker.mjs:544` already refuses to bind a
|
||||
tester unless it is exactly `true` (`tester-off`). `hosts.revoke` checks the same var again for the grant's
|
||||
business. A missing value or any other value means not dev, so `tester-dev-only` fails closed. I2's install
|
||||
never sets the var, so .53 and .54 are never dev (mos-1139).
|
||||
|
||||
Q188's "Mos" is read as the assistant role, Jason's main interaction agent (mos-1132). That is Mos's reading,
|
||||
labelled as such in the PRD 1.8 approval notes, and Jason settles it at prd.approve. This replaces mos-1120's
|
||||
"Mos revokes with `--tester`".
|
||||
|
||||
`hosts.revoke` is its own broker verb in a new module, `bus/src/host-grants.mjs`, next to the executor. It
|
||||
doesn't go through 210's `credential-verbs.mjs:21-24`, which refuses `credential-unknown` when the business
|
||||
file has no reference. After row a no `host.<id>` reference is in the business file at all (N3).
|
||||
|
||||
Order, local first so the part that matters can't wait on the network:
|
||||
|
||||
1. One store transaction deletes the `host_grants` record, if there is one, and records `grant.revoked
|
||||
{tag, hostLine: 'pending'}`. The tag is computed (X2 step 2), so it exists even with no record (N4). The
|
||||
next `host.run` refuses `host-not-granted` (X4). A new grant needs a new decision.
|
||||
2. Right after the commit, with no `await` between: 210 `revoke` if `Credentials` holds a reference
|
||||
(memory, abort the `host.run` or the executor in flight, unlink the key file), or `abortClaim` if it
|
||||
holds only a claim (X5). Steps 1 and 2 are the "instant" part, and neither touches the network. If the
|
||||
unlink fails, the verb reports it. The key is already out of memory, and the next start removes the
|
||||
file (below).
|
||||
3. `remove <user> <tag>` on the host through the installer. Success records `grant.revoke.closed {tag,
|
||||
hostLine: 'removed'}`. If the host can't be reached, it records `grant.revoke.closed {tag, hostLineLeft:
|
||||
<tag>}`, and `sweep` clears it later.
|
||||
|
||||
A restart between the steps (N4) leaves a `grant.revoked` with `hostLine: 'pending'` and no
|
||||
`grant.revoke.closed`. At start, before `Credentials` loads, the reconcile unlinks that key file if it is
|
||||
there. No reference loads for it, because the record is gone (X4). After the bus is up, the reconcile runs
|
||||
step 3 and closes the marker. `status` shows an open marker as a revoke in progress.
|
||||
|
||||
Step 3 closes the marker even when step 2's unlink failed. So the file doesn't wait on the marker: at every
|
||||
start, after the grant reconcile (X3) and before `Credentials` loads, the bus unlinks each `host.<id>` key
|
||||
file in the agent store that has no `host_grants` record (N1 of duckworth-0063). The grant reconcile runs
|
||||
first, so a key an interrupted executor wrote is already handled there.
|
||||
|
||||
Step 3 is defense in depth. After step 1 the stack holds no copy of the key. The line only matters if
|
||||
something copied the key file before the revoke, which section 9's same-user limit allows.
|
||||
|
||||
Remote processes that the killed ssh started may keep running (210 section 6). Revoke cuts the stack's
|
||||
connection, not what already runs on the host.
|
||||
|
||||
`hosts remove` (E4) refuses while grants exist or a line is left. Revoking the installer itself is
|
||||
`hosts remove` and ops-01 deleting the line.
|
||||
|
||||
## 7. Row 210's per-call decision becomes manual mode
|
||||
|
||||
Row 210 lands as reviewed (mos-1115). Its D4 stays the code path: a `host.run` whose class is `gated` needs a
|
||||
decision bound to host, argv hash and stdin hash, spent once.
|
||||
|
||||
What changes is when the class is `gated`:
|
||||
|
||||
- Under PRD 1.7, `host.sudo` is a hard wait, so every call is gated. That is today.
|
||||
- Under PRD 1.8 with REQ-CRED-4, `host.sudo` is routed, and a definition that lists it within-role runs
|
||||
granted commands with no decision (210 section 5).
|
||||
- Manual mode (Q188) puts the per-call decision back. Honker's REQ-TRUST-2 takes the list form: the
|
||||
organization file holds a list of actions in manual mode, empty by default, set only by the user. While
|
||||
an action is listed, it classifies `gated` for every role. `#classification` checks the list first
|
||||
(`broker.mjs:597-609`).
|
||||
|
||||
Manual mode is built from REQ-TRUST-2, not in the grant rows (sage-1527 Q-S3).
|
||||
|
||||
## 8. PRD lines
|
||||
|
||||
Honker's PRD 1.8 draft (honker/row194-b2 3c30f1036) carries this design as REQ-GRANT-1 to -4, generalized to
|
||||
all three methods. There is no REQ-CRED-5. Duckworth's PRD review (duckworth-0057) notes that this design
|
||||
uses `credential.mint` with the method in the `grant` body, not a new `host.grant` action. Points r2 sends
|
||||
Honker to match:
|
||||
|
||||
- REQ-GRANT-2 says a host's identity comes from the host side "when the user enrolls it". Under mos-1120
|
||||
the user approves the enrollment and the stack runs it, so "when the user approves its enrollment" fits
|
||||
better.
|
||||
- REQ-GRANT-4 names the assistant as a revoker. Honker kept it under mos-1124 d, and this design now follows
|
||||
it (section 6). Done in PRD r3 d16ccdb91.
|
||||
- An enrollment names a target user with sudo, and the Approve covers that user by name (E1, mos-1125).
|
||||
|
||||
I don't edit the PRD.
|
||||
|
||||
## 9. Limits, said plainly
|
||||
|
||||
- **The installer key is root on a host with a privileged user.** It can add a line for a user in
|
||||
`privileged`, and that user has sudo. It sits in `<configDir>/secrets`, readable by the stack's OS user
|
||||
like every other key (199 D6). The writer limits which users it can touch, and a user with sudo is
|
||||
touchable only after Jason approved that user by name in the enrollment (E1, E3). It doesn't limit what
|
||||
those users can do.
|
||||
- **Rights gained later are caught at the next add, not before.** A user who gains sudo or `docker` after
|
||||
enrollment, and isn't in `privileged`, is refused on the next `add`. Lines already in place stay until a
|
||||
revoke, `remove` or `sweep` (ops-01 Q-O2 point 3).
|
||||
- **A host line has no `command=`** (ops-01, mos-1125). `add` writes `from=` and `restrict` only, so the host
|
||||
doesn't enforce the granted argv prefixes. The bus enforces them (210 D6). A copied agent key is a full
|
||||
shell as that user from the stack's address. A forced command per key that checks
|
||||
`SSH_ORIGINAL_COMMAND` against the granted list would close that, but it means the writer also carries
|
||||
every commands change to the host, and 210's argv encoding has to round-trip through it. I'd make it a
|
||||
follow-up row and not part of rows a and b.
|
||||
- **The seat network isn't confined** (`seat/src/sandbox.mjs:29-31`, 210 section 6). An agent granted a
|
||||
command that writes a file as root can plant its own key and ssh around the bus. `from=` doesn't help,
|
||||
because the sandbox shares the stack's address. A network-confined sandbox closes it (Sage's follow-up).
|
||||
- **The human proof is cooperative same-UID** (`bus/src/human.mjs:50-52`). Approve is as strong as
|
||||
`verifyHuman`, no stronger. This path adds nothing weaker, but grants raise the stakes of that check.
|
||||
- **Approve is not "runs without a click"** until PRD 1.8 routes `host.sudo` (section 0).
|
||||
- **Enrollment still needs ops-01 once per host.** I don't see a way around it without a trust anchor on the
|
||||
host, and I'd rather say so than claim the flow is hands-free.
|
||||
- **The pin needs two reads to agree.** A wrong pin needs both the fingerprints in the request and the
|
||||
stack's network path to the host changed in the same way. Either one alone makes the raise refuse.
|
||||
- **Tags assume one stack per business id.** Two stacks that both run a business with the same id and grant
|
||||
the same agent name on the same host user would compute the same tag. Nothing runs two stacks today.
|
||||
|
||||
## 10. Tests and mutants (for the rows, not built here)
|
||||
|
||||
Tests use a scratch sshd on 127.0.0.1 with throwaway keys, as row 210 does, with `mosaic-keys` running as the
|
||||
scratch sshd's forced command. The writer's checks are tested by calling its functions with a temp dir as
|
||||
the root and a fake group and sudo lookup. The installed entry point fixes the `/etc` paths and passes no
|
||||
such argument. No real host is contacted.
|
||||
|
||||
| Test | Claim |
|
||||
|---|---|
|
||||
| G1 | a `credential.mint` raise with `grant` from a role that isn't the chief refuses `grant-chief-only`, and nothing is stored. A `grant` on any other action refuses `invalid-request` |
|
||||
| G2 | a raise for an agent whose definition lacks `host` refuses `host-not-declared` |
|
||||
| G3 | a raise with a wildcard, shell or empty argv prefix refuses, the same cases as 210 D6 |
|
||||
| G4 | the rendered block is appended by the broker, the chief's question text can't forge it, and a question over 4096 characters with the block refuses `invalid-request` |
|
||||
| G5 | a `credential.mint` raise with `grant` routes to `human`, a tester thread can't raise it, and an approved `credential.mint` without `grant` runs nothing |
|
||||
| G6 | a yes from the human via cli starts the executor, while a no, a withdraw, or an arbiter resolve start nothing |
|
||||
| G7 | executor end to end on scratch sshd: line installed with the `from=` and tag, verify login works, the grant record written with its decision id, key file 0600, `grant.applied` with the tag |
|
||||
| G8 | each failure row of X3 leaves the stated state, and a failed `remove` leaves `hostLineLeft` |
|
||||
| G9 | a registry, role definition or commands change between raise and Approve refuses `decision-mismatch` before any remote step |
|
||||
| G10 | a second start for the same decision refuses `decision-consumed`, and `#consumeGrant` refuses an unresolved or agent-resolved decision |
|
||||
| G11 | a bus restart after `install`, after the key file is written and before `commit`, and after `commit` each reconcile to the state before the decision: no key file, no grant, no line, `grant.failed interrupted` with the tag. A restart after an enrollment's `registry` step drops the entry and the installer key. A restart after the resolve commits and before step 1 spends the decision, ends `grant.failed interrupted` with a message, and nothing runs later, for a grant and for an enrollment (B2) |
|
||||
| G12 | revoke: the grant record is gone and the `pending` marker written, then in the same turn the call in flight dies and the key file is gone, then the host line is gone and the marker closed, in that order |
|
||||
| G13 | revoke with the host down still does steps 1 and 2 and reports `hostLineLeft` |
|
||||
| G14 | `organization.set`, `limits.set` and `instances.set` still refuse `hosts` and `manual`. After row a, a business file with `hosts` or a role grant refuses at load |
|
||||
| G15 | no event, message, log line or receipt holds private key material (210 D8 check on every body) |
|
||||
| G16 | `mosaic-keys` refuses bad requests without calling the writer. The writer refuses a user not matching the pattern (`../x` included), a `privileged` user not in `users`, users outside `users`, root, uid under 1000, `mosaic-installer`, `localadmin`, a member of each refused group (`disk`, `libvirt` and `incus-admin` included), a user with a `sudo -l` line who isn't in `privileged`, bad tags and bad key text, and takes `from=` from the host file |
|
||||
| G17 | a `host-enroll` raise refuses loopback and this machine's names, bad fingerprints, an enrolled id and the reserved users. A keyscan set that differs from the request's fingerprints by one key, extra or missing, refuses `host-key-mismatch` and stores nothing. On Approve the executor writes the entry and the installer key 0600, records `host.enrolled`, and its message holds no private material. Of two approved enrollments for one `hostId`, the second ends `grant.failed host-enrolled` with its decision spent, and the first record, pin and installer key are unchanged. A direct second insert fails on the primary key (B1) |
|
||||
| G18 | `hosts.check` with a wrong pin exits 255, refuses `host-key-mismatch`, runs nothing and leaves the host `pending-install` |
|
||||
| G19 | a grant naming a host that isn't `ready` refuses `host-not-ready` |
|
||||
| G20 | `hosts.check` from the chief, the tester and the human marks the host `ready`, and any other role refuses |
|
||||
| G26 | revoke by the human works. The assistant's works in its own organization and refuses another's. The tester's works where `bus.tester` is exactly `true`, and refuses `tester-dev-only` where the grant's business has it missing or set to anything else (N8). Any other role refuses |
|
||||
| G21 | `hosts.remove` refuses `host-in-use` while a grant names the host, `host-line-left` while a line is left, and any agent other than the tester |
|
||||
| G22 | one bus, no restart: enroll, check, grant, a `host.run` that works, a commands change that the next `host.run` uses, and a revoke that the next `host.run` refuses `credential-unavailable` (B1) |
|
||||
| G23 | a revoke during install (a scratch `mosaic-keys` that waits on a fifo) ends `grant.failed revoked` with no key file, no grant and no line. A revoke before the executor starts, with no reference and no claim, records `grant.revoked`, and step 1 then ends `grant.failed revoked`. A grant whose claim finds a running `host.run` ends `grant.failed host-busy` with its decision spent and a message (B2) |
|
||||
| G24 | agent and host names at 64 characters with `.` and `_` give distinct tags of the fixed length. `sweep` leaves another business's line and an unrecorded `mosaic-` line on the same user (B4) |
|
||||
| G25 | the writer refuses a symlinked `/etc/ssh/mosaic-keys`, a symlinked user file, a group-writable dir, and a tmp that is a symlink or not a regular file. A leftover regular tmp is unlinked and the write goes ahead. A first `add` creates the file. A written file is root-owned 0644 (B5, N6) |
|
||||
| G27 | `hosts.revoke` works for an agent whose business file has no credentials reference (N3). A restart after revoke step 1, and one after step 2, each end at start with no key file, no record, the line removed and the marker closed (N4). A key file left by a failed step 2 unlink is gone after the next start, though the marker closed |
|
||||
| G28 | a commands change commits at step 5, a revoke follows before `grant.applied`, and the bus restarts: the start ends with no grant record, no reference and no key file. The same with a failure after step 5 in place of the restart (C1) |
|
||||
|
||||
Mutants, a first list. The row's BUILD.md numbers them and adds more:
|
||||
|
||||
- drop the chief check
|
||||
- start the executor on a `credential.mint` with no `grant`
|
||||
- skip the `ready` check
|
||||
- skip the keyscan compare, or compare only one key
|
||||
- skip the definition hash compare
|
||||
- drop the definition check
|
||||
- skip the commands hash compare
|
||||
- skip the host record hash compare
|
||||
- swap the order of install and the grant record write
|
||||
- skip `remove` on a verify failure
|
||||
- skip the start sweep
|
||||
- skip the reconcile's remote `remove`
|
||||
- skip the `grant.step` before install
|
||||
- `host.run` reads the business file instead of the grant record
|
||||
- revoke leaves the grant record
|
||||
- skip `register` before `issue`
|
||||
- drop the abort check before `issue`
|
||||
- add an `await` between the abort check and `issue`
|
||||
- revoke ignores a claim with no reference
|
||||
- build the tag without the business
|
||||
- `sweep` removes any `mosaic-` line
|
||||
- drop `from=` in the writer
|
||||
- let the writer touch a line outside the tag pattern
|
||||
- drop `O_NOFOLLOW` on the user file
|
||||
- drop one group from the writer's refused list
|
||||
- drop the user pattern check before the path use
|
||||
- treat a `privileged` entry as enough without `users`
|
||||
- let the writer accept a sudo user not in `privileged`
|
||||
- run revoke's host step before the local steps
|
||||
- start the executor on an arbiter resolve
|
||||
- start it on `no`
|
||||
- write the private key into the event body
|
||||
- drop the "not yet enrolled" check in E2 step 1
|
||||
- drop the `hosts` primary key
|
||||
- claim before consume, or put an `await` between them
|
||||
- skip the start check for a resolved, unspent grant decision
|
||||
- make `commit` async
|
||||
- route `hosts.revoke` through `credential-verbs.mjs`
|
||||
- skip the revoke marker reconcile
|
||||
- let the undo to `previous` insert a record (an upsert)
|
||||
- let the undo to `previous` run past a newer `grant.revoked`
|
||||
- let the executor act on an abort after step 5 commits
|
||||
- skip the start sweep of key files with no record
|
||||
- treat a missing `bus.tester` as dev
|
||||
|
||||
## 11. Not in this draft
|
||||
|
||||
- Gitea and GitHub grants (`method` `gitea` and `github`). The same flow: chief raise, Jason approves, the
|
||||
executor mints a token through an admin credential Jason enters, writes the reference, and revoke deletes
|
||||
the token at the service. The admin scope is refused today (`vocabulary.mjs:105-106`), so that is a
|
||||
design of its own.
|
||||
- The Console Approve (#435) and the Console token entry. Both wait on Q175 sign-in (#412).
|
||||
- The network-confined sandbox (Sage's follow-up from 210 section 6).
|
||||
- A per-key forced command on the host (section 9).
|
||||
- Changing an enrolled host's user or sudo mark. Today that is `hosts remove` and a new enrollment.
|
||||
|
||||
## 12. Questions
|
||||
|
||||
Answered since r1:
|
||||
|
||||
- Q-S1 (sage-1527): no `grant.ask` in row a. Messaging the chief, with the definition gating the raise, is
|
||||
enough. If review finds a forgery path, the verb comes then. Duckworth agrees (N4).
|
||||
- Q-S2 (mos-1120, sage-1527): `<configDir>/secrets/hosts/<business>/<hostId>/installer`.
|
||||
- Q-S3 (sage-1527): manual mode is Honker's REQ-TRUST-2, and REQ-CRED-5 is folded into REQ-GRANT-1 to -4.
|
||||
- Q-S4 (sage-1527): the split stands, both rows are mine, and Duckworth reviews both.
|
||||
- Row a (#436a): enrollment as the approved request, the four `tools/host` files, the store records and
|
||||
their migration, `host.run` reading them, and `hosts status`. More than r2's 1 day, because of the
|
||||
migration and moving 210's tests. My estimate is 1.5 days.
|
||||
- Row b: the raise binding, the executor, reconcile and revoke, after row a. r5 adds the `hosts.revoke`
|
||||
verb and its marker, so my estimate goes from 1.5 days to 1.75.
|
||||
- Both come after row 210 lands and need the 1.8 line before landing. Target: in review by 10-24, so
|
||||
row a lands before the 10-26 installs (S1, mos-1133).
|
||||
- Q-M1 (mos-1120): Mos revokes as the tester. Replaced by mos-1132, see below and section 6.
|
||||
- Q-O1 (ops-01 through mos-1125): yes, with the six changes now in E3.
|
||||
- Q-O2 (ops-01, reply to gizmoduck-0063): the `privileged` file is accepted, with four rules and four writer
|
||||
points, all in E3 and section 9.
|
||||
- Q-S5 (mos-1124 d, through honker-0035): the assistant may revoke grants in its own organization, and the
|
||||
tester only on a dev instance. PRD 1.8 r3 (d16ccdb91) REQ-GRANT-4 keeps that. Section 6 follows it.
|
||||
- **Q-M2 (mos-1132).** ops-01 is a T3 seat and can't message a chief. Host keys never travel by message.
|
||||
The request carries expected fingerprints, and the raise runs its own keyscan (E1). On the #433 VM, Mos or
|
||||
Sage passes the fields to the chief as the tester (Q173). On .53 and .54, Jason sends the request in the
|
||||
Console (Q118), with text Mos builds from ops-01's values.
|
||||
- **Mos as a revoker (mos-1132).** Q188's "Mos" is read as the stack's assistant role, Jason's main
|
||||
interaction agent. This is Mos's reading, labelled as such, and goes in the PRD 1.8 approval notes. Jason
|
||||
settles it at prd.approve. Section 6 is written to it.
|
||||
|
||||
Open: none from this draft.
|
||||
@@ -1,538 +0,0 @@
|
||||
# Row 210 (#424, Gitea #1707): agent host access with sudo through the credential store
|
||||
|
||||
Gizmoduck, 2026-10-20, r3. Design only, no code. Reviewer Duckworth, gate owner Sage. Due 2026-10-27 (Q184).
|
||||
|
||||
- Brief: `docs/plans/2026-10-20_host-credentials.md` at 9db578ff5.
|
||||
- Base read: `origin/refactor` 9ba31b5b3. Row 199 read as DESIGN.md r6 (4d3007ec9) and its code and
|
||||
BUILD.md at addee664a on `megavolt/row199`.
|
||||
- PRD: REQ-CRED-3 in Honker's 1.7 draft (`agents/honker/work/queue-194/PRD-1.7-DRAFT.md:111-115`, 11cb0423b).
|
||||
Jason approved PRD 1.7 (d52febb5 yes at 20:36:47Z, mos-1089), so REQ-CRED-3 is in.
|
||||
- Scope (sage-1468): this row ships the `host.run` broker op, the grant check, the executor and the receipts,
|
||||
all testable over `broker.sock`. The `host_run` harness tool is out, as 199 left its publish tool out
|
||||
(199 BUILD.md deviation 12). Sage files one follow-up row for the agent-facing harness tools, publish and
|
||||
`host_run`, in `packages/harness`. Duckworth's r2 N5 (the tool help says a gated call leaves stdin under
|
||||
4 KiB) belongs to that row.
|
||||
- Branch `gizmoduck/row210`. Row claimed in-progress (rev 2361) after Sage cleared the after (rev 2359). It
|
||||
lands only after 199, rebased onto 199's landing (Q177).
|
||||
|
||||
Rounds:
|
||||
- r3 (duckworth-0043, REVIEW-r2.md at 52b5fee63, approved with C1, and sage-1468, sage-1472): C1 Q-J0's revoke
|
||||
line now matches section 6, the connection is cut and a started command may keep running. Q-S3 answered,
|
||||
the harness tool is out (scope line above, D10). N1 the runner list takes Duckworth's whole list, and D6
|
||||
says the per-call decision is the control. N2 numeric shorthand, unspecified and mapped addresses and
|
||||
`*.localhost` refuse (D3, T1). N3 the pids file records each pid's start time (D7, T27). N4 the mutant
|
||||
table is in order.
|
||||
- r2 (sage-1450): no `"any"` grant. The command boundary is an argv allowlist per host grant, and load refuses
|
||||
a wildcard (D6, T26, M6, M28). PRD 1.7 is frozen, so `host.sudo` stays a hard wait and Jason decides each
|
||||
call. Section 5 now holds the proposed 1.8 routing requirement. D5 is confirmed against 199's deviation 11.
|
||||
The seat network gap is a known limit with what closes it (section 6). Cites now point at 199's code.
|
||||
- r2 (duckworth-0042, REVIEW-r1.md at 8140b7f39): B1 events name host, user and argv in clear (D5 step 6,
|
||||
T22, M30). B2 a gated call's question shows stdin in full (D4, T9, M31). B3 the key and processes die with
|
||||
the bus, and bus start sweeps leftovers (D7, T27, M32 to M34). B4 Q-J0 for Jason in section 10, worded to
|
||||
stand alone (mos-1088). Notes taken: N1 (E12, runGit's kill shape), N2 (loopback refused, own-host limit),
|
||||
N3 (process groups), N4 (stdin can make a prefix root), N5 (`-l` and a separate address), N6 (D8 framing),
|
||||
N7 (section 7).
|
||||
|
||||
## 1. What the code does today
|
||||
|
||||
| # | Fact | Cite |
|
||||
|---|---|---|
|
||||
| E1 | The bus reads credentials for two kinds only, `gitea` and `vikunja` | `bus/src/credentials.mjs:22`, `business/src/vocabulary.mjs:103` |
|
||||
| E2 | A credential value must match `^[-A-Za-z0-9._~]+\n?$`, at least 16 and at most 16384 bytes. An OpenSSH private key fails it at the first `-----BEGIN` space | `bus/src/credentials.mjs:84-87` |
|
||||
| E3 | `assertClean` searches `JSON.stringify(result)` for each token. JSON escapes a newline, so a multi-line secret is never found: `JSON.stringify("a\nb").includes("a\nb")` is `false` (run on node here) | `bus/src/credentials.mjs:95-103` |
|
||||
| E4 | `use()` turns any exception in the operation into `service-failed`, then runs `assertClean` | `bus/src/credentials.mjs:104-117` |
|
||||
| E5 | Host to bus-child IPC is serial, one request at a time, with a 10 s timeout. A slow reply breaks the channel | `cli/src/host.mjs:31`, `:270` |
|
||||
| E6 | The web verbs run in the bus child over `broker.sock`. `requestWeb` checks keys, runs `#secretCheck`, checks the grant `roles[s.role].web`, records `action.allowed` (requested), calls `onAdmit()` so the server raises the socket timeout, runs the service and records completion | `bus/src/broker.mjs:1492-1530`, `bus/src/server.mjs:87-95` |
|
||||
| E7 | `roles[role].web` comes from the resolved tool limit. A role entry in the bus holds `authority`, `web` and `credentials` | `bus/src/business.mjs:17-20` |
|
||||
| E8 | A business-file role entry takes `definition`, `holder`, `vars`, `tracker`, `credentials`. Anything else refuses at load | `business/src/business.mjs:89` |
|
||||
| E9 | The chief's `organization.set` takes only `ORGANIZATION_KEYS`. The human-only `instances.set` takes `INSTANCE_KEYS` and refuses any definition that declares a credential (`organization-credentials-out-of-scope`). Holder, credentials and tracker stay hand edits | `bus/src/broker.mjs:145-147`, `:1248-1298`, `business/src/instance-settings.mjs:1-5`, `:22-31` |
|
||||
| E10 | An action outside ROUTED is a hard wait and classifies `gated` for every role. No role file may grant one | `bus/src/broker.mjs:44-70`, `business/src/vocabulary.mjs:58-88`, PRD REQ-WAIT-2 and 3 |
|
||||
| E11 | The seat sandbox does not confine the network, abstract Unix sockets or the host UID | `seat/src/sandbox.mjs:29-31` |
|
||||
| E12 | On refactor the bus child spawns nothing. With 199 it spawns git: `publishRemote` (`bus/src/runtime.mjs:245` at addee664a) runs `runGit`, a detached group leader that gets SIGTERM at the limit, then SIGKILL to the group after a grace period | `seat/src/checkout.mjs:95-138` at addee664a |
|
||||
| E13 | Role networks are `none`, `api-only`, `open`. The bundle carries the value, and the sandbox's own comment says the network is not confined (E11) | `business/src/vocabulary.mjs:101`, `harness/src/bundle.mjs:252` |
|
||||
|
||||
Row 199 gives, in code at addee664a: `useFor` (`bus/src/credentials.mjs:228`), `issue` (`:156`), `revoke`
|
||||
(`:197`), `remove` (`:211`), the kind list (`:49`), the token pattern (`:19`, `:143`), the store dir's file
|
||||
regex `^(gitea|vikunja)(\.tmp)?$` (`:222`) and `withGitea` (`bus/src/runtime.mjs:233`). From its design: the store at `<configDir>/secrets/agents/<business>/<agent>/<kind>` (D1), the `{store: true}`
|
||||
reference (D2), the read path (D3, D3a), `useFor(session, kind, op)` with the call-site allowlist test (D5),
|
||||
the `publish` grant shape, a list on the role entry plus a hard-wait action whose decision binds the commit
|
||||
(D9), and the human-only lifecycle verbs with `changes.jsonl` receipts (section 6).
|
||||
|
||||
## 2. Probe (throwaway keys, scratch sshd on 127.0.0.1:22410, OpenSSH 10.5p1)
|
||||
|
||||
Run in `/mnt/storage/scratch/gizmo-210-probe` with keys made for the probe and deleted after. No real key or
|
||||
host was touched.
|
||||
|
||||
| Probe | Result |
|
||||
|---|---|
|
||||
| `ssh-add -` with the private key on stdin, to an agent on a private socket | works, "Identity added: (stdin)" |
|
||||
| `IdentityAgent=<sock>` with `IdentitiesOnly=yes` and `IdentityFile=none` | refused, `Permission denied (publickey)`. With `IdentitiesOnly`, ssh offers only agent keys that match a configured identity |
|
||||
| same with `IdentityFile=<the public key file>` | works, rc 0 |
|
||||
| `-i /dev/fd/3` with the key on fd 3 | fails, "Identity file /dev/fd/3 not accessible". ssh closes inherited fds, so the fd route is out |
|
||||
| a known_hosts file pinning the wrong key under `HostKeyAlias` with `StrictHostKeyChecking=yes` | refused, "Host key verification failed", rc 255 |
|
||||
| `printf '%s|'` with `'a b'` and `'it'\''s'` single-quoted, plus an unquoted `$((1+1))` as a control | prints `a b|it's|2|`. The quoted elements arrive whole, and only the unquoted control expanded |
|
||||
| remote `exit 7` | ssh exits 7 |
|
||||
| killing the agent | its socket file is gone |
|
||||
|
||||
## 3. Decisions
|
||||
|
||||
### D1. The kind and its naming
|
||||
|
||||
One key per (agent, host). The kind name is `host.<hostId>`, so the store file is
|
||||
`<configDir>/secrets/agents/<business>/<agent>/host.<hostId>` (199 D1, unchanged). `hostId` matches
|
||||
`^[a-z0-9][a-z0-9-]{0,31}$`.
|
||||
|
||||
The closed kind set becomes `gitea`, `vikunja` and the family `host.<hostId>`. One exported helper,
|
||||
`credentialKind(name)` in `vocabulary.mjs`, answers whether a name is a kind, and each place that checks
|
||||
kinds calls it: `SERVICES` users (`role.mjs:105-118`), 199's `bus/src/credentials.mjs:49` and the store
|
||||
file regex at `:222`, the broker's service
|
||||
check, the CLI `issue` date flag and 199's remove regex. A build test greps for literal `'gitea', 'vikunja'`
|
||||
lists outside `vocabulary.mjs` and fails on a new one.
|
||||
|
||||
Why per host and not per agent. A key that leaks, or a host that is retired, is revoked for that host alone.
|
||||
ops-01 installs one public key per host, and an `authorized_keys` line names one agent. The cost is more keys
|
||||
for Jason to issue, one per grant.
|
||||
|
||||
A role definition declares the kind once as `{"service": "host"}` with no scopes. The business file binds the
|
||||
concrete hosts (D3). A definition can't name a host, because hosts belong to one stack instance.
|
||||
|
||||
The date is `--rotate-by DATE`, as for gitea. An ssh key doesn't expire on its own, so `status()` reports
|
||||
`rotation-due` or `valid`, the gitea branch.
|
||||
|
||||
### D2. The value check
|
||||
|
||||
`checkValue(kind, text)` replaces the one regex at `credentials.mjs:84`. For `gitea` and `vikunja` it is
|
||||
today's check, unchanged. For `host.*` the value must be:
|
||||
|
||||
- PEM framed by `-----BEGIN OPENSSH PRIVATE KEY-----` and `-----END OPENSSH PRIVATE KEY-----`, LF line ends,
|
||||
at most 16384 bytes (the existing cap).
|
||||
- An `openssh-key-v1` blob with cipher `none`, kdf `none`, exactly one key, type `ssh-ed25519`. Parsed in
|
||||
node with `Buffer`, no ssh-keygen call, so the check runs at bus start without a process.
|
||||
- The two check integers equal, and the public key in the private section equals the one in the header.
|
||||
|
||||
Anything else refuses `credential-format`. An encrypted key refuses, because the bus has no way to ask for a
|
||||
passphrase and `BatchMode=yes` would fail at use time instead of at load. The parsed public key is kept with
|
||||
the entry so `status` and D7 can print its fingerprint without touching the private part.
|
||||
|
||||
### D3. The grant
|
||||
|
||||
Four layers. Each must hold, and the code checks all four on every call.
|
||||
|
||||
1. **Role definition, reviewed commit.** `roles/<def>.json` lists `{"service": "host"}` in `credentials`. That
|
||||
is the "reviewed role change" in the brief. Once a PRD version routes `host.sudo` (section 5), the same
|
||||
reviewed commit puts `host.sudo` in that definition's `authority.withinRole`.
|
||||
2. **Host registry, business file, hand edit.** A new top-level key:
|
||||
```
|
||||
"hosts": { "<hostId>": { "address": "10.1.1.53", "port": 22, "user": "localadmin",
|
||||
"hostKeys": ["ssh-ed25519 AAAA..."] } }
|
||||
```
|
||||
`address` is an IPv4 or IPv6 literal or a DNS name matching `^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$`.
|
||||
An address of this machine refuses at load (r2 N2, r3 N2). For an IP literal, which `net.isIP` decides,
|
||||
that is `127.0.0.0/8`, `0.0.0.0/8`, `::1`, `::`, and an IPv4-mapped `::ffff:` form of either IPv4 range.
|
||||
For a name, it is `localhost` and any name ending in `.localhost`. A name made only of digits and dots
|
||||
refuses unless `net.isIP` reads it as IPv4, so the shorthand `127.1` and the integer `2130706433`, which
|
||||
pass the DNS pattern and which the resolver reads as 127.0.0.1, refuse. A label starting `0x` refuses for
|
||||
the same reason. `port` is 1 to 65535. `user` matches `^[a-z_][a-z0-9_-]{0,31}$`. `hostKeys` is a non-empty list of
|
||||
`ssh-ed25519`, `ecdsa-sha2-nistp256` or `ssh-rsa` public key lines, type and base64 only, no comment.
|
||||
3. **Agent grant, business file, hand edit.** A new role-entry key beside 199's `publish`:
|
||||
```
|
||||
"hosts": { "<hostId>": { "commands": [["systemctl", "status"], ["journalctl", "-u"]] } }
|
||||
```
|
||||
Each `hostId` must be in the registry, and the role's definition must declare `host`. A role entry with
|
||||
`hosts` whose definition lacks `host` refuses at load and names `roles.<agent>.hosts`. `commands` is the
|
||||
command boundary (D6).
|
||||
4. **The key.** `credentials["host.<hostId>"] = {"store": true, "rotateBy": DATE}` with the file present
|
||||
(199 D2, D3a). A grant without an issued key refuses `credential-unavailable` at call time, as 199 does.
|
||||
|
||||
Who writes each layer is in section 4. Neither the chief nor any agent can reach layers 2 to 4: `hosts` is
|
||||
outside `ORGANIZATION_KEYS` and `INSTANCE_KEYS` (E9), `instances.set` already refuses a definition that
|
||||
declares credentials (E9), and 199's `issue` is human-only.
|
||||
|
||||
### D4. The action and the decision binding
|
||||
|
||||
`host.sudo` is a new action in ACTIONS and in neither ROUTED list. Until a PRD version routes it, it is a hard
|
||||
wait and classifies `gated` for every role (E10). Each call then needs a decision the human resolved. PRD 1.7
|
||||
is frozen (sage-1450, decision d52febb5 cites its sha256), so under 1.7 Jason decides every `host.run`.
|
||||
|
||||
The decision binds one call, the way 199 D9 binds `pushCommit`. When the action is `host.sudo`, the
|
||||
`decision.raise` event body gets `hostRun = {host, argvSha256, stdinSha256}`, from the raise's own args. The
|
||||
question shown to the human ends with the host id, the user, the argv with each element JSON-quoted, and the
|
||||
stdin in full as one JSON string, so Jason sees all of what runs (B2). The broker appends that text itself, and
|
||||
the agent can't shape it. The whole question keeps the 4096-character cap (`broker.mjs:126`, `:1654`), so on a
|
||||
gated call stdin is as large as the cap allows and no larger. A raise that doesn't fit refuses
|
||||
`invalid-request`. `argv ["bash"]` can't be granted at all (D6), and `["tee", "/etc/x"]` with a stdin Jason
|
||||
can't see is ruled out by showing it. `#authorityFor` refuses `decision-mismatch` when the call's host, argv hash or stdin hash differs, as
|
||||
`holderRun` does at `broker.mjs:796`. A decision is spent once (`decision-consumed`).
|
||||
|
||||
After routing, a definition that lists `host.sudo` within-role runs without a decision, and `decision` is
|
||||
optional for that class only. The grant layers in D3 still apply.
|
||||
|
||||
### D5. The executor: a `broker.sock` verb in the bus child
|
||||
|
||||
```
|
||||
host.run {"host", "argv": [..], "stdin"?, "timeout"?, "decision"?}
|
||||
-> {"exit", "signal", "stdout", "stderr", "truncated": {"stdout", "stderr"}, "ms"} or a refusal
|
||||
```
|
||||
|
||||
It runs in the bus child, shaped like `requestWeb` (E6), not as a host IPC op. A sudo command can take
|
||||
minutes and the host channel times out at 10 s and serves one request at a time (E5). The key is in the bus
|
||||
child's memory already (199 D3), so no ticket has to cross to the CLI host. Row 199's publish needs a ticket
|
||||
because git and the working repo live in the CLI host. ssh needs neither. 199's BUILD.md deviation 11
|
||||
(addee664a) keeps credential use in the bus child too: the push runs there under `withGitea`, and the host
|
||||
socket only checks and polls. Sage confirmed D5 matches that shape (sage-1450). `host.run` needs no host
|
||||
socket and no poll, because the agent calls `broker.sock` directly. The helper is `withHost(session, host,
|
||||
fn)`, beside `withGitea`, calling `useFor(session, "host." + host, fn)`.
|
||||
|
||||
Order of checks. Each refusal goes through `broker.refuse()` and logs `action.refused`.
|
||||
|
||||
1. `keys(args)`, then `#secretCheck(args)`, then identity from the cap.
|
||||
2. `host` is in the registry (`host-unknown`).
|
||||
3. `host` is in `roles[s.role].hosts` (`host-not-granted`). This is the REQ-CRED-3 check "a grant exists for
|
||||
that agent and that host".
|
||||
4. `argv` shape (D6) and `commands` match (`command-not-granted`).
|
||||
5. `#checkAuthority(s, "host.sudo", {decision, target: host, context})` (D4).
|
||||
6. Record `action.allowed` phase `requested` with host, address, user and argv in clear (B1), and stdin as
|
||||
size and sha256. Every arg already passed `#secretCheck` in step 1, as the web verbs log their url
|
||||
(`broker.mjs:1521`). After routing, the event log is the record of what ran as root. Then `onAdmit()`, which
|
||||
sets the socket timeout to the call's timeout plus 10 s, as the web branch does (`server.mjs:95`).
|
||||
7. `credentials.useFor(s, "host." + host, run)`, where `run` is D7.
|
||||
8. D8's leak check on the output, then `#secretCheck` on the result.
|
||||
9. Record phase `completed` with host, argv, exit, signal, ms, and size and sha256 of stdout and stderr. The
|
||||
output itself never goes into an event. A failure records `host-failed` with the same fields.
|
||||
|
||||
Only one `host.run` per (agent, host) runs at a time. A second refuses `host-busy`. That keeps a stuck call
|
||||
from piling up ssh processes and makes revoke (D9) simple.
|
||||
|
||||
Spawns reuse `runGit`'s shape (E12): an argv array, never a shell, `detached: true` so each process leads its
|
||||
own group, SIGTERM to the group at the limit and SIGKILL after a grace period. If the helper can be shared, it
|
||||
is, rather than a second copy. `env` is `{PATH: "/usr/bin:/bin"}` only, plus `SSH_AUTH_SOCK` for `ssh-add`.
|
||||
Each spawn is wrapped in `setpriv --pdeathsig KILL --`, the prefix the seat sandbox already uses
|
||||
(`seat/src/sandbox.mjs:44`), so it dies when the bus child dies (B3).
|
||||
|
||||
### D6. The command boundary
|
||||
|
||||
`argv` is an array of 1 to 256 strings. Each is 1 to 4096 bytes of UTF-8 with no NUL, and the total is at most
|
||||
32768 bytes. `argv[0]` must not contain `/` unless it starts with `/`. `stdin` is optional, a string of at most
|
||||
65536 bytes, and goes through `#secretCheck` with the args.
|
||||
|
||||
The remote command is built by the bus, never taken as a string:
|
||||
|
||||
```
|
||||
sudo -n -- 'argv0' 'argv1' ...
|
||||
```
|
||||
|
||||
Each element is POSIX single-quoted: wrap in `'`, and replace each `'` with `'\''`. The probe shows the
|
||||
remote shell gets each element back exactly (section 2). `sudo -n` fails instead of prompting, so a host
|
||||
without NOPASSWD returns sudo's error, not a hang. The host's login shell for `user` must be POSIX. That is a
|
||||
host precondition for ops-01, listed in section 7.
|
||||
|
||||
`commands` in the grant is an argv allowlist and nothing else. It is a non-empty list of argv prefixes. An
|
||||
argv passes when its first N elements equal some prefix of length N, compared element by element as literal
|
||||
strings, with no globbing. `[["systemctl", "status"]]` admits `systemctl status nginx` and refuses
|
||||
`systemctl restart nginx`.
|
||||
|
||||
There is no wildcard grant (sage-1450). Business-file load refuses, naming `roles.<agent>.hosts.<id>.commands`:
|
||||
|
||||
- any value that isn't a list, including the strings `"any"` and `"*"`,
|
||||
- an empty list, an empty prefix, a non-string element, or a duplicate prefix,
|
||||
- an element containing `*`, `?` or `[`, so nobody writes a glob that the matcher would read literally,
|
||||
- a prefix whose first element, by basename, is a shell, a command runner or an interpreter. Shells: `sh`,
|
||||
`bash`, `dash`, `zsh`, `ksh`, `fish`, `busybox`. Runners: `su`, `sudo`, `doas`, `pkexec`, `runuser`, `env`,
|
||||
`xargs`, `nohup`, `setsid`, `timeout`, `nice`, `systemd-run`, `chroot`, `nsenter`, `unshare`, `setpriv`,
|
||||
`capsh`, `flock`, `stdbuf`, `ionice`, `chrt`, `taskset`, `strace`, `script`, `watch`. Interpreters:
|
||||
`python3`, `perl`, `ruby`, `node`, `awk` (Sage's ruling on duckworth-0043 N1). Each would turn the prefix
|
||||
back into "any". T26 has one case per name.
|
||||
|
||||
That list can't be complete, and it isn't the control. Under 1.7 the control is Jason's decision on each
|
||||
call, bound to host, argv and stdin (D4, Q-S1). The list only refuses the obvious cases at load, early. Many commands can still start a shell or an editor as root (`systemctl edit`,
|
||||
`find -exec`, interpreters). The allowlist limits mistakes and narrows what a grant means, but it is not a
|
||||
sandbox. Stdin goes to the command, so some prefixes are root on their own: `[["tee"]]` or `[["dd"]]` writes
|
||||
any file as root from stdin (N4). Section 6 says what remains.
|
||||
|
||||
Output: stdout and stderr each capped at 1 MiB. Past the cap the bus keeps reading and drops the rest so the
|
||||
remote side doesn't block, and sets `truncated`. `timeout` defaults to 60 s, maximum 900 s. At the timeout
|
||||
the bus sends SIGTERM to the ssh process group, then SIGKILL after 5 s, and answers `signal`. ssh's own exit
|
||||
code 255 and a remote command exiting 255 can't be told apart. The result returns stderr so the agent can see
|
||||
which, and the doc says so.
|
||||
|
||||
### D7. Handing the key to ssh
|
||||
|
||||
Per call, inside `useFor`'s operation:
|
||||
|
||||
1. `mkdtemp` a dir under `<configDir>/secrets/run/` with mode 0700. `<configDir>` is never bound into a sandbox
|
||||
(199 D3). The socket path is checked against the 107-byte Unix socket limit before use, refusing
|
||||
`host-failed` if over.
|
||||
2. Write two files in it, mode 0600: `known_hosts` with one line per pinned key, `mosaic-host-<hostId> <type>
|
||||
<base64>`, and `id.pub` with the entry's public key (D2).
|
||||
3. Write `<dir>/pids` with the bus child's pid. Each line in it is a pid and that process's start time,
|
||||
field 22 of `/proc/<pid>/stat`, so a reused pid never matches (duckworth-0043 N3). Start `ssh-agent -D -a <dir>/a.sock` as its own group leader,
|
||||
append its pid, and wait for the socket. Run `ssh-add -t <timeout + 30> -` with `SSH_AUTH_SOCK` set and the
|
||||
private key written to its stdin. The key never touches disk, argv or env. `-t` makes ssh-agent drop the key
|
||||
at the call's timeout plus 30 s even if nothing else runs (B3). ssh is the second group leader, and its pid
|
||||
is appended too. Timeout, revoke and the `finally` kill both groups, ssh-agent's and ssh's (N3).
|
||||
4. Run ssh with exactly these options and nothing from any config file:
|
||||
```
|
||||
ssh -F /dev/null -T -p <port>
|
||||
-o BatchMode=yes -o IdentitiesOnly=yes -o IdentityFile=<dir>/id.pub -o IdentityAgent=<dir>/a.sock
|
||||
-o StrictHostKeyChecking=yes -o UserKnownHostsFile=<dir>/known_hosts -o GlobalKnownHostsFile=/dev/null
|
||||
-o HostKeyAlias=mosaic-host-<hostId> -o UpdateHostKeys=no -o CheckHostIP=no -o VerifyHostKeyDNS=no
|
||||
-o ForwardAgent=no -o ForwardX11=no -o ClearAllForwardings=yes -o PermitLocalCommand=no
|
||||
-o ProxyCommand=none -o ProxyJump=none -o ControlMaster=no -o ControlPath=none
|
||||
-o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3
|
||||
-l <user> -- <address> <remote command from D6>
|
||||
```
|
||||
The user goes in `-l` and the address is its own argument, so nothing depends on how ssh splits `@` or on
|
||||
an IPv6 literal (N5). `-F /dev/null` stops `~/.ssh/config` from adding a ProxyCommand, a different key or forwarding. The pin is
|
||||
`StrictHostKeyChecking=yes` against a file the bus wrote from the registry, so there is no trust on first
|
||||
use: an unknown or changed host key refuses with rc 255 (probe, section 2) and the bus answers
|
||||
`host-key-mismatch` when stderr has "Host key verification failed".
|
||||
5. In a `finally`: kill both groups, remove the dir. A test asserts the dir is gone after success, refusal,
|
||||
timeout and an exception thrown inside the operation.
|
||||
6. If the bus child is killed, the `finally` never runs. `--pdeathsig` kills ssh-agent and ssh with it, and
|
||||
`-t` drops the key anyway. At bus start, before `broker.sock` listens, the bus sweeps
|
||||
`<configDir>/secrets/run/`. A recorded pid counts as alive only when it exists and its start time matches.
|
||||
For each dir whose bus pid is no longer alive, it sends SIGKILL to each recorded pid that is alive and
|
||||
whose `/proc/<pid>/cmdline` names that dir, then removes the dir. A dir whose bus pid is alive is left
|
||||
alone. Any other entry there refuses start, as 199 refuses a stray file
|
||||
in the store (`credentials.mjs:222`). Revoke after a restart has nothing in flight to reach.
|
||||
|
||||
The 0600 temp key file route the brief allows is not used. The agent route keeps the private key off disk,
|
||||
and the fd route doesn't work (probe).
|
||||
|
||||
### D8. The leak check
|
||||
|
||||
This is defense in depth (N6). The remote never holds the private key and forwarding is off, so output can
|
||||
carry the key only if the agent already had it. `assertClean` misses a multi-line key (E3). A `host.*` entry
|
||||
in the shared token map makes its loop compare against a value that never matches, which is harmless. For a `host.*` entry, `useFor` checks the result with
|
||||
`assertCleanKey(result, entry)`, which searches the JSON text for:
|
||||
|
||||
- each base64 body line of the PEM of 24 characters or more,
|
||||
- the whole body with line breaks removed,
|
||||
- the 32-byte ed25519 private seed as lowercase hex and as base64 at each of the three byte offsets.
|
||||
|
||||
A match refuses `credential-leak`. The output is dropped, and the event records only sizes and hashes. The
|
||||
existing `assertClean` still runs for the token kinds. The same function runs on `stdin` before the call, so
|
||||
an agent can't round-trip a key it somehow has back through the host.
|
||||
|
||||
### D9. Lifecycle
|
||||
|
||||
199's verbs carry over with `KIND = host.<hostId>`. Two additions:
|
||||
|
||||
- `issue AGENT host.<id>` checks D2, then prints the public key line and a suggested `authorized_keys` line
|
||||
for ops-01: `from="<stack host address>",restrict <type> <base64> mosaic-<business>-<agent>-<id>`. It
|
||||
prints only public material.
|
||||
- `revoke` also kills a `host.run` in flight for that agent and host (process group SIGKILL) before it drops
|
||||
the key. The call answers `credential-unavailable`. Remote processes that the killed ssh started may keep
|
||||
running (section 6).
|
||||
|
||||
`status` shows each host entry with its fingerprint (SHA256 of the public key), the rotate date and state.
|
||||
The tester gets `status` and `revoke` only, as in 199.
|
||||
|
||||
### D10. Where the files change
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `business/src/vocabulary.mjs` | `host.sudo` in ACTIONS, `credentialKind`, host id pattern |
|
||||
| `business/src/business.mjs` | top-level `hosts` registry, role-entry `hosts`, load checks from D3 |
|
||||
| `business/src/role.mjs` | `{"service": "host"}` in `checkCredentials` with no scopes |
|
||||
| `business/src/credentials.mjs` | `host.*` refs take `rotateBy` |
|
||||
| `bus/src/credentials.mjs` | kind check via `credentialKind`, `checkValue` (D2), `assertCleanKey` (D8), busy and kill map |
|
||||
| `bus/src/host-run.mjs` (new) | D6 argv and quoting, D7 ssh-agent and ssh spawn, cleanup |
|
||||
| `bus/src/broker.mjs` | `host.run` verb (D5), `host.sudo` in `#authorityFor` with `hostRun` (D4) |
|
||||
| `bus/src/server.mjs` | `host.run` branch beside the web branch with `onAdmit` |
|
||||
| `bus/src/business.mjs` | carry `hosts` onto the bus role entry |
|
||||
| `cli` | 199's `credentials issue` accepts `host.<id>`, prints D9's lines |
|
||||
|
||||
No `packages/harness` file changes. The `host_run` tool is Sage's follow-up row (sage-1468).
|
||||
|
||||
Row 199's own kind and regex are not changed inside row 199. This row rebases onto 199's landing and changes
|
||||
them here.
|
||||
|
||||
## 4. Who grants (brief item, "yours to propose with reasons")
|
||||
|
||||
Proposal: **Jason, at every layer that widens access. No agent, chief included, and not the tester.**
|
||||
|
||||
| Layer | Who | How | Why |
|
||||
|---|---|---|---|
|
||||
| Definition declares `host` (and later lists `host.sudo`) | Jason | reviewed commit to `roles/*.json`, as for any role authority (AGENTS.md invariant 7) | It decides which kinds of agent may ever hold a host key. Review puts a second seat on it |
|
||||
| Host registry and pins | Jason, with ops-01's key fingerprints | hand edit of the business file | The pin is the host identity. A wrong pin is a man-in-the-middle on root. It must come from the host side, not from a first connection |
|
||||
| Agent grant `roles.<agent>.hosts` | Jason | hand edit of the business file | Q183: "not all agents will need to have that access". The per-agent, per-host choice is his |
|
||||
| Key `issue` | Jason | 199's human-only `issue` | The key passes through the caller. The tester is an agent (199 section 6, duckworth N1) |
|
||||
| `authorized_keys` on the host | ops-01 | host side, from the line `issue` prints | ops-01 owns the hosts (brief) |
|
||||
| Revoke | Jason, Mos, or the tester | `revoke` | Taking access away should be quick and needs no new trust |
|
||||
|
||||
Why not a reviewed commit for the agent grant too. The business file lives in `<configDir>`, outside the
|
||||
repository (AGENTS.md invariant 2), and per-instance grants already stay hand edits there (E9, 199 D9 step 2).
|
||||
Moving grants into the repo would put host addresses and the per-instance roster under review, which is a
|
||||
bigger change than this row. The definition layer is the reviewed one.
|
||||
|
||||
Why not the chief. A chief with this power could hire an agent and grant it root. 199 already keeps hires
|
||||
uncredentialed (199 D13). `instances.set` refuses credentialed definitions today (E9), and this row keeps it
|
||||
that way.
|
||||
|
||||
REQ-CRED-3 says the who-grants answer is set by this design and approved by Jason. The table above is that
|
||||
answer.
|
||||
|
||||
## 5. The routing gap and a proposed 1.8 requirement
|
||||
|
||||
PRD 1.7 routes `role.hire`, `role.fire` and `git.push.remote`, not `host.sudo`, and 1.7 is frozen (sage-1450).
|
||||
This row builds for 1.7 as it stands. `host.sudo` is a hard wait under REQ-WAIT-3, and Jason decides each
|
||||
`host.run`, bound to host, argv and stdin (D4). That is safe, and slower than Q183's "allow agents to perform
|
||||
their jobs". The routing goes in 1.8. Sage takes the line below to Mos for Jason. I don't edit the PRD.
|
||||
|
||||
Proposed for PRD 1.8, beside REQ-CRED-3:
|
||||
|
||||
> **REQ-CRED-4.** The action `host.sudo` is routed. A role whose definition lists it within-role may run a
|
||||
> command with sudo on a host without a decision for each call, but only when all of these hold: the
|
||||
> definition declares the host credential kind, the business file grants that agent that host, the command
|
||||
> matches the grant's argv allowlist, and the agent's key for that host is issued. A grant names exact
|
||||
> argv prefixes. A wildcard grant is refused. A role without `host.sudo` within-role still needs a decision
|
||||
> for each call. Jason adds `host.sudo` to a definition only through a reviewed role change.
|
||||
|
||||
Routing is then one reviewed commit: `host.sudo` into both ROUTED lists and into the chosen definitions'
|
||||
`withinRole`, as 199 does for `git.push.remote`.
|
||||
|
||||
The brief also says this row lands only after the PRD version holding REQ-CRED-3 is approved. Jason approved
|
||||
1.7 (d52febb5 yes at 20:36:47Z, mos-1089), so that holds. 1.7 doesn't route `host.sudo`, so Q-S1 stands.
|
||||
|
||||
## 6. Limits, said plainly
|
||||
|
||||
- **Known limit: the seat network isn't confined, so root can outlive the grant.** `seat/src/sandbox.mjs:29-31`
|
||||
says the sandbox doesn't confine the network (E11). An agent whose allowlist admits any command that can
|
||||
write a file as root, or start a shell, can add its own key to `authorized_keys` on the host. It can then
|
||||
ssh straight from its sandbox, around the bus, the events and revoke. An `authorized_keys` `from=` option
|
||||
doesn't help, because the sandbox shares the stack host's address. The allowlist (D6) narrows this but
|
||||
doesn't close it.
|
||||
What closes it: a seat sandbox with its own network namespace (`bwrap --unshare-net`), where the only way
|
||||
out is the bus sockets and the model proxy, so role network `none` and `api-only` are enforced and not just
|
||||
carried in the bundle (E13). With that in place, a planted key is useless from the sandbox. Until then,
|
||||
ops-01's host-side audit (sudo log, `authorized_keys` changes) is the check after the fact. Sage is
|
||||
reporting the gap to Mos as its own follow-up (sage-1450). It is not this row.
|
||||
- **The stack's own machine.** A registry entry for the machine the stack runs on, or any machine holding the
|
||||
stack's `<configDir>`, gives an agent with a root-writing command every agent's keys and tokens, not only
|
||||
root there (N2). Load refuses loopback addresses, but it can't know every address of the stack's machine.
|
||||
Jason doesn't register it.
|
||||
- **Revoke stops the stack's path, not remote processes.** Killing ssh may leave a remote process running.
|
||||
`-T` gives no tty, so there is no SIGHUP from a closing terminal.
|
||||
- **Same OS user.** Anything running as the stack's OS user outside a sandbox can read the key file, as 199 D6
|
||||
says for every kind.
|
||||
- **Exit 255** is ssh's failure code and also a remote exit code (D6).
|
||||
- **The pin trusts the registry.** Whoever writes the business file controls which host key is trusted. That
|
||||
is Jason (section 4).
|
||||
|
||||
## 7. Host preconditions (ops-01, not this row)
|
||||
|
||||
- `user` has NOPASSWD sudo and a POSIX login shell. `sudo` has no `requiretty` for that user.
|
||||
- The agent's key is in `authorized_keys` with `from="<stack host>",restrict`.
|
||||
- The host's public keys are handed to Jason for the registry.
|
||||
- Duckworth's #423 r5 condition (duckworth-0035) applies. If ops-01 changes #423 step 1 to fit this design,
|
||||
for example per-agent `from=...,restrict` lines for localadmin, that change goes to Duckworth as a diff
|
||||
before it runs. That is the live part, not this row (N7).
|
||||
|
||||
## 8. Tests
|
||||
|
||||
All tests use a scratch sshd on 127.0.0.1 started by the test, with keys made by the test, and a fake `sudo`
|
||||
on the test user's PATH that prints its argv as JSON and runs nothing as root. The sshd config sets
|
||||
`AuthorizedKeysFile` and `HostKey` in the test's `mkdtemp` dir. A helper skips the ssh tests with a visible
|
||||
reason only when `/usr/bin/sshd` is missing, and the gate fails if any of them skipped. No test reaches
|
||||
another host.
|
||||
|
||||
| # | Test | Refusal or result |
|
||||
|---|---|---|
|
||||
| T1 | business load: `hosts` registry field checks, bad address, each own-machine form from D3 (`127.0.0.2`, `0.0.0.0`, `::1`, `::`, `::ffff:127.0.0.1`, `::ffff:0.0.0.0`, `localhost`, `a.localhost`, `127.1`, `2130706433`, `0x7f.1`), with `10.1.1.53` and `host.example` as controls, port, user, empty `hostKeys`, a key line with a comment | load refuses, names the key path |
|
||||
| T2 | role entry `hosts` naming an unknown host, or on a definition without `host` | load refuses `roles.<agent>.hosts` |
|
||||
| T3 | `organization.set` and `instances.set` with `hosts` in args | `invalid-request`, and the file is unchanged |
|
||||
| T4 | value check: a valid unencrypted ed25519 key loads. An encrypted key, an RSA key, two keys, CRLF, a gitea token in a `host.*` slot, and a PEM in a `gitea` slot each refuse | `credential-format` |
|
||||
| T5 | `host.run` with an unknown host | `host-unknown` |
|
||||
| T6 | `host.run` on a host not in the caller's grant, including a host granted to another agent | `host-not-granted` |
|
||||
| T7 | argv shape: empty, a NUL, too long, too many, a relative path with `/`, non-string | `invalid-request` |
|
||||
| T8 | allowlist: a matching argv runs, a longer argv runs, a shorter one and a differing element refuse | `command-not-granted` |
|
||||
| T9 | before routing: no decision refuses, a decision for another argv, another host or another stdin refuses, a spent one refuses. The stored question carries host, user, argv and the full stdin, and a raise whose question would pass 4096 characters refuses | `gated`, `decision-mismatch`, `decision-consumed`, `invalid-request` |
|
||||
| T10 | quoting: argv with spaces, quotes, `$()`, backticks, `;`, newline and glob characters reaches fake sudo unchanged | fake sudo's JSON equals argv |
|
||||
| T11 | the remote command starts `sudo -n --` | fake sudo sees `-n` and `--` first |
|
||||
| T12 | exit code and signal pass through, timeout kills the process group and answers `signal` | result fields |
|
||||
| T13 | output cap: 2 MiB of stdout returns 1 MiB and `truncated.stdout: true`, and the call doesn't hang | result fields |
|
||||
| T14 | host key pin: the sshd presents a key not in `hostKeys` | `host-key-mismatch`, and no command ran (fake sudo's marker file absent) |
|
||||
| T15 | a registry with the right key passes on a fresh dir with no `~/.ssh/known_hosts` in HOME. A HOME with a `~/.ssh/config` setting a ProxyCommand doesn't change the call | runs, and the proxy marker is absent |
|
||||
| T16 | leak: the fake sudo prints the private key file (multi-line), and in a second case prints only one body line, and in a third the seed in hex | `credential-leak`, the event has no output |
|
||||
| T17 | `stdin` containing a body line of the key | `credential-leak` before any spawn |
|
||||
| T18 | cleanup: after success, refusal, timeout and a thrown error, the run dir is gone and no `ssh-agent` or `ssh` with that dir in argv survives | dir absent, pgrep empty |
|
||||
| T19 | the private key never appears in the argv or env of any spawned process (read from `/proc/<pid>/cmdline` and `environ` while the fake sudo sleeps) | absent |
|
||||
| T20 | second concurrent call for the same agent and host | `host-busy` |
|
||||
| T21 | revoke during a sleeping call kills it, and the next call refuses | `credential-unavailable` |
|
||||
| T22 | events: `action.allowed` requested and completed carry host, address, user and argv in clear, and sizes and hashes of stdin, stdout and stderr. Never the output, the stdin or the key | event bodies |
|
||||
| T23 | `issue` of a `host.*` kind prints the public line and the `authorized_keys` suggestion and never the private key | stdout and stderr checked |
|
||||
| T24 | build grep: no literal `'gitea', 'vikunja'` kind list outside `vocabulary.mjs`, and 199's `credentials.use(` allowlist unchanged | grep |
|
||||
| T25 | no role file in `roles/` lists `host.sudo` while it is a hard wait (REQ-WAIT-2) | grep |
|
||||
| T27 | SIGKILL the bus child while fake sudo sleeps: the ssh and ssh-agent pids are gone within 2 s. A call with `timeout` 1 leaves an agent whose key is gone after 31 s (`ssh-add -l` on its socket, agent kept alive by the test). The next bus start removes the run dir, kills a planted live process whose cmdline names it, leaves a dir whose bus pid is alive, and leaves a live process whose pid matches but whose start time doesn't | pids, `ssh-add -l`, dir state |
|
||||
| T26 | load refuses `commands` as `"any"`, `"*"`, `{}`, `[]`, `[[]]`, a non-string element, a duplicate prefix, an element with `*`, `?` or `[`, and a prefix starting with each listed shell or runner, `/bin/bash` included | load refuses, names `roles.<agent>.hosts.<id>.commands` |
|
||||
|
||||
## 9. Mutants
|
||||
|
||||
One per refusal, plus the two the brief names. Each must be killed.
|
||||
|
||||
| # | Mutant | Killed by |
|
||||
|---|---|---|
|
||||
| M1 | registry check skipped | T5 |
|
||||
| M2 | agent grant check skipped | T6 |
|
||||
| M3 | grant check reads any agent's `hosts` | T6 |
|
||||
| M4 | each argv shape check removed, one mutant each (5) | T7 |
|
||||
| M5 | prefix match compares only `argv[0]` | T8 |
|
||||
| M6 | load accepts a string `commands` (wildcard grant) | T26 |
|
||||
| M7 | `#checkAuthority` call removed | T9 |
|
||||
| M8 | `hostRun` argv hash not compared | T9 |
|
||||
| M9 | `hostRun` stdin hash not compared | T9 |
|
||||
| M10 | quoting drops the `'\''` replacement | T10 |
|
||||
| M11 | `sudo -n` loses `-n` | T11 |
|
||||
| M12 | **host key pin skipped**: `StrictHostKeyChecking=accept-new` | T14 |
|
||||
| M13 | **pin skipped another way**: `UserKnownHostsFile` set to the user's default | T14, T15 |
|
||||
| M14 | `-F /dev/null` removed | T15 |
|
||||
| M15 | **key leaks into output**: `assertCleanKey` not called | T16 |
|
||||
| M16 | leak check uses only the joined body | T16 (one-line case) |
|
||||
| M17 | stdin leak check removed | T17 |
|
||||
| M18 | `finally` cleanup removed | T18 |
|
||||
| M19 | key passed with `-i` from a temp file left on disk | T18, T19 |
|
||||
| M20 | output cap removed | T13 |
|
||||
| M21 | timeout kill removed | T12 |
|
||||
| M22 | busy check removed | T20 |
|
||||
| M23 | revoke doesn't kill the in-flight call | T21 |
|
||||
| M24 | event records stdout | T22 |
|
||||
| M25 | each value check removed, one mutant each (encrypted, type, count, CRLF) | T4 |
|
||||
| M26 | `organization.set` accepts `hosts` | T3 |
|
||||
| M27 | load check for a definition without `host` removed | T2 |
|
||||
| M28 | shell and runner check removed, and a second mutant compares `argv[0]` without taking the basename | T26 |
|
||||
| M29 | glob character check removed | T26 |
|
||||
| M30 | `requested` event drops argv | T22 |
|
||||
| M31 | the question omits stdin | T9 |
|
||||
| M32 | `--pdeathsig` dropped from the spawns | T27 |
|
||||
| M33 | `ssh-add` without `-t` | T27 |
|
||||
| M34 | start sweep removed, a second mutant sweeps without checking the bus pid, and a third ignores the start time | T27 |
|
||||
| M35 | loopback check removed | T1 |
|
||||
|
||||
The mutant script follows row 201's shape: copy the packages to scratch, anchor each mutant once, run each
|
||||
test file in its own process group with a timeout, control first, exit 1 on any survivor. It runs on Sage's
|
||||
slot.
|
||||
|
||||
## 10. Questions
|
||||
|
||||
- Q-S1, answered (sage-1450): 1.7 is frozen. Hard wait under 1.7, and section 5 holds the 1.8 proposal.
|
||||
- Q-S1 still holds after 1.7's approval, which doesn't route `host.sudo` (sage-1468).
|
||||
- Q-S2, answered (sage-1450): 199's deviation 11 keeps credential use in the bus child, so D5 stays.
|
||||
- **Q-J0 (Jason, through Mos, before landing, B4, mos-1088).** Word for word:
|
||||
|
||||
> Q-J0 (#424). Who may give an agent sudo on another machine?
|
||||
> Proposal: only you. No agent can, and that includes the chief of staff and the tester.
|
||||
> You make a grant in four steps, all yours. A reviewed change to the agent's role allows machine keys at all.
|
||||
> You list the machine and its fingerprint. You give that agent that machine with an exact list of allowed
|
||||
> commands. You issue the key, and ops-01 installs it. Until a later PRD says otherwise, you also approve
|
||||
> each command before it runs. You, Mos or the tester can revoke at once. The stack drops the key and cuts
|
||||
> its connection. A command already started on the machine may keep running. A grant of "any" command is
|
||||
> root on that machine, so the design refuses it. Some listed commands, such as a file writer, still reach
|
||||
> root, so grant a machine only to an agent you'd trust as root there.
|
||||
> Do you approve: only you grant machine access, as above? Yes or no. I recommend yes.
|
||||
|
||||
Mos records the answer in DECISIONS.md in jason-master-restart.
|
||||
- **Q-J1 (Jason, through Mos, at the live window):** the first grants, each an argv allowlist per agent and
|
||||
host. Not needed for the build.
|
||||
- Q-S3, answered (sage-1468): the `host_run` harness tool is out of this row. Sage files a follow-up row for
|
||||
publish and `host_run` in `packages/harness` (scope line at the top).
|
||||
@@ -0,0 +1,11 @@
|
||||
# Gosalyn
|
||||
|
||||
Worker seat for Mosaic Stack, MVP track T2 (Case 2, the creative writing hub, Vikunja #416).
|
||||
|
||||
- T3 thread: 7604438a-0ded-41c8-bd3f-26f57fdb5cd2
|
||||
- Model: Opus 5.5 (Claude Code, T3)
|
||||
- Launched by Mos (Jason's main agent, T3 38bc4c81-6270-4021-b00c-6a15c43b9b0e), who briefs the seat
|
||||
- Repository lead and queue: Sage (T3 1ef1e4f8-3ead-4208-beca-38f9f1add079)
|
||||
- Branch: gosalyn/case2-design, pushed only by this seat, landed on refactor by Sage
|
||||
|
||||
Work lives in `work/`. The Case 2 design is `work/case2/DESIGN.md`.
|
||||
@@ -0,0 +1,124 @@
|
||||
# Re-pin 490e2771d to 4429a104f (row 192, Q175 phase 1), with npm ci in the new copy
|
||||
|
||||
Sage, 2026-10-20, draft 1, for mos-1099 item 3 under DECISIONS Q173. Mos checks this file at its commit before
|
||||
P0. Any change to this file or to NEW after the check means Mos checks again (mos-0849/0850).
|
||||
|
||||
This is a smaller window than `repin-rows-177-185.md` (draft 2, which Mos checked in mos-0929). Where a step is
|
||||
the same, this file names that file's step and says what differs. The 7332 matrix, the snapshot, the W9 list and
|
||||
the rollback compare are reused unchanged.
|
||||
|
||||
## Who, and what nobody does
|
||||
|
||||
- Sage operates, as the tester. Darkwing holds the gate lane for row 190 round 3 tonight, so I take the
|
||||
operator seat. Mos verifies each step's output before the next one and runs `rehearse` (P3), which reads the
|
||||
live config and a live backup (`docs/TOOLS.md` `rehearse`: Jason or Mos runs it).
|
||||
- Jason has no step. Q173 covers a re-pin of this test system with a backup first and a STATE report after.
|
||||
Nothing is pruned.
|
||||
- Nobody acts as Jason: no `--by jason`, no human shell or tmux pane, every bus read and write carries
|
||||
`--tester`, and no request carries `jason.woltje`. `bus.tester` stays as it is; if it isn't `true`, stop at P1.
|
||||
- No pi (mos-0865 D2, P19 #383). No Save in the human Console, no `mosaic name`.
|
||||
- Outside reach doesn't change. The Console keeps its current flags. `--sso` stays off: row 192's sign-in code is
|
||||
dormant without the flag, and `cli.mjs:57` refuses `--sso` with the preview flags the pointer carries anyway.
|
||||
The proof pair stays unset. The tunnel still forwards 7332 only (P4 and W10 check it).
|
||||
|
||||
## Names
|
||||
|
||||
- OLD, live: `490e2771d3d057ab76991e2c7859cd251857e64c`, activation `20261020T192956Z-490e2771d3d0`, pointer
|
||||
`pm: true`, schema 3i, Console `--port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username
|
||||
--allow-user jason.woltje --tester-port 7333` (no `--send`, read from `stack-release.sh status` between 21:36Z and 21:39Z).
|
||||
- NEW: `4429a104f6d65ccf165f6c83b5d1dce3147dfde0`, row 192's landing, 106 commits past OLD counting records.
|
||||
The commits after it (5b8def2f1 plans, 176a6bf4b sessions, 305857c8f queue) are records and don't move NEW. If
|
||||
another code row lands before W0 (row 190 is the likely one), NEW stays here unless Mos rules otherwise.
|
||||
- REPO: the canonical checkout. `activate` runs REPO's `webui-service.sh`, so the four service scripts are held
|
||||
at NEW for the window (P5).
|
||||
- `P=/mnt/storage/scratch/sage-pin-de352dfaf` (`business-check.mjs`, `console-check.sh`, checked against
|
||||
`agents/sage/work/window-plan/repin-de352dfaf/SCRIPTS.sha256`).
|
||||
- `WIN=agents/sage/work/window-repin-4429a104f/`, where every transcript goes.
|
||||
- `SNAP=$WIN/snapshot.mjs`, a copy of `agents/darkwing/work/window-rows-177-185/snapshot.mjs`, sha256
|
||||
`639fd28a2674aaa03ce824e3394b4052f21cdef7d6fa49bae61a9927ed341e67` (mos-0899). I check the sha before S1, S2
|
||||
and any D3.
|
||||
|
||||
## What NEW carries over OLD
|
||||
|
||||
The path-limited log over `packages`, `scripts`, `roles`, `contracts`, `adapters`, `skills` and the package files:
|
||||
|
||||
| Row | Commit | Live effect |
|
||||
|---|---|---|
|
||||
| 186 | 269134d8d | Tool only: the activate plan's backup line says what the run will do. |
|
||||
| 187 | 9a71e4e80 | Tool: activate sets, carries and drops the proof pair. The pointer has no pair, so nothing changes. |
|
||||
| 188 | 8fc0ee216 | Tool: proof file checks on a carried pair. No pair, no effect. |
|
||||
| 163 | 8ecc7a239 | Tool: `rehearse`, which P3 uses, and `bus start --no-launch`. CLI `cli.mjs` and `launcher.mjs` gain the `--no-launch` path; the default start is unchanged. |
|
||||
| 189 | ace27dc5e | Tool: `activate --no-send`. Live already runs without `--send`; the pointer carries that. |
|
||||
| 192 | 4429a104f | Console `--sso` (off here), `scripts/mosaic-config.mjs` load becomes async, `webui-service.sh` renders `--sso` only when given, and a new dependency, oauth4webapi 3.8.8. `cli.mjs` imports `sso.mjs` at the top, so the Console won't start from a copy without that module. |
|
||||
|
||||
No schema step: `SCHEMA_VERSION` (`packages/bus/src/store.mjs:17`) is 3i at OLD and NEW, and no migration was
|
||||
added. `--plan` must say `migration: none`. No roles, skills, contracts, adapters or plug-in kinds changed. So: no
|
||||
harness set, no skill carry, no `pm-on`. One `activate --pm`.
|
||||
|
||||
## npm ci in the new copy
|
||||
|
||||
mos-1099 asks for npm ci in the new copy. `stack-copy.sh copy` already does it: it extracts the archive to a temp
|
||||
dir, refuses without `package.json` and `package-lock.json` (:55), runs `npm ci --no-audit --no-fund` there and
|
||||
writes no copy if it fails (:56). W0 checks the result: `node_modules/oauth4webapi/package.json` in the copy reads
|
||||
3.8.8, and `node --input-type=module -e "await import('oauth4webapi')"` from the copy exits 0. The live copy is
|
||||
163M; `/home` has 30G free (94% used), enough for one more.
|
||||
|
||||
## Backups
|
||||
|
||||
| Before | Backup | Taken by |
|
||||
|---|---|---|
|
||||
| W5 | the stopped store, `$DATA/backups/stack/<W5 activation id>/` with SHA256SUMS, DATA_ROOT, SCHEMA 3i, STAMP | `stack-release.sh` (row 185: every activate) |
|
||||
| W5 | the rollback set (units, wrapper, pointer) | `stack-release.sh` |
|
||||
| W9 | the business file, `cp -p` to `~/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-<stamp>` with its sha256, as evidence nothing wrote it | Sage, in W0b |
|
||||
|
||||
## Before the window (nothing stopped)
|
||||
|
||||
| # | Who | Step |
|
||||
|---|-----|------|
|
||||
| P0 | Mos | The `$P` sha check, as in the 177-185 plan P0. |
|
||||
| P1 | Sage | The business file, read only, the same values as the 177-185 plan P1. I report its sha256 now rather than assume the 06:15Z value. `business-check.mjs` for OLD now and for NEW after W0: LOAD_OK both. |
|
||||
| W0 | Sage | `cd $REPO && scripts/stack-copy.sh copy $NEW`, in a gap between gate-lane runs, since npm ci is heavy. Then the two npm checks above, and `diff <(git -C $REPO show $NEW:package-lock.json) $COPIES/$NEW/package-lock.json` empty. W0 writes only under `copies/` and changes nothing live, so it moves before P2 and P3, which need the copy. |
|
||||
| P2 | Sage | `console-check.sh $NEW` against a stub Console of NEW on a scratch port, once without and once with `--tester-port` on a second scratch port. CONSOLE_PASS both. |
|
||||
| P3 | Mos | `scripts/stack-release.sh rehearse $NEW mosaic-stack --backup 20261020T192956Z-490e2771d3d0 --port <scratch>`. It must pass every gate check but the PM's, with the pointer's Console flags. It stops no unit and writes no pointer. Transcript in `$WIN/p3/`. |
|
||||
| P4 | Sage | The tunnel read, as in the 177-185 plan P4. |
|
||||
| P5 | Sage | `git -C $REPO diff --quiet $NEW HEAD -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh` and an empty `git status --porcelain` on the same four. Row 190 round 3 touches `webui-service.sh`; if it lands before W5, P5 fails and the window waits for Mos's ruling. |
|
||||
| P6 | Sage | The 7332 matrix (a)-(g) on OLD, as in the 177-185 plan, saved as `$WIN/matrix-old.txt`. |
|
||||
|
||||
## The window
|
||||
|
||||
| # | Who | Step |
|
||||
|---|-----|------|
|
||||
| W0b | Sage | The business file copy. |
|
||||
| W1 | Sage | Idle check (`mosaic agents --business mosaic-stack --tester`, `mosaic launches list`), the start stamp from `date -u`, then S1 `node $SNAP --store "$DATA/bus/bus.sqlite" --held`. |
|
||||
| W2 | Sage | `stack-release.sh status mosaic-stack`: pointer on OLD, `pm: true`, 3i, last line ok, unit flags match. |
|
||||
| W3 | Sage | `stack-release.sh activate $NEW mosaic-stack --pm --plan`. Must show from OLD to NEW, `migration: none`, the backup line, and the Console line with OLD's flags exactly: no `--send`, no `--sso`, no proof pair, `--tester-port 7333`. Any other difference stops here. |
|
||||
| W4 | Sage | The unit reads, as in the 177-185 plan W4. |
|
||||
| W5 | Sage, on Mos's go | `stack-release.sh activate $NEW mosaic-stack --pm`. Exit 0, `result ok`, every gate check including `pm_launched`. Record the activation id, the backup path and STAMP, and the backup SHA256SUMS sha256. Then `status`. |
|
||||
| W9 | Sage, after Mos verifies W5 | The 177-185 plan's W9 list unchanged, the chief turn and the one `assistant` thread turn included (mos-0929 Q3), plus two lines: the webui journal since the W5 stamp (with `UTC`) has one `Proxy: proxy proof off` and no `Sign-in:` line, and `ls -l /proc/<Console pid>/cwd` names the NEW copy. |
|
||||
| W10 | Sage | The browser check on 127.0.0.1:7333 and the matrix again as `$WIN/matrix-new.txt`, identical to `matrix-old.txt`. Then P4 again. |
|
||||
| W11 | Sage | STATE report in `$WIN/STATE.md`, posted on P19 386 and P19 412 and on Gitea #1690. |
|
||||
|
||||
## Stop conditions and rollback
|
||||
|
||||
The same as the 177-185 plan. Two failures in one window: stop and report (mos-0823).
|
||||
|
||||
Rollback: `stack-release.sh rollback mosaic-stack --plan`, then the real run. The previous distinct copy in the
|
||||
log is 490e2771d, both are 3i, so no `--restore` and no data loss. OLD takes the same Console flags. The R1 and
|
||||
R1post compares are as in the 177-185 plan. A restore past W5 follows that plan's D3 steps, with Mos deciding.
|
||||
|
||||
Keep every copy. Prune nothing.
|
||||
|
||||
## Questions for the plan check
|
||||
|
||||
- Q1: NEW is row 192 alone, not bundled with row 190. Row 190 (several instances) changes `stack-release.sh`
|
||||
and `webui-service.sh` and deserves its own window. I recommend running this one first.
|
||||
- Q2: Sage operates instead of Darkwing, who is on the row 190 gate. OK?
|
||||
- Q3: W0 before P2 and P3. It writes a copy only. OK?
|
||||
|
||||
## What Sage ran for this plan
|
||||
|
||||
Read only, 2026-10-20 between 21:36Z and 21:39Z (clock read 21:39:50Z at the commit): `stack-release.sh status mosaic-stack` (pointer, last five log
|
||||
lines, unit flags match, 106 commits of drift), `git rev-list --count`, `SCHEMA_VERSION` at both ends, the
|
||||
path-limited log, `git diff --stat` on the service scripts, CLI and config loader, `stack-copy.sh:23-56`,
|
||||
`webui/src/cli.mjs:15-120`, `docs/TOOLS.md` on `rehearse`, `du -sh` of the live copy and `df -h /home`. Nothing
|
||||
live was changed.
|
||||
@@ -1363,3 +1363,5 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-20T18:41:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design D1/D2 check (DESIGN.md blob eba861f5d2bb at d55d86bb0), static git diff 817e8f51b..d55d86bb0 | ack: D1 and D2 match; design approved; Mos C1 sequence confirm still open; no Gitea/queue record
|
||||
2026-10-20T18:57:10Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r2 (#1688, DESIGN.md sha256 5493c9df3ec0) | CHANGES to Darkwing, cc Sage: B1 claim check on MOSAIC_CONFIG-only selection, B2 shared prefix drop-in dirs; N1-N7 notes; static read, no slot, no Gitea or queue record
|
||||
2026-10-20T19:00:04Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r3 (#1688, DESIGN.md sha256 98236a1aff98) | APPROVE to Darkwing, cc Sage: B1 and B2 closed with M13 and M14, N7 settled by Sage's 48ebee0ff; static read, no slot; manifest round gets a queue review record (sage-1337)
|
||||
2026-10-20T20:40:15Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 1 review, candidate 8862feccc (static) | CHANGES: B1 base drift, row 189 ace27dc5e conflicts in stack-release.sh, rebase + full gate rerun; N1-N7 notes; Gitea 29634, queue rev 2362 (uncommitted, left for Sage)
|
||||
2026-10-20T21:29:20Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 2 review, candidate 537a02392 (static) | CHANGES: B1 base drift, row 192 4429a104f conflicts in mosaic-config.mjs and webui-service.sh (async load); round 2 content fine; Gitea 29685, queue rev 2447 (uncommitted, left for Sage)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
# Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426)
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-1078 (Vikunja P19 #426) and its go mos-1083 item 1. Owner Rocko,
|
||||
reviewer Dewey, gate owner Sage. Base: refactor at this brief's commit. Starts after row 191.
|
||||
|
||||
## Why
|
||||
|
||||
Morgana force-pushed a seat branch on 2026-10-20 (morgana-0016). Mos took it to Jason. Force-push is a
|
||||
Q173 exclusion, and canon says never force-push, but nothing mechanical stops one. Mos: "Done means a
|
||||
force-push from a seat worktree is refused, with a test that shows it."
|
||||
|
||||
Earlier the same day, a busy check chained as `| tail -1 &&` passed while the slot was BUSY. The script it
|
||||
called prints BUSY or CLEAR, but it exits 0 either way.
|
||||
|
||||
The guard has two layers:
|
||||
- (a) Gitea protection with force-push off on seat branch patterns and on refactor, next and main. ops-01
|
||||
sets it up, through Mos (mos-1082). That part isn't this row.
|
||||
- (b) The client hook below. It can be bypassed with `--no-verify`, so (a) is the real guard and (b) is the
|
||||
early refusal.
|
||||
|
||||
## What ships
|
||||
|
||||
1. `scripts/git-hooks/pre-push` (new). For each ref line git passes on stdin:
|
||||
- It refuses when the remote sha isn't all zeros and isn't an ancestor of the local sha (`git
|
||||
merge-base --is-ancestor`). The refusal names the ref and both shas, and the hook exits 1.
|
||||
- A new branch passes. A delete (local sha all zeros) is refused.
|
||||
- Any failure of the check refuses too, for example an unknown remote object. The hook fetches nothing.
|
||||
If the remote tip isn't present locally, it refuses and says to fetch first.
|
||||
2. Install, following `scripts/queue-commit.sh --install-hook`. Either add a `--install-push-hook` there or
|
||||
ship a small `scripts/install-git-hooks.sh`, whichever fits the existing code better. Say which in BUILD.md.
|
||||
- The install copies the hook into the common `.git/hooks`, which every seat worktree shares.
|
||||
- It refuses when `core.hooksPath` is set (the husky trap, 2026-10-09). It refuses when an installed
|
||||
pre-push differs from the repository copy, unless `--force-install` is given.
|
||||
- It never edits the installed copy in place.
|
||||
3. `scripts/slot-busy.sh` (new), the shared busy check.
|
||||
- It prints one BUSY line per matching process, or CLEAR.
|
||||
- It exits 0 on CLEAR, 1 on BUSY and 2 on its own error.
|
||||
- It matches `node --test`, `gate.sh`, `mutant`, `scripts/test-*.sh` and `docker compose` command lines.
|
||||
It skips its own process and shell snapshots.
|
||||
- It reads `/proc/<pid>/cmdline` and the cwd link only, never file contents under another seat's
|
||||
directory.
|
||||
- Usage in `docs/TOOLS.md`: `bash scripts/slot-busy.sh > LOG && gate`, with `set -o pipefail` in any
|
||||
script that pipes it. Never `| tail -1 &&`.
|
||||
|
||||
## Tests
|
||||
|
||||
Add them to `scripts/test-land-work.sh` or a new `scripts/test-git-guard.sh` (say which). Run against a scratch
|
||||
bare remote and a scratch clone with a worktree, never against origin:
|
||||
- A rewritten branch pushed with `--force` from a worktree is refused, and the remote ref is unchanged.
|
||||
- A `+refspec` push is refused.
|
||||
- A fast-forward push passes.
|
||||
- A new branch passes.
|
||||
- A delete is refused.
|
||||
- A remote tip that's missing locally is refused.
|
||||
- The install refuses with `core.hooksPath` set, and refuses a differing installed hook.
|
||||
- `slot-busy.sh` exits 1 with a fake `node --test` process running (a sleeping stand-in with that argv),
|
||||
and 0 without one.
|
||||
- `slot-busy.sh | tail -1 && false` doesn't apply here. The test runs `bash scripts/slot-busy.sh >/dev/null
|
||||
&& echo ran`, and asserts it doesn't print `ran` while BUSY.
|
||||
|
||||
Mutants, each killed:
|
||||
- drop the ancestor check
|
||||
- treat an unknown remote object as passing
|
||||
- let a delete pass
|
||||
- install over a set `core.hooksPath`
|
||||
- `slot-busy.sh` exits 0 on BUSY
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Install the hook in the canonical checkout only after landing, as its own step with Sage. Tests use
|
||||
scratch repositories.
|
||||
- No push to origin from a test. No change to Gitea config. That's ops-01's.
|
||||
- Don't touch `~/.mosaic`, `~/.mosaic-dev` or `~/.config/mosaic-dev`.
|
||||
|
||||
## Gate
|
||||
|
||||
Dewey approves the manifest. The new or changed `scripts/test-*.sh` file and every other `scripts/test-*.sh`
|
||||
are green on Sage's gate, and the mutants are killed. Done when the hook is installed in the canonical
|
||||
checkout and a scripted force-push from a seat worktree to a scratch remote is refused there. ops-01's
|
||||
server proof (a refused force-push, plus a fast-forward push and a new branch that go through) is recorded
|
||||
in Vikunja #426 by Mos.
|
||||
@@ -1,77 +1,66 @@
|
||||
# Brief: Melanie's hub instance on its own host (row 195, was 190b)
|
||||
# Brief: the Case 1 install on 10.1.1.53 (row 195)
|
||||
|
||||
Sage, 2026-10-20. Rewritten for Mos's rulings mos-0998 and mos-1005 (Vikunja #418, #419). The first version
|
||||
put the hub under its own Linux user on dragon-lin. That form can't pass Duckworth's B7 there, because the
|
||||
factory uid's `sudo -n` succeeds on dragon-lin (Mos's recon, brain-redux
|
||||
`projects/mosaic-mvp/MELANIE-HOST-RECON.md`). Melanie's own server is ruled out too: it has 1.2 GiB RAM free
|
||||
with her two Hermes bots running, and co-tenancy would share her user and her passwordless sudo. The hub runs
|
||||
on a separate host. Which host is Jason's decision (#419): hardware outside the cluster, a cluster VM with
|
||||
factory PVE root removed, or a cluster VM with that residual accepted. This row is written so the host choice
|
||||
doesn't change it. Gitea #1693. On the 10-27 MVP path. Federation R8 (Gizmoduck's design r4, 04a6112be) needs
|
||||
this row or nothing.
|
||||
Sage, 2026-10-20, third version, for Darkwing's rewrite request and sage-1439. Gitea #1693.
|
||||
|
||||
Owner Darkwing (Opus 5.5), reviewer Duckworth (author of B5 and B7), gate owner Sage. Chained on row 190
|
||||
(Q177): the hub runs under row 190's config canon. Design first. The first deliverable is
|
||||
`agents/darkwing/work/queue-190b/DESIGN.md`.
|
||||
Why it changed:
|
||||
- The first two versions put Melanie's hub on its own Linux user, then on its own host, Jason's choice under
|
||||
#419.
|
||||
- Q181 settled the hosts: Case 1 (Jason's software factory) runs on 10.1.1.53 and Case 2 (Melanie's hub) on
|
||||
10.1.1.54.
|
||||
- Q182 and Q183 made the hub's privilege checks a recorded acceptance. They report and don't refuse
|
||||
(federation R8, mos-1055, mos-1063, mos-1066).
|
||||
- The Case 2 install on .54 is Gosalyn's C2-13 plus its window (#416, due by 2026-10-27). It is not this row.
|
||||
|
||||
This row now installs Case 1 on .53. On the 10-27 MVP path (mos-0974, T1 item 1).
|
||||
|
||||
Owner Darkwing (Opus 5.5), reviewer Duckworth, gate owner Sage. Chained on row 190's candidate (the config
|
||||
canon) and on C2-13's candidate (the install script), Q177. It reuses C2-13's `case2-install.sh`,
|
||||
`case2-precheck.sh` and `case2-unit.sh` by input, not by copy. A gap in them goes to Gosalyn as a C2-13
|
||||
change, or into this row's manifest as a named, reviewed change to those files. Design first:
|
||||
`agents/darkwing/work/queue-195/DESIGN.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
Melanie's hub instance (Case 2, C2-13) runs on its own host. No factory agent can read or write the hub's
|
||||
config, data root or secrets, reach its bus socket, or log in to the hub host. Her writing reaches the hub only
|
||||
through the existing git.mosaicstack.dev remotes, books repo only.
|
||||
Jason's software factory runs on 10.1.1.53 as `mosaic` under `/srv/mosaic`, in the same unit form as Case 2
|
||||
(design 9, Q181). It uses row 190's config canon and the model account the factory uses today (Q185,
|
||||
mos-1081 item 1). No new provider account.
|
||||
|
||||
## Provision (blank until Jason answers #419)
|
||||
## Provision
|
||||
|
||||
Who provisions the host and how is left open. Sizing from Mos (mos-0998): 4 vCPU, 8 GB RAM, 64 GB disk,
|
||||
internal network only. Each agent process measures 300 to 390 MB RSS on dragon-lin. The design takes the host
|
||||
as given: a fresh Linux host with a named admin path, reachable by the deploying seat while the row runs.
|
||||
- ops-01 runs the root steps over ssh as `localadmin`: `useradd mosaic`, `/srv/mosaic` and the unit through
|
||||
`case2-unit.sh` from a root-owned checkout. #423 step 1 (localadmin with NOPASSWD sudo on .53 and .54) was
|
||||
applied at 20:05:47Z (mos-1066, mos-1077), and nothing else in #423 gates this row.
|
||||
- #423 step 4 (jarvis sudo) waits until Case 1 is live on .53.
|
||||
- The host list comes from Q181. Agents may run read-only checks on .53 as `localadmin`.
|
||||
|
||||
## The design must state
|
||||
|
||||
1. The deploy. Exactly what Darkwing installs on the hub host and how: the stack copy, node, the units, the
|
||||
config dir and data root under row 190's canon (O1: `~/.config/mosaic-NAME`, and "`~` is the home of the OS
|
||||
user that runs the instance"). Which commands run as which user on the hub host, and which need its root.
|
||||
2. Re-pin, rollback and backup on the hub host, and who runs them after the deploy key is gone.
|
||||
3. How the hub gets model access without a copy of jwoltje's `claude-session.env` or any factory credential.
|
||||
If that needs the credentials row (T1 item 6), say so and name the interim. If it needs a new subscription
|
||||
or a new provider account, flag it for Jason and don't pick one (mos-1008).
|
||||
4. The books repo path. Which git.mosaicstack.dev remote, which credential the hub holds for it, and that it
|
||||
can't read or push any other repository.
|
||||
5. The admin path and the end state. The host ends with no key the factory uid can read. The design names the
|
||||
admin path that remains (for example the hypervisor console, or a key kept off the factory uid) and who
|
||||
holds it.
|
||||
6. The acceptance script, as code Duckworth can read. It takes Gizmoduck's list of infrastructure gate items
|
||||
as its input (mos-1011), not a fixed set of checks. Each item names a path, its closed form and its
|
||||
accepted form, and the script reports which form holds for each, or fails. The list covers at least:
|
||||
- federation R8's separate-host form: the hub's bus socket, data root and config dir don't exist on the
|
||||
factory host, the factory host mounts nothing of the hub's, and no factory agent holds a login or key
|
||||
for the hub host
|
||||
- B7: from the factory uid, `sudo -n`, `doas -n`, `su`, polkit, and `ssh -o BatchMode=yes` to the hub
|
||||
host all fail
|
||||
- the hypervisor and backup paths ops-01 measured (mos-1011: one PVE node the factory key has root on,
|
||||
PBS on that node with unencrypted backups, a flat /16 LAN), each closed or accepted by Jason
|
||||
7. The identity provider and TLS front end (B8, mos-1008, mos-1011). Either the factory holds no credential
|
||||
that can create users, change groups, issue tokens or impersonate in the issuer the hub trusts, or the hub
|
||||
uses an issuer the factory can't administer. A brand, tenant or app inside the same Authentik does not
|
||||
count as separate. The same holds for the TLS front end that serves the hub. The proof is a failed admin
|
||||
call from every token the factory's root can read. No credential value is printed in that proof.
|
||||
1. The install as input values to C2-13's scripts: user, home, business file, roles, the human id, the
|
||||
token slot path. Which commands run as `mosaic` through `sudo -u mosaic -H`, and which as root through
|
||||
ops-01.
|
||||
2. Where Case 1 differs from Case 2: the factory roles in place of the library business, no library sync,
|
||||
and any precheck item that doesn't apply, each with a reason.
|
||||
3. Model access through the existing factory account (Q185). Placing the credential on .53 is a step under
|
||||
the Q173 secrets rule, done in the window. It is never copied by the script, and its value is never
|
||||
printed.
|
||||
4. Re-pin, rollback and backup on .53, and who runs them.
|
||||
5. The acceptance checks, as code Duckworth can read. Each check names a path and gives it one of three
|
||||
marks: closed, accepted under Q182 or Q183, or "not named in Q182 or Q183". An accepted path doesn't
|
||||
fail the row. An unnamed one is reported to Mos, who takes it to Jason (the R8 N1 rule). The functional
|
||||
checks fail the row when they fail: the unit runs as `mosaic`, the Console answers on its port on the
|
||||
internal network only, and one tester message gets a reply.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Provisioning, hypervisor access and any infra or k3s change are Jason's (Q173 exclusions). Mos takes the
|
||||
hypervisor residual (factory root on the PVE nodes, Q172) to Jason. It doesn't block this row.
|
||||
- Melanie's server (`[email protected]`) is reference only, read only, under Jason's authorization in
|
||||
mos-0995. Nothing on it changes, and her Hermes bots keep running.
|
||||
- No tunnel, Traefik or other exposure outside the internal network. That waits for Jason, after Q175 SSO.
|
||||
- No credential value is printed, logged or committed. Keys made for the deploy are removed at the end, and
|
||||
the acceptance script proves it.
|
||||
- The admin-call proof in point 7 and any call to the issuer or the TLS front end run only in a window Mos
|
||||
approves, never as a test or an anonymous probe (Q175).
|
||||
- Tests in the repository use scratch roots and stand-ins. The live deploy and the acceptance run on the hub
|
||||
host are a window with a backup first (where there is state) and a STATE report after.
|
||||
- Infra and k3s changes, exposure outside the internal network, and spend stay Jason's (Q173 exclusions).
|
||||
- No change to dragon-lin's `~/.mosaic-dev`, `~/.config/mosaic-dev`, its live units or `~/.mosaic`.
|
||||
- No credential value is printed, logged or committed.
|
||||
- Tests use scratch roots and stand-ins. Nothing in a test reaches .53.
|
||||
- The run on .53 is a window with Jason's or Mos's go, a backup first where state exists, and a STATE report
|
||||
after.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, then the manifest. Any repository change is green on Sage's gate (cli suite and
|
||||
every `scripts/test-*.sh`), with the design's mutants killed. The row is done when the acceptance script passes
|
||||
on the hub host from the factory uid, Duckworth reads its output, and Mos confirms.
|
||||
every `scripts/test-*.sh`), with the design's mutants killed. The row is done when the window's acceptance
|
||||
run passes on .53, Duckworth reads its output, and Mos confirms.
|
||||
|
||||
+42
-3
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
|
||||
<!-- mosaic-queue:begin -->
|
||||
|
||||
Generated from `docs/plans/queue.json` revision 2247 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 2455 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -225,9 +225,48 @@ Generated from `docs/plans/queue.json` revision 2247 by `queue render`. Do not e
|
||||
| 186 | stack-release --plan: the backup line says what the run will do (P19 404, row 185 N1/N2) | darkwing; reviewers filbert | #1684 | done | — | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the three brief mutants killed (sage) | `docs/plans/2026-10-20_plan-backup-line.md` § Brief: the activate plan's backup line says what the run will do | Landed 269134d8d (author Darkwing) on 3c5da3a91. Patch-id c97b9f6e25cd matches the r1 candidate (manifest 449e9121, Filbert approve 29419). Sage gate on b367f5730+patch 09:00:20Z-09:08:59Z: node 2681/0, scripts 422/0, queue 156, network removed; 3c5da3a91 adds only a SESSIONS line. Not live: the stack runs copy 490e2771d3d0. Follow-ups on P19 404: Filbert M6 gap, store-unclean reading. |
|
||||
| 187 | stack-release activate carries the row 178 proof pair (mos-0928 Part B A0) | darkwing; reviewers filbert | #1685 | done | 186 done | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-proof-pair.md` § Brief: activate carries the row 178 proof pair | Landed 9a71e4e80 (author Darkwing) on d17328a55; patch-id 6b57c47bc661 matches candidate 52a5ecc1d143 and Filbert's r1 reading. Sage gate passed 10:21:55Z: cli 446/446 and every package and scripts/test-*.sh green. Filbert approve 29424, rev 2204; follow-ups N1-N4 filed on P19. |
|
||||
| 188 | stack-release tests: a carried proof pair and a proof file inside the repository (P19 409, 410; gates Part B) | darkwing; reviewers filbert | #1686 | done | 187 settled | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_proof-test-gaps.md` § Brief: tests for the two proof-file paths row 187 left open | Landed 8fc0ee216 (author Darkwing) on 23a609c5c; patch-id c9a56b167c07 matches candidate 86aa549bad75 and Filbert's r1 reading; stack-release.sh blob 2586a2a37cd2 unchanged. Sage gate passed 10:58Z: cli 453/453 and every package and scripts/test-*.sh green. Filbert approve 29433, rev 2212; N1 (REPO logical path) to P19 after Part B (mos-0959). |
|
||||
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | in-review, round 1, request posted | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | — |
|
||||
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-progress | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
|
||||
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | done | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | Landed ace27dc5e on refactor (cherry-pick of candidate d6116771f onto a4a42dbac). Gate GATE_PASSED on 0c177e9ef (base 0217b71c8), 2026-10-20T19:09:37Z-19:20:36Z, 25 suites rc=0. patch-id ce89378718f6 for both 0c177e9ef and ace27dc5e; range-diff differs only in the message. Landed manifest hashes to 67af69bd (Dewey approve 29485). Base drift 0217b71c8..a4a42dbac: 7 doc/README files, none on manifest paths, no test reads them. Brief re-pinned to blob 29a9e6d2d. |
|
||||
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-review, round 2, request posted | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
|
||||
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | in-progress | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | — |
|
||||
| 192 | Q175 phase 1: the Console verifies SSO itself (--sso), OIDC RP with oauth4webapi 3.8.8, no live change (MVP T1.2, #412) | launchpad; reviewers webby | #1690 | done | — | Webby approves the manifest; webui and cli suites, scripts/test-config.sh and every scripts/test-*.sh green on Sage's gate; design section 9 mutants killed (sage) | `docs/plans/2026-10-20_sso-phase1.md` § Brief: Q175 phase 1, the Console verifies SSO itself (--sso) | Landed 4429a104f on refactor (squash of launchpad/row192 53bb59462..37582e820, round 2 manifest 6f8cee14...7787). patch-id 4a4869026 matches the reviewed diff on all 18 paths. Gate on 088087ac9: package suites 2758/0, scripts 429/0, mutants 22 of 23 killed (#23 equivalent, sage-1407). npm ci in canonical, oauth4webapi 3.8.8. Live re-pin is a window with Mos (mos-1099 item 3). |
|
||||
| 193 | many humans per business, human roles, private and shared threads, one access.json store (MVP T1.3, Vikunja #414/#415) | morgana; reviewers duckworth | #1691 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, business, cli and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users.md` § Brief: many humans per business, human roles, private and shared threads | — |
|
||||
| 194 | agent lifecycle from the Console and the CLI (create, launch, terminate, regenerate, delete) with a per-agent hire/fire veto (MVP T1.5, Vikunja #414/#415) | honker; reviewers webby | #1692 | in-progress | — | Webby approves DESIGN.md then the manifest; bus, cli, business and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto | — |
|
||||
| 195 | row 195: the Case 1 install on 10.1.1.53 as mosaic under /srv/mosaic, reusing C2-13's scripts by input under row 190's canon (Q181, Q182/Q183, Q185; ops-01 root steps under #423 step 1) | darkwing; reviewers duckworth | #1693 | briefed | 190 done | Duckworth approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Brief: the Case 1 install on 10.1.1.53 (row 195) | sage-1439: rewritten to Case 1 on .53 (f24f2cd40). Chains on C2-13's candidate too once C2-13 is queued. Darkwing starts DESIGN.md after row 190's mutants. |
|
||||
| 196 | the human boundary across uids and humans: Console, tester listener, broker.sock and the path-bound human proof (P19 333, before federation R3, mos-0989) | gander; reviewers duckworth | #1694 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-boundary.md` § Brief: the human boundary across uids and humans (P19 333) | — |
|
||||
| 197 | a second gate lane: two full gates at once on dragon-lin, design first (mos-0977 item 6) | herb; reviewers webby | #1695 | in-review, round 1, request posted | — | Webby approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate, singly and in the two-lane proof (sage) | `docs/plans/2026-10-20_second-gate-lane.md` § Brief: a second gate lane, two full gates at once on dragon-lin | Order with row 198 (Sage, mos-1040 N1): 197 lands first. After B1 the shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. Row 198 rebases onto 197's landing before its manifest review. |
|
||||
| 198 | retire the control board writer on 7331 (unauthenticated loopback writer, Q175; mos-1008) | bushroot; reviewers webby | #1696 | in-progress | 192 done, 197 done | Webby approves INVENTORY.md then the manifest; control-board, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_retire-control-board.md` § Brief: retire the control board writer on 7331 | Order with row 197 (Sage, mos-1040 N1): 197 lands first. Row 198 rebases onto 197's landing before its manifest review; shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. |
|
||||
| 199 | MVP T1 item 6: a credential store per agent, own git identity, pushes to a remote (G11, G13; mos-0974) | megavolt; reviewers duckworth | #1697 | in-progress | — | Duckworth approves DESIGN.md; Mos rules on the authority model; Duckworth approves the manifest; bus, business, cli and tasks suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: a credential store per agent, own git identity, pushes to a remote | — |
|
||||
| 200 | stack-release.sh activate --sso for the Q175 phase 2 window (row 192 F3; chained on 192) | launchpad; reviewers webby | #1698 | in-review, round 2, request posted | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite) | — |
|
||||
| 201 | FED R1: access.json loader and validator, and covers (#414) | gizmoduck; reviewers duckworth | #1699 | in-review, round 1, request posted | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R1, the access.json loader and validator | — |
|
||||
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | in-review, round 1, request posted | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
|
||||
| 203 | FED R2: the federation key in the system config (#414) | quackerjack; reviewers webby | #1701 | briefed | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R2, the federation key in the system config | Chained on row 192's candidate (Q177): starts on 192's candidate, lands after 192 (Sage) |
|
||||
| 204 | 193a: access level check widened to viewer (#1691) | morgana; reviewers duckworth | #1702 | in-review, round 1, request posted | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193a, the access level check widened to viewer | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
|
||||
| 205 | 193b1: viewer verbs, board refusals and the message filter (#1691; also after FED R3 and R4 when filed) | morgana; reviewers duckworth | #1703 | queued | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1, viewer verbs, board refusals and the message filter | — |
|
||||
| 206 | 193b2: thread shares, schema 3j (#1691; live needs a --migrate window with Jason) | morgana; reviewers duckworth | #1704 | queued | 205 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3j) | — |
|
||||
| 207 | 193c: mosaic access user verbs and --level viewer (#1691) | morgana; reviewers duckworth | #1705 | queued | 202 done | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193c, mosaic access user verbs and --level viewer | — |
|
||||
| 208 | 193d: Console shared list, share controls and viewer read-only (#1691; also after FED R6 when filed) | morgana; reviewers duckworth | #1706 | queued | 192 done, 206 done | Duckworth approves the manifest; webui suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193d, the Console's shared list, share controls and viewer read-only | — |
|
||||
| 209 | FED R8: the hub window on 10.1.1.54, a recorded acceptance (#414, r4.11 b93f098f0; live window run by Jason and Mos, after the Case 2 install #416, Bean and Chet live and both Hermes gateways stopped) | sage; reviewers duckworth | — | queued | — | Every entry check's output is in the R8 record with who ran it; Jason signs in to the hub and sends one message, Melanie revokes, his next request is refused; Duckworth checks the record (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R8, the hub window on 10.1.1.54 | Stays queued until its entry gates near: Case 2 install on .54 (#416, by 2026-10-27), Bean and Chet live, both Hermes gateways on .54 stopped, R7, #412 phase 2, #419 answer. Filed per mos-1073 after duckworth-0034 (Sage) |
|
||||
| 210 | Agent host access with sudo through the credential store: grant per agent and host, the stack does ssh and sudo, the agent never sees the key (#1707, Vikunja #424, Q183/Q184, due 10-27; chained on row 199's candidate) | gizmoduck; reviewers duckworth | #1707 | in-progress | — | Duckworth approves DESIGN.md, then the manifest; bus, business, cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_host-credentials.md` § Brief: agent host access with sudo through the credential store (#424) | Design runs before 199 lands (Q177 chain on 199's candidate). after cleared so Gizmoduck can claim; lands only after 199 and rebases onto it. |
|
||||
| 211 | Force-push guard: scripts/git-hooks/pre-push refuses non-fast-forward and delete pushes from every worktree, and scripts/slot-busy.sh exits 1 on BUSY (#1709, Vikunja #426, mos-1078/1083) | rocko; reviewers dewey | #1709 | briefed | — | Dewey approves the manifest; the new or changed scripts/test-*.sh and every other scripts/test-*.sh green on Sage's gate; the mutants killed; hook installed in the canonical checkout with a scripted refused force-push (sage) | `docs/plans/2026-10-20_force-push-guard.md` § Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426) | — |
|
||||
| 212 | Case 2 C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | gosalyn; reviewers webby | #1713 | in-progress | — | Webby approves the manifest. The library tests and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | — |
|
||||
| 213 | Case 2 C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | morgana; reviewers webby | #1714 | in-progress | — | Webby approves the manifest. The business, bus and harness suites, `scripts/test-config.sh` and every `scripts/test-*.sh` are green on Sage's gate, and the `models` mutant is killed. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | — |
|
||||
| 214 | Case 2 C2-11a editor wikilink node, after measuring whether [[ round-trips | rocko; reviewers filbert | #1715 | briefed | — | Filbert approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11a editor wikilink node, after measuring whether [[ round-trips | — |
|
||||
| 215 | Case 2 C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | herb; reviewers filbert | #1716 | briefed | — | Filbert approves the manifest. The business, seat, cli, project, bus and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | — |
|
||||
| 216 | Case 2 C2-05a lookup index and queries | researcher; reviewers filbert | #1717 | briefed | — | Filbert approves the manifest. The library suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05a lookup index and queries | — |
|
||||
| 217 | Case 2 C2-06 the cited-reply guard, the page store and the three notices | launchpad; reviewers webby | #1718 | briefed | — | Webby approves the manifest. The library, bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-06 the cited-reply guard, the page store and the three notices | — |
|
||||
| 218 | Case 2 C2-12 the setup sheet for Melanie and the survey run sheet | sage; reviewers webby | #1719 | briefed | — | Doc only. Webby approves it against C2-01's real flags. Before landing, grep `packages/*/tests` and `scripts/test-*.sh` to confirm no test reads either file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12 the setup sheet for Melanie and the survey run sheet | — |
|
||||
| 219 | Case 2 C2-09a one thread per creative instance | rocko; reviewers dewey | #1720 | briefed | — | Dewey approves the manifest. The bus, cli and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. Watch for hash pins on `bus.js` and `broker.mjs` (`packages/bus/tests/channel-proof.test.mjs` pins a webui file). (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09a one thread per creative instance | — |
|
||||
| 220 | Case 2 C2-10a no raw session view (M7) and no raw Discord engine (M8) | researcher; reviewers filbert | #1721 | briefed | — | Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10a no raw session view (M7) and no raw Discord engine (M8) | — |
|
||||
| 221 | Case 2 C2-20 Bean and Chet persona import, with a count-only dry run | gosalyn; reviewers webby | #1722 | briefed | — | Webby approves the manifest. The test file runs green on its own and the cli suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20 Bean and Chet persona import, with a count-only dry run | — |
|
||||
| 222 | Case 2 C2-03a fetch-only sync | megavolt; reviewers dewey | #1723 | briefed | — | Dewey approves the manifest. The library and bus suites are green on Sage's gate, along with every `scripts/test-*.sh`, and the mutant is killed. A search of the test run's output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03a fetch-only sync | — |
|
||||
| 223 | Case 2 C2-07 runner retry on a guard refusal, and the runner's notices | researcher; reviewers filbert | #1724 | briefed | — | Filbert approves the manifest. The harness suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-07 runner retry on a guard refusal, and the runner's notices | — |
|
||||
| 224 | Case 2 C2-05b broker lookup verbs and typed tools | rocko; reviewers dewey | #1725 | briefed | — | Dewey approves the manifest. The bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05b broker lookup verbs and typed tools | — |
|
||||
| 225 | Case 2 C2-10b Console chat: cited items and the status line | researcher; reviewers dewey | #1726 | briefed | — | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. Grep for hash pins on `bus.js` and `serve.mjs` before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10b Console chat: cited items and the status line | — |
|
||||
| 226 | Case 2 C2-13 install script and precheck for the host | gosalyn; reviewers webby | #1727 | briefed | — | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. The run on the host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13b install script and library precheck | — |
|
||||
| 227 | Case 2 C2-14 end-to-end acceptance on a scratch root | launchpad; reviewers dewey | #1728 | briefed | — | Dewey runs the script and approves. It is green on Sage's gate with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-14 end-to-end acceptance on a scratch root | — |
|
||||
| 228 | Row A: ZAI_API_KEY passes through ENV_ALLOW to sessions, README line, pass-through test (XS, Haiku high) | researcher; reviewers webby | #1729 | in-review, round 1, request posted | — | Webby approves the manifest. The mutant is killed. The seat suite and the cli suite are green on Sage's gate with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row A, ZAI_API_KEY in the session env allowlist | — |
|
||||
| 229 | Row B: per-instance pi agent dir for Chet's Codex login, design only, starts after 10-27 (#428) | gosalyn; reviewers webby | #1730 | queued | — | A live check, with Jason's or Mos's go, that two clients on one ChatGPT login don't revoke each other's refresh tokens. No build row starts before it passes. Held until after 10-27. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row B, a per-instance pi agent dir for Chet's Codex login (#428) | — |
|
||||
| 230 | Case 2 C2-13a unit render and host precheck (case2-unit.sh, case2-precheck.sh) | gosalyn; reviewers webby | #1732 | briefed | — | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every scripts/test-*.sh. A run on a host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13a unit render and host precheck | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+7794
-15
File diff suppressed because it is too large
Load Diff
Generated
+11
-1
@@ -9,7 +9,8 @@
|
||||
"version": "0.0.3",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1"
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
"oauth4webapi": "3.8.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
@@ -1933,6 +1934,15 @@
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
"version": "3.8.8",
|
||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz",
|
||||
"integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "6.40.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz",
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@
|
||||
"description": "Minimal Mosaic Stack container proof of concept: one Pi agent, four local contract files, one real model request returning MOSAIC_HELLO_OK.",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1"
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
"oauth4webapi": "3.8.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,45 @@ Switching the Console, bus or wrapper, changing any unit, and starting or
|
||||
stopping either service requires Mos's go for that live step. Agents build
|
||||
and test fixtures; they stop before the live sequence.
|
||||
|
||||
## Sign-in in the Console (Q175 phase 1, #1690)
|
||||
|
||||
`serve --sso [--send] [--tester-port N]` makes the Console an OpenID Connect
|
||||
relying party. It reads `console.sso` from the system config (`publicOrigin`
|
||||
and one or more providers, each with its issuer, client id, a client secret
|
||||
slot, the signing algorithms it accepts and the subjects allowed in). The
|
||||
design is `agents/sage/work/q175-sso/DESIGN.md`. `--sso` can't be combined
|
||||
with `--public-host`, `--auth-header`, `--allow-user` or `--proof-*`; `--send`
|
||||
takes `--sso` in place of the preview set.
|
||||
|
||||
- The client secret is read once at start from
|
||||
`<config dir>/secrets/mosaic-stack/<slot>`. The start refuses a slot that is
|
||||
a symbolic link, not a regular file, owned by another user, has any group or
|
||||
other mode bit, or sits in the repository, the `--checkout` directory or a
|
||||
stack copy. The value is never printed.
|
||||
- At start the Console fetches each provider's discovery document and JWKS. A
|
||||
provider that can't be reached (a network error, a timeout, a status other
|
||||
than 200, or a body that isn't JSON) leaves the Console up with that
|
||||
provider's `/auth/login` at 503 and a retry every 5 s, doubling, capped at 5
|
||||
minutes. A document that fails a check (issuer, algorithms, S256, a usable
|
||||
key) refuses the start. `/healthz` adds `sso: { <id>: "ready" | "unreachable"
|
||||
| "refused" }`.
|
||||
- The Console listener serves only the Host of `publicOrigin`, whatever port
|
||||
it listens on. Any other Host, a loopback one included, gets 403 `public
|
||||
Host required`, except `GET /healthz`, which stays open on loopback for the
|
||||
status and release gates. The loopback operator path in the preview section
|
||||
above does not exist under `--sso`.
|
||||
- Without a session, `/api/*` gets 401 `sign-in required` and a page load gets
|
||||
a 303 to `/auth/login?return=<path>`. The page then shows the signed-out line
|
||||
with a link back to sign-in. A session lasts at most 8 hours and ends after 1
|
||||
hour without a page load or a write; a restart ends every session.
|
||||
- The page shows Sign out only under `--sso`. It posts to `/auth/logout`, which
|
||||
ends the session and goes to the provider's end-session endpoint when it has
|
||||
one, or to `/auth/signed-out`.
|
||||
- With `--tester-port`, the tester listener refuses any request carrying an
|
||||
`Authorization` header, a `__Host-mosaic_session` or `__Host-mosaic_login`
|
||||
cookie, or an `x-authentik-*` header (403 `tester listener takes no
|
||||
sign-in`). It never serves `/auth/*`.
|
||||
|
||||
## Message a role (row 76, #1570)
|
||||
|
||||
```sh
|
||||
|
||||
@@ -58,8 +58,9 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
if (one(req, 'origin') !== preview.origin) return refuse(403, 'cross-origin');
|
||||
} else {
|
||||
if (!send || !named || !preview) return refuse(403, 'read-only');
|
||||
if (one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin');
|
||||
// Q175 phase 1 (#1690): under --sso the session was checked before this route.
|
||||
if (preview.sso !== true && one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
|
||||
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin');
|
||||
}
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
|
||||
}
|
||||
|
||||
@@ -7,20 +7,23 @@ import { views } from '../../bus/src/views.mjs';
|
||||
import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs';
|
||||
import { configDir } from '../../business/src/business.mjs';
|
||||
import { consoleReads, redactor } from './reads.mjs';
|
||||
import { createSso, readSlot } from './sso.mjs';
|
||||
import { statSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
||||
const args = process.argv.slice(2);
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
|
||||
try {
|
||||
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
|
||||
if (args.shift() !== 'serve') throw new Error(usage);
|
||||
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
|
||||
let sendOn = false, testerPort = null;
|
||||
let sendOn = false, ssoOn = false, testerPort = null;
|
||||
while (args.length) {
|
||||
const flag = args.shift();
|
||||
// Row 76 (#1570): the one flag without a value.
|
||||
if (flag === '--send') { sendOn = true; continue; }
|
||||
// Q175 phase 1 (#1690): the Console signs people in itself, from console.sso in config.json.
|
||||
if (flag === '--sso') { ssoOn = true; continue; }
|
||||
const value = args.shift();
|
||||
if (!value) throw new Error(`missing value for ${flag}`);
|
||||
if (flag === '--port') {
|
||||
@@ -51,9 +54,10 @@ try {
|
||||
else throw new Error(`unknown option: ${flag}`);
|
||||
}
|
||||
if (testerPort !== null && (testerPort === options.port || testerPort === 7332)) throw new Error('--tester-port must differ from --port and cannot be the forwarded port 7332');
|
||||
if (ssoOn && (Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*');
|
||||
options.preview = previewOptions(preview);
|
||||
const proof = proofOptions(proofFlags, options.preview);
|
||||
if (sendOn && !options.preview) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
|
||||
if (sendOn && !options.preview && !ssoOn) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
|
||||
// The bus views (slice 1 S5) need the system config. Without it the
|
||||
// board still serves and the bus routes answer not-configured.
|
||||
// The Queue, Business and Settings reads (row 54) need no bus; without
|
||||
@@ -77,31 +81,46 @@ try {
|
||||
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
||||
} catch (err) {
|
||||
const why = redactor({ configDir: configDir() })(err.message);
|
||||
if (ssoOn) throw new Error(`--sso needs the system config (${why})`);
|
||||
if (sendOn) throw new Error(`--send needs the bus configured (${why})`);
|
||||
if (testerPort !== null) throw new Error(`--tester-port needs the bus configured (${why})`);
|
||||
busLine = `Bus: not configured (${why})`;
|
||||
}
|
||||
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
|
||||
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, proof, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
|
||||
// The sign-in config is checked again here, then each slot file, before anything listens. A
|
||||
// provider that can't be reached starts degraded; one whose discovery is refused stops the start.
|
||||
let sso = null;
|
||||
if (ssoOn) {
|
||||
if (!system.console?.sso) throw new Error('--sso needs console.sso in the system config');
|
||||
const secrets = new Map(system.console.sso.providers.map(p => [p.id, readSlot(configDir(), p, { repo: REPO, checkout: options.checkout ?? null })]));
|
||||
sso = await createSso(system.console.sso, { secrets });
|
||||
}
|
||||
let server;
|
||||
try {
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
|
||||
} catch (err) { sso?.close(); throw err; }
|
||||
let testerServer = null;
|
||||
if (testerPort !== null) {
|
||||
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
|
||||
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
|
||||
try {
|
||||
testerServer = await startServer({ host: '127.0.0.1', port: testerPort, board: options.board,
|
||||
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
|
||||
tester: true, testerNoSignIn: ssoOn, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
|
||||
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
|
||||
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
|
||||
// Row 177 (#1675): the tester's own chat, through the tester CLI; 7332's stays the human's.
|
||||
chat: createChat({ call }),
|
||||
});
|
||||
} catch (err) { await new Promise(resolve => server.close(resolve)); throw err; }
|
||||
} catch (err) { sso?.close(); await new Promise(resolve => server.close(resolve)); throw err; }
|
||||
}
|
||||
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
|
||||
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
|
||||
// One line per provider: id, issuer, subject count and state. No client id, secret or sub.
|
||||
const health = sso?.health() ?? {};
|
||||
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send)' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
|
||||
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${proofLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { server.close(); testerServer?.close(); });
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
|
||||
} catch (err) {
|
||||
console.error(`refused: ${err.message}`);
|
||||
process.exitCode = 2;
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
// The first one sets body.signed-out (the listener below); after that no Console fetch is made.
|
||||
const out = () => document.body.classList.contains('signed-out');
|
||||
const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
|
||||
// Q175 phase 1 (#1690): under --sso the Console itself answers a read without a session with 401
|
||||
// `sign-in required`; that signs the page out too, and the line links to the Console's sign-in.
|
||||
// Any other 401 stays an error. `sso` comes from /api/config.
|
||||
let sso = false;
|
||||
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
|
||||
const signedOut = () => Object.assign(new Error('the sign-in proxy redirected it, so it never reached the Console'), { signedOut: true });
|
||||
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
|
||||
const source = value => value ? `<span class="source">${esc(labels[value] || value)}</span>` : '';
|
||||
@@ -110,6 +115,7 @@
|
||||
const res = await fetch(path, { cache: 'no-store', redirect: 'manual' });
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
let body; try { body = await res.json(); } catch { body = { error: `HTTP ${res.status}` }; }
|
||||
if (signInRequired(res, body)) { sso = true; signOut(); throw signedOut(); }
|
||||
return { ok: res.ok, body };
|
||||
}
|
||||
function blockNode(b) {
|
||||
@@ -304,6 +310,7 @@
|
||||
const res = await fetch(path, value === undefined ? { cache: 'no-store', redirect: 'manual' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value), redirect: 'manual' });
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
const result = await res.json();
|
||||
if (signInRequired(res, result)) { sso = true; signOut(); throw signedOut(); }
|
||||
if (!res.ok) throw Object.assign(new Error(result.error || `HTTP ${res.status}`), { status: res.status });
|
||||
return result;
|
||||
}
|
||||
@@ -452,7 +459,7 @@
|
||||
});
|
||||
// Signed out: everything drawn from a Console read goes, because none of it can be refreshed. The
|
||||
// line says so once; the send form (bus.js) keeps its draft and receipt. Reload is the way back.
|
||||
document.addEventListener('mosaic-signed-out', () => {
|
||||
document.addEventListener('mosaic-signed-out', e => {
|
||||
if (out()) return;
|
||||
document.body.classList.add('signed-out');
|
||||
clearTimeout(timer); convGen++;
|
||||
@@ -461,9 +468,13 @@
|
||||
for (const id of ['projects', 'waiting', 'seen', 'sessions']) $(id).replaceChildren();
|
||||
$('refresh').disabled = $('pause').disabled = true;
|
||||
const line = $('signed-out');
|
||||
line.innerHTML = '<p><b>Signed out.</b> Reload the page to sign in.</p>'; line.hidden = false;
|
||||
line.innerHTML = sso || e.detail?.signIn
|
||||
? `<p><b>Signed out.</b> <a href="/auth/login?${esc(new URLSearchParams({ return: location.pathname + location.search }))}">Sign in again</a>.</p>`
|
||||
: '<p><b>Signed out.</b> Reload the page to sign in.</p>';
|
||||
line.hidden = false; $('sign-out').hidden = true;
|
||||
// Focus moves to the line only if what had it is gone or disabled, never out of a form being typed in.
|
||||
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
|
||||
});
|
||||
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
// Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso.
|
||||
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
})();
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
const out = () => document.body.classList.contains('signed-out');
|
||||
const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
|
||||
const signedOut = () => new ReadError('signed-out', 'The sign-in proxy redirected the read.', 0);
|
||||
// Q175 phase 1 (#1690, sage-1340 F1): under --sso a read without a session gets 401 `sign-in
|
||||
// required` from the Console itself. That signs out the same way; any other 401 stays an error.
|
||||
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
|
||||
const signOutSso = () => { if (!out()) document.dispatchEvent(new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })); };
|
||||
// `replies` takes a message id as `id`; trail takes `subject`.
|
||||
async function fetchRead(verb, subject) {
|
||||
if (out()) throw signedOut();
|
||||
@@ -38,6 +42,7 @@
|
||||
catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
try { body = await res.json(); } catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||||
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
|
||||
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
|
||||
return { ...body, rows: body.rows, at: body.at };
|
||||
}
|
||||
@@ -50,6 +55,7 @@
|
||||
try { res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' }); } catch { throw unreachable(); }
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
try { body = await res.json(); } catch { throw unreachable(); }
|
||||
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
|
||||
if (!res.ok || !body || typeof body !== 'object' || Array.isArray(body)) throw Object.assign(new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status), { module: true });
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
|
||||
<button type="button" class="cmdk" id="cmdk" aria-haspopup="dialog" aria-controls="cmdk-dialog"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><span>Go to a view, decision or task</span><kbd>Ctrl K</kbd></button>
|
||||
<p class="fresh" id="fresh"><span id="fresh-board"></span><span id="fresh-bus"></span></p>
|
||||
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button></div></header>
|
||||
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button><form id="sign-out" method="post" action="/auth/logout" hidden><button class="btn" type="submit">Sign out</button></form></div></header>
|
||||
<div class="frame">
|
||||
<nav class="tree" aria-label="Console"><h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
|
||||
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
|
||||
|
||||
@@ -61,6 +61,8 @@ async function body(req, max = 4096) {
|
||||
// Row 65 (mos-0840) adds `routes`: each instance's harness, model, thinking and fallback.
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'];
|
||||
const DECISION_LISTS = ['roles', 'recent'];
|
||||
// Q175 phase 1 (#1690, F8): either sign-in cookie, which the tester listener refuses under --sso.
|
||||
const SIGN_IN_COOKIE = /(?:^|;)\s*__host-mosaic_(?:session|login)\s*=/i;
|
||||
// Row 177 (#1675): the chat writes the tester listener lets through, exactly the four chatHTTP routes.
|
||||
const TESTER_CHAT_WRITE = /^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/(?:messages|open))$/i;
|
||||
async function busRead(res, bus, verb, search) {
|
||||
@@ -160,14 +162,18 @@ async function busRead(res, bus, verb, search) {
|
||||
// broker's to refuse. Nothing retries; an outcome the bus can't confirm is reported as unknown.
|
||||
const SEND_KEYS = ['to', 'body', 'in_reply_to'];
|
||||
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
||||
// Q175 phase 1 (#1690): under --sso the writer comes from a session the handler already checked,
|
||||
// so it carries `sso: true` and the public origin; the preview writer still checks its header.
|
||||
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
|
||||
const ownOrigin = p => p.origin ?? `https://${p.host}`;
|
||||
async function busSend(req, res, { send, preview, log }) {
|
||||
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required' });
|
||||
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
|
||||
let to = null;
|
||||
// `to` is quoted and cut short so a crafted value can't forge a log line.
|
||||
const line = outcome => log(`${new Date().toISOString()} send user=${JSON.stringify(preview.user)} to=${typeof to === 'string' ? JSON.stringify(to.slice(0, 64)) : 'none'} ${outcome}`);
|
||||
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a send needs Sec-Fetch-Site: same-origin');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -241,10 +247,10 @@ export function proofOptions({ proofHeader, proofFile } = {}, preview = null) {
|
||||
// REQ-WEB-1: same signed-in send boundary, a distinct strict settings payload.
|
||||
async function settingsSend(req, res, { settingsWrite, preview, log }) {
|
||||
const refuse = (status, error, message) => json(res, status, { error, message, sent: false });
|
||||
if (one(req, preview.header) !== preview.user) {
|
||||
if (!signedIn(req, preview)) {
|
||||
return refuse(401, 'sign-in required', 'sign in again');
|
||||
}
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) {
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) {
|
||||
return refuse(403, 'cross-origin request refused', 'settings need the Console origin');
|
||||
}
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') {
|
||||
@@ -292,8 +298,8 @@ async function seatSend(req, res, { route, write, preview, log }) {
|
||||
// The instance is quoted and cut short so a crafted value can't forge a log line.
|
||||
const line = outcome => log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -335,8 +341,8 @@ async function instanceSend(req, res, { instancesWrite, preview, log }) {
|
||||
// The instance is quoted and cut short so a crafted value can't forge a log line. Vars are never logged.
|
||||
const line = outcome => log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -382,8 +388,8 @@ async function routesSend(req, res, { routesWrite, preview, log }) {
|
||||
let names = [];
|
||||
const line = outcome => log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -420,8 +426,8 @@ async function projectFileSend(req, res, { projectSave, preview, log }) {
|
||||
const line = outcome => log(`${new Date().toISOString()} project-file user=${JSON.stringify(preview.user)} project=${quoted(target?.project)} path=${quoted(target?.path)} ${outcome}`);
|
||||
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a save needs the Console\'s own Origin');
|
||||
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a save needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a save needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, FILE_SAVE_MAX)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -460,13 +466,17 @@ async function projectFileRead(res, read, path, search) {
|
||||
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
|
||||
}
|
||||
}
|
||||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
|
||||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode or `sso`.
|
||||
// `sso` is createSso() from sso.mjs (Q175 phase 1): the human listener signs people in itself.
|
||||
// `testerNoSignIn` makes the tester listener refuse anything that looks like a sign-in (--sso). `log`
|
||||
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, tester = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerNoSignIn = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
|
||||
if (tester && sso !== null) throw new Error('tester listener cannot use sso');
|
||||
if (sso !== null && (preview !== null || proof !== null)) throw new Error('--sso cannot be combined with --auth-header, --allow-user or --proof-*');
|
||||
if (tester && host !== '127.0.0.1') throw new Error('tester listener must bind 127.0.0.1');
|
||||
// Row 177 (#1675): chat is allowed. The tester listener's chat binds the tester, so the bus keeps it on tester threads.
|
||||
if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
|
||||
@@ -476,21 +486,26 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
|
||||
}
|
||||
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
|
||||
if (send !== null && (preview === null || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
|
||||
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader('x-content-type-options', 'nosniff');
|
||||
res.setHeader('referrer-policy', 'no-referrer');
|
||||
res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
|
||||
// browsers apply form-action to that redirect, so those origins (from discovery) are allowed too.
|
||||
res.setHeader('content-security-policy', `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'${sso ? sso.formTargets().map(o => ` ${o}`).join('') : ''}`);
|
||||
// Presence, even with an empty value: never turn the public listener into a tester ingress.
|
||||
if (!tester && Object.hasOwn(req.headers, 'x-mosaic-tester')) return json(res, 403, { error: 'tester listener required' });
|
||||
if (tester && testerAuthHeader && Object.hasOwn(req.headers, testerAuthHeader.toLowerCase())) return json(res, 403, { error: 'proxy header refused' });
|
||||
if (tester && testerProofHeader && Object.hasOwn(req.headers, testerProofHeader.toLowerCase())) return json(res, 403, { error: 'proxy proof header refused' });
|
||||
// Under --sso the tester listener reads no cookie and takes no sign-in of any kind (design section 6).
|
||||
if (tester && testerNoSignIn && (Object.hasOwn(req.headers, 'authorization') || SIGN_IN_COOKIE.test(req.headers.cookie ?? '') || Object.keys(req.headers).some(h => h.startsWith('x-authentik-'))))
|
||||
return json(res, 403, { error: 'tester listener takes no sign-in' });
|
||||
let path, search;
|
||||
// Node keeps the first of repeated Host headers; a second one (a loopback Host, then NAME) is
|
||||
// never legitimate, so it is refused before any check reads the Host.
|
||||
if ((req.headersDistinct.host?.length ?? 0) > 1) return json(res, 400, { error: 'repeated Host refused' });
|
||||
// Preview mode: a request for the public name, exactly, as the proxy forwards it.
|
||||
const named = preview !== null && (req.headers.host ?? '').toLowerCase() === preview.host;
|
||||
const named = preview !== null ? (req.headers.host ?? '').toLowerCase() === preview.host : sso !== null && (req.headers.host ?? '').toLowerCase() === sso.publicHost;
|
||||
// The proof precedes every auth-header read, including the wrong-Host fence.
|
||||
if (proof !== null) {
|
||||
const values = req.headersDistinct[proof.header];
|
||||
@@ -512,19 +527,34 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
}
|
||||
({ pathname: path, search } = new URL(req.url, 'http://localhost'));
|
||||
} catch { return json(res, 400, { error: 'invalid URL' }); }
|
||||
// Under --sso the human listener serves only the public Host, whatever its port; GET /healthz
|
||||
// alone stays open on a loopback Host for the status and release gates (Q-C).
|
||||
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
|
||||
// No CORS. JSON content type and same-origin checks keep browser forms out.
|
||||
if (req.headers.origin && req.headers.origin !== (named ? `https://${preview.host}` : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
|
||||
if (named) {
|
||||
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
|
||||
let signer = preview;
|
||||
if (sso !== null && named) {
|
||||
if (path.startsWith('/auth/')) return sso.handle(req, res, path, search);
|
||||
// Only a page load or a write keeps a session alive; the page's background reads don't (Q-D).
|
||||
const session = path === '/healthz' ? null : sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) });
|
||||
if (!session && path !== '/healthz') {
|
||||
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
|
||||
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
|
||||
return res.end();
|
||||
}
|
||||
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin };
|
||||
}
|
||||
if (named && preview !== null) {
|
||||
// One value, equal to the user id; the proxy strips client copies and sets it after sign-in.
|
||||
const values = req.headersDistinct[preview.header];
|
||||
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
|
||||
}
|
||||
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : preview;
|
||||
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : signer;
|
||||
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name'].includes(path) && !TESTER_CHAT_WRITE.test(path))
|
||||
return json(res, 403, { error: 'read-only', sent: false });
|
||||
if(path==='/api/settings'&&req.method==='POST'){
|
||||
if(!send||!settingsWrite||!named)return json(res,403,{error:'read-only',sent:false});
|
||||
return settingsSend(req,res,{settingsWrite,preview,log});
|
||||
return settingsSend(req,res,{settingsWrite,preview:signer,log});
|
||||
}
|
||||
if (Object.hasOwn(SEAT_ROUTES, path)) {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
@@ -544,18 +574,18 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
}
|
||||
if (path === '/api/project-file' && req.method === 'POST') {
|
||||
if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return projectFileSend(req, res, { projectSave, preview, log });
|
||||
return projectFileSend(req, res, { projectSave, preview: signer, log });
|
||||
}
|
||||
// Message sending has its own bounded payload and repeats the identity check.
|
||||
if (send && path === '/api/bus/message') {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
return busSend(req, res, { send, preview, log });
|
||||
return busSend(req, res, { send, preview: signer, log });
|
||||
}
|
||||
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
|
||||
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
|
||||
}
|
||||
// Nothing else but GET and HEAD gets past here in preview mode, whatever the Host.
|
||||
if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
|
||||
if ((preview !== null || sso !== null) && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
|
||||
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
|
||||
const conversation = path === '/api/conversations' || path === '/api/conversation';
|
||||
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
|
||||
@@ -607,8 +637,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
|
||||
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
|
||||
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
if (!file) return json(res, 404, { error: 'not found' });
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Q175 phase 1 (#1690): the shape of config.json's optional `console` key. The one loader,
|
||||
// scripts/mosaic-config.mjs, runs this on every read, so a bad key refuses the config for every
|
||||
// tool (design section 4, C1). The Console's --sso start runs it again before it uses the value.
|
||||
// Shape only: nothing here touches the network or a slot file. A refusal names the key path and
|
||||
// never quotes a value, so a refusal can't print a client id or a subject.
|
||||
|
||||
// The asymmetric algorithms oauth4webapi 3.8.8 verifies (its supported(), build/index.js). No HS*
|
||||
// and no none, so a client secret can never be a signing key.
|
||||
export const ALGS = Object.freeze(['PS256', 'ES256', 'RS256', 'PS384', 'ES384', 'RS384', 'PS512', 'ES512', 'RS512', 'Ed25519', 'EdDSA', 'ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87']);
|
||||
const PROVIDER_ID = /^[a-z0-9-]{1,32}$/;
|
||||
const SLOT = /^[a-z0-9-]{1,64}$/;
|
||||
const SUB = /^[\x21-\x7e]{1,255}$/;
|
||||
const CLIENT_ID = /^[\x21-\x7e]{1,255}$/;
|
||||
// The name goes where the preview's --allow-user went (serve.mjs USER): visible ASCII, no comma.
|
||||
const NAME = /^[\x21-\x2b\x2d-\x7e]{1,64}$/;
|
||||
// The bus keeps these for the tester and the human role (section 6), so no subject can sign in as one.
|
||||
const RESERVED_NAMES = ['tester', 'human'];
|
||||
|
||||
const object = v => typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
function refuse(where, why) {
|
||||
throw new Error(`${where} ${why}`);
|
||||
}
|
||||
function keys(value, where, allowed, required = allowed) {
|
||||
if (!object(value)) refuse(where, 'must be a JSON object');
|
||||
for (const key of Object.keys(value)) if (!allowed.includes(key)) refuse(`${where}.${key}`, 'is not a supported key');
|
||||
for (const key of required) if (!Object.hasOwn(value, key)) refuse(`${where}.${key}`, 'is required');
|
||||
}
|
||||
function httpsURL(value, where) {
|
||||
let url;
|
||||
if (typeof value !== 'string' || value.length > 512) refuse(where, 'must be an https URL of at most 512 characters');
|
||||
try { url = new URL(value); } catch { refuse(where, 'must be an https URL'); }
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || value.includes('?') || value.includes('#')) {
|
||||
refuse(where, 'must be https with no credentials, query or fragment');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// Returns the value unchanged when it passes; throws an Error naming the bad key path otherwise.
|
||||
export function checkConsole(value, where = 'console') {
|
||||
keys(value, where, ['sso']);
|
||||
const sso = value.sso, at = `${where}.sso`;
|
||||
keys(sso, at, ['publicOrigin', 'providers']);
|
||||
const origin = httpsURL(sso.publicOrigin, `${at}.publicOrigin`);
|
||||
if (origin.origin !== sso.publicOrigin) refuse(`${at}.publicOrigin`, 'must be an https origin with no path, query, fragment or trailing slash');
|
||||
if (!Array.isArray(sso.providers) || sso.providers.length === 0 || sso.providers.length > 8) refuse(`${at}.providers`, 'must be a list of 1 to 8 providers');
|
||||
const ids = new Set();
|
||||
sso.providers.forEach((p, i) => {
|
||||
const pat = `${at}.providers[${i}]`;
|
||||
keys(p, pat, ['id', 'issuer', 'clientId', 'clientSecret', 'algs', 'subjects']);
|
||||
if (typeof p.id !== 'string' || !PROVIDER_ID.test(p.id)) refuse(`${pat}.id`, 'must match [a-z0-9-]{1,32}');
|
||||
if (ids.has(p.id)) refuse(`${pat}.id`, 'repeats an earlier provider id');
|
||||
ids.add(p.id);
|
||||
httpsURL(p.issuer, `${pat}.issuer`);
|
||||
if (typeof p.clientId !== 'string' || !CLIENT_ID.test(p.clientId)) refuse(`${pat}.clientId`, 'must be 1 to 255 visible characters');
|
||||
keys(p.clientSecret, `${pat}.clientSecret`, ['slot']);
|
||||
if (typeof p.clientSecret.slot !== 'string' || !SLOT.test(p.clientSecret.slot)) refuse(`${pat}.clientSecret.slot`, 'must match [a-z0-9-]{1,64}');
|
||||
if (!Array.isArray(p.algs) || p.algs.length === 0) refuse(`${pat}.algs`, 'must be a non-empty list');
|
||||
p.algs.forEach((alg, j) => {
|
||||
if (!ALGS.includes(alg)) refuse(`${pat}.algs[${j}]`, `must be one of ${ALGS.join(', ')}; HS* and none are refused`);
|
||||
});
|
||||
if (new Set(p.algs).size !== p.algs.length) refuse(`${pat}.algs`, 'repeats an algorithm');
|
||||
if (!Array.isArray(p.subjects) || p.subjects.length === 0 || p.subjects.length > 64) refuse(`${pat}.subjects`, 'must be a list of 1 to 64 subjects');
|
||||
const subs = new Set();
|
||||
p.subjects.forEach((s, j) => {
|
||||
const sat = `${pat}.subjects[${j}]`;
|
||||
keys(s, sat, ['sub', 'name']);
|
||||
if (typeof s.sub !== 'string' || !SUB.test(s.sub)) refuse(`${sat}.sub`, 'must be 1 to 255 visible characters');
|
||||
if (subs.has(s.sub)) refuse(`${sat}.sub`, 'repeats an earlier subject');
|
||||
subs.add(s.sub);
|
||||
if (typeof s.name !== 'string' || !NAME.test(s.name)) refuse(`${sat}.name`, 'must be 1 to 64 visible characters');
|
||||
if (RESERVED_NAMES.includes(s.name.toLowerCase())) refuse(`${sat}.name`, `must not be ${RESERVED_NAMES.join(' or ')}`);
|
||||
});
|
||||
});
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md): the Console as an OIDC relying
|
||||
// party. Used only by the human listener under --sso; startServer refuses it on the tester listener.
|
||||
// Every URL handed to a provider is built from the configured publicOrigin, never from a request's
|
||||
// Host. A refusal logs one fixed code and the provider id, never an exception, its message or its
|
||||
// cause: oauth4webapi's errors carry token bodies and claims there.
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, relative, isAbsolute, resolve } from 'node:path';
|
||||
import { checkConsole } from './sso-config.mjs';
|
||||
|
||||
// oauth4webapi loads in createSso, not here: cli.mjs imports this module on every start, and a
|
||||
// Console without --sso must not need the package (pinned-copy.test.mjs builds copies whose lock
|
||||
// installs nothing). A real copy installs it from the root lock.
|
||||
let oauth;
|
||||
|
||||
export const SESSION_COOKIE = '__Host-mosaic_session';
|
||||
export const LOGIN_COOKIE = '__Host-mosaic_login';
|
||||
const ATTRS = 'Path=/; Secure; HttpOnly; SameSite=Lax';
|
||||
const LOGIN_MAX_AGE = 600;
|
||||
const SESSION_ABSOLUTE = 8 * 3600 * 1000, SESSION_IDLE = 3600 * 1000;
|
||||
const MAX_SESSIONS = 64, MAX_LOGINS = 32;
|
||||
const FETCH_TIMEOUT = 10000;
|
||||
const RETRY_CAP = 300000;
|
||||
const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'discovery-failed', 'jwks-failed']);
|
||||
|
||||
const sha256 = value => createHash('sha256').update(value).digest('hex');
|
||||
const escape = text => String(text).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
||||
const under = (parent, child) => { const rel = relative(parent, child); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
|
||||
|
||||
// The slot file holding a provider's client secret (design section 4, D1). Only the Console's --sso
|
||||
// start calls this. A refusal names the provider and the check, never the path's contents.
|
||||
export function readSlot(configDir, provider, { repo, checkout = null, copies = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'mosaic-stack', 'copies') } = {}) {
|
||||
const where = `console.sso provider ${provider.id} client secret slot`;
|
||||
const file = join(resolve(configDir), 'secrets', 'mosaic-stack', provider.clientSecret.slot);
|
||||
let st;
|
||||
try { st = lstatSync(file); } catch { throw new Error(`${where} is missing`); }
|
||||
if (st.isSymbolicLink()) throw new Error(`${where} must not be a symbolic link`);
|
||||
let fd, real, text;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
st = fstatSync(fd);
|
||||
if (!st.isFile()) throw new Error(`${where} must be a regular file`);
|
||||
if (st.uid !== process.getuid()) throw new Error(`${where} must be owned by the Console's user`);
|
||||
if (st.mode & 0o077) throw new Error(`${where} must have no group or other permission bits`);
|
||||
real = realpathSync(file);
|
||||
for (const root of [repo, checkout, copies].filter(Boolean)) {
|
||||
let base;
|
||||
try { base = realpathSync(root); } catch { base = resolve(root); }
|
||||
if (under(base, real)) throw new Error(`${where} must not be inside the repository or a stack copy`);
|
||||
}
|
||||
text = readFileSync(fd, 'utf8');
|
||||
} catch (err) {
|
||||
throw err.message?.startsWith(where) ? err : new Error(`${where} is not readable`);
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
const secret = text.endsWith('\n') ? text.slice(0, -1) : text;
|
||||
if (!secret || /[\r\n]/.test(secret)) throw new Error(`${where} must hold one non-empty line`);
|
||||
return secret;
|
||||
}
|
||||
|
||||
export function cookies(req, name) {
|
||||
const found = [];
|
||||
for (const part of (req.headers.cookie ?? '').split(';')) {
|
||||
const at = part.indexOf('=');
|
||||
if (at > 0 && part.slice(0, at).trim() === name) found.push(part.slice(at + 1).trim());
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// The JWKS check at discovery, the same filter the library's key selection uses (kty, alg, use,
|
||||
// key_ops, crv; RSA at least 2048 bits), without a kid since no token is at hand yet.
|
||||
function usableKey(jwk, alg) {
|
||||
const kty = { RS: 'RSA', PS: 'RSA', ES: 'EC', Ed: 'OKP', ML: 'AKP' }[alg.slice(0, 2)];
|
||||
if (jwk === null || typeof jwk !== 'object' || jwk.kty !== kty) return false;
|
||||
if (jwk.alg !== undefined && jwk.alg !== alg) return false;
|
||||
if (jwk.use !== undefined && jwk.use !== 'sig') return false;
|
||||
if (Array.isArray(jwk.key_ops) && !jwk.key_ops.includes('verify')) return false;
|
||||
const crv = { ES256: 'P-256', ES384: 'P-384', ES512: 'P-521', Ed25519: 'Ed25519', EdDSA: 'Ed25519' }[alg];
|
||||
if (crv && jwk.crv !== crv) return false;
|
||||
if (kty === 'RSA' && (typeof jwk.n !== 'string' || Buffer.from(jwk.n, 'base64url').length * 8 < 2048)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
class Unreachable extends Error {}
|
||||
class Refused extends Error {}
|
||||
const https = value => { try { return new URL(value).protocol === 'https:'; } catch { return false; } };
|
||||
|
||||
// One provider's discovery and JWKS. Throws Unreachable (network, timeout, a status other than 200,
|
||||
// a body that isn't JSON) or Refused (a JSON document that fails a check). Q-B, design section 4.
|
||||
async function discover(p, fetchOptions) {
|
||||
let response, text;
|
||||
const options = () => ({ ...fetchOptions, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
||||
try {
|
||||
response = await oauth.discoveryRequest(new URL(p.issuer), options());
|
||||
if (response.status !== 200 || !/^application\/json\b/i.test(response.headers.get('content-type') ?? '')) throw new Error();
|
||||
text = await response.text();
|
||||
JSON.parse(text);
|
||||
} catch { throw new Unreachable(); }
|
||||
let as;
|
||||
try {
|
||||
as = await oauth.processDiscoveryResponse(new URL(p.issuer), new Response(text, { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
} catch { throw new Refused(); }
|
||||
if (as.issuer !== p.issuer) throw new Refused();
|
||||
if (!Array.isArray(as.id_token_signing_alg_values_supported) || !p.algs.some(a => as.id_token_signing_alg_values_supported.includes(a))) throw new Refused();
|
||||
if (!Array.isArray(as.code_challenge_methods_supported) || !as.code_challenge_methods_supported.includes('S256')) throw new Refused();
|
||||
if (![as.authorization_endpoint, as.token_endpoint, as.jwks_uri].every(https)) throw new Refused();
|
||||
if (as.end_session_endpoint !== undefined && !https(as.end_session_endpoint)) throw new Refused();
|
||||
let jwks;
|
||||
try {
|
||||
const fetcher = fetchOptions[oauth.customFetch] ?? fetch;
|
||||
const res = await fetcher(as.jwks_uri, { method: 'GET', headers: { accept: 'application/json, application/jwk-set+json' }, redirect: 'manual', signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
||||
if (res.status !== 200) throw new Error();
|
||||
jwks = JSON.parse(await res.text());
|
||||
} catch { throw new Unreachable(); }
|
||||
if (jwks === null || typeof jwks !== 'object' || !Array.isArray(jwks.keys)) throw new Refused();
|
||||
const usable = p.algs.filter(alg => jwks.keys.some(k => usableKey(k, alg)));
|
||||
if (!usable.length) throw new Refused();
|
||||
return as;
|
||||
}
|
||||
|
||||
// `config` is console.sso, checked again here; `secrets` maps provider id to its client secret
|
||||
// (readSlot). `fetch` is for tests: a fetch that trusts only the fake OP's certificate.
|
||||
// `retryFirst` is for tests too; production waits 5 s, doubling, capped at 5 minutes.
|
||||
export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000 } = {}) {
|
||||
checkConsole({ sso: config });
|
||||
oauth ??= await import('oauth4webapi');
|
||||
const publicOrigin = config.publicOrigin;
|
||||
const publicHost = new URL(publicOrigin).host;
|
||||
const redirectUri = `${publicOrigin}/auth/callback`;
|
||||
const fetchOptions = customFetch ? { [oauth.customFetch]: customFetch } : {};
|
||||
const sessions = new Map(), logins = new Map();
|
||||
const timers = new Set();
|
||||
let closed = false;
|
||||
const refuse = (code, provider, extra = '') => {
|
||||
if (!CODES.has(code)) throw new Error(`unknown sso log code ${code}`);
|
||||
log(`${new Date().toISOString()} sso refused code=${code} provider=${provider}${extra}`);
|
||||
};
|
||||
|
||||
const providers = new Map(config.providers.map(p => {
|
||||
if (typeof secrets?.get(p.id) !== 'string') throw new Error(`console.sso provider ${p.id} has no client secret`);
|
||||
return [p.id, {
|
||||
config: p, state: 'unreachable', as: null, jwks: {},
|
||||
client: { client_id: p.clientId, id_token_signed_response_alg: [...p.algs] },
|
||||
auth: oauth.ClientSecretBasic(secrets.get(p.id)),
|
||||
subjects: new Map(p.subjects.map(s => [s.sub, s.name])),
|
||||
}];
|
||||
}));
|
||||
|
||||
async function attempt(entry, atStart) {
|
||||
try {
|
||||
entry.as = await discover(entry.config, fetchOptions);
|
||||
entry.jwks = {};
|
||||
entry.state = 'ready';
|
||||
} catch (err) {
|
||||
if (err instanceof Refused) {
|
||||
entry.state = 'refused';
|
||||
refuse('discovery-failed', entry.config.id);
|
||||
if (atStart) throw new Error(`console.sso provider ${entry.config.id} refused at discovery: the issuer, algorithms, S256 or JWKS check failed`);
|
||||
return;
|
||||
}
|
||||
entry.state = 'unreachable';
|
||||
refuse('discovery-failed', entry.config.id);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
function retry(entry, delay) {
|
||||
if (closed) return;
|
||||
const timer = setTimeout(async () => {
|
||||
timers.delete(timer);
|
||||
try { await attempt(entry, false); } catch { retry(entry, Math.min(delay * 2, RETRY_CAP)); }
|
||||
}, delay);
|
||||
timer.unref();
|
||||
timers.add(timer);
|
||||
}
|
||||
for (const entry of providers.values()) {
|
||||
try { await attempt(entry, true); } catch (err) {
|
||||
if (!(err instanceof Unreachable)) throw err;
|
||||
retry(entry, retryFirst);
|
||||
}
|
||||
}
|
||||
|
||||
const page = (res, status, title, html, headers = {}) => {
|
||||
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers });
|
||||
res.end(`<!doctype html><meta charset="utf-8"><title>${escape(title)}</title><main style="font-family:sans-serif;max-width:32rem;margin:4rem auto"><h1>${escape(title)}</h1>${html}</main>\n`);
|
||||
};
|
||||
const redirect = (res, location, setCookie = []) => {
|
||||
res.writeHead(303, { location, 'cache-control': 'no-store', ...(setCookie.length ? { 'set-cookie': setCookie } : {}) });
|
||||
res.end();
|
||||
};
|
||||
const failed = (res, setCookie = []) => page(res, 403, 'Sign-in failed', '<p><a href="/auth/login">Sign in again</a></p>', setCookie.length ? { 'set-cookie': setCookie } : {});
|
||||
const expire = name => `${name}=; ${ATTRS}; Max-Age=0`;
|
||||
const remember = (map, key, value, cap) => {
|
||||
while (map.size >= cap) map.delete(map.keys().next().value);
|
||||
map.set(key, value);
|
||||
};
|
||||
|
||||
// B2: only a URL on publicOrigin itself; anything else, or nothing, is publicOrigin's `/`. The
|
||||
// Location is absolute and its origin checked again: dot segments can leave a pathname of `//host`,
|
||||
// which as a relative Location a browser reads as another origin (webby-0019 B1, sage-1403).
|
||||
function returnURL(ret) {
|
||||
const home = `${publicOrigin}/`;
|
||||
try {
|
||||
if (typeof ret !== 'string' || !ret) return home;
|
||||
const u = new URL(ret, publicOrigin);
|
||||
if (u.origin !== publicOrigin) return home;
|
||||
const out = new URL(`${publicOrigin}${u.pathname}${u.search}`);
|
||||
return out.origin === publicOrigin ? out.href : home;
|
||||
} catch { return home; }
|
||||
}
|
||||
|
||||
async function start(res, entry, ret) {
|
||||
const state = oauth.generateRandomState(), nonce = oauth.generateRandomNonce(), verifier = oauth.generateRandomCodeVerifier();
|
||||
const challenge = await oauth.calculatePKCECodeChallenge(verifier);
|
||||
const id = randomBytes(32).toString('base64url');
|
||||
remember(logins, sha256(id), { provider: entry.config.id, state, nonce, verifier, ret: typeof ret === 'string' ? ret.slice(0, 2048) : '/', expires: Date.now() + LOGIN_MAX_AGE * 1000 }, MAX_LOGINS);
|
||||
const url = new URL(entry.as.authorization_endpoint);
|
||||
for (const [k, v] of Object.entries({ client_id: entry.config.clientId, response_type: 'code', scope: 'openid profile email', redirect_uri: redirectUri, state, nonce, code_challenge: challenge, code_challenge_method: 'S256' })) url.searchParams.set(k, v);
|
||||
return redirect(res, url.href, [`${LOGIN_COOKIE}=${id}; ${ATTRS}; Max-Age=${LOGIN_MAX_AGE}`]);
|
||||
}
|
||||
|
||||
function login(res, query) {
|
||||
const asked = query.get('provider');
|
||||
const ret = query.get('return');
|
||||
if (asked === null && providers.size > 1) {
|
||||
const items = [...providers.values()].map(e => `<li><a href="/auth/login?${escape(new URLSearchParams({ provider: e.config.id, ...(ret ? { return: ret } : {}) }))}">${escape(e.config.id)}</a></li>`).join('');
|
||||
return page(res, 200, 'Sign in', `<ul>${items}</ul>`);
|
||||
}
|
||||
const entry = asked === null ? [...providers.values()][0] : providers.get(asked);
|
||||
if (!entry) return page(res, 404, 'Unknown sign-in provider', '<p><a href="/auth/login">Sign in</a></p>');
|
||||
if (entry.state !== 'ready') return page(res, 503, 'sign-in unavailable', '<p>The sign-in provider can\'t be reached. Try again shortly.</p>');
|
||||
return start(res, entry, ret);
|
||||
}
|
||||
|
||||
// Design section 1, steps 2-7. Each refusal ends here with one log line and the 403 page.
|
||||
async function callback(res, query, req) {
|
||||
const clear = [expire(LOGIN_COOKIE)];
|
||||
const ids = cookies(req, LOGIN_COOKIE);
|
||||
const key = ids.length === 1 ? sha256(ids[0]) : null;
|
||||
const record = key === null ? undefined : logins.get(key);
|
||||
if (key !== null) logins.delete(key);
|
||||
// The provider comes from the record, never from the query.
|
||||
const entry = record && providers.get(record.provider);
|
||||
if (!record || record.expires <= Date.now() || !entry) {
|
||||
refuse('login-record-missing', record?.provider ?? 'none');
|
||||
return failed(res, clear);
|
||||
}
|
||||
const pid = entry.config.id;
|
||||
if (entry.state !== 'ready') { refuse('discovery-failed', pid); return failed(res, clear); }
|
||||
let params;
|
||||
try { params = oauth.validateAuthResponse(entry.as, entry.client, query, record.state); } catch (err) {
|
||||
refuse(err instanceof oauth.AuthorizationResponseError ? 'auth-error-response' : 'state-mismatch', pid);
|
||||
return failed(res, clear);
|
||||
}
|
||||
const options = () => ({ ...fetchOptions, [oauth.jwksCache]: entry.jwks, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
||||
let response;
|
||||
try { response = await oauth.authorizationCodeGrantRequest(entry.as, entry.client, entry.auth, params, redirectUri, record.verifier, options()); } catch {
|
||||
refuse('grant-failed', pid); return failed(res, clear);
|
||||
}
|
||||
let result, claims;
|
||||
try {
|
||||
result = await oauth.processAuthorizationCodeResponse(entry.as, entry.client, response, { expectedNonce: record.nonce, requireIdToken: true });
|
||||
claims = oauth.getValidatedIdTokenClaims(result);
|
||||
} catch (err) {
|
||||
refuse(err instanceof oauth.ResponseBodyError ? 'grant-failed' : 'id-token-invalid', pid);
|
||||
return failed(res, clear);
|
||||
}
|
||||
// Step 5: the library skips this for a token-endpoint ID token; point 2 needs it, on the same
|
||||
// Response object. Its throw ends the callback.
|
||||
try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) {
|
||||
const fetching = !(err instanceof oauth.OperationProcessingError || err instanceof oauth.UnsupportedOperationError) || [oauth.RESPONSE_IS_NOT_CONFORM, oauth.RESPONSE_IS_NOT_JSON].includes(err.code);
|
||||
refuse(fetching ? 'jwks-failed' : 'signature-invalid', pid);
|
||||
return failed(res, clear);
|
||||
}
|
||||
// Step 6: our own checks on top of the library's.
|
||||
let header;
|
||||
try { header = JSON.parse(Buffer.from(result.id_token.split('.')[0], 'base64url').toString('utf8')); } catch { header = null; }
|
||||
if (!header || !entry.config.algs.includes(header.alg)) { refuse('alg-refused', pid); return failed(res, clear); }
|
||||
if (claims.iss !== entry.config.issuer) { refuse('issuer-mismatch', pid); return failed(res, clear); }
|
||||
const name = typeof claims.sub === 'string' ? entry.subjects.get(claims.sub) : undefined;
|
||||
if (name === undefined) {
|
||||
refuse('subject-unknown', pid, ` iss=${JSON.stringify(String(claims.iss).slice(0, 255))} sub=${JSON.stringify(String(claims.sub).slice(0, 255))}`);
|
||||
return failed(res, clear);
|
||||
}
|
||||
// Step 7: the session, then the return path.
|
||||
const id = randomBytes(32).toString('base64url'), now = Date.now();
|
||||
remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS);
|
||||
return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]);
|
||||
}
|
||||
|
||||
// The session for this request, or null. `touch` is true only for a page load or a write (Q-D).
|
||||
function session(req, { touch = false } = {}) {
|
||||
const ids = cookies(req, SESSION_COOKIE);
|
||||
if (ids.length !== 1) return null;
|
||||
const key = sha256(ids[0]);
|
||||
const s = sessions.get(key);
|
||||
if (!s) return null;
|
||||
const now = Date.now();
|
||||
if (now - s.created >= SESSION_ABSOLUTE || now - s.seen >= SESSION_IDLE) { sessions.delete(key); return null; }
|
||||
if (touch) s.seen = now;
|
||||
return s;
|
||||
}
|
||||
|
||||
function logout(req, res) {
|
||||
const one = name => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
||||
if (one('origin') !== publicOrigin || one('sec-fetch-site') !== 'same-origin') {
|
||||
return page(res, 403, 'Sign-out refused', '<p>Sign out from the Console page.</p>');
|
||||
}
|
||||
const ids = cookies(req, SESSION_COOKIE);
|
||||
const key = ids.length === 1 ? sha256(ids[0]) : null;
|
||||
const s = key === null ? undefined : sessions.get(key);
|
||||
if (key !== null) sessions.delete(key);
|
||||
const entry = s && providers.get(s.provider);
|
||||
let location = `${publicOrigin}/auth/signed-out`;
|
||||
if (entry?.as?.end_session_endpoint) {
|
||||
const url = new URL(entry.as.end_session_endpoint);
|
||||
url.searchParams.set('id_token_hint', s.idToken);
|
||||
url.searchParams.set('post_logout_redirect_uri', `${publicOrigin}/auth/signed-out`);
|
||||
url.searchParams.set('client_id', entry.config.clientId);
|
||||
location = url.href;
|
||||
}
|
||||
return redirect(res, location, [expire(SESSION_COOKIE)]);
|
||||
}
|
||||
|
||||
// `/auth/*` on the human listener; the caller has already checked the Host.
|
||||
async function handle(req, res, path, search) {
|
||||
const query = new URLSearchParams(search);
|
||||
if (path === '/auth/logout') {
|
||||
if (req.method !== 'POST') return page(res, 405, 'Method not allowed', '');
|
||||
return logout(req, res);
|
||||
}
|
||||
if (req.method !== 'GET') return page(res, 405, 'Method not allowed', '');
|
||||
if (path === '/auth/login') return login(res, query);
|
||||
if (path === '/auth/callback') return callback(res, query, req);
|
||||
if (path === '/auth/signed-out') return page(res, 200, 'Signed out', '<p><a href="/auth/login">Sign in</a></p>');
|
||||
return page(res, 404, 'Not found', '');
|
||||
}
|
||||
|
||||
return {
|
||||
publicOrigin, publicHost, sessions, logins, handle, session, returnURL,
|
||||
health: () => Object.fromEntries([...providers].map(([id, e]) => [id, e.state])),
|
||||
formTargets: () => [...new Set([...providers.values()].filter(e => e.as?.end_session_endpoint).map(e => new URL(e.as.end_session_endpoint).origin))],
|
||||
summary: () => config.providers.map(p => ({ id: p.id, issuer: p.issuer, subjects: p.subjects.length })),
|
||||
close() { closed = true; for (const t of timers) clearTimeout(t); timers.clear(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Q175 phase 1 (#1690, design section 9): a fake OpenID provider for the --sso tests. A loopback
|
||||
// https server with a self-signed certificate made in a temp dir; `op.fetch` is a fetch that trusts
|
||||
// only that certificate, so the code under test needs no insecure flag. It serves any number of
|
||||
// issuers (`/<name>/`) at once, each with discovery, a JWKS, authorization, token and end-session
|
||||
// endpoints. Keys come from node:crypto. Nothing here reaches any other host.
|
||||
import { constants, createHash, createHmac, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { createServer, request } from 'node:https';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const b64url = value => Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)).toString('base64url');
|
||||
|
||||
export function rsaKey(kid = `rsa-${randomBytes(4).toString('hex')}`, bits = 2048) {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: bits });
|
||||
return { kid, alg: 'RS256', privateKey, jwk: { ...publicKey.export({ format: 'jwk' }), kid, use: 'sig', alg: 'RS256' } };
|
||||
}
|
||||
export function ecKey(kid = `ec-${randomBytes(4).toString('hex')}`) {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
return { kid, alg: 'ES256', privateKey, jwk: { ...publicKey.export({ format: 'jwk' }), kid, use: 'sig', alg: 'ES256' } };
|
||||
}
|
||||
export function psKey(kid = `ps-${randomBytes(4).toString('hex')}`) {
|
||||
const k = rsaKey(kid);
|
||||
return { ...k, alg: 'PS256', jwk: { ...k.jwk, alg: 'PS256' } };
|
||||
}
|
||||
|
||||
// A compact JWS. `alg` HS256 uses `hmac` as the key, `none` has an empty signature.
|
||||
export function jwt(header, payload, { key = null, hmac = null } = {}) {
|
||||
const input = `${b64url(header)}.${b64url(payload)}`;
|
||||
let sig = '';
|
||||
if (header.alg === 'HS256') sig = createHmac('sha256', hmac).update(input).digest('base64url');
|
||||
else if (header.alg === 'RS256') sig = sign('sha256', Buffer.from(input), key).toString('base64url');
|
||||
else if (header.alg === 'PS256') sig = sign('sha256', Buffer.from(input), { key, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }).toString('base64url');
|
||||
else if (header.alg === 'ES256') sig = sign('sha256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' }).toString('base64url');
|
||||
return `${input}.${sig}`;
|
||||
}
|
||||
|
||||
function certificate() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'fake-oidc-'));
|
||||
try {
|
||||
const r = spawnSync('openssl', ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-days', '1',
|
||||
'-subj', '/CN=127.0.0.1', '-addext', 'subjectAltName=IP:127.0.0.1', '-keyout', join(dir, 'key.pem'), '-out', join(dir, 'cert.pem')], { encoding: 'utf8' });
|
||||
if (r.status !== 0) throw new Error(`openssl failed: ${r.stderr}`);
|
||||
return { key: readFileSync(join(dir, 'key.pem')), cert: readFileSync(join(dir, 'cert.pem')) };
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
// One issuer's state. `discovery` is merged over the defaults; `mode` is 'ok', 'down' (the socket is
|
||||
// destroyed) or 'html' (200 with an HTML page) for discovery.
|
||||
function issuerState(name, base, opts) {
|
||||
const keys = opts.keys ?? [rsaKey()];
|
||||
return {
|
||||
name, issuer: `${base}/${name}/`, keys, retired: [], clientId: opts.clientId ?? `client-${name}`, clientSecret: opts.clientSecret ?? `secret-${name}-${randomBytes(6).toString('hex')}`,
|
||||
discovery: opts.discovery ?? {}, mode: 'ok', jwksOverride: null, codes: new Map(), acceptTwice: false, requirePkce: true,
|
||||
tokenRequests: [], authorizeRequests: [], jwksFetches: 0, discoveryFetches: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fakeOidc() {
|
||||
const { key, cert } = certificate();
|
||||
const issuers = new Map();
|
||||
const server = createServer({ key, cert }, (req, res) => {
|
||||
const url = new URL(req.url, 'https://127.0.0.1');
|
||||
const [, name, ...rest] = url.pathname.split('/');
|
||||
const iss = issuers.get(name);
|
||||
const route = rest.join('/');
|
||||
const json = (status, body, type = 'application/json') => { res.writeHead(status, { 'content-type': type }); res.end(typeof body === 'string' ? body : JSON.stringify(body)); };
|
||||
if (!iss) return json(404, { error: 'no issuer' });
|
||||
if (route === '.well-known/openid-configuration') {
|
||||
iss.discoveryFetches++;
|
||||
if (iss.mode === 'down') return req.socket.destroy();
|
||||
if (iss.mode === 'html') return json(200, '<!doctype html><title>Sign in</title>', 'text/html');
|
||||
return json(200, discovery(iss));
|
||||
}
|
||||
if (route === 'jwks') {
|
||||
iss.jwksFetches++;
|
||||
return json(200, iss.jwksOverride ?? { keys: iss.keys.map(k => k.jwk) });
|
||||
}
|
||||
if (route === 'authorize') { iss.authorizeRequests.push(Object.fromEntries(url.searchParams)); return json(200, { ok: true }); }
|
||||
if (route === 'end-session') return json(200, { ok: true });
|
||||
if (route === 'token' && req.method === 'POST') {
|
||||
let text = '';
|
||||
req.on('data', c => (text += c));
|
||||
req.on('end', () => {
|
||||
const form = new URLSearchParams(text);
|
||||
const basic = Buffer.from((req.headers.authorization ?? '').replace(/^Basic /, ''), 'base64').toString();
|
||||
const [id, secret] = basic.split(':').map(decodeURIComponent);
|
||||
iss.tokenRequests.push({ form: Object.fromEntries(form), clientId: id });
|
||||
if (id !== iss.clientId || secret !== iss.clientSecret) return json(401, { error: 'invalid_client' });
|
||||
const grant = iss.codes.get(form.get('code'));
|
||||
if (!grant) return json(400, { error: 'invalid_grant' });
|
||||
if (!iss.acceptTwice) iss.codes.delete(form.get('code'));
|
||||
if (form.get('redirect_uri') !== grant.redirectUri) return json(400, { error: 'invalid_grant', error_description: 'redirect_uri' });
|
||||
if (iss.requirePkce && createHash('sha256').update(form.get('code_verifier') ?? '').digest('base64url') !== grant.challenge) return json(400, { error: 'invalid_grant', error_description: 'pkce' });
|
||||
return json(200, { access_token: randomBytes(16).toString('hex'), token_type: 'Bearer', expires_in: 300, id_token: grant.idToken });
|
||||
});
|
||||
return;
|
||||
}
|
||||
return json(404, { error: 'not found' });
|
||||
});
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||
const base = `https://127.0.0.1:${server.address().port}`;
|
||||
|
||||
const discovery = iss => ({
|
||||
issuer: iss.issuer, authorization_endpoint: `${iss.issuer}authorize`, token_endpoint: `${iss.issuer}token`, jwks_uri: `${iss.issuer}jwks`,
|
||||
end_session_endpoint: `${iss.issuer}end-session`, response_types_supported: ['code'], subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: [...new Set(iss.keys.map(k => k.alg))], code_challenge_methods_supported: ['S256'],
|
||||
...iss.discovery,
|
||||
});
|
||||
|
||||
// A fetch over node:https that trusts only this certificate. oauth4webapi passes a Headers object
|
||||
// and a string or URLSearchParams body.
|
||||
const opFetch = (input, init = {}) => new Promise((resolve, reject) => {
|
||||
const url = new URL(typeof input === 'string' ? input : input.href ?? input.url);
|
||||
if (url.protocol !== 'https:' || url.hostname !== '127.0.0.1' || url.port !== String(server.address().port)) return reject(new TypeError(`fake OP fetch refuses ${url.origin}`));
|
||||
const headers = Object.fromEntries(new Headers(init.headers ?? {}));
|
||||
const body = init.body === undefined || init.body === null ? undefined : String(init.body);
|
||||
if (body !== undefined) headers['content-length'] = Buffer.byteLength(body);
|
||||
const req = request(url, { method: init.method ?? 'GET', headers, ca: cert, signal: init.signal }, res => {
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const h = new Headers();
|
||||
for (const [k, v] of Object.entries(res.headers)) h.set(k, Array.isArray(v) ? v.join(', ') : v);
|
||||
const response = new Response(res.statusCode === 204 ? null : Buffer.concat(chunks), { status: res.statusCode, headers: h });
|
||||
Object.defineProperty(response, 'url', { value: url.href });
|
||||
resolve(response);
|
||||
});
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
|
||||
return {
|
||||
base, fetch: opFetch, cert,
|
||||
issuer(name, opts = {}) { const iss = issuerState(name, base, opts); issuers.set(name, iss); return iss; },
|
||||
// A code for `iss` whose ID token is built from the start redirect's nonce, the claims and header
|
||||
// overrides. `challenge` defaults to the start redirect's; `idToken` replaces the token outright.
|
||||
grant(iss, authorizeUrl, { sub = `user-${iss.name}`, claims = {}, header = {}, key = iss.keys[0], idToken = null, challenge = null, redirectUri = null } = {}) {
|
||||
const q = new URL(authorizeUrl).searchParams;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = { iss: iss.issuer, sub, aud: iss.clientId, iat: now, exp: now + 300, nonce: q.get('nonce'), ...claims };
|
||||
for (const [k, v] of Object.entries(payload)) if (v === undefined) delete payload[k];
|
||||
const token = idToken ?? jwt({ alg: key.alg, kid: key.kid, typ: 'JWT', ...header }, payload, { key: key.privateKey, hmac: iss.clientSecret });
|
||||
const code = randomBytes(12).toString('base64url');
|
||||
iss.codes.set(code, { idToken: token, challenge: challenge ?? q.get('code_challenge'), redirectUri: redirectUri ?? q.get('redirect_uri') });
|
||||
return { code, idToken: token, payload };
|
||||
},
|
||||
rotate(iss, next = rsaKey()) { iss.retired.push(...iss.keys); iss.keys = [next]; return next; },
|
||||
close: () => new Promise(r => { server.close(r); server.closeAllConnections(); }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ function fakeCopy(dir) {
|
||||
writeFileSync(join(copyRoot(dir), 'packages', 'webui', 'src', 'cli.mjs'), '');
|
||||
return copyRoot(dir);
|
||||
}
|
||||
const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\)$/m;
|
||||
const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\) or --sso$/m;
|
||||
const TEMPLATE = readFileSync(join(REPO, 'packages', 'webui', 'systemd', 'mosaic-webui.service.in'), 'utf8');
|
||||
|
||||
// The unit as the base script renders it (row 75): the template with its four
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Q175 phase 1 (#1690, design test 24, sage-1340 F1, sage-1347 F7): the page under --sso. A read
|
||||
// the Console answers 401 `sign-in required` signs the page out the way a proxy redirect does, and
|
||||
// the line links to /auth/login with the current path and query; any other 401 stays an error. Both
|
||||
// fetch paths in app.js and both in bus.js (fetchRead and fetchApi) do it. The Sign out form shows
|
||||
// only when /api/config says sso. The board-held.test.mjs stub, with two lists of paths: those in
|
||||
// window.__signIn answer 401 `sign-in required`, those in window.__other 401 `unauthenticated`. A
|
||||
// page URL holding `sso` gets `sso: true` added to /api/config. Poll timers fire after 150 ms.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: { pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, cto: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
|
||||
const stub = `(() => {
|
||||
const real = window.fetch.bind(window);
|
||||
window.__reqs = []; window.__signIn = []; window.__other = [];
|
||||
const timeout = window.setTimeout.bind(window);
|
||||
window.setTimeout = (f, ms, ...rest) => timeout(f, ms === 10000 ? 150 : ms, ...rest);
|
||||
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const path = String(url).split('?')[0];
|
||||
window.__reqs.push(path);
|
||||
if (window.__signIn.includes(path)) return answer(401, { error: 'sign-in required' });
|
||||
if (window.__other.includes(path)) return answer(401, { error: 'unauthenticated' });
|
||||
if (path === '/api/config' && /sso/.test(location.search)) return answer(200, { ...(await (await real(url, init)).json()), sso: true });
|
||||
return real(url, init);
|
||||
};
|
||||
})();`;
|
||||
|
||||
async function setup() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-sso-browser-'));
|
||||
const store = new Store(root), b0 = new Broker({ store, businesses });
|
||||
b0.request(b0.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi', address: 'pm-run' }), { verb: 'role.claim' });
|
||||
const reader = b0.bindReader({ business: 'demo' });
|
||||
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
|
||||
const f = await fixture(), b = await browser({ machine: true });
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
|
||||
await b.viewport(1280);
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||||
const shown = sel => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && !!e.getClientRects().length})()`);
|
||||
const reqs = () => b.evaluate('window.__reqs.length');
|
||||
const settle = () => new Promise(r => setTimeout(r, 600));
|
||||
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
|
||||
return { b, base, wait, text, shown, reqs, settle, done };
|
||||
}
|
||||
|
||||
const LOADED = 'document.querySelector("table.sessions [data-open=\'proj/agent1\']")';
|
||||
const LINE = 'Signed out. Sign in again.';
|
||||
|
||||
test('a 401 sign-in required on each fetch path signs the page out with a sign-in link and stops the refresh; another 401 stays an error', { timeout: 120000 }, async () => {
|
||||
const { b, base, wait, text, shown, reqs, settle, done } = await setup();
|
||||
// The line, its link back to this page, the Sign out form gone, and no further fetch.
|
||||
const out = async ret => {
|
||||
await wait('!document.querySelector("#signed-out").hidden');
|
||||
assert.equal(await text('#signed-out'), LINE);
|
||||
assert.equal(await b.evaluate('document.querySelector("#signed-out a").getAttribute("href")'), `/auth/login?return=${encodeURIComponent(ret)}`);
|
||||
assert.equal(await shown('#sign-out'), false);
|
||||
assert.equal(await b.evaluate('document.querySelector("#refresh").disabled'), true);
|
||||
const n = await reqs();
|
||||
await b.evaluate('document.querySelector("#refresh").click(), true');
|
||||
await settle();
|
||||
assert.equal(await reqs(), n, 'the page fetched after signing out');
|
||||
};
|
||||
try {
|
||||
// app.js api(): the Board read, with sso on from /api/config.
|
||||
await b.navigate(`${base}?sso&x=1`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__signIn.push('/api/board'), document.querySelector('#refresh').click(), true`);
|
||||
await out('/?sso&x=1');
|
||||
|
||||
// app.js getJSON: the conversation catalogue behind History.
|
||||
await b.navigate(`${base}?sso&conv`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__signIn.push('/api/conversations'), document.querySelector('table.sessions [data-history="proj/agent1"]').click(), true`);
|
||||
await out('/?sso&conv');
|
||||
|
||||
// app.js api(): the board POST behind Seen.
|
||||
await b.navigate(`${base}?sso&seen`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`);
|
||||
await wait('document.querySelector("#inspection [data-seen]")');
|
||||
await b.evaluate(`window.__signIn.push('/api/seen'), document.querySelector('#inspection [data-seen]').click(), true`);
|
||||
await out('/?sso&seen');
|
||||
|
||||
// bus.js fetchRead and fetchApi, on a page whose /api/config has no sso: the link comes from
|
||||
// the 401 itself (F1).
|
||||
await b.navigate(`${base}?read#/inbox`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__signIn.push('/api/bus/inbox'), document.querySelector('#refresh').click(), true`);
|
||||
await out('/?read');
|
||||
await b.navigate(`${base}?module`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__signIn.push('/api/queue'), location.hash = '#/queue', true`);
|
||||
await out('/?module');
|
||||
|
||||
// Any other 401 is an error: no sign-out, and the polls go on.
|
||||
await b.navigate(`${base}?sso&other`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__other.push('/api/board', '/api/bus/inbox', '/api/queue'), document.querySelector('#refresh').click(), true`);
|
||||
await settle();
|
||||
assert.equal(await b.evaluate('document.querySelector("#signed-out").hidden'), true);
|
||||
assert.equal(await b.evaluate('document.body.classList.contains("signed-out")'), false);
|
||||
const n = await reqs();
|
||||
await settle();
|
||||
assert.ok(await reqs() > n, 'the refresh stopped on another 401');
|
||||
assert.equal(await shown('#sign-out'), true);
|
||||
} finally { await done(); }
|
||||
});
|
||||
|
||||
test('the Sign out form shows only when /api/config says sso, and posts to /auth/logout', { timeout: 120000 }, async () => {
|
||||
const { b, base, wait, shown, done } = await setup();
|
||||
try {
|
||||
await b.navigate(base);
|
||||
await wait(LOADED);
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
assert.equal(await shown('#sign-out'), false);
|
||||
await b.navigate(`${base}?sso`);
|
||||
await wait(`${LOADED} && !document.querySelector("#sign-out").hidden`);
|
||||
assert.equal(await shown('#sign-out'), true);
|
||||
assert.deepEqual(await b.evaluate('(() => { const f = document.querySelector("#sign-out"); return [f.method, f.getAttribute("action"), f.querySelector("button").textContent, f.querySelector("button").type]; })()'), ['post', '/auth/logout', 'Sign out', 'submit']);
|
||||
} finally { await done(); }
|
||||
});
|
||||
@@ -0,0 +1,589 @@
|
||||
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md section 9): the Console as an
|
||||
// OIDC relying party (--sso). Every provider is the fake OP in helpers/fake-oidc.mjs on loopback;
|
||||
// no test reaches a real provider, the live Console or a real config or slot file. The public name
|
||||
// is console.example.test, sent only as a Host header to the test's own loopback listener. Each
|
||||
// refusal checks the status, that no session cookie was set and that the send stub never ran.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createSso, readSlot, SESSION_COOKIE, LOGIN_COOKIE } from '../src/sso.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { b64url, ecKey, fakeOidc, jwt, psKey, rsaKey } from './helpers/fake-oidc.mjs';
|
||||
|
||||
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
|
||||
const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
||||
const MSG = JSON.stringify({ to: 'coder', body: 'Please look at row 192.' });
|
||||
// Test 19: every log line any test here captured, and every token, code, state, nonce, verifier,
|
||||
// client secret and session id any test produced. The last test checks the one against the other.
|
||||
const ALL_LINES = [], PRODUCED = new Set();
|
||||
|
||||
// node:http, not fetch: fetch can't set Host, Origin or Sec-Fetch-Site.
|
||||
function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
|
||||
const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
|
||||
let text = '';
|
||||
res.on('data', c => (text += c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
const setCookies = r => [r.headers['set-cookie'] ?? []].flat();
|
||||
const sessionOf = r => setCookies(r).find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0] ?? null;
|
||||
const writeHeaders = cookie => ({ ...(cookie ? { cookie } : {}), origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
|
||||
|
||||
function providerFor(iss, extra = {}) {
|
||||
return { id: iss.name, issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: iss.name }, algs: [...new Set(iss.keys.map(k => k.alg))], subjects: [{ sub: `user-${iss.name}`, name: `person-${iss.name}` }], ...extra };
|
||||
}
|
||||
|
||||
// A fake OP with `issuers`, createSso over them, and the human listener with a counting send.
|
||||
async function setup(t, { issuers = [{ name: 'a' }], provider = {}, before = null, retryFirst = 5000, server = {} } = {}) {
|
||||
const op = await fakeOidc();
|
||||
t.after(() => op.close());
|
||||
const iss = issuers.map(o => op.issuer(o.name, o));
|
||||
for (const i of iss) PRODUCED.add(i.clientSecret);
|
||||
before?.(op, iss);
|
||||
const config = { publicOrigin: ORIGIN, providers: iss.map(i => providerFor(i, provider[i.name] ?? {})) };
|
||||
const lines = [];
|
||||
const log = line => { lines.push(line); ALL_LINES.push(line); };
|
||||
const sso = await createSso(config, { secrets: new Map(iss.map(i => [i.name, i.clientSecret])), fetch: op.fetch, log, retryFirst });
|
||||
t.after(() => sso.close());
|
||||
const sends = [];
|
||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => [{ verb: v }]]));
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async args => { sends.push(args); return { id: 'msg-0001' }; }, log, ...server });
|
||||
t.after(() => close(web));
|
||||
return { op, iss, sso, port: web.address().port, lines, sends, config, bus };
|
||||
}
|
||||
|
||||
// GET /auth/login; returns the authorize URL and the login cookie (name=value).
|
||||
async function begin(env, { provider = null, ret = null, query = null, host = HOST } = {}) {
|
||||
const q = query ?? new URLSearchParams({ ...(provider ? { provider } : {}), ...(ret !== null ? { return: ret } : {}) }).toString();
|
||||
const r = await call(env.port, { path: `/auth/login${q ? `?${q}` : ''}`, host });
|
||||
assert.equal(r.status, 303, `login start: ${r.status} ${r.body}`);
|
||||
const cookie = setCookies(r)[0].split(';')[0];
|
||||
const location = r.headers.location;
|
||||
const params = new URL(location).searchParams;
|
||||
PRODUCED.add(cookie.split('=')[1]);
|
||||
for (const k of ['state', 'nonce', 'code_challenge']) PRODUCED.add(params.get(k));
|
||||
for (const record of env.sso.logins.values()) PRODUCED.add(record.verifier);
|
||||
return { r, cookie, location, params };
|
||||
}
|
||||
function callback(env, begun, { code, state = begun.params.get('state'), extra = {}, cookie = begun.cookie } = {}) {
|
||||
const q = new URLSearchParams({ ...(code !== undefined ? { code } : {}), state, ...extra });
|
||||
return call(env.port, { path: `/auth/callback?${q}`, headers: cookie ? { cookie } : {} });
|
||||
}
|
||||
function grant(env, begun, iss, opts = {}) {
|
||||
const g = env.op.grant(iss, begun.location, opts);
|
||||
PRODUCED.add(g.code);
|
||||
PRODUCED.add(g.idToken);
|
||||
return g;
|
||||
}
|
||||
// A whole sign-in: start, a code at the OP, the callback. Returns the callback answer and the
|
||||
// session cookie (name=value) when one was set.
|
||||
async function signIn(env, { iss = env.iss[0], ret = null, opts = {} } = {}) {
|
||||
const begun = await begin(env, { provider: env.iss.length > 1 ? iss.name : null, ret });
|
||||
const g = grant(env, begun, iss, opts);
|
||||
const r = await callback(env, begun, { code: g.code });
|
||||
const session = sessionOf(r);
|
||||
if (session) PRODUCED.add(session.split('=')[1]);
|
||||
return { begun, g, r, session };
|
||||
}
|
||||
function refused(env, r, code) {
|
||||
assert.equal(r.status, 403, `expected 403, got ${r.status}`);
|
||||
assert.equal(sessionOf(r), null, 'a session cookie was set');
|
||||
assert.equal(env.sends.length, 0, 'the send stub ran');
|
||||
assert.ok(setCookies(r).includes(`${LOGIN_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`), 'the login cookie was not expired');
|
||||
if (code) assert.match(env.lines.at(-1), new RegExp(` sso refused code=${code} provider=`), env.lines.at(-1));
|
||||
}
|
||||
|
||||
test('positive: an RS256 provider signs in and sends one message, and the session reads', async t => {
|
||||
const env = await setup(t);
|
||||
assert.notEqual(env.port, 7332);
|
||||
const { r, session } = await signIn(env, { ret: '/board?x=1' });
|
||||
assert.equal(r.status, 303);
|
||||
assert.equal(r.headers.location, `${ORIGIN}/board?x=1`);
|
||||
assert.ok(session);
|
||||
const send = await call(env.port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: MSG });
|
||||
assert.equal(send.status, 200, JSON.stringify(send.body));
|
||||
assert.deepEqual(env.sends, [{ to: 'coder', body: 'Please look at row 192.' }]);
|
||||
assert.match(env.lines.at(-1), / send user="person-a" to="coder" id=msg-0001$/);
|
||||
const read = await call(env.port, { path: '/api/bus/inbox', headers: { cookie: session } });
|
||||
assert.equal(read.status, 200);
|
||||
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
|
||||
assert.equal(config.body.sso, true);
|
||||
assert.equal(config.body.send, true);
|
||||
});
|
||||
|
||||
test('positive: an ES256 provider signs in and sends', async t => {
|
||||
const env = await setup(t, { issuers: [{ name: 'e', keys: [ecKey()] }] });
|
||||
assert.deepEqual(env.config.providers[0].algs, ['ES256']);
|
||||
const { session } = await signIn(env);
|
||||
assert.ok(session);
|
||||
assert.equal((await call(env.port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: MSG })).status, 200);
|
||||
assert.equal(env.sends.length, 1);
|
||||
});
|
||||
|
||||
test('positive: two providers each sign in their own subjects, and one provider\'s subject is unknown to the other', async t => {
|
||||
const env = await setup(t, { issuers: [{ name: 'a' }, { name: 'b', keys: [ecKey()] }] });
|
||||
const list = await call(env.port, { path: '/auth/login?return=%2Fboard' });
|
||||
assert.equal(list.status, 200);
|
||||
assert.match(list.body, /provider=a&return=%2Fboard/);
|
||||
assert.match(list.body, /provider=b&return=%2Fboard/);
|
||||
const [a, b] = env.iss;
|
||||
assert.ok((await signIn(env, { iss: a })).session);
|
||||
assert.ok((await signIn(env, { iss: b })).session);
|
||||
const names = [...env.sso.sessions.values()].map(s => s.name);
|
||||
assert.deepEqual(names, ['person-a', 'person-b']);
|
||||
const cross = await signIn(env, { iss: b, opts: { sub: 'user-a' } });
|
||||
refused({ ...env, sends: [] }, cross.r, 'subject-unknown');
|
||||
});
|
||||
|
||||
test('1: a forged x-authentik header without a cookie is 401 on the API and a sign-in redirect on a page', async t => {
|
||||
const env = await setup(t);
|
||||
const forged = { 'x-authentik-username': 'jason.woltje', origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
||||
for (const [method, path, body] of [['POST', '/api/bus/message', MSG], ['POST', '/api/settings', '{}'], ['GET', '/api/config', undefined]]) {
|
||||
const r = await call(env.port, { method, path, headers: forged, body });
|
||||
assert.equal(r.status, 401, `${method} ${path}`);
|
||||
assert.deepEqual(r.body, { error: 'sign-in required' });
|
||||
}
|
||||
const page = await call(env.port, { path: '/', headers: forged });
|
||||
assert.equal(page.status, 303);
|
||||
assert.equal(page.headers.location, '/auth/login?return=%2F');
|
||||
const deep = await call(env.port, { path: '/board?x=1' });
|
||||
assert.equal(deep.headers.location, '/auth/login?return=%2Fboard%3Fx%3D1');
|
||||
assert.equal(env.sends.length, 0);
|
||||
});
|
||||
|
||||
test('2: an HS256 ID token keyed with the client secret and a real RSA kid is refused', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
const { r } = await signIn(env, { opts: { header: { alg: 'HS256', kid: iss.keys[0].kid } } });
|
||||
refused(env, r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('3: a wrong aud, and an aud list holding another client with no azp, are refused', async t => {
|
||||
const env = await setup(t);
|
||||
refused(env, (await signIn(env, { opts: { claims: { aud: 'another-client' } } })).r, 'id-token-invalid');
|
||||
refused(env, (await signIn(env, { opts: { claims: { aud: [env.iss[0].clientId, 'another-client'] } } })).r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('4: a token that expired 10 minutes ago is refused', async t => {
|
||||
const env = await setup(t);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
refused(env, (await signIn(env, { opts: { claims: { iat: now - 900, exp: now - 600 } } })).r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('5: another issuer, and the issuer without its trailing slash, are refused', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
refused(env, (await signIn(env, { opts: { claims: { iss: `${env.op.base}/other/` } } })).r, 'id-token-invalid');
|
||||
refused(env, (await signIn(env, { opts: { claims: { iss: iss.issuer.slice(0, -1) } } })).r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('6: a nonce mismatch and a missing nonce are refused', async t => {
|
||||
const env = await setup(t);
|
||||
refused(env, (await signIn(env, { opts: { claims: { nonce: 'not-the-nonce' } } })).r, 'id-token-invalid');
|
||||
refused(env, (await signIn(env, { opts: { claims: { nonce: undefined } } })).r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('7: state mismatch, no login cookie, a replay, an expired record and two login cookies are refused', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
let begun = await begin(env);
|
||||
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, state: 'wrong-state' }), 'state-mismatch');
|
||||
begun = await begin(env);
|
||||
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, cookie: null }), 'login-record-missing');
|
||||
// Replay: the OP takes the code twice, so only the take-and-delete rule refuses the second callback.
|
||||
iss.acceptTwice = true;
|
||||
begun = await begin(env);
|
||||
const g = grant(env, begun, iss);
|
||||
const first = await callback(env, begun, { code: g.code });
|
||||
assert.equal(first.status, 303);
|
||||
PRODUCED.add(sessionOf(first).split('=')[1]);
|
||||
refused(env, await callback(env, begun, { code: g.code }), 'login-record-missing');
|
||||
iss.acceptTwice = false;
|
||||
begun = await begin(env);
|
||||
for (const record of env.sso.logins.values()) record.expires = Date.now() - 1;
|
||||
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code }), 'login-record-missing');
|
||||
begun = await begin(env);
|
||||
const other = await begin(env);
|
||||
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, cookie: `${begun.cookie}; ${other.cookie}` }), 'login-record-missing');
|
||||
});
|
||||
|
||||
test('8: the token request carries the PKCE verifier, and the OP refuses a wrong one', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
const { r, begun } = await signIn(env);
|
||||
assert.equal(r.status, 303);
|
||||
const { form } = iss.tokenRequests.at(-1);
|
||||
assert.equal(begun.params.get('code_challenge_method'), 'S256');
|
||||
const { createHash } = await import('node:crypto');
|
||||
assert.equal(createHash('sha256').update(form.code_verifier).digest('base64url'), begun.params.get('code_challenge'));
|
||||
refused(env, (await signIn(env, { opts: { challenge: 'not-the-challenge' } })).r, 'grant-failed');
|
||||
});
|
||||
|
||||
test('9: alg none, a changed payload under a real kid, and an unknown kid even after the refetch are refused', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
// (a) alg none: the alg rule, before any signature check.
|
||||
refused(env, (await signIn(env, { opts: { header: { alg: 'none' } } })).r, 'id-token-invalid');
|
||||
// (b) a real kid's signature over another payload: only the signature check catches it.
|
||||
let begun = await begin(env);
|
||||
const real = env.op.grant(iss, begun.location);
|
||||
PRODUCED.add(real.idToken);
|
||||
const [h, , s] = real.idToken.split('.');
|
||||
const forged = `${h}.${b64url({ ...real.payload, name: 'changed' })}.${s}`;
|
||||
refused(env, await callback(env, begun, { code: grant(env, begun, iss, { idToken: forged }).code }), 'signature-invalid');
|
||||
// (c) a key the JWKS never held, again after the cache is old enough to refetch.
|
||||
t.mock.timers.enable({ apis: ['Date'], now: Date.now() });
|
||||
const stranger = rsaKey('not-in-jwks');
|
||||
refused(env, (await signIn(env, { opts: { key: stranger } })).r, 'signature-invalid');
|
||||
const fetches = iss.jwksFetches;
|
||||
t.mock.timers.tick(61000);
|
||||
refused(env, (await signIn(env, { opts: { key: stranger } })).r, 'signature-invalid');
|
||||
assert.equal(iss.jwksFetches, fetches + 1, 'no refetch');
|
||||
});
|
||||
|
||||
test('10: a valid token for a subject not configured is 403 with one subject-unknown line', async t => {
|
||||
const env = await setup(t);
|
||||
const before = env.lines.length;
|
||||
const { r } = await signIn(env, { opts: { sub: 'stranger' } });
|
||||
refused(env, r, 'subject-unknown');
|
||||
const added = env.lines.slice(before);
|
||||
assert.equal(added.length, 1);
|
||||
assert.match(added[0], new RegExp(`^\\S+ sso refused code=subject-unknown provider=a iss=${JSON.stringify(env.iss[0].issuer).replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')} sub="stranger"$`));
|
||||
});
|
||||
|
||||
test('11: start refusals: HS or none algs, reserved names, HS-only discovery, an empty JWKS, no S256, an issuer mismatch, a short RSA key', async t => {
|
||||
const op = await fakeOidc();
|
||||
t.after(() => op.close());
|
||||
const start = (iss, extra = {}) => createSso({ publicOrigin: ORIGIN, providers: [providerFor(iss, extra)] }, { secrets: new Map([[iss.name, iss.clientSecret]]), fetch: op.fetch, log: l => ALL_LINES.push(l) });
|
||||
const a = op.issuer('a');
|
||||
PRODUCED.add(a.clientSecret);
|
||||
for (const [extra, why] of [
|
||||
[{ algs: ['HS256'] }, /algs\[0\] must be one of .*HS\* and none are refused/],
|
||||
[{ algs: ['none'] }, /algs\[0\] must be one of/],
|
||||
[{ subjects: [{ sub: 'u', name: 'tester' }] }, /subjects\[0\]\.name must not be tester or human/],
|
||||
[{ subjects: [{ sub: 'u', name: 'Human' }] }, /subjects\[0\]\.name must not be tester or human/],
|
||||
]) await assert.rejects(start(a, extra), why);
|
||||
const refusedAtDiscovery = /console\.sso provider \w+ refused at discovery/;
|
||||
// The mosaic-preview shape: HS256 only.
|
||||
const hs = op.issuer('hs', { discovery: { id_token_signing_alg_values_supported: ['HS256'] } });
|
||||
await assert.rejects(start(hs, { algs: ['RS256'] }), refusedAtDiscovery);
|
||||
const empty = op.issuer('empty');
|
||||
empty.jwksOverride = { keys: [] };
|
||||
await assert.rejects(start(empty), refusedAtDiscovery);
|
||||
const plain = op.issuer('plain', { discovery: { code_challenge_methods_supported: ['plain'] } });
|
||||
await assert.rejects(start(plain), refusedAtDiscovery);
|
||||
const moved = op.issuer('moved');
|
||||
moved.discovery = { issuer: `${op.base}/elsewhere/` };
|
||||
await assert.rejects(start(moved), refusedAtDiscovery);
|
||||
const small = op.issuer('small', { keys: [rsaKey('small', 1024)] });
|
||||
await assert.rejects(start(small), refusedAtDiscovery);
|
||||
// The control: the same OP and provider start.
|
||||
const sso = await start(a);
|
||||
assert.deepEqual(sso.health(), { a: 'ready' });
|
||||
sso.close();
|
||||
await assert.rejects(startServer({ port: 0, sso, preview: { host: HOST, header: 'remote-user', user: 'jason' } }), /--sso cannot be combined/);
|
||||
});
|
||||
|
||||
test('11: slot files: a symlink, mode 0644, mode 0640 and a file under the repository are refused, and no message holds the value', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-sso-slot-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const dir = join(root, 'config'), slots = join(dir, 'secrets', 'mosaic-stack');
|
||||
mkdirSync(slots, { recursive: true });
|
||||
const provider = slot => ({ id: 'a', clientSecret: { slot } });
|
||||
const value = 'slot-value-do-not-print';
|
||||
const opts = { repo: join(root, 'repo'), copies: join(root, 'copies') };
|
||||
writeFileSync(join(slots, 'good'), `${value}\n`, { mode: 0o600 });
|
||||
assert.equal(readSlot(dir, provider('good'), opts), value);
|
||||
writeFileSync(join(root, 'target'), value, { mode: 0o600 });
|
||||
symlinkSync(join(root, 'target'), join(slots, 'link'));
|
||||
for (const [slot, mode] of [['wide', 0o644], ['group', 0o640]]) { writeFileSync(join(slots, slot), value); chmodSync(join(slots, slot), mode); }
|
||||
writeFileSync(join(slots, 'two'), `${value}\nmore\n`, { mode: 0o600 });
|
||||
writeFileSync(join(slots, 'empty'), '\n', { mode: 0o600 });
|
||||
const cases = [
|
||||
['missing', /slot is missing$/, opts],
|
||||
['link', /must not be a symbolic link$/, opts],
|
||||
['wide', /must have no group or other permission bits$/, opts],
|
||||
['group', /must have no group or other permission bits$/, opts],
|
||||
['two', /must hold one non-empty line$/, opts],
|
||||
['empty', /must hold one non-empty line$/, opts],
|
||||
// The config dir itself under the repository, and under a --checkout.
|
||||
['good', /must not be inside the repository or a stack copy$/, { ...opts, repo: root }],
|
||||
['good', /must not be inside the repository or a stack copy$/, { ...opts, checkout: dir }],
|
||||
['good', /must not be inside the repository or a stack copy$/, { ...opts, copies: dir }],
|
||||
];
|
||||
for (const [slot, why, o] of cases) {
|
||||
let err = null;
|
||||
try { readSlot(dir, provider(slot), o); } catch (e) { err = e; }
|
||||
assert.ok(err, `${slot} was read`);
|
||||
assert.match(err.message, /^console\.sso provider a client secret slot /);
|
||||
assert.match(err.message, why, slot);
|
||||
assert.equal(err.message.includes(value), false);
|
||||
assert.equal(err.message.includes(root), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('11: the CLI refuses --sso with the old flags and without console.sso, and starts with a good one', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'wsso-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const env = { ...process.env, MOSAIC_CONFIG: join(root, 'cfg', 'config.json'), XDG_DATA_HOME: join(root, 'xdg') };
|
||||
for (const flags of [['--auth-header', 'remote-user'], ['--public-host', HOST], ['--allow-user', 'jason'], ['--proof-header', 'x-proof']]) {
|
||||
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso', ...flags], { encoding: 'utf8', timeout: 10000, env });
|
||||
assert.equal(r.status, 2, flags.join(' '));
|
||||
assert.match(r.stderr, /^refused: --sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-\*$/m);
|
||||
}
|
||||
const missing = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
|
||||
assert.equal(missing.status, 2);
|
||||
assert.match(missing.stderr, /^refused: --sso needs the system config \(/);
|
||||
const op = await fakeOidc();
|
||||
t.after(() => op.close());
|
||||
const a = op.issuer('a');
|
||||
PRODUCED.add(a.clientSecret);
|
||||
mkdirSync(join(root, 'cfg', 'secrets', 'mosaic-stack'), { recursive: true });
|
||||
writeFileSync(join(root, 'ca.pem'), op.cert);
|
||||
const base = { configVersion: 1, environment: 'development', dataRoot: join(root, 'data'), execution: { backend: 'docker', provider: 'zai', model: 'm' } };
|
||||
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify(base));
|
||||
const noKey = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
|
||||
assert.equal(noKey.status, 2);
|
||||
assert.match(noKey.stderr, /^refused: --sso needs console\.sso in the system config$/m);
|
||||
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify({ ...base, console: { sso: { publicOrigin: ORIGIN, providers: [providerFor(a)] } } }));
|
||||
writeFileSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), `${a.clientSecret}\n`, { mode: 0o644 });
|
||||
const wide = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
|
||||
assert.equal(wide.status, 2);
|
||||
assert.match(wide.stderr, /^refused: console\.sso provider a client secret slot must have no group or other permission bits$/m);
|
||||
chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o600);
|
||||
// The child trusts the fake OP's certificate through NODE_EXTRA_CA_CERTS; the code under test has no flag for it.
|
||||
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { env: { ...env, NODE_EXTRA_CA_CERTS: join(root, 'ca.pem') } });
|
||||
t.after(() => child.kill('SIGKILL'));
|
||||
let out = '', err = '';
|
||||
child.stderr.on('data', c => (err += c));
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`no start line: ${out} ${err}`)), 15000);
|
||||
child.stdout.on('data', c => { out += c; if (out.includes('Ctrl-C')) { clearTimeout(timer); resolve(); } });
|
||||
child.on('exit', () => { clearTimeout(timer); reject(new Error(`exited: ${err}`)); });
|
||||
});
|
||||
assert.match(out, new RegExp(`^Sign-in: ${ORIGIN}/ \\(--sso\\), read-only$`, 'm'));
|
||||
assert.match(out, new RegExp(`^Sign-in provider: a issuer ${a.issuer.replace(/[.]/g, '\\.')} subjects 1 \\(ready\\)$`, 'm'));
|
||||
assert.equal(out.includes(a.clientSecret) || err.includes(a.clientSecret), false);
|
||||
assert.equal(out.includes(a.clientId), false);
|
||||
const port = Number(out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//m)[1]);
|
||||
const health = await call(port, { path: '/healthz', host: `127.0.0.1:${port}` });
|
||||
assert.deepEqual(health.body, { ok: true, sso: { a: 'ready' } });
|
||||
child.kill('SIGTERM');
|
||||
});
|
||||
|
||||
test('12: under --sso the tester listener refuses each kind of sign-in, has no /auth, and startServer refuses tester with sso', async t => {
|
||||
const env = await setup(t);
|
||||
const tester = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus: env.bus, tester: true, testerNoSignIn: true, log: () => {} });
|
||||
t.after(() => close(tester));
|
||||
const port = tester.address().port, host = `127.0.0.1:${port}`;
|
||||
const { session, g, begun } = await signIn(env);
|
||||
assert.ok(session);
|
||||
const plain = await call(port, { path: '/api/config', host });
|
||||
assert.equal(plain.status, 200, 'control: a request with no sign-in is served');
|
||||
for (const headers of [
|
||||
{ cookie: session },
|
||||
{ cookie: `theme=dark; ${begun.cookie}` },
|
||||
{ cookie: `${SESSION_COOKIE.toLowerCase()}=x` },
|
||||
{ authorization: `Bearer ${g.idToken}` },
|
||||
{ 'x-authentik-jwt': g.idToken },
|
||||
{ 'X-Authentik-Username': 'jason.woltje' },
|
||||
]) {
|
||||
const r = await call(port, { path: '/api/config', host, headers });
|
||||
assert.equal(r.status, 403, JSON.stringify(Object.keys(headers)));
|
||||
assert.deepEqual(r.body, { error: 'tester listener takes no sign-in' });
|
||||
}
|
||||
assert.equal((await call(port, { path: '/auth/login', host })).status, 404);
|
||||
await assert.rejects(startServer({ port: 0, tester: true, sso: env.sso }), /tester listener cannot use sso/);
|
||||
});
|
||||
|
||||
test('13: idle and absolute expiry, logout then reuse, a cross-origin logout, and background reads that don\'t keep a session', async t => {
|
||||
const env = await setup(t);
|
||||
const read = cookie => call(env.port, { path: '/api/bus/inbox', headers: { cookie } });
|
||||
const record = () => [...env.sso.sessions.values()].at(-1);
|
||||
// Background reads don't move `seen`; a page load does.
|
||||
let { session } = await signIn(env);
|
||||
const old = Date.now() - 59 * 60 * 1000;
|
||||
record().seen = old;
|
||||
assert.equal((await read(session)).status, 200);
|
||||
assert.equal(record().seen, old, 'a background read refreshed the session');
|
||||
assert.equal((await call(env.port, { path: '/', headers: { cookie: session } })).status, 200);
|
||||
assert.ok(record().seen > old, 'a page load did not refresh the session');
|
||||
// Idle: 59 minutes of background reads, then one more past the hour.
|
||||
record().seen = Date.now() - 61 * 60 * 1000;
|
||||
assert.equal((await read(session)).status, 401);
|
||||
// Absolute.
|
||||
({ session } = await signIn(env));
|
||||
record().created = Date.now() - 8 * 3600 * 1000;
|
||||
assert.equal((await call(env.port, { path: '/', headers: { cookie: session } })).headers.location, '/auth/login?return=%2F');
|
||||
// Logout needs same-origin; refused, the session stays.
|
||||
({ session } = await signIn(env));
|
||||
const cross = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'cross-site' } });
|
||||
assert.equal(cross.status, 403);
|
||||
assert.equal((await read(session)).status, 200);
|
||||
const noOrigin = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, 'sec-fetch-site': 'same-origin' } });
|
||||
assert.equal(noOrigin.status, 403);
|
||||
assert.equal((await read(session)).status, 200);
|
||||
const out = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } });
|
||||
assert.equal(out.status, 303);
|
||||
const end = new URL(out.headers.location);
|
||||
assert.equal(`${end.origin}${end.pathname}`, `${env.iss[0].issuer}end-session`);
|
||||
assert.equal(end.searchParams.get('post_logout_redirect_uri'), `${ORIGIN}/auth/signed-out`);
|
||||
assert.equal(end.searchParams.get('client_id'), env.iss[0].clientId);
|
||||
assert.ok(end.searchParams.get('id_token_hint'));
|
||||
assert.deepEqual(setCookies(out), [`${SESSION_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`]);
|
||||
assert.equal((await read(session)).status, 401);
|
||||
assert.equal((await call(env.port, { method: 'GET', path: '/auth/logout', headers: { cookie: session } })).status, 405);
|
||||
});
|
||||
|
||||
test('13: the CSP lets the Sign out form reach the provider\'s end-session origin, and only under --sso', async t => {
|
||||
const env = await setup(t);
|
||||
const r = await call(env.port, { path: '/healthz', host: `127.0.0.1:${env.port}` });
|
||||
assert.match(r.headers['content-security-policy'], new RegExp(`; form-action 'self' ${env.op.base.replace(/[.]/g, '\\.')}$`));
|
||||
const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
|
||||
t.after(() => close(plain));
|
||||
const p = await call(plain.address().port, { path: '/healthz', host: `127.0.0.1:${plain.address().port}` });
|
||||
assert.match(p.headers['content-security-policy'], /; form-action 'self'$/);
|
||||
});
|
||||
|
||||
test('14: open redirects land on /, and a path on the Console lands there', async t => {
|
||||
const env = await setup(t);
|
||||
for (const [query, where] of [
|
||||
['return=%2F%2Fevil.example', `${ORIGIN}/`],
|
||||
['return=https%3A%2F%2Fevil.example', `${ORIGIN}/`],
|
||||
['return=%2F%5Cevil.example', `${ORIGIN}/`],
|
||||
['return=/%09/evil.example', `${ORIGIN}/`],
|
||||
// Dot segments that leave a pathname of //evil.example stay on the Console (webby-0019 B1).
|
||||
['return=%2F.%2F%2Fevil.example', `${ORIGIN}//evil.example`],
|
||||
['return=%2Fx%2F..%2F%2Fevil.example', `${ORIGIN}//evil.example`],
|
||||
[`return=${encodeURIComponent(`${ORIGIN}//evil.example`)}`, `${ORIGIN}//evil.example`],
|
||||
['return=%2Fboard%2F.%2Fx', `${ORIGIN}/board/x`],
|
||||
['return=%2Fboard%3Fx%3D1', `${ORIGIN}/board?x=1`],
|
||||
['', `${ORIGIN}/`],
|
||||
]) {
|
||||
const begun = await begin(env, { query });
|
||||
const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code });
|
||||
assert.equal(r.status, 303);
|
||||
assert.equal(r.headers.location, where, query);
|
||||
assert.equal(new URL(r.headers.location).origin, ORIGIN, query);
|
||||
PRODUCED.add(sessionOf(r).split('=')[1]);
|
||||
}
|
||||
});
|
||||
|
||||
test('15: a callback for A carrying B\'s code and iss, and a provider= query, is refused', async t => {
|
||||
const env = await setup(t, { issuers: [{ name: 'a' }, { name: 'b' }] });
|
||||
const [, b] = env.iss;
|
||||
for (const extra of [{ iss: b.issuer, provider: 'b' }, { provider: 'b' }]) {
|
||||
const begun = await begin(env, { provider: 'a' });
|
||||
// B's code against A's PKCE challenge and nonce, for B's own subject: only the record rule
|
||||
// (this callback belongs to A) stands in the way.
|
||||
const g = grant(env, begun, b, { sub: 'user-b' });
|
||||
const r = await callback(env, begun, { code: g.code, extra });
|
||||
refused(env, r);
|
||||
}
|
||||
assert.equal(b.tokenRequests.length, 0, 'B\'s token endpoint was called');
|
||||
});
|
||||
|
||||
test('16: a provider with RS256 and ES256 takes either, and refuses PS256 with a usable PS256 key in the JWKS', async t => {
|
||||
const rsa = rsaKey(), ec = ecKey(), ps = psKey();
|
||||
const env = await setup(t, { issuers: [{ name: 'a', keys: [rsa, ec, ps] }], provider: { a: { algs: ['RS256', 'ES256'] } } });
|
||||
assert.ok((await signIn(env, { opts: { key: rsa } })).session);
|
||||
assert.ok((await signIn(env, { opts: { key: ec } })).session);
|
||||
refused(env, (await signIn(env, { opts: { key: ps } })).r, 'id-token-invalid');
|
||||
});
|
||||
|
||||
test('17: 33 started logins leave 32 records, and the first one\'s callback finds none', async t => {
|
||||
const env = await setup(t);
|
||||
const first = await begin(env);
|
||||
for (let i = 0; i < 32; i++) await begin(env);
|
||||
assert.equal(env.sso.logins.size, 32);
|
||||
refused(env, await callback(env, first, { code: grant(env, first, env.iss[0]).code }), 'login-record-missing');
|
||||
});
|
||||
|
||||
test('18: after a key rotation a sign-in works through one refetch, and the removed kid is refused', async t => {
|
||||
const env = await setup(t);
|
||||
const [iss] = env.iss;
|
||||
t.mock.timers.enable({ apis: ['Date'], now: Date.now() });
|
||||
const old = iss.keys[0];
|
||||
assert.ok((await signIn(env)).session);
|
||||
const fetches = iss.jwksFetches;
|
||||
env.op.rotate(iss);
|
||||
t.mock.timers.tick(61000);
|
||||
assert.ok((await signIn(env)).session);
|
||||
assert.equal(iss.jwksFetches, fetches + 1);
|
||||
refused(env, (await signIn(env, { opts: { key: old } })).r, 'signature-invalid');
|
||||
});
|
||||
|
||||
test('20 and 21: the store holds no raw session id, and the cookie strings are exact', async t => {
|
||||
const env = await setup(t);
|
||||
const begun = await begin(env);
|
||||
assert.match(setCookies(begun.r)[0], new RegExp(`^${LOGIN_COOKIE}=[A-Za-z0-9_-]{43}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=600$`));
|
||||
for (const key of env.sso.logins.keys()) assert.match(key, /^[0-9a-f]{64}$/);
|
||||
const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code });
|
||||
const set = setCookies(r);
|
||||
assert.equal(set.length, 2);
|
||||
assert.equal(set[0], `${LOGIN_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||||
assert.match(set[1], new RegExp(`^${SESSION_COOKIE}=[A-Za-z0-9_-]{43}; Path=/; Secure; HttpOnly; SameSite=Lax$`));
|
||||
const id = set[1].split(';')[0].split('=')[1];
|
||||
PRODUCED.add(id);
|
||||
for (const [key, value] of env.sso.sessions) {
|
||||
assert.match(key, /^[0-9a-f]{64}$/);
|
||||
assert.notEqual(key, id);
|
||||
assert.equal(JSON.stringify(value).includes(id), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('22: a loopback Host is 403 on a page, a read and a write; /healthz on loopback is 200; a redirect_uri never comes from the Host', async t => {
|
||||
const env = await setup(t);
|
||||
const { session } = await signIn(env);
|
||||
const loop = `127.0.0.1:${env.port}`;
|
||||
for (const [method, path, body] of [['GET', '/', undefined], ['GET', '/api/board', undefined], ['GET', '/api/bus/inbox', undefined], ['POST', '/api/bus/message', MSG]]) {
|
||||
// A loopback Host on this listener's port; any other Host is already the older non-local refusal.
|
||||
for (const host of [loop, `localhost:${env.port}`]) {
|
||||
const r = await call(env.port, { method, path, host, body, headers: { cookie: session, 'content-type': 'application/json' } });
|
||||
assert.equal(r.status, 403, `${method} ${path} ${host}`);
|
||||
assert.deepEqual(r.body, { error: 'public Host required' });
|
||||
}
|
||||
}
|
||||
const health = await call(env.port, { path: '/healthz', host: loop });
|
||||
assert.equal(health.status, 200);
|
||||
assert.deepEqual(health.body, { ok: true, sso: { a: 'ready' } });
|
||||
assert.equal(env.sends.length, 0);
|
||||
const begun = await begin(env, { host: HOST.toUpperCase() });
|
||||
assert.equal(begun.params.get('redirect_uri'), `${ORIGIN}/auth/callback`);
|
||||
});
|
||||
|
||||
test('23: a provider down at start leaves the Console up and 503, and the retry makes sign-in work', async t => {
|
||||
let env = await setup(t, { retryFirst: 50, before: (op, [a]) => { a.mode = 'down'; } });
|
||||
assert.equal((await call(env.port, { path: '/auth/login' })).status, 503);
|
||||
assert.deepEqual((await call(env.port, { path: '/healthz', host: `127.0.0.1:${env.port}` })).body.sso, { a: 'unreachable' });
|
||||
assert.match(env.lines.at(-1), / sso refused code=discovery-failed provider=a$/);
|
||||
env.iss[0].mode = 'ok';
|
||||
for (let i = 0; i < 100 && env.sso.health().a !== 'ready'; i++) await new Promise(r => setTimeout(r, 20));
|
||||
assert.deepEqual(env.sso.health(), { a: 'ready' });
|
||||
assert.ok((await signIn(env)).session);
|
||||
// A 200 HTML page at the discovery URL is unreachable too, and the Console starts.
|
||||
env = await setup(t, { before: (op, [a]) => { a.mode = 'html'; } });
|
||||
assert.deepEqual(env.sso.health(), { a: 'unreachable' });
|
||||
// HS-only discovery at start refuses the start.
|
||||
await assert.rejects(setup(t, { before: (op, [a]) => { a.discovery = { id_token_signing_alg_values_supported: ['HS256'] }; } }), /refused at discovery/);
|
||||
});
|
||||
|
||||
test('19: no captured log line holds a token, code, state, nonce, verifier, client secret or session id', () => {
|
||||
assert.ok(ALL_LINES.length > 20, `only ${ALL_LINES.length} lines`);
|
||||
assert.ok(PRODUCED.size > 50, `only ${PRODUCED.size} values`);
|
||||
for (const line of ALL_LINES) {
|
||||
for (const value of PRODUCED) if (value) assert.equal(line.includes(value), false, `a log line holds a produced value: ${line.slice(0, 80)}`);
|
||||
assert.match(line, /^\S+ (sso refused code=[a-z-]+ provider=[a-z0-9-]+( iss="[^"]*" sub="[^"]*")?|send user="[^"]+" to="[^"]*" (id=msg-0001|error=[a-z -]+))$/, line);
|
||||
}
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const SUPPORTED_CONFIG_VERSION = 1;
|
||||
const SUPPORTED_ENVIRONMENTS = new Set(["development", "production"]);
|
||||
@@ -106,13 +107,30 @@ function isAncestorOf(ancestor, candidate) {
|
||||
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function validate(document, file) {
|
||||
// Q175 phase 1 (#1690): the optional `console` key, checked for shape only by the Console's own
|
||||
// checker. It is imported only when the key is there, so a config without it never needs the
|
||||
// Console's code, and a copy of this file alone still reads one.
|
||||
async function validateConsole(value) {
|
||||
let checkConsole;
|
||||
try {
|
||||
({ checkConsole } = await import(pathToFileURL(path.join(import.meta.dirname, "..", "packages", "webui", "src", "sso-config.mjs")).href));
|
||||
} catch {
|
||||
fail(2, 'the "console" key needs packages/webui/src/sso-config.mjs beside this loader');
|
||||
}
|
||||
try {
|
||||
return checkConsole(value);
|
||||
} catch (error) {
|
||||
fail(2, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function validate(document, file) {
|
||||
if (!isPlainObject(document)) {
|
||||
fail(2, "configuration must be a JSON object");
|
||||
}
|
||||
rejectUnknownKeys(
|
||||
document,
|
||||
["configVersion", "environment", "dataRoot", "execution"],
|
||||
["configVersion", "environment", "dataRoot", "execution", "console"],
|
||||
"configuration",
|
||||
);
|
||||
|
||||
@@ -146,6 +164,10 @@ function validate(document, file) {
|
||||
fail(2, `unsupported execution.adapter: ${JSON.stringify(adapter)} (supported: ${[...SUPPORTED_ADAPTERS].join(", ")})`);
|
||||
}
|
||||
|
||||
// Last, so the older checks keep their order. The key is in the output only when the file has
|
||||
// it, so a config without it prints what it always printed.
|
||||
const consoleKey = Object.hasOwn(document, "console") ? await validateConsole(document.console) : undefined;
|
||||
|
||||
return {
|
||||
configVersion: document.configVersion,
|
||||
environment: document.environment,
|
||||
@@ -156,10 +178,11 @@ function validate(document, file) {
|
||||
model: document.execution.model,
|
||||
adapter,
|
||||
},
|
||||
...(consoleKey === undefined ? {} : { console: consoleKey }),
|
||||
};
|
||||
}
|
||||
|
||||
function load(file) {
|
||||
async function load(file) {
|
||||
const raw = readRaw(file);
|
||||
if (raw === null) {
|
||||
fail(3, `configuration not found: ${file} (run scripts/bootstrap.sh to create it)`);
|
||||
@@ -170,7 +193,7 @@ function load(file) {
|
||||
} catch (error) {
|
||||
fail(2, `configuration is not valid JSON (${file}): ${error.message}`);
|
||||
}
|
||||
return validate(document, file);
|
||||
return await validate(document, file);
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
@@ -194,7 +217,7 @@ const file = configPath();
|
||||
switch (operation) {
|
||||
case "bootstrap": {
|
||||
if (fs.existsSync(file)) {
|
||||
load(file); // validate only; never rewrite
|
||||
await load(file); // validate only; never rewrite
|
||||
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -206,26 +229,26 @@ switch (operation) {
|
||||
fs.writeFileSync(fd, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
|
||||
} catch (error) {
|
||||
if (error.code === "EEXIST") {
|
||||
load(file);
|
||||
await load(file);
|
||||
process.exit(0);
|
||||
}
|
||||
fail(1, `unable to create configuration: ${error.message}`);
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
load(file);
|
||||
await load(file);
|
||||
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
case "validate": {
|
||||
const resolved = load(file);
|
||||
const resolved = await load(file);
|
||||
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
case "env": {
|
||||
const resolved = load(file);
|
||||
const resolved = await load(file);
|
||||
process.stdout.write(
|
||||
[
|
||||
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
|
||||
|
||||
@@ -151,6 +151,52 @@ else
|
||||
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} env exports resolve correctly"
|
||||
fi
|
||||
|
||||
# --- Q175 phase 1 (#1690), design test 25: the optional console key ---
|
||||
# Shape only. The slot path is a FIFO, so a loader that opened the slot would block and the
|
||||
# timeout would fail the case; no case reads a slot file.
|
||||
mkdir -p "$SANDBOX/secrets/mosaic-stack"
|
||||
mkfifo "$SANDBOX/secrets/mosaic-stack/console-sso-test"
|
||||
sso_body() {
|
||||
printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m"},"console":%s}' "$DATA_ROOT" "$1"
|
||||
}
|
||||
# provider [ALGS] [CLIENT_SECRET]: one provider entry; an empty argument takes the valid value.
|
||||
provider() {
|
||||
local algs='["RS256"]' secret='{"slot":"console-sso-test"}'
|
||||
[ -n "${1:-}" ] && algs="$1"
|
||||
[ -n "${2:-}" ] && secret="$2"
|
||||
printf '{"id":"fake","issuer":"https://op.test/app/","clientId":"client-1","clientSecret":%s,"algs":%s,"subjects":[{"sub":"user-1","name":"jason.woltje"}]}' "$secret" "$algs"
|
||||
}
|
||||
cfg sso-valid.json "$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider)"']}}')"
|
||||
OUT="$(MOSAIC_CONFIG="$SANDBOX/sso-valid.json" timeout 20 $CONFIG_OP validate 2>&1)"
|
||||
[ $? -eq 0 ] && printf '%s' "$OUT" | grep -q '"publicOrigin": "https://console.test"'
|
||||
check "valid console.sso loads and is in the output" $?
|
||||
|
||||
# expect_refusal NAME PATH-IN-MESSAGE BODY: exit 2, and the message names the bad key path.
|
||||
expect_refusal() {
|
||||
cfg sso-bad.json "$2"
|
||||
local err rc
|
||||
err="$(MOSAIC_CONFIG="$SANDBOX/sso-bad.json" timeout 20 $CONFIG_OP validate 2>&1 >/dev/null)"
|
||||
rc=$?
|
||||
[ "$rc" -eq 2 ] && printf '%s' "$err" | grep -qF -- "$3"
|
||||
check "$1 (exit $rc)" $?
|
||||
}
|
||||
expect_refusal "console.sso HS256 alg exits 2 naming the path" \
|
||||
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '["HS256"]')"']}}')" 'console.sso.providers[0].algs[0] must be one of'
|
||||
expect_refusal "console.sso alg none exits 2 naming the path" \
|
||||
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '["RS256","none"]')"']}}')" 'console.sso.providers[0].algs[1] must be one of'
|
||||
expect_refusal "console.sso missing slot field exits 2 naming the path" \
|
||||
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '' '{}')"']}}')" 'console.sso.providers[0].clientSecret.slot is required'
|
||||
expect_refusal "console.sso slot name outside the pattern exits 2 naming the path" \
|
||||
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '' '{"slot":"../x"}')"']}}')" 'console.sso.providers[0].clientSecret.slot must match'
|
||||
expect_refusal "unknown key under console exits 2 naming the path" \
|
||||
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider)"']},"extra":1}')" 'console.extra is not a supported key'
|
||||
|
||||
# Without the key: the output is exactly what the loader printed before phase 1 (sage-1340, F2).
|
||||
cfg no-console.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"}}'
|
||||
EXPECTED="$(printf '{\n "configVersion": 1,\n "environment": "development",\n "dataRoot": "%s",\n "execution": {\n "backend": "docker",\n "provider": "zai",\n "model": "m",\n "adapter": "pi"\n }\n}' "$DATA_ROOT")"
|
||||
[ "$(MOSAIC_CONFIG="$SANDBOX/no-console.json" timeout 20 $CONFIG_OP validate)" = "$EXPECTED" ]
|
||||
check "config without console loads with unchanged output" $?
|
||||
|
||||
# --- validation must not modify the file ---
|
||||
SUM_INVALID_BEFORE=$(sha256sum "$SANDBOX/invalid.json" | cut -d' ' -f1)
|
||||
MOSAIC_CONFIG="$SANDBOX/invalid.json" $CONFIG_OP validate >/dev/null 2>&1
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
# character set and refused otherwise. --proof-header NAME --proof-file PATH
|
||||
# go together under preview; only the path is carried here, never the secret.
|
||||
# render checks ownership, mode and location without opening the proof file.
|
||||
# --sso (Q175 phase 1, #1690): the Console signs people in itself from
|
||||
# console.sso in config.json. It refuses the preview set and the proof pair,
|
||||
# and --send takes it in place of the preview set. Nothing secret is carried.
|
||||
#
|
||||
# There is no start, stop, enable or restart verb. The Console reads the
|
||||
# main listener's bus through the human transport; the optional loopback
|
||||
@@ -68,7 +71,7 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
|
||||
UNIT_NAME="mosaic-webui.service"
|
||||
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
|
||||
FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
|
||||
FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
|
||||
--tester-port: 1024..65535, different from --port and never 7332; loopback tester listener.
|
||||
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
|
||||
|
||||
@@ -91,6 +94,7 @@ PUBLIC_HOST=""
|
||||
AUTH_HEADER=""
|
||||
ALLOW_USER=""
|
||||
SEND=0
|
||||
SSO=0
|
||||
PROOF_HEADER=""
|
||||
PROOF_FILE=""
|
||||
PROOF_SET=0
|
||||
@@ -114,6 +118,7 @@ parse_flags() {
|
||||
--proof-header) [ $# -ge 2 ] || usage; PROOF_HEADER="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
|
||||
--proof-file) [ $# -ge 2 ] || usage; PROOF_FILE="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
|
||||
--send) SEND=1; SERVE_SET=1; shift ;;
|
||||
--sso) SSO=1; SERVE_SET=1; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
@@ -166,9 +171,13 @@ serve_flags() {
|
||||
[ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
|
||||
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
|
||||
[ -z "$ALLOW_USER" ] || preview=$((preview + 1))
|
||||
# serve refuses --send without the preview set; refuse it here first,
|
||||
# so no unit is written that would only exit 2 at start.
|
||||
[ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user)"
|
||||
# serve refuses --sso with the preview set or the proof pair, and --send
|
||||
# without --sso or the preview set; refuse them here first, so no unit is
|
||||
# written that would only exit 2 at start.
|
||||
if [ "$SSO" = 1 ] && { [ "$preview" -ne 0 ] || [ "$PROOF_SET" = 1 ]; }; then
|
||||
die "--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*"
|
||||
fi
|
||||
[ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || [ "$SSO" = 1 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso"
|
||||
if [ "$preview" -ne 0 ]; then
|
||||
[ "$preview" -eq 3 ] || die "--public-host, --auth-header and --allow-user go together: all three or none"
|
||||
case "$PUBLIC_HOST" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac
|
||||
@@ -201,6 +210,7 @@ serve_flags() {
|
||||
{ [ "$proof_mode" = 600 ] || [ "$proof_mode" = 400 ]; } && [ "$proof_uid" = "$(id -u)" ] || die "invalid proxy proof file metadata"
|
||||
flags="$flags --proof-header $PROOF_HEADER --proof-file $PROOF_FILE"
|
||||
fi
|
||||
[ "$SSO" = 0 ] || flags="$flags --sso"
|
||||
[ "$SEND" = 0 ] || flags="$flags --send"
|
||||
[ "$TESTER_SET" = 0 ] || flags="$flags --tester-port $TESTER_PORT"
|
||||
echo "$flags"
|
||||
@@ -385,14 +395,17 @@ status_unit() {
|
||||
else
|
||||
echo "copy: none; the unit runs from $wd"
|
||||
fi
|
||||
# render puts --send after the allowed user and optional proof pair,
|
||||
# before the tester port. The allowed user
|
||||
# render puts --send after the allowed user and optional proof pair, or
|
||||
# after --sso, before the tester port. The allowed user
|
||||
# starts with a letter or digit (N5), so it is never the value --send.
|
||||
if grep -Eq '^ExecStart=.* --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)? --send( --tester-port [0-9]+)?$' "$target"; then
|
||||
if grep -Eq '^ExecStart=.*( --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)?| --sso) --send( --tester-port [0-9]+)?$' "$target"; then
|
||||
echo "send: on (--send; human messages to roles)"
|
||||
else
|
||||
echo "send: off (read-only)"
|
||||
fi
|
||||
if grep -Eq '^ExecStart=.* --sso( --send)?( --tester-port [0-9]+)?$' "$target"; then
|
||||
echo "sign-in: the Console's own (--sso)"
|
||||
fi
|
||||
local tester_port
|
||||
tester_port="$(sed -n 's/^ExecStart=.* --tester-port \([0-9][0-9]*\)$/\1/p' "$target")"
|
||||
if [ -n "$tester_port" ]; then
|
||||
|
||||
Reference in New Issue
Block a user