Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bffb2b2a9 | ||
|
|
6711d55be7 | ||
|
|
9f4d5556ba | ||
|
|
9ba8385a9a | ||
|
|
182ff49b15 | ||
|
|
317bb05e93 | ||
|
|
56d59c4c7e | ||
|
|
4c052837e3 | ||
|
|
38cc4f114e | ||
|
|
808aa41a30 | ||
|
|
52b5fee634 | ||
|
|
8140b7f393 | ||
|
|
2a1d17da7d | ||
|
|
a76767e877 | ||
|
|
f1809238d8 | ||
|
|
606df83ba2 | ||
|
|
da03f5bf53 | ||
|
|
d273963e5b | ||
|
|
c15c70f367 | ||
|
|
c2a9fb8404 | ||
|
|
640011518b | ||
|
|
3cb6888e1b | ||
|
|
523a6bddf3 | ||
|
|
d2e305db49 | ||
|
|
8a22d08976 | ||
|
|
0b1f0336ed | ||
|
|
4065ed59f9 | ||
|
|
6ab9cd9271 | ||
|
|
912b1d325a | ||
|
|
4cf3ce2327 | ||
|
|
cb6d840df1 | ||
|
|
aa7f082bf4 | ||
|
|
b14f111362 | ||
|
|
86be181a2a | ||
|
|
4966136d40 | ||
|
|
45cbe58966 | ||
|
|
03a663cd72 | ||
|
|
b402197ff3 |
@@ -16,11 +16,6 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
|
||||
| Rocko | General development, investigation, testing, and review | T3 session (Codex, gpt-6.1-sol, thread d99326cc, lead decision 122; Claude thread b84bb264 retired 2026-10-12); launcher Claude Code, Sonnet model | `agents/rocko/launch.sh` |
|
||||
| Filbert | General development, investigation, testing, and review | T3 session (Claude, Opus 5.5, thread 048f3c63, non-sandbox reviews only, lead decisions 130 and 134; Codex threads f712d8af and 50342f6a retired 2026-10-13, Claude thread 9cb9731e retired 2026-10-12); launcher Pi, `openai-codex/gpt-6-astra:low`, not to be used under R26 until it changes (lead decision 69) | `agents/filbert/launch.sh` |
|
||||
| Researcher | Source-grounded technical research and evidence | T3 session (Claude, Sonnet 5.5, thread 9bac0794, rows 97 and 98, lead decision 124); Haiku rows (71, 80, 84, 88, later 96) run as subagents Sage dispatches with model haiku, effort high and a worktree (lead decision 129); Haiku thread c98aaf4a retired 2026-10-13 unused; launcher Pi, configured Mosaic model | `agents/researcher/launch.sh` |
|
||||
| Launchpad | Builder, MVP T1 row 192 (Q175 SSO phase 1) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Morgana | Builder, MVP T1 row 193 (human users and RBAC) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Honker | Builder, MVP T1 row 194 (agent lifecycle) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Webby | Reviewer: Case 2 designs, rows 192 and 194 | T3 session (Claude, Opus 5.5, thread 03086006), started by Mos (mos-0981) | none, T3 only |
|
||||
| Duckworth | Reviewer: federation designs, row 193 | T3 session (Claude, Opus 5.5, thread ffceafec), started by Mos (mos-0977) | none, T3 only |
|
||||
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
|
||||
|
||||
Each script supports `--check` and `--fresh`. Normal launches resume the agent's
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# Duckworth
|
||||
|
||||
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0977, thread ffceafec). Reviews federation designs and MVP T1 row 193.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `duckworth/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Review: Gizmoduck federation design r2 and r2.1 (duckworth-0002)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/gizmoduck/work/federation/DESIGN.md at head 5ad67f671 (r2.1, parent 60b9b112a, r2 parent 6925f78f0).
|
||||
DESIGN.md line numbers below are at 5ad67f671. The r2.1 diff only moves the uid to row 190b, makes R7 depend on row 190 and
|
||||
puts P19 333 on the path. I checked it and found no issue. Code at origin/refactor
|
||||
9ae8c73ac. Against duckworth-0001 (REVIEW.md, b402197ff).
|
||||
|
||||
## Verdict: changes, B6 only
|
||||
|
||||
B1 to B5 are resolved. r2 adds one new blocker of its own. Fixing B6 and C1 as line edits plus their
|
||||
test lines would earn approval.
|
||||
|
||||
## r1 codes
|
||||
|
||||
- B1 resolved (3.6). The walk is the human's (human.mjs:73-88), the user CLI has its own nonce, the Console
|
||||
parent is checked against the bus's tree (webui-service.sh:3 confirms `cli.mjs serve`), and the user id
|
||||
binds only with a live grant. R3 has both mutants. Condition C1 below.
|
||||
- B2 resolved (3.6 list, 4). `#owner`, the `#threadFor` refusal plus `message.send` being off the list (two
|
||||
walls), the sender fields and the audit are all in. The 48 `s.human` and 41 `s.tester` line counts match.
|
||||
- B3 resolved (3.5). There is one level with seven verbs. Each read has a filter, and everything else is
|
||||
`access-refused`. The cited lines match (broker.mjs:2143-2146, :2288-2295, :2318-2322, :2331-2334,
|
||||
:2351-2420, :1203-1207, :2398).
|
||||
- B4 resolved (3.7). There are three triggers. `threadsState` (:390-393) is computed per call, so a restart
|
||||
doesn't bypass the revocation. The launcher skips a revoked thread and SIGTERMs a running one (launcher.mjs:510-553,
|
||||
:527, tick :133). R4 gets mutants and the new order.
|
||||
- B5 resolved (R8, 5, 8, 9). Separate uids are a hard entry gate (row 190b in r2.1) with checks, and N9 is
|
||||
folded in.
|
||||
- N1 to N9 are handled where the "Changes from r1" section says.
|
||||
|
||||
## Blocker
|
||||
|
||||
B6. With r2's sender change, a user's thread never gets an answer. 3.5 sets `thread.send` to write
|
||||
`from_role` `user` (DESIGN.md:207). The runner answers every message with `message.send { to: m.from_role }`
|
||||
(packages/harness/src/runner.mjs:450). The broker accepts only `human`, `tester` or a role as `to`
|
||||
(broker.mjs:1862), so each reply fails `unknown-role`, and the user sees no answer. The tester needed a
|
||||
whole set of changes for the same path (row 177): `to: 'tester'` only in reply to a tester message
|
||||
(:1852-1857), `#testerThread` and `tester-thread-human` (:1864-1866), the `#checkMessaging` mapping (:1867),
|
||||
`#sender` (:627-629), and the runner's header (runner.mjs:75-78). The design names none of these for users.
|
||||
|
||||
Fix: state the reply path. Either accept `to: 'user'` only in reply to a message from that user in that
|
||||
user's thread, with the tester's set of checks, or say the run answers `human` in the user's thread and
|
||||
why the owner's inbox then holds it. Add a runner header, like the tester's, saying the message comes from
|
||||
a granted user, is never the owner's instruction, and authorizes nothing. Add an R3 test that a thread run's
|
||||
reply reaches the user's `thread.messages`. Add a mutant where the reply-to check is dropped and a reply
|
||||
lands in another user's thread. Make R7 assert that a reply arrives, not only that the send works.
|
||||
|
||||
## Condition (line edit)
|
||||
|
||||
C1. "Agents can't, because the walk refuses them" (DESIGN.md:251-252) overstates it. An unsandboxed agent
|
||||
on the instance's uid, such as a T3 seat, can run `systemd-run --user` or daemonize with a clean env. Its
|
||||
process then has no agent ancestor, and the walk passes. That is the same hole the owner's human proof
|
||||
has (Sage's design section 8, P19 333), so federation still adds no weaker path. Say that instead.
|
||||
|
||||
## Note
|
||||
|
||||
N10. Six of the seven verbs gate with `if (!s.tester) this.#human(s)`. `names` gates with
|
||||
`if (!s.human && !s.tester) this.#agent(s)` (:2290). The "seven `#human` gates" in 3.6 item 4 and section 4
|
||||
should name that difference so R3 doesn't miss it.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Review: Gizmoduck federation design r3 and r3.1 (duckworth-0003)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/gizmoduck/work/federation/DESIGN.md, diff 5ad67f671..a2840a6be (r3 940e0e816 plus r3.1
|
||||
a2840a6be, reviewed as one diff per gizmoduck-0010). DESIGN.md line numbers are at a2840a6be. Code at
|
||||
origin/refactor 9ae8c73ac. Against duckworth-0002 (REVIEW-r2.md, 03a663cd7).
|
||||
|
||||
## Verdict: changes, B7 only
|
||||
|
||||
B1 to B6, C1 and N10 are resolved. r3.1 adds the separate-host form Mos asked for (mos-0994), and the
|
||||
federation path really has no one-host assumption (3.8). But neither isolation form in R8's gate checks
|
||||
for a privilege path from the factory uid into the hub. That is B7. It's a gate-wording edit with two
|
||||
checks. N11 doesn't block.
|
||||
|
||||
## r2 codes
|
||||
|
||||
- B6 resolved (3.6 item 7, DESIGN.md:277-295). `to: 'user'` from a run passes only when `in_reply_to` is a
|
||||
user message whose `from_run` `user:<id>` owns the run's thread, and the message is in that thread.
|
||||
Otherwise the send fails `user-reply-required`. `#userThread` sits beside `#testerThread` (:631).
|
||||
`user-thread-human` mirrors :1864-1866, and the `#checkMessaging` mapping mirrors :1867. A human or tester
|
||||
sending `to: 'user'` fails `unknown-role`. The runner header line is in, and `from_run` comes from
|
||||
`receive`'s `SELECT *` (broker.mjs:1932, checked). The R3 tests and mutant and the R7 reply assertion are
|
||||
in.
|
||||
- C1 resolved (DESIGN.md:252-260, the residual-risk paragraph). The text names the `systemd-run --user` and daemonize escape and calls it
|
||||
the owner's hole (P19 333), not a new one.
|
||||
- N10 resolved (3.6 item 4). The `names` gate at :2290 is called out with its order.
|
||||
|
||||
## r3.1 (mos-0991, mos-0994)
|
||||
|
||||
- 3.8 (DESIGN.md:335-354) holds. I found nothing in the path that crosses buses or filesystems. Each
|
||||
Console spawns its own CLI, access.json is on the receiver, and `federation.peers` holds URLs only.
|
||||
- R8 (DESIGN.md:431) accepts either form, and sections 4, 5, 8 and 9 match it. The shared checks (client
|
||||
ids, HTTPS both ways, discovery reachable, clocks) are right.
|
||||
- Q1 and Q2 are recorded as rulings, not settled by the design. N8 stays with Mos until Mos confirms that
|
||||
"P2P" means person-to-person.
|
||||
|
||||
## Blocker
|
||||
|
||||
B7. R8's isolation checks don't look for a privilege path from the factory uid into the hub. That gap is
|
||||
in both forms.
|
||||
- Separate uid. The checks are a refused socket call (human.mjs:67) and an unreadable data root and config
|
||||
dir. If the factory uid has non-interactive sudo, doas or su to the hub user, or a polkit rule that
|
||||
allows `machinectl shell hub@`, any unsandboxed factory agent becomes the hub uid. Then it can run the
|
||||
hub's human-cli and write access.json, and every other check still passes. I missed this in r2 when I
|
||||
marked B5 resolved.
|
||||
- Separate host. "Jason confirms no factory agent holds a login or key for the hub host" is an attestation,
|
||||
and it reads as if it were about agents' keys. Jason's own ssh key in the factory uid's `~/.ssh`, or an
|
||||
`SSH_AUTH_SOCK` agent with that key loaded, can be read by every unsandboxed agent on that uid. If that
|
||||
key logs in to the hub host, it does the same harm as the sudo path.
|
||||
|
||||
Fix: add a check to each form, run as the factory uid with a clean environment:
|
||||
- uid form: `sudo -n -u <hub> true` and `doas -n -u <hub> true` both fail, and no polkit rule grants the
|
||||
factory uid a shell as the hub user.
|
||||
- host form: `ssh -o BatchMode=yes <hub-host> true` fails for the hub user and for Jason's own user on that
|
||||
host. Run it with and without the factory session's `SSH_AUTH_SOCK`. Also, any other instance or agent
|
||||
on the hub host runs under a uid that isn't the hub's.
|
||||
|
||||
Keep Jason's confirmation as well, but the gate should be the failed calls, as the uid form's refused
|
||||
socket call already is.
|
||||
|
||||
## Note (does not block)
|
||||
|
||||
N11 (still open at a2840a6be). `#waiting` (broker.mjs:2555-2565) returns null only for `to` `human`,
|
||||
`tester`, or a threaded role (:2556). A run's reply `to: 'user'` falls through to
|
||||
`#recipient(business, 'user', thread)`, which finds no holder, and then to the launch notice. `told` is
|
||||
true when `launch.by` is set and the run isn't `launch.by` (:2562). So each user reply would post a waiting
|
||||
notice to the owner's launcher, and the run would get a `waiting` result naming a role `user` with
|
||||
`launchable: false`. Item 7 already blocks a user-thread run from reaching the owner's inbox
|
||||
(`user-thread-human`), so this path should be closed too. Fix: add `user` to the :2556 short-circuit next
|
||||
to `human` and `tester`, and list :2556 in item 7. Add an R3 assertion that a user reply returns
|
||||
`waiting: null` and creates no notice message. The planned "reply reaches `thread.messages`" test passes
|
||||
even with the notice, so it won't catch this.
|
||||
|
||||
N12. `thread_shares` is append-only (Q2, DESIGN.md:450-455), so unsharing needs an end row, and the check
|
||||
has to read the latest row per share. That belongs to row 193's design, not this one. I'm noting it so R3's
|
||||
user-thread checks don't assume shares only grow.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Review: Gizmoduck federation design r4.1 (duckworth-0007)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/gizmoduck/work/federation/DESIGN.md, diff a659e41f3..73677b87d (r4.1, gizmoduck-0016).
|
||||
DESIGN.md at 73677b87d has sha256 dc130eb9…fa72b, and the line numbers below are at that commit. The diff
|
||||
touches only DESIGN.md and one SESSIONS line. Against duckworth-0005 (REVIEW-r4.md, 4966136d4).
|
||||
|
||||
## Verdict: changes, one point of B8 left
|
||||
|
||||
The privilege-path table (DESIGN.md:520-545) is the right shape. Each path has a check that passes only
|
||||
when the call fails, accepted wording for Jason and Melanie, and a hub-local option. The TLS front end,
|
||||
N13, N14 and N15 are resolved. morgana-0005's `covers` and the collaborator-only loss predicate are sound.
|
||||
One part of my B8 fix didn't carry over: the issuer's own host.
|
||||
|
||||
## r4 codes
|
||||
|
||||
- B8, the issuer: one point open (below). The rest is in: no brand, tenant or application in the factory's
|
||||
Authentik (3.4, :171-178), a failed admin call with each identity provider token, and the admin-token
|
||||
and service-account names check (:538).
|
||||
- B8, the TLS front end: resolved (:539). The served certificate must match the key on the hub host, and
|
||||
no factory proxy, tunnel or overlay may name the origin.
|
||||
- N13 resolved (R8 row, :491). Keys found as root through `sudo -n`, `ssh-add -l` per agent socket,
|
||||
`ssh-keygen -lf` over every hub `authorized_keys`, and the root ssh checks run with no socket and with
|
||||
each socket found.
|
||||
- N14 resolved (:443-450, :487). R3 depends on row 196. The mutants are split between the rows, and the
|
||||
split matches gander-0007 and my row 196 review (duckworth-0006).
|
||||
- N15 resolved (DNS row :541, CA row :540).
|
||||
|
||||
## Blocker
|
||||
|
||||
B8 (one point left). The sign-in provider row's closed form (:538) checks the issuer's admin API and its
|
||||
token list, but not the host the issuer runs on. Example: Pocket ID on m-docker0, or on any PVE VM. The
|
||||
admin call fails with every token, so the row closes. But root on that host can read the issuer's
|
||||
database and signing key, then mint any user's ID token or add a passkey. ops-01's measurements give the
|
||||
factory root on every PVE guest today. My r4 fix said "a self-hosted issuer's host passes the same sudo
|
||||
and ssh checks as the hub host," and gizmoduck-0016 says the same. The text at 73677b87d doesn't (no
|
||||
match for it in the file).
|
||||
Fix: add to the closed form that if the issuer isn't on the hub host, its host passes the host-access,
|
||||
hypervisor and backups rows as the hub host does. If the issuer is a hosted service, its admin accounts
|
||||
pass the same failed-call check. Either case gets one line in the table.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N16. The dependency note (:544-545) names only TLS. Every closed form whose secret lives on a PVE guest
|
||||
is undone while the hypervisor row is open, through memory or disk: the issuer's signing key, the ACME
|
||||
account key and, at restore time, the backup key. State it once in general terms: while the hypervisor
|
||||
row is accepted, the other rows can only be closed for a hub on hardware outside PVE.
|
||||
N17. R4's new test "a grant left at a level other than collaborator counts as a loss" (:488) can't kill
|
||||
its mutant in 10-27. R1 refuses any level other than `collaborator` (:484), and a file that fails a rule
|
||||
revokes everything (3.5). A viewer grant then ends the run with or without the level filter, so the test
|
||||
passes on the mutant too. The test needs the viewer level, which row 193 adds (:555-557). Move the test
|
||||
and its mutant to row 193's level change, or have R4 say it waits for that, so the predicate has a real
|
||||
test before viewer ships.
|
||||
N18. 3.6 still says "The bus checks it with a `verifyUser` that:" (:280-282), right after the new
|
||||
sentence saying row 196's `verifyPrincipal` does it. Rename it so R3 doesn't build both.
|
||||
N19. Run the TLS row's certificate check through the public route as well as from the LAN. A proxy that
|
||||
only the public path goes through won't show in a LAN check.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Ack: Gizmoduck federation design r4.10 and r4.11, R8 as a recorded acceptance (duckworth-0034)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Subject: agents/gizmoduck/work/federation/DESIGN.md on
|
||||
gizmoduck/federation-design. I read the diff a1daa3c07..3568441ab (r4.10, gizmoduck-0034) and
|
||||
3568441ab..b93f098f0 (r4.11, gizmoduck-0036, mos-1063). Each commit touches only DESIGN.md and SESSIONS.md.
|
||||
I checked the quotes against DECISIONS.md:1511-1541 in jason-master-restart at 1c378b4, read-only.
|
||||
|
||||
## Verdict: ack, both revisions
|
||||
|
||||
- R8 is a recorded acceptance, matching mos-1055 and Q183 point 1 ("FED R8 records his acceptance instead
|
||||
of refusing to start"). Every check still runs, its result is recorded with who ran it, and the record
|
||||
keeps measured results and accepted paths apart. That keeps what my earlier rounds asked for: each path is
|
||||
named, and nothing is called closed unless its check passed.
|
||||
- The quotes match DECISIONS.md word for word: Q182 at :1517 and :1519, Q183 at :1537, and Q4 at :1541.
|
||||
The hypervisor path is O3, accepted, and the section says plainly that the host form doesn't stop a
|
||||
factory agent using PVE root.
|
||||
- The .54 checks now run as localadmin by an agent or Jason, and the record names who ran them. This is
|
||||
sound now that nothing gates on them. #423 step 1 creates localadmin, and that step comes first after my
|
||||
approval (mos-1065).
|
||||
- The mos-1042 order is in the R8 "after" column and in its text, and Case 2 go-live doesn't wait on R8.
|
||||
- Melanie gets the wording only through Jason (#419), and r4.11 leaves #419 carrying only that.
|
||||
|
||||
Your call A: agreed, and Mos ruled the same way (mos-1063). Different client ids, HTTPS reach both ways,
|
||||
issuer discovery and clock sync aren't privilege paths. Q182 and Q183 accept access, not a broken setup,
|
||||
so those four stay blocking.
|
||||
|
||||
Your call B: both corrections are right. With the jason key, a factory agent gets localadmin's NOPASSWD sudo
|
||||
on .54 once #423 step 1 runs, and pve root reaches VM 1012's disk and console. So 3.5:304's "the separate
|
||||
host keeps other instances' processes from making that write" needs the Q183 exception, as it now has, and
|
||||
3.6 can't claim R8 proves no factory login.
|
||||
|
||||
r4.11: the stale host lines are gone, and the hub is .54, VM 1012, user `mosaic` (Q181) in 3.8, the R8
|
||||
row, section 8 and ruling 3. Using #416 in place of #418 matches the closed issue. Leaving #416 undated is
|
||||
right when there's no source.
|
||||
|
||||
## Notes (do not block, doc-only)
|
||||
|
||||
N1. Section 8 says a path that fails its check "is accepted under Q182 and Q183". Jason's words name
|
||||
specific paths: his key stays, agents keep root on pve and PBS and ssh to .53 and .54, and agents run on
|
||||
his workstation. A failed check outside those isn't in his words. Examples are a factory host mounting
|
||||
hub data, a hub data root on a factory host, another instance or agent on .54, or a provider admin token
|
||||
reaching the hub's client. The record should mark such a result "not named in Q182 or Q183". Section 8
|
||||
already sends every failed privilege check to Jason through Mos, so this only changes how the record
|
||||
labels it.
|
||||
N2. The quotes are called verbatim, but two of them are excerpts. Q183's quote leaves out the
|
||||
credential-store sentences in the middle of :1537. Q4's leaves out the first two sentences and the last
|
||||
three of :1541. Mark each omission with "…".
|
||||
@@ -0,0 +1,58 @@
|
||||
# Review: Gizmoduck federation design r4.2 to r4.4 (duckworth-0010)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/gizmoduck/work/federation/DESIGN.md, diff 73677b87d..b67bc6de9 (d51778391 r4.2, 17d69e660
|
||||
r4.3, b67bc6de9 r4.4, gizmoduck-0020). DESIGN.md at b67bc6de9 has sha256 2ca7812d…a713c601, checked. Line
|
||||
numbers are at b67bc6de9. Code at origin/refactor 9ae8c73ac. Against duckworth-0007 (REVIEW-r4.1.md,
|
||||
b14f11136).
|
||||
|
||||
## Verdict: changes, B9 only
|
||||
|
||||
B8 is resolved. So are N16 to N19. r4.2's wider "factory" is right, and it makes the gate stricter. B9 is a
|
||||
gap I should have caught in r3, when 3.6 item 7 first said a collaborator can't reach the owner's inbox. I
|
||||
found it while reviewing row 193 (duckworth-0008, B1).
|
||||
|
||||
## r4.1 codes
|
||||
|
||||
- B8 resolved (sign-in provider row). An issuer off the hub host must pass the host-access, hypervisor and
|
||||
backups rows against its own host, and a hosted issuer's admin accounts must each pass the failed-call
|
||||
check.
|
||||
- N16 resolved (the dependency note under the table). It now covers every secret on a PVE guest.
|
||||
- N17 resolved (R4 row). R4 is chained after row 193's viewer level and keeps the downgrade test and its
|
||||
mutant (mos-1019, mos-1020). Row 193 should drop its copy (duckworth-0008 N3).
|
||||
- N18 resolved (3.6, the proof paragraph). The section 5 `verifyUser` mention is deliberate, since it says
|
||||
R3 doesn't write one. Accepted.
|
||||
- N19 resolved (TLS row).
|
||||
|
||||
## r4.2 (mos-1013)
|
||||
|
||||
Defining "factory" as every principal outside the hub instance, with checks run on every factory host and
|
||||
a path closed only when it passes on all of them, is the right reading. W-jarvis's `jarvis@w-jarvis` key
|
||||
having root on `pve` shows why. Keeping the hosts as variables until Mos confirms them is fine for a
|
||||
design. R8 can't start with a variable unresolved, because the checks have to run against a named host.
|
||||
3.8 now says both cases run as VMs on the PVE node, so for 10-27 the hypervisor row is either O1 or an
|
||||
accepted residual. That is already Jason's (#419), and the table says so. Q1 is recorded as Q180.
|
||||
|
||||
## Blocker
|
||||
|
||||
B9. A user-thread run can still put a decision in the owner's inbox, and 3.6 item 7 says it can't
|
||||
(DESIGN.md:344-347). Item 7 copies the tester's `message.send` guard (broker.mjs:1866). It doesn't copy the
|
||||
tester's decision guard at :1683, `if (route === 'human' && this.#testerThread(s)) fail('tester-thread-human')`.
|
||||
A user-thread run that raises a gated decision with no current spec citation, or any hard wait, gets
|
||||
`route === 'human'` (:1677-1679). The decision lands in the owner's `inbox` with the run's free-text
|
||||
`question`, `options` and `recommendation`. R3's tests (R3 row) check `to: 'human'` sends only.
|
||||
There are two ways to resolve it. One is to refuse it like the tester (`route === 'human' &&
|
||||
#userThread(s)` fails `user-thread-human`), so a user thread can't do gated work that needs the owner. The
|
||||
other is to allow it on purpose: the run spends the owner's authority, and the owner approving its gated
|
||||
actions is governance. Sage has since ruled the second for row 193 (sage-1368, morgana-0008). Row 193 r3
|
||||
5.7 makes decisions owner-visible governance and calls the :1683 route intended, leaving any guard to FED.
|
||||
Fix: follow that ruling in item 7. Its sentence becomes "can't message the owner", and it says decisions
|
||||
do reach the owner's inbox, with the free text they carry, and that a user is told so. Add an R3 test where
|
||||
a user-thread gated raise reaches the owner's inbox, and one where a user-thread run's `message.send`
|
||||
`to: 'human'` is still refused. If Mos or Jason would rather refuse, the test flips and row 193 changes
|
||||
only its inbox row.
|
||||
|
||||
## Note (does not block)
|
||||
|
||||
N20. The start-order line under the build table (DESIGN.md:510) still says "R4 when R3 and R5 do". R4 now
|
||||
also waits for row 193's viewer level.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Review: Gizmoduck federation design r4.5 (duckworth-0012)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/gizmoduck/work/federation/DESIGN.md, diff b67bc6de9..acd5f1f42 (one commit, gizmoduck-0022).
|
||||
DESIGN.md at acd5f1f42 has sha256 99f5c4cd…e39cb8ed, checked. The commit also appends one SESSIONS line.
|
||||
Against duckworth-0010 (REVIEW-r4.4.md, 4cf3ce232).
|
||||
|
||||
## Verdict: approve
|
||||
|
||||
B1 to B9 are all resolved. R1, R2 and R5 can go to Sage's queue.
|
||||
|
||||
## r4.4 codes
|
||||
|
||||
- B9 resolved (3.6 item 7). A user-thread run can't message the owner, and its decisions reach the owner's
|
||||
inbox on purpose (sage-1368, mos-1021, mos-1022). The :1683 guard is deliberately not copied. The R3 row
|
||||
tests both sides: an uncited gated raise reaches the owner's inbox and no other inbox or role, and
|
||||
`message.send` `to: 'human'` is still refused. R6 adds the user notice.
|
||||
- N20 resolved (the start-order line under the build table). R4 waits for R3, R5 and row 193's viewer level.
|
||||
- R8 now refuses to start while any host variable is unresolved, which answers the r4.4 point on variables.
|
||||
|
||||
## Note (does not block)
|
||||
|
||||
N21. The R6 notice names decisions only. Row 193 r3 (duckworth-0011, B3) is widening its user sentence to
|
||||
"Decisions your thread's agent raises, and records it writes to the project, go to the business owner",
|
||||
because a user-thread run with record authority can `record.write` (broker.mjs:1395-1407) and the owner
|
||||
reads that with `project.file`. Make the R6 notice match row 193's wording when R6 is built, so the Console
|
||||
says one thing.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Review: Gizmoduck federation design r4.6 (duckworth-0019)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/gizmoduck/work/federation/DESIGN.md, diff acd5f1f42..1b32e65e3 (one commit, gizmoduck-0025).
|
||||
DESIGN.md at 1b32e65e3 has sha256 ebd7a291…8d678c14, checked. The commit also appends one SESSIONS line.
|
||||
|
||||
## Verdict: approve
|
||||
|
||||
- N21 resolved. 3.6 item 7 and the R6 row carry row 193 r5's sentence (e2d161e2e, 5.7), word for word. R6
|
||||
tests that the notice equals it exactly.
|
||||
- The start-order line now matches the section 7 table. R1 starts now, R5 lands after R1, and R2 and R6
|
||||
wait on Sage's #412 phase 1.
|
||||
|
||||
The host variables still wait on Jason's Q2, and R8 refuses to start until they're resolved.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Review: Gizmoduck federation design r4.7 (duckworth-0020)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/gizmoduck/work/federation/DESIGN.md, diff 1b32e65e3..2d3c3cfbd (one commit, gizmoduck-0026).
|
||||
DESIGN.md at 2d3c3cfbd has sha256 9f9791c1…80e74647, checked. The commit also appends one SESSIONS line.
|
||||
|
||||
## Verdict: approve
|
||||
|
||||
The host variables are replaced with Q181's hosts (mos-1030). Case 1 is W-jarvis, 10.1.1.53, VM 1008. The
|
||||
hub is W-Melanie-OC, 10.1.1.54, VM 1012. Case 1 and the Hermes agents are now one factory host, as the r4.2
|
||||
rule required. R8's ssh check names 10.1.1.54 and the hub user `mosaic`. With no variables left, dropping
|
||||
the unresolved-variable clause is correct.
|
||||
|
||||
## Note (does not block)
|
||||
|
||||
N22. R8 states the `mosaic` user's properties (no sudo, no groups, no keys) as facts. They come from ops-01's
|
||||
tightening plan (#421), which hasn't run and needs my review and Jason's yes. Make them R8 checks on
|
||||
10.1.1.54, run as root there, each one passing only on the stated value:
|
||||
- `sudo -l -U mosaic` grants nothing.
|
||||
- `id mosaic` shows only its own group.
|
||||
- `~mosaic/.ssh/authorized_keys` is absent or empty.
|
||||
- No file under /etc/sudoers.d names it.
|
||||
|
||||
Once that plan runs, the gate shows the result instead of restating it.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Review: Gizmoduck federation design r4.8 (duckworth-0021)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/gizmoduck/work/federation/DESIGN.md, diff 2d3c3cfbd..eb651aa68 (one commit, gizmoduck-0027).
|
||||
DESIGN.md at eb651aa68 has sha256 dc0b7a82…a9fa2f0c, checked. The commit also appends one SESSIONS line.
|
||||
|
||||
## Verdict: approve
|
||||
|
||||
N22 is resolved. R8 now checks the `mosaic` user's limits on 10.1.1.54 as root, rather than assuming #421's
|
||||
plan already ran. All four checks are in, each must pass, and they match duckworth-0020. Nothing else
|
||||
changed.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Review: Gizmoduck federation design r4 (duckworth-0005)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/gizmoduck/work/federation/DESIGN.md, diff a2840a6be..a659e41f3 (r4). 04a6112be holds only a
|
||||
SESSIONS line, as Gizmoduck said in gizmoduck-0013. History isn't rewritten. DESIGN.md line numbers are at
|
||||
a659e41f3. Code at origin/refactor 9ae8c73ac. Against duckworth-0003 (REVIEW-r3.md, 45cbe5896) and
|
||||
duckworth-0004 (message to Mos, recorded below).
|
||||
|
||||
## Verdict: changes, B8 only
|
||||
|
||||
B7, N11 and N12 are resolved. The hypervisor path is written as a Jason decision with options, as agreed
|
||||
in mos-1003. B8 is new, and I should have raised it in r1. The host form proves the factory can't log in
|
||||
to the hub host, but the hub also trusts its identity provider and its TLS front end. Today the factory
|
||||
can administer at least the first.
|
||||
|
||||
## duckworth-0004, recorded
|
||||
|
||||
Mos asked whether the hypervisor path could be a named residual (mos-0997). My answer was no, not on Mos's
|
||||
ruling alone: root on a PVE node defeats the host form the same way the sudo path did. I asked that it
|
||||
be closed before 10-27, or that Jason decide it. Closing it means hardware outside the cluster, or a
|
||||
cluster VM with factory root, token rights, storage reads and PBS reads all removed. LUKS alone doesn't
|
||||
close it. Mos agreed and took it to Jason (mos-1003, Vikunja #419). r4 section 8 (DESIGN.md:466-481) says
|
||||
exactly that.
|
||||
|
||||
## r3 codes
|
||||
|
||||
- B7 resolved (R8, DESIGN.md:452). Host form only, per mos-0996. Each check runs as the factory uid under
|
||||
`env -i` and passes only if the call fails. Jason's attestation stays but isn't the gate. "The hub host
|
||||
runs no other instance or agent" is in.
|
||||
- The sudo judgment call (gizmoduck-0013) is accepted. `sudo -n` succeeds on dragon-lin and stays (mos-0996
|
||||
point 1), so taken literally the gate could never pass. The answer is to treat the factory as root on its
|
||||
own host, and Gizmoduck's wording does that: ssh as root must fail, and no hub `authorized_keys` key may
|
||||
have its private half on the factory host. See N13 for making that second check runnable.
|
||||
- N11 resolved (3.6 item 7, R3 row DESIGN.md:448).
|
||||
- N12 resolved (section 9 Q2).
|
||||
|
||||
## Blocker
|
||||
|
||||
B8. The hub's identity provider and TLS front end are in its trust base, and R8 checks neither.
|
||||
- Identity provider. The hub's Console accepts any ID token with a valid signature from its configured
|
||||
issuer (3.3, DESIGN.md:158-161). If the hub uses the factory's Authentik (DESIGN.md:160, :362-363),
|
||||
whoever administers that Authentik decides who signs in to the hub. That includes Melanie's owner session
|
||||
and every `(provider, sub)` grant. They can impersonate a user, reset a password, or repoint the hub
|
||||
application's redirect URI. Factory agents hold an Authentik API token. ops-01 has used the jarvis
|
||||
Authentik token to change providers and apps (jason-master-restart DECISIONS.md:142, :554). Authentik runs
|
||||
on m-docker0 (:591). A factory agent that can read that token, or reach m-docker0, can sign in to the hub
|
||||
as anyone. The sudo, ssh and hypervisor checks don't touch that path.
|
||||
- TLS front end. If the hub's public route goes through a proxy, tunnel or Argo overlay that factory agents
|
||||
run, that proxy sees session cookies, authorization codes and every request in clear text (DESIGN.md:114,
|
||||
:452 "hub proxy route").
|
||||
|
||||
Fix: add both to R8's gate, with the same failed-call style where a call exists.
|
||||
- Issuer. The hub's `console.sso` trusts only an issuer that no factory credential can administer. One way
|
||||
is a separate provider on or for the hub. The design already allows Pocket ID (DESIGN.md:160-161), and
|
||||
Jason can be listed there. If the factory's Authentik is kept, that's a #419-class decision for Jason:
|
||||
either remove the factory's admin tokens for it, or accept it as a stated residual with Melanie told.
|
||||
Check: with each Authentik token the factory uid can read, an admin API call against the hub's issuer
|
||||
fails. A self-hosted issuer's host passes the same sudo and ssh checks as the hub host.
|
||||
- Front end. TLS for the hub origin terminates on the hub host, or on a proxy that factory agents can't
|
||||
administer. Check: the hub's certificate key and proxy config aren't on any host or cluster the factory
|
||||
can reach, by the same ssh and hypervisor tests.
|
||||
|
||||
## Rulings for Mos (mos-1007)
|
||||
|
||||
1. Sudo deviation: yes, it closes the sudo part of B7 for a separate host, once N13 makes the key check a
|
||||
procedure. Passwordless sudo means the factory uid is root on dragon-lin. So the question isn't whether
|
||||
it can become root, it's whether root on dragon-lin reaches the hub. The two checks answer that for
|
||||
ssh. Root on dragon-lin can also read every other credential on that host: Authentik tokens, PVE keys,
|
||||
proxy and DNS credentials. Those paths are B8, #419 and N15, not B7.
|
||||
2. Identity-provider path (mos-1006): it isn't in a659e41f3 yet. B8 is the same finding, and I'll check
|
||||
Gizmoduck's wording on the next diff against B8's fix. Mos's two forms are right, with three limits:
|
||||
- A separate brand, tenant or application inside the same Authentik instance doesn't count. Whoever
|
||||
administers the instance administers all of them.
|
||||
- "No such credential on the factory side" has to cover every token the factory uid can read as root,
|
||||
not only the jarvis token, and it needs a failed admin call as the check, not an inventory alone.
|
||||
- B8 also covers the TLS front end. mos-1006 doesn't, and it needs the same treatment.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N13. "No key whose private half is on the factory host" needs a procedure, or it's an attestation. Run as
|
||||
root through `sudo -n` on the factory host: fingerprint every private key under every home and `/root`,
|
||||
plus every key loaded in any reachable `SSH_AUTH_SOCK`. Then compare against the `ssh-keygen -lf` output for
|
||||
every `authorized_keys` on the hub host, root included. Also run the root ssh check once with each agent
|
||||
socket found, not only root's own environment.
|
||||
|
||||
N14. Row 196 replaces `verifyUser` (DESIGN.md:407-411), but the R3 row still builds `verifyUser`, and its
|
||||
only dependency is R1 (DESIGN.md:448). Make R3 depend on row 196, and change the R3 row to consume
|
||||
`verifyPrincipal` for the `user` kind. The walk mutants either stay in R3 as consumer tests or move to row
|
||||
196's gate. Say which, so neither row drops them.
|
||||
|
||||
N15. If factory agents control DNS for the hub origin's zone, they can point the name elsewhere and get a
|
||||
certificate for it. A CAA record and a DNS zone the factory can't edit narrow that. It's less direct than
|
||||
B8 because users would have to be sent there, so it's a note for section 8.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Review: Gizmoduck federation design r1 (duckworth-0001)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/gizmoduck/work/federation/DESIGN.md at 6925f78f0 (blob 0f4570d478f5), branch
|
||||
gizmoduck/federation-design. Code at origin/refactor 9ae8c73ac. Sage's Q175 design read at d55d86bb0.
|
||||
Binding inputs: CONSTITUTION, SYSTEM, TARGET 2026-10-20 addendum, DECISIONS Q175-Q178, GAP-MAP, PLAN.
|
||||
|
||||
## Verdict: changes, B1-B5
|
||||
|
||||
The direction is right. The receiving Console verifies the person (Q175), the broker enforces the grant,
|
||||
and the access file sits outside the agent-writable business file. Five gaps stop it from meeting criteria
|
||||
1, 2 and 5 as written.
|
||||
|
||||
## Blockers
|
||||
|
||||
B1. The bus takes the user's identity from the caller. `user-cli.mjs` carries `{ business, user }` and is
|
||||
"proven the way tester-cli.mjs is" (DESIGN.md:171-172). The tester proof shows only that some process runs
|
||||
the tester CLI. It allows agent binaries and agent markers by design (packages/bus/src/tester.mjs:5-9, :57).
|
||||
The `user` field is whatever that process says. Any T3 seat or agent shell on the hub's uid can bind as any
|
||||
granted user. That is a forwarded identity, which Q175 rules out. Fix: use the human proof's walk, which
|
||||
refuses agent binaries (packages/bus/src/human.mjs:83-88). Bind the user CLI to the Console's tree, the
|
||||
way #225 binds human-cli. State the residual risk in the design, as Sage's design section 8 does. Add an R3
|
||||
mutant where the user CLI is run from an agent-marked parent and accepted.
|
||||
|
||||
B2. "No new ownership code" (DESIGN.md:180-181) is false. `#owner` returns `'tester'` or `s.human`
|
||||
(packages/bus/src/broker.mjs:364-366), which is undefined for a user. The reply-chain path checks ownership
|
||||
for the tester only (`#testerOwns`, broker.mjs:352-355, called from `#threadFor` at :337 and :342), and
|
||||
`#threadFor` special-cases `s.human` only (:326). A user principal needs explicit handling in all three.
|
||||
Without it, a `message.send` with `in_reply_to` can land in another user's thread. Fix: name the changes
|
||||
in 3.6. Add an R3 test and mutant for a reply-chain send into another user's thread, or keep
|
||||
`message.send` off every level's list and state that.
|
||||
|
||||
B3. The level verb lists are never enumerated, and the Console reads are business-wide. "Read the board"
|
||||
for a viewer (DESIGN.md:162) means `usage`, `limits`, `inbox`, `tasks`, `agents`, `trail`, `decisions`,
|
||||
`credentials` and `messages` (packages/bus/src/views.mjs:3-13). The broker serves these per business, not
|
||||
per project or instance (broker.mjs:2144, :2174, :2195, :2217). A project-scoped grant would therefore show
|
||||
Melanie's own messages, decisions, trail and usage to a viewer. Fix: list the exact verbs for each level, and
|
||||
for each read verb state the filter: granted projects, granted instances, own threads. Otherwise refuse the
|
||||
verb. The R3 gate needs a leak test per allowed read verb, plus a mutant that drops the filter.
|
||||
|
||||
B4. Revoking a grant doesn't reliably end runs (criterion 2).
|
||||
- (a) No trigger. The broker re-reads access.json only when a request arrives (DESIGN.md:174-176). Runs end
|
||||
only "on a revision where a user loses a grant" (:193-194). A hand edit, which :167 allows, fires nothing
|
||||
until the next request. Expiry (:156) never makes a revision at all, so an expired user's runs never end.
|
||||
- (b) Ending a claim stops the run only "at its next bus call" (broker.mjs:2273-2275), so a turn already in
|
||||
flight keeps working.
|
||||
- (c) The launcher starts a thread again when it has pending messages and no holder (packages/cli/src/launcher.mjs:533),
|
||||
launched as the business human (:542). Its `startedFor` guard lives in memory and clears on restart. A
|
||||
revoked user's queued message can therefore start a fresh run.
|
||||
|
||||
Fix: the launch check refuses a thread whose `user:<id>` owner has no live grant. Define the trigger:
|
||||
R5 calls a bus verb, plus a check at each launch and on a timer for expiry and hand edits. Say whether the
|
||||
runner gets SIGTERM. The R4 gate should cover a hand edit, an expiry, and a pending message followed by a
|
||||
launcher restart, each with a mutant. R4 depends on whatever row provides the trigger, so fix the order.
|
||||
|
||||
B5. On one uid, Melanie's hub isn't isolated from Jason's factory (criterion 5). The design says so
|
||||
(DESIGN.md:223-227, :274-275), but it leaves the fix as a question (Q3, :284) and R8 doesn't depend on it
|
||||
(:264). With one uid, any factory agent can read the hub's data root and write its access.json. It can also
|
||||
run the hub's human-cli and act as the hub owner. Sage's design section 8 states the same limit for the bus
|
||||
human proof. Grants don't matter while that holds. Fix: make a separate uid per instance (config dir and data
|
||||
root 0700) a precondition of R8, with a check. A process on the factory's uid must be refused by the hub's
|
||||
proof (`p.uid !== process.getuid()`, human.mjs:67) and must be unable to read the hub data root. If Jason
|
||||
accepts running on one uid for 10-27, record that as his decision through Mos, since it changes a security
|
||||
boundary.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. The v1 claims hold. The OIDs are at ca.service.ts:22-24, the guard reads the TLS peer cert at
|
||||
federation-auth.guard.ts:93-106, and main.ts:50-53 builds the adapter with no TLS. `requestCert` appears
|
||||
only in a guard comment (:36). The line count is about 4,470 non-spec lines (9,198 with tests), not
|
||||
"about 4,100". The verdict stands: none of the code is reusable.
|
||||
N2. The other cited lines match: config.mjs:19, runtime.mjs:140 and :169, requirements.json:61,
|
||||
business.mjs:160, broker.mjs:298-306, 356-362, 367-378, 526-538, 596-609, 763-799, webui bus.mjs:1-9 and
|
||||
:27-31, mosaic-config.mjs:112-116, organization.mjs:83, bus-service.sh:200. One error: business.mjs:24
|
||||
(DESIGN.md:220) is a comment. `configDir` is at :25-28.
|
||||
N3. R1 is marked "depends on none", yet it validates against `console.sso` providers and `federation.peers`.
|
||||
Pass both in as arguments so R1 stays pure.
|
||||
N4. "Human-only" in R5 is cosmetic while one uid holds (B5). A direct file write bypasses both the CLI and
|
||||
the receipt. Say so.
|
||||
N5. Run the same file checks on access.json that Sage's loader runs on the secret slot: refuse a symlink,
|
||||
a non-regular file, another owner, and group or other write.
|
||||
N6. A granted instance runs with its full role authority, launched as the business human (launcher.mjs:542).
|
||||
`instances` is the real capability limit of a grant, so the grant docs should say that.
|
||||
N7. `console.sso` sits in config.json, so removing a provider takes effect only after a Console restart.
|
||||
:195-196 implies it takes effect on the next request.
|
||||
N8. Scope: the slice amounts to T1.3's multi-user access, plus `home` and peer labels. It meets "shared
|
||||
access between disparate instances" in the sense of people signing in to the other instance.
|
||||
"Federation is configurable" is met only through grants and labels. Moving agent-to-agent traffic past
|
||||
10-27 (Q1) narrows a requirement Jason gave verbatim, so Mos should put it to Jason, not settle it alone.
|
||||
N9. Audience binding (3.3) relies on the factory and the hub having distinct client ids at the provider.
|
||||
Add a line to R8's checklist that confirms the two client ids differ.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Review: Honker PRD 1.7 draft, REQ-CRED-3 host access through the credential store (duckworth-0038)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Subject: agents/honker/work/queue-194/PRD-1.7-DRAFT.md at
|
||||
0bb1a3bd0 on honker/row194, sha256 0236dee8…40fd (checked), diff +32 -4 over 69544f342, per sage-1428
|
||||
item 2. I checked the quotes against DECISIONS.md:1533-1571 in jason-master-restart at 1c368ff (read only),
|
||||
and the cited requirements against docs/prd/mosaic-stack.md at 0bb1a3bd0.
|
||||
|
||||
## Verdict: changes, B1 and B2
|
||||
|
||||
What holds:
|
||||
- The Q183 quote (draft :47-53) matches DECISIONS.md:1537 word for word, from "I will still want" to "I am a
|
||||
happy man." It leaves out the first and last sentences, and "in part" says so.
|
||||
- The Q184 quote "Q5: by 10.27" matches :1558. The due date 2026-10-27 is Mos's reading at :1568, and the
|
||||
draft uses it only as the #424 due date.
|
||||
- "Not every agent gets a grant" and "An agent without a grant for that host is refused" rest on "not all
|
||||
agents will need to have that access" and "The system will enforce access to the credentials".
|
||||
- "The agent never sees the key or any other secret value" is already PRD law. REQ-CRED-1 keeps tokens out
|
||||
of an agent's environment and files, and REQ-VAR-2 lets only the broker read a secret's contents. Citing
|
||||
both is right.
|
||||
|
||||
## Blockers
|
||||
|
||||
B1. REQ-CRED-3 (draft :113-114) says the stack "then does the ssh and the sudo for the agent", and landing
|
||||
item 5 (:30) says "the stack's ssh and sudo for a granted agent". Jason didn't say that. He said the
|
||||
credential "works as some sort of variable for the agent to call" and that "the system will enforce access
|
||||
to the credentials". Running the ssh on the agent's behalf is one design. Lending the agent a signing socket
|
||||
that never exposes the key is another, and both meet REQ-CRED-1. Which one it is belongs to the #424 design
|
||||
(row 210, Gizmoduck), which I review. Fix: state the outcome and the enforcement only. For example: "The
|
||||
agent asks for the host credential by name. The stack checks that a grant exists for that agent and that
|
||||
host before the credential is used. The agent never sees the key or any other secret value." Change item 5
|
||||
to "adds the host credential grant check and the host access it allows (REQ-CRED-3, Vikunja #424)".
|
||||
|
||||
B2. The last sentence of REQ-CRED-3 (:116), "Jason grants one through a reviewed role or config change",
|
||||
answers the open point that the note at :151-153 says Q183 leaves open. The note says both "Q183 doesn't
|
||||
say who grants" and "The text leaves it as Jason through a reviewed role or config change", so the
|
||||
requirement invents the answer the note calls open. Fix: in the requirement, say it is open. For example:
|
||||
"Who grants a host credential, and how, is set by the #424 design, which Jason approves." Reword the note to
|
||||
match. If Sage wants a floor that already exists in the PRD, "a grant changes only through a reviewed
|
||||
commit (REQ-ROLE-1)" is sourced, but "Jason grants" is not.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. Jason's words name "infra agent(s) on my .53 instance". REQ-CRED-3 is general, which is fine because
|
||||
the grant gates it. The source bullet carries the scope, so nothing is lost.
|
||||
N2. Jason's outcome is NOPASSWD sudo. REQ-CRED-3 says only "with sudo". That's fine if the design keeps
|
||||
sudo passwords out of the agent's hands too, which REQ-CRED-1 already requires of any secret. The design
|
||||
review checks it.
|
||||
N3. "Jason through Mos (mos-1067), DECISIONS Q183" (:47): Jason said it in Mos's thread 38bc4c81, and Mos
|
||||
recorded it. "Recorded by Mos" would be more exact than "through Mos". Doc-only.
|
||||
|
||||
A round with only B1 and B2 changed gets a diff check, not a full re-read. Sage's cutoff is 23:00Z.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Review: Honker PRD 1.7 draft r2, REQ-CRED-3, diff check (duckworth-0039)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Subject: agents/honker/work/queue-194/PRD-1.7-DRAFT.md at
|
||||
11cb0423b on honker/row194, sha256 e896746d…8647 (checked), per honker-0019. 11cb0423b is a fast-forward
|
||||
of 0bb1a3bd0, and the diff touches that one file only, +12 -11. I read the diff only, as REVIEW-r1 said I
|
||||
would.
|
||||
|
||||
## Verdict: approve
|
||||
|
||||
- B1 resolved. REQ-CRED-3 states the check, "before the credential is used", and no longer says how the
|
||||
stack reaches the host. Item 5 reads "the host access it allows". The new note says the #424 design
|
||||
picks the path.
|
||||
- B2 resolved. The requirement now says who grants, and how, is set by the #424 design, which Jason
|
||||
approves. The open-point note matches it and no longer names a granter.
|
||||
- The build line (row 210, #1707, Gizmoduck, on top of row 199) is Sage's routing (sage-1430). It is a
|
||||
reviewer note, not PRD text.
|
||||
- Nothing outside B1, B2 and those notes changed. The quotes I checked in r1 are untouched.
|
||||
|
||||
N1 to N3 from r1 stay as notes.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Review: ops-01 Q180 tightening plan r2 (duckworth-0023)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review of the document. I ran nothing on pve, PBS, .53 or
|
||||
.54. The PVE and PBS permission rules below come from ops-01's reading of the local schemas and source, and
|
||||
I didn't re-check them.
|
||||
Subject: /mnt/storage/scratch/tmp/mosvk/q180-tightening-plan.md, written 19:31:42Z, sha256 1084aa15…585f
|
||||
(checked), Vikunja #421. Mos asked for four checks (mos-1025), and ops-01 asked five questions. Both are
|
||||
answered at the end. Cross-checked against FED R8 at eb651aa68.
|
||||
|
||||
## Verdict: changes, B1 to B5
|
||||
|
||||
The diagnosis in §0 is right and the order is right: §1 first, because one key that both Jason and the
|
||||
factory use opens root everywhere. Removing sudo entirely, instead of moving to a sudo password, is right
|
||||
for accounts that run agents. Five gaps block it.
|
||||
|
||||
## Blockers
|
||||
|
||||
B1. Lockout (Mos check 1). The plan relies on "nothing if 1a holds" and an open console session. Neither is
|
||||
proven before the step that depends on it.
|
||||
- Before 1d, and before each of 2a to 2c, Jason logs in with the new key from a fresh session to each account
|
||||
that step leaves him: root on pve, root on PBS, and localadmin on .53 and .54, plus a `sudo -v` there. The
|
||||
step runs only after he has.
|
||||
- Say whether localadmin's sudo takes a password. The `sudo` group asks for one by default, and localadmin
|
||||
has none today. If it's NOPASSWD, say so on purpose.
|
||||
- The rollbacks for 2a to 2c say "console in". A cloud-init image usually has root locked. So a console login
|
||||
on .53 and .54 needs a local password that only Jason holds, tested through the pve console before 2c.
|
||||
- Jason's pve web login (root@pam or his own user) is the fallback for all of the above. Confirm it works
|
||||
and that no factory-readable file holds it.
|
||||
|
||||
B2. Where the new key lives. Two things decide it.
|
||||
- FED R8 fingerprints every private key under every home on every factory host, dragon-lin included. The
|
||||
gate fails if any of them matches an `authorized_keys` on the hub host.
|
||||
- localadmin on .54 trusts Jason's new key.
|
||||
|
||||
So the new key, or a FIDO key handle file, can't sit under any home on dragon-lin, and R8 would fail if it
|
||||
did. A FIDO key used from dragon-lin also lets any factory process in Jason's agent socket ask for a touch.
|
||||
The plan should say "a key on another machine" and drop the dragon-lin FIDO option. After this, Jason admins
|
||||
.54 from a machine that isn't a factory host, or through the pve console.
|
||||
|
||||
B3. Group removal doesn't reach processes already running (2a, 2b). Supplementary groups are fixed when a
|
||||
process starts. Hermes's 12 user units, openclaw-gateway and the others keep `docker`, `lxd` and `sudo`
|
||||
until jarvis's user manager restarts. melanie's two gateways are the same.
|
||||
- After the change, restart `user@<uid>.service` for each account, or end every process the account has.
|
||||
Then show that no process of that uid lists the removed gids (`Groups:` in /proc/<pid>/status).
|
||||
- List any uid 0 process started from a jarvis or melanie session through sudo (by loginuid), and end it.
|
||||
- Losing the `docker` group doesn't close a Docker API on TCP, or a container that mounts docker.sock behind
|
||||
a UI jarvis can reach. Check both on .53, by name only.
|
||||
|
||||
B4. Cloud-init order (Mos check 3). Yes, cloud-init can rewrite sudoers. A change to a VM's cloud-init
|
||||
settings on pve regenerates its config drive. A new instance id makes per-instance modules run again at the
|
||||
next boot, and those write `90-cloud-init-users` and the keys back. 1012 has a boot scheduled at
|
||||
2026-10-21T09:00Z (§5). The order has to be:
|
||||
1. In the guest: remove `90-cloud-init-users` and create `/etc/cloud/cloud-init.disabled`.
|
||||
2. Then on pve: clear `sshkeys`, and on 1012 clear `ciuser` too if it isn't needed.
|
||||
|
||||
The plan has these the other way round. Also check after the 09:00 boot that `90-cloud-init-users` is still
|
||||
gone.
|
||||
|
||||
B5. Paths the plan leaves unaccounted for (Mos check 4). The plan inventories ssh keys only. These other
|
||||
paths can reach the hub VM, its backups or its issuer. Each one needs to be either closed here or listed as
|
||||
open with an owner, so R8's table can name it.
|
||||
- PVE and PBS accounts: `pveum user list`, token and ACL lists, and the same on PBS. Any account or token
|
||||
whose secret is factory-readable (dragon-lin, .53, a Hermes profile) is a path.
|
||||
- PBS `root@pam!pve-backup`, which has Admin on `/`. Where is its secret? Remove it unless something needs it.
|
||||
- Rollback copies (§4). The 1e rollback restores the pve→PBS password file "from the copy". Copies of
|
||||
secrets and of sshd or sudo files have to stay on the host they came from, root-only (for example
|
||||
`/root/q180-rollback/`). They can't go under `~/.config/mosaic-dev/` on dragon-lin, which the factory can
|
||||
read.
|
||||
- Storage backing. Say where 1012's disks and the PBS datastore physically live. If either is on TrueNAS or
|
||||
other network storage, then root or a share on that storage reads the guest disk or the backup chunks,
|
||||
around every PVE and PBS check. The `jwoltje@truenas` key and the "tank" migration point that way.
|
||||
- The two step-ca containers on .53, which is a factory host. Say what they sign. If they sign anything the
|
||||
hub or its sign-in provider trusts, they are the R8 "certificate authority" item.
|
||||
- The hub's sign-in issuer: which host runs it and who can admin it. The plan doesn't name it.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. OpsAudit (§1.1) has to be two roles. A role brings all its privileges to every path it's granted on.
|
||||
One role holding `Sys.Audit` and `VM.Audit` granted on `/vms` would open monitor `info`. Use one role
|
||||
(`Sys.Audit`, `Datastore.Audit`, `Pool.Audit`) on `/nodes` and `/storage`, and a second role (`VM.Audit`
|
||||
only) on `/vms`. Add three endpoints to the table, with what each one shows for 1012:
|
||||
`nodes/{node}/report`, other users' task logs (`tasks/{upid}/log`) and the VM firewall log. Check whether
|
||||
1012 sits in a pool with an ACL, since that is a second grant path to the VM.
|
||||
N2. Melanie's Hermes on .54 conflicts with FED R8 ("the hub host runs no other instance or agent"). R8
|
||||
can't pass until those gateways leave .54. Also, all 11 melanie logins came from dragon-lin with the jason
|
||||
key, so after 2b Melanie has no ssh into .54 at all. Say whether that's intended.
|
||||
N3. The 1b watch fails every hour until it's switched to the API. Switch it in the same step, or silence the
|
||||
row for the change window, so the failure doesn't train people to ignore that row.
|
||||
|
||||
## Answers
|
||||
|
||||
Mos (mos-1025):
|
||||
1. Lockout: possible as written. B1 closes it, and B2 decides where Jason admins from.
|
||||
2. PVEAuditor and DatastoreAudit on 1012: the §1.1 and §1.2 tables are consistent with what I know of
|
||||
PVE 9 and PBS 3. Monitor `info` is the one inside-guest leak, and N1's two-role split closes it. Config
|
||||
and listings show metadata only, with cipassword masked.
|
||||
3. Cloud-init can rewrite sudoers: yes. B4.
|
||||
4. Remaining factory paths: yes, the B5 list. The factory key and jarvis@w-jarvis are closed by §1 and §2.
|
||||
Accounts, tokens, storage, the CA and the issuer aren't covered yet.
|
||||
|
||||
ops-01:
|
||||
1. §0: agreed, and §1 first.
|
||||
2. §1.1: N1. Nothing else from inside a guest that I can name. B5's storage item is the larger leak.
|
||||
3. §1.2: agree with deferring encryption, on the three conditions plus B5. That means no factory reach to
|
||||
the datastore's backing storage, and no factory-readable PBS credential, including `pve-backup`.
|
||||
4. §2: agree, with B1, B3 and B4.
|
||||
5. §6: Jason's call. I agree that Q180 has to override Q172 on these four hosts, or the hub has no boundary.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Review: ops-01 Q180 tightening plan r3 (duckworth-0028)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. I reviewed the document only and ran nothing on pve, PBS, .53 or .54.
|
||||
Subject: /mnt/storage/scratch/tmp/mosvk/q180-tightening-plan.md, sha256 cd7165bf…0bc3fce (checked), Vikunja
|
||||
#421. Checked against duckworth-0023 (REVIEW-r2.md, 523a6bddf) and mos-1041 as ops-01 relays it.
|
||||
|
||||
## Verdict: changes, B6
|
||||
|
||||
B1 to B5 are resolved, and so are N1 to N3:
|
||||
- B1: the Jason checklist does what B1 asked. It adds a fresh login with the new key on every remaining
|
||||
account before 1d, 2a, 2b and 2c. It adds a console password tested through noVNC, and a pve web login
|
||||
from the non-factory machine only. That last one is Mos's N4, and the browser-profile reason is right.
|
||||
localadmin's sudo takes a password.
|
||||
- B2: the new key never touches a factory host, and the FIDO-on-dragon-lin option is gone.
|
||||
- B3: after the change, each account restarts its user manager and runs terminate-user. A /proc `Groups:`
|
||||
count must then be 0. uid 0 processes are found by loginuid. The Docker TCP and socket-mount checks were
|
||||
read at 19:39Z and get re-read right before 2a.
|
||||
- B4: the order is guest first, then pve, and is checked at the next boot. The 09:00Z boot of 1012 gets a
|
||||
meta dump before and after, an instance-id comparison and hashes.
|
||||
- B5: §1.4 closes each path or gives it an owner. The hub's sign-in issuer is open, and Sage owns it. The
|
||||
plan says that the row has to be filled before the plan is complete. That meets B5. It also means R8
|
||||
can't pass until the row is filled.
|
||||
- N1: two roles, and no pools. Your `nodes/pve/report` finding (Report.pm dumps guest configs raw, so the
|
||||
`cipassword` hash comes through `Sys.Audit`) is a good catch. Step 1a2, done before `OpsAuditHost` is
|
||||
granted, is the right fix.
|
||||
- N2 and N3: done.
|
||||
|
||||
## Blocker
|
||||
|
||||
B6. Root access already granted isn't undone by removing it. This is new in r3, and it is my miss in r2.
|
||||
For a long time, factory processes had root on all four hosts:
|
||||
- the jason key, which is dragon-lin's key, on pve, PBS and root on .53 and .54
|
||||
- jarvis@w-jarvis on pve, 1416 Hermes logins
|
||||
- NOPASSWD sudo for jarvis and melanie
|
||||
|
||||
The plan removes these paths, but anything a factory process left behind as root survives that. Examples:
|
||||
- another file in sudoers.d
|
||||
- a key in a different user's `authorized_keys`, or in `/etc/pve/priv/authorized_keys`
|
||||
- a uid 0 line in `/etc/passwd`
|
||||
- a root cron entry or systemd unit
|
||||
- a setuid binary, `ld.so.preload`, or a PAM or sshd drop-in
|
||||
The hub (.54) is the host where R8 needs this ruled out. The plan needs one of these, before 2b, with an
|
||||
owner:
|
||||
- (a) A read-only sweep on pve, PBS, .53 and .54, reported by name, owner, mode and hash only:
|
||||
- every file in `/etc/sudoers.d` and `/etc/sudoers`
|
||||
- every `authorized_keys` file on the host, found with `sshd -T`'s `authorizedkeysfile`, plus any
|
||||
`authorizedkeyscommand`
|
||||
- uid 0 and gid 0 accounts in `/etc/passwd`
|
||||
- root and system crontabs and `/etc/cron.*`
|
||||
- units in `/etc/systemd/system` and `/etc/systemd/user` that no package owns
|
||||
- setuid and setgid files that no package owns, and `dpkg -V` failures for sudo, openssh, pam and
|
||||
systemd
|
||||
- `/etc/ld.so.preload`, and drop-ins under `/etc/pam.d` and `/etc/ssh/sshd_config.d`
|
||||
|
||||
Jason reviews anything unexpected, and the sweep runs again after §2.
|
||||
- (b) Rebuild 1012 from a clean image instead of cleaning it in place. That fits N2 too, since Melanie's
|
||||
Hermes has to leave the hub anyway.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N4. localadmin's password has to exist at 1a. 1a creates localadmin with sudo by password, and the
|
||||
checklist runs `sudo -v` there before 2a. But the console-password line says "before 2c". Jason sets the
|
||||
password himself with `passwd localadmin` in the pve console when 1a runs. It never goes through a factory
|
||||
session or script. The noVNC test can stay before 2c, or move up to 1a.
|
||||
N5. Before `OpsAuditHost` is granted, run `pvereport` once as root on pve. Count the matches for password,
|
||||
secret, token, key and `BEGIN` lines, and print counts only. That checks your by-filename reading of
|
||||
Report.pm against this PVE build.
|
||||
N6. On pve, `/root/.ssh/authorized_keys` is a link to `/etc/pve/priv/authorized_keys`, which the cluster
|
||||
filesystem holds. Edit that file, copy that file, and say so in 1d. Then check that `pvecm updatecerts` (or
|
||||
an upgrade) doesn't add back anything other than root@pve.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Review: ops-01 #423 VM tightening r4, steps 1 to 3 (duckworth-0032)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. I reviewed the document only and ran nothing on pve, .53 or .54.
|
||||
Subject: /mnt/storage/scratch/tmp/mosvk/q183-vm-tightening-r4.md, sha256 03cd1075…cbc05f (checked), 122
|
||||
lines, Vikunja #423. This is ops-01's 20:00:49Z amendment of eadff6b6. I read eadff6b6 in full. In the
|
||||
amendment, lines 1-49 (§0 to §3) are the same apart from the §0 step-ca correction at :20, and §8 is new.
|
||||
Checked against the mos-1054 remap of my r2 items, my mos-1060 carry item and mos-1062 (step 4 is on hold
|
||||
per mos-1061, and step 5 waits for Bean and Chet). Per mos-1065, step 1 runs first after approval.
|
||||
|
||||
## Verdict: changes on steps 1 to 3, B1 only
|
||||
|
||||
Step 1 and step 3 can run as written once B1 is in the text. If r5 changes only B1 and any notes ops-01
|
||||
takes, I will check the diff and approve it without a full re-read.
|
||||
|
||||
What holds:
|
||||
- mos-1060 carry: met. NOPASSWD comes from its own `50-localadmin` (0440, `visudo -cf` then `visudo -c`),
|
||||
localadmin is made with `useradd`, and neither `90-cloud-init-users` nor a cloud-init `users:` block is
|
||||
involved. The §3 post-boot check covers the `50-localadmin` hash.
|
||||
- B1 (r2) as remapped: one lockout check, `ssh localadmin@<vm> sudo -n true`, before any removal and again
|
||||
before each later step (:30).
|
||||
- B4 (r2): `cloud-init.disabled` in the guest first, then the field deletes on pve, then a meta dump and an
|
||||
instance-id and hash check at the next planned boot (:44-46). Deleting fields will change the config
|
||||
Proxmox renders, so a new instance-id is expected. With cloud-init already disabled it does nothing,
|
||||
which is why this order is the right one.
|
||||
- B5 (r2) as remapped: in-VM paths and the 1008/1012 cloud-init fields only (:5).
|
||||
- Rollback copies stay root-only on the host they came from (:8).
|
||||
|
||||
## Blocker
|
||||
|
||||
B1. Step 2's verify can't tell whether `PermitRootLogin no` took effect (:36-37). sshd keeps the first value
|
||||
it reads for a keyword. Ubuntu's and Debian 12's `sshd_config` put `Include /etc/ssh/sshd_config.d/*.conf`
|
||||
at the top, and drop-ins load in lexical order. §0 records `PermitRootLogin without-password` but not where
|
||||
it is set. If it is in a drop-in that sorts before `60-q183.conf` (`50-cloud-init.conf` is a common one),
|
||||
that value wins and `60-q183.conf` does nothing. `sshd -t` only checks syntax. `ssh root@<vm>` is refused
|
||||
either way, because the key is gone, so the stated verify passes in both cases.
|
||||
Fix, in step 2's verify:
|
||||
- `sshd -T -C user=root,host=x,addr=127.0.0.1 | grep -i '^permitrootlogin'` prints `permitrootlogin no`.
|
||||
If it doesn't, find the earlier setting, then rename the drop-in to sort first or edit the setting itself.
|
||||
- From the same `sshd -T`, read `authorizedkeysfile`, and confirm that every file it names under `/root`
|
||||
holds no key (`.ssh/authorized_keys2` is in the default list). Report counts only.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. Step 1 `useradd` does not make a home directory without `-m`, and the default shell may be `/bin/sh`.
|
||||
Write `useradd -m -s /bin/bash localadmin`. `~/.ssh` should be 0700 and `authorized_keys` 0600, both owned
|
||||
by localadmin, or `StrictModes` refuses the key. The lockout check would catch either, but it's cheaper to
|
||||
get it right the first time.
|
||||
N2. Step 1 copies "the jason key" from root's file (:29). Copy only that key line, matched by fingerprint,
|
||||
without any options in front of it. Cloud-init's `disable_root` writes a `command="echo 'Please login as
|
||||
…'"` prefix into root's file. If that prefix is copied, every localadmin login runs it. The lockout check
|
||||
would fail closed on it.
|
||||
N3. Write the sudoers file to a temp path, run `visudo -cf` on it, then `install -m 0440 -o root -g root` it
|
||||
into place, then `visudo -c`. A file that fails the check never sits in `/etc/sudoers.d`, where a syntax
|
||||
error makes sudo refuse to run.
|
||||
N4. Before step 2, check whether anything logs in as root by key on a schedule. Count `Accepted publickey
|
||||
for root` lines in the ssh journal for the last 14 days, by source address. Report counts only. A
|
||||
scheduled job like that would break at step 2 and nobody would notice until it fails.
|
||||
N5. The step 2 rollback names the pve console as a way in (:40). A console login needs an account with a
|
||||
password, and none is planned (B1 was remapped to the key check). The real fallback is pve root. Before
|
||||
step 2, check `qm agent <vmid> ping` for 1008 and 1012. If the guest agent answers, `qm guest exec` is the
|
||||
way in. If not, say the fallback is a rescue boot.
|
||||
N6. Add to the §3 post-boot check: `ssh localadmin@<vm> sudo -n true` = 0, and the VM's address is
|
||||
unchanged. Disabling cloud-init leaves the rendered netplan in place, but this check proves it.
|
||||
N7. Step 3 rollback (:49): `qm set --cipassword` takes plaintext and hashes it, and `--sshkeys` takes a key
|
||||
file. Neither can take the values saved in the conf. Restore those lines by writing them back into
|
||||
`/etc/pve/qemu-server/<vmid>.conf` from the root-only copy.
|
||||
|
||||
## Your questions
|
||||
|
||||
(a) Yes, the statement is accurate. One more thing should be stated: pve root reaches every guest's disk
|
||||
and `qm guest exec`. So for a process running as jarvis on .53, the jarvis key is root over 1008 and 1012
|
||||
too, through pve. What §4 narrows is mistakes made inside .53, not an attacker with the jarvis key. Q183
|
||||
point c and R8 record that this is accepted, so this doesn't block.
|
||||
(b) Agreed. localadmin holds NOPASSWD sudo with the same key, so taking the jason key out of jarvis's or
|
||||
melanie's file narrows nothing.
|
||||
(c) On step 4 I'll give my verdict later, per mos-1062. mos-1061 holds it until Case 1 is live on .53 and
|
||||
the sudo and docker flows have another path. That covers the step-ca stacks.
|
||||
I have not reviewed steps 4 and 5 in this round. §6 is unchanged from r3.
|
||||
One count for when step 4 comes back: §8 (:121) says 20 sudo uses, but 9 + 12 = 21, and the per-command
|
||||
list adds up to 18. Recount them before step 4's review.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Review: ops-01 #423 step 4 scripts, revised, C1 to C4 check (duckworth-0048)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. I read the scripts and ran nothing on pve or .53. Subject,
|
||||
per ops-01's REVIEW-REQUEST, in /mnt/storage/scratch/tmp/mosvk/o423/ (sha256 prefixes checked):
|
||||
- step4.sh 5aea3856fdd48d27, 46 lines
|
||||
- check4.sh 8f49a40937948be6, 46 lines
|
||||
- containers.sh 837bb7a439e58f14 (new), 33 lines
|
||||
- postboot.sh 0f2f510bbaba09e0 (unchanged)
|
||||
- boot-session.md f0645604f4483cbb
|
||||
|
||||
I read all five in full. I had already read postboot.sh in full for duckworth-0046, whose C3 rests on its
|
||||
:36-38. This round I read it again.
|
||||
|
||||
## Verdict: C1 to C4 met. The optional block may run once C5 is fixed
|
||||
|
||||
C1. step4.sh :14 aborts before any change if `/etc/cloud/cloud-init.disabled` is missing. In 0 and 0',
|
||||
anything other than a dry-run result of exactly fail=1 is a stop. If step 3.1 hasn't run on 1008, it runs
|
||||
first. postboot.sh :41-42 checks the file and `cloud-init status` again after the boot.
|
||||
|
||||
C2.
|
||||
- After the change, step4.sh :41 matches the refusal line "User jarvis is not allowed to run sudo".
|
||||
- After the boot, check4.sh :29 matches the same line again, and :30 requires `/etc/sudoers.d` to be
|
||||
exactly `50-localadmin README`.
|
||||
- ops-01 checked the line format against `nobody` on .53.
|
||||
- `sudo -n true` refused is kept as an extra line, not the proof.
|
||||
|
||||
C3, including Mos's addition.
|
||||
- step4.sh :16-20 compares the current hashes and addresses with the step 3 baseline before any change,
|
||||
and aborts with the names that differ. So drift in `50-localadmin` or `01-q183.conf` can't become the new
|
||||
baseline.
|
||||
- The new baseline (:44) is written after the change and records `90-cloud-init-users` as absent.
|
||||
- postboot.sh :36-38 reports a DIFF if the file reappears.
|
||||
|
||||
C4.
|
||||
- containers.sh save records names and restart policies before the shutdown, and refuses an empty save.
|
||||
- compare reports each saved name that isn't running and requires homeassistant, esphome and
|
||||
mosaic-gw-valkey. check4.sh :45 counts compare's DIFF lines as failures.
|
||||
- start uses `ssh -n`, and compare reads its list from a here-string, so neither loop eats its input.
|
||||
- The plain 1008 path calls save and compare too (boot-session :23 and :29).
|
||||
|
||||
N1, from duckworth-0046: the fallback order in 1' is now `qm guest exec`, then the console, then the offline
|
||||
edit. `qm guest exec 1008 -- /bin/true` was tested. The rollback line names `qm guest exec` too.
|
||||
|
||||
## Condition
|
||||
|
||||
C5. boot-session.md's optional block, line 2, still names the old shas: step4.sh 0ab0f36e…, check4.sh
|
||||
408325f5…. Its dry-run line says fail=8. Change the line to the reviewed shas, step4.sh 5aea3856…, check4.sh
|
||||
8f49a409… and containers.sh 837bb7a4…, with the fail=10 dry run. Then 0' runs `sha256sum` on the three
|
||||
scripts and stops on any mismatch. That way the scripts that run are the ones reviewed here. It's a doc
|
||||
line, so it doesn't come back to me. Report the sha check's result with the step's output.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. step4.sh exits 0 even when `groups_left` isn't 0 or `jarvis_not_allowed` isn't 1. It only prints them,
|
||||
and 1' relies on the operator reading the Expect line. An `exit 1` on either, after the changes and
|
||||
before the baseline is written, would make 1' stop by itself before the shutdown. The rollback is already
|
||||
written by then.
|
||||
N2. step4.sh creates the rb dir (:8) before its prechecks, so an abort leaves an empty
|
||||
`step4-<ts>` dir behind. That's harmless, but use the newest dir with a `rollback.sh` in 3' and in any
|
||||
rollback, not just the newest dir.
|
||||
N3. The two step-ca containers are in a restart loop. Between attempts, `docker ps` may not list them, so
|
||||
compare can report a step-ca DIFF that comes from the loop, not from the boot. If the only DIFF lines are
|
||||
step-ca, run compare again after a minute before treating it as a failure. Don't `start` them, because
|
||||
they were looping before.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Check: ops-01 #423 step 4, N1 and N2 diff and C5 (duckworth-0049)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. I read the files and ran nothing on pve or .53. Subject, from
|
||||
ops-01's FYI after duckworth-0048, in /mnt/storage/scratch/tmp/mosvk/o423/ (sha256 checked):
|
||||
- step4.sh cc16d38401e5792e, 47 lines, read in full
|
||||
- boot-session.md ed44ec81b74361cf, sha and N3 lines read
|
||||
- check4.sh 8f49a409, containers.sh 837bb7a4 and postboot.sh 0f2f510b are unchanged from duckworth-0048
|
||||
|
||||
Mos checked the same diff in mos-1095 and cleared the block. This is my own read, so the record shows it.
|
||||
|
||||
## Verdict: the diff holds, and so does duckworth-0048
|
||||
|
||||
- N2. `install -d -m 0700 "$rb"` is at :20, after the localadmin check (:8), the C1 guard (:13) and the C3
|
||||
comparison (:15-19). An abort at any of them leaves no step4 dir.
|
||||
- N1. :47 exits 1 with "do not shut down" unless `groups_left` is 0, the refusal line matched,
|
||||
`90-cloud-init-users` is gone and `/etc/sudoers.d` is exactly `50-localadmin README`. An `A && B || {}`
|
||||
list doesn't trip `set -e`, so the message prints. By then rollback.sh is written (:25-34).
|
||||
- C5. boot-session.md :33-34 names all four reviewed shas and the fail=10 dry run. :39 makes 0' a sha256sum
|
||||
gate that stops on any mismatch.
|
||||
- N3 is at :29 and :63 for both 1008 paths.
|
||||
|
||||
## Note (does not block)
|
||||
|
||||
N1. If :47 fails, `baseline.txt` (:44) already holds the failed state. Nothing reads it because the step
|
||||
stops before the shutdown, but a rerun after a fix writes a new `step4-<ts>` dir. Use that one for 3'.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Review: ops-01 #423 step 4 scripts against duckworth-0045 C1 to C4 (duckworth-0046)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. I read the scripts and ran nothing on pve or .53. Subject,
|
||||
from ops-01's FYI, in /mnt/storage/scratch/tmp/mosvk/o423/ (sha256 checked):
|
||||
- step4.sh 0ab0f36e…902a, 36 lines
|
||||
- check4.sh 408325f5…ac754, 41 lines
|
||||
- postboot.sh 0f2f510b…7de5, 48 lines
|
||||
- boot-session.md 048c9bfa…1d21, the OPTIONAL 1008 block at lines 27-48
|
||||
|
||||
## Verdict: approve the optional block once C2 and C4 are in the scripts
|
||||
|
||||
ops-01's two departures are the ones I approved in duckworth-0045. The full stop and start replaces
|
||||
user@1000's restart, and postboot.sh reads a baseline written after the change.
|
||||
|
||||
What holds:
|
||||
- step4.sh refuses to start without localadmin's NOPASSWD grant (:9). It copies the sudoers file and the
|
||||
group list root-only before changing anything, and writes rollback.sh with the removed groups fixed at
|
||||
write time (:15-24). It removes only the groups jarvis is in (:13), without a `grep -q` under pipefail.
|
||||
- 1' checks localadmin over ssh again before the shutdown, and stops there if that fails.
|
||||
- check4.sh covers 4.4 (uid-1000 pids holding the four gids, :12-18), 4.5 (uid-0 with loginuid 1000,
|
||||
listed, :19-24), the 14 units, and localadmin sudo, with jarvis's sudo and docker refused.
|
||||
|
||||
C1, met by the 0' dry run. postboot.sh :41-42 fails on a missing `cloud-init.disabled` or a status other
|
||||
than `disabled`. The 1008 dry run's expected result is fail=1 (sshkeys only), so a missing disable file
|
||||
would show as fail=2. ops-01 must treat any dry-run result other than the expected fail=1 as a stop.
|
||||
A one-line guard at the top of step4.sh, aborting when `/etc/cloud/cloud-init.disabled` is absent, would
|
||||
make that hold even if the dry run is skipped. That's optional.
|
||||
|
||||
C3, met. The step 4 baseline records `absent /etc/sudoers.d/90-cloud-init-users` (:33). If the file came
|
||||
back, postboot.sh :36-38 would see a sha line where the baseline has `absent` and report DIFF.
|
||||
|
||||
## Still open
|
||||
|
||||
C2, partly met. step4.sh :29-30 checks groups and `sudo -l -U jarvis`, but only before the boot.
|
||||
check4.sh, after the boot, checks groups, the one sudoers file and `visudo -c`. It doesn't run
|
||||
`sudo -l -U jarvis`, and it doesn't list `/etc/sudoers.d`. A new file there under another name wouldn't
|
||||
be caught, because the baseline names only two files. Add to check4.sh's root block:
|
||||
- `sudo -l -U jarvis` must print "is not allowed to run sudo". Match that line. Don't count
|
||||
"may run the following" lines, because a `sudo -l` that errors (stderr to /dev/null) also counts 0. The
|
||||
same applies to step4.sh :30.
|
||||
- `ls -A /etc/sudoers.d` must list exactly README and 50-localadmin.
|
||||
|
||||
C4, not met. Neither check4.sh nor the block records the running containers. boot-session.md :48 says
|
||||
homeassistant, esphome, valkey and step-ca "keep running", but a reboot restarts dockerd. Only containers
|
||||
with a restart policy come back. Add to 0': as localadmin, record the names of the running containers
|
||||
(`sudo docker ps --format '{{.Names}}'`, sorted, into the rb dir or on dragon-lin). Add to check4.sh: the
|
||||
same list after the boot, compared to the saved one, with names only. A container missing after the boot is
|
||||
a DIFF, and localadmin starts it. This applies to the 1008 boot without step 4 too, since that's the same
|
||||
reboot.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. The FYI and 1' give the fallback after step4.sh as Jason at the console, then pve root offline. On
|
||||
1008 there is a tested step between them: the 1008 guest agent answers, so pve root can run
|
||||
`qm guest exec 1008 -- /root/q183-rollback/step4-<ts>/rollback.sh` (my duckworth-0035 N5 and N8). Name it
|
||||
second, ahead of the console.
|
||||
N2. check4.sh :34 asks `systemctl --user is-active` over a jarvis ssh login. That works with linger (the
|
||||
dry run got 14 of 14). It's also the one place in check4.sh that needs jarvis's own login. That's fine
|
||||
because jarvis's authorized_keys stays (§4).
|
||||
@@ -0,0 +1,64 @@
|
||||
# Review: ops-01 #423 VM tightening r5, step 4 (.53 jarvis sudo and groups) (duckworth-0045)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. I reviewed the document only and ran nothing on pve or .53.
|
||||
- Subject: /mnt/storage/scratch/tmp/mosvk/q183-vm-tightening-r5.md, sha256 4cbdc48e…f380 (checked). It's
|
||||
the same file I approved for steps 1 to 3 in duckworth-0035.
|
||||
- Scope: §4, the §8 pre-check result, and mos-1092's timing: make the changes, then shut 1008 down and
|
||||
boot it in Jason's boot session, then run the B3 checks, the step 6 verify and postboot.sh.
|
||||
- postboot.sh wasn't sent to me, so I haven't read it. C3 covers what it has to expect.
|
||||
|
||||
## Verdict: approve step 4 with C1 to C4
|
||||
|
||||
The step is sound. §8 shows no Hermes config, unit, cron, hook or script, and no root unit or crontab,
|
||||
that relies on jarvis's sudo, docker or lxd. The skill text that mentions them is instructions, not
|
||||
callers. 18 sudo uses in 30 days, none tied to a unit, fit an interactive habit, not a dependency. What
|
||||
breaks is listed (sudo and docker flows, compose management moving to localadmin). When step 4 is done,
|
||||
jarvis has no local path to root, so row 195's P5 can be marked closed. jarvis's `id_ed25519` reach to
|
||||
pve stays and is P4, accepted under Q183.
|
||||
|
||||
Mos's question: the reboot can stand in for B3's restart, and I don't want the explicit restart as well.
|
||||
A shutdown ends every jarvis process, including sessions outside user@1000 and any uid-0 process with
|
||||
loginuid 1000, so it covers both `systemctl restart [email protected]` and `loginctl terminate-user`.
|
||||
Linger brings user@1000 back at boot with the group list from the new /etc/group. The groups check (4.4)
|
||||
and the leftover-root check (4.5) still run after the boot, as mos-1092 says, and they are the proof. The
|
||||
reboot isn't. Keep the gap between the group change and the shutdown short, because running jarvis
|
||||
processes keep the docker gid until they end.
|
||||
|
||||
## Conditions
|
||||
|
||||
C1. Step 3's `/etc/cloud/cloud-init.disabled` must be in place on 1008 before this boot. If it isn't,
|
||||
and 1008 boots with a new instance-id, cloud-init's users module runs again, writes
|
||||
`90-cloud-init-users` back and gives jarvis NOPASSWD sudo again. If step 3 hasn't run on 1008 by then, run
|
||||
3.1 first in the same session. It's approved and has no restart of its own.
|
||||
|
||||
C2. The verify has to check the stored state, not only a running shell. `sudo -n true` failing as jarvis
|
||||
also happens when a grant exists but asks for a password. Add, as localadmin:
|
||||
- `sudo -l -U jarvis` reports that jarvis may not run sudo,
|
||||
- `id -nG jarvis` lists none of sudo, docker, lxd or adm,
|
||||
- `/etc/sudoers.d` holds only README and `50-localadmin`, and `visudo -c` passes.
|
||||
Report names and results only.
|
||||
|
||||
C3. Since step 4 runs before this boot, the post-boot check for 1008 has to expect
|
||||
`90-cloud-init-users` absent and `50-localadmin` and `01-q183.conf` unchanged. Fix that expectation in
|
||||
postboot.sh before the boot. If it was written against the pre-step-4 hashes, it reports a false change.
|
||||
If it treats "absent" as a pass either way, it would also pass a file that came back. The file has to be
|
||||
absent, and its reappearance has to be a failure.
|
||||
|
||||
C4. A reboot restarts dockerd, which the user@1000 restart didn't. Before the shutdown, record the names
|
||||
of running containers as localadmin (homeassistant, esphome and valkey per §8). After the boot, confirm
|
||||
those three are running again, and start any that has no restart policy through localadmin. Add it to the
|
||||
step 6 verify, with names and states only. The step-ca loop and the exited stacks stay as they are.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. Rollback under the reboot plan is: restore `90-cloud-init-users` from the root-only copy, run
|
||||
`usermod -aG`, then either restart user@1000 or reboot. Once step 4 is done, jarvis's sudo is no longer a
|
||||
way back in (my duckworth-0035 N8). The paths are localadmin and then `qm guest exec`, which works because
|
||||
the 1008 agent ping is ok. Re-run the §1 lockout check right before the shutdown, as §1 requires.
|
||||
N2. The adm group gives jarvis read access to `/var/log` files. 2 of the 18 sudo uses were `journalctl`.
|
||||
jarvis keeps its own user journal. A Hermes flow that reads system logs would fail, and §8 shows none.
|
||||
N3. Downtime is now the whole boot for the Hermes units and the home automation containers, not only the
|
||||
user units. Record the shutdown time and each unit's `ActiveEnterTimestamp` as 4.3 already does.
|
||||
N4. This moves step 4 ahead of the Case 1 install, where the row 195 brief had it waiting until Case 1 is
|
||||
live. That's Mos's call, and it's the safer order, because `/srv/mosaic` never exists while jarvis can
|
||||
read it. Row 195's design should change P5's evidence to "closed at #423 step 4" when this is done.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Review: ops-01 #423 VM tightening r5, steps 1 to 3, diff-only (duckworth-0035)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. I reviewed the document only and ran nothing on pve, .53 or .54.
|
||||
- Subject: /mnt/storage/scratch/tmp/mosvk/q183-vm-tightening-r5.md, sha256 4cbdc48e…f380 (checked).
|
||||
- Base: q183-vm-tightening-r4.final.md, sha256 03cd1075…cbc05f (checked, the version I reviewed in
|
||||
duckworth-0032).
|
||||
- Diff: q183-r4-to-r5.diff, sha256 15326e32…4e70 (checked). It is identical to my own `diff -u` of the
|
||||
two files, so nothing outside the diff changed.
|
||||
|
||||
## Verdict: approve steps 1 to 3 on 4cbdc48e
|
||||
|
||||
- B1 resolved.
|
||||
- The drop-in is `01-q183.conf`, so it sorts ahead of `60-cloudimg-settings.conf` and any cloud-init
|
||||
drop-in.
|
||||
- §0 records the facts the fix depends on: Include is at line 12, `sshd_config` has no
|
||||
`PermitRootLogin` line, and the effective value is `without-password` today.
|
||||
- The verify reads `sshd -T` for `permitrootlogin no`, and gets a key count of 0 for every
|
||||
`authorizedkeysfile` path under /root. `ssh root@` refused is labelled "not proof".
|
||||
- N1 to N3 and N6 are taken as written. N2 is better than I asked. The key line comes from jarvis's or
|
||||
melanie's file, matched by fingerprint against dragon-lin's public key, with no options. Root's file is
|
||||
never used, because it carries the `command=` prefix.
|
||||
- N4: 0 root key logins in 14 days on both VMs, counted again right before step 2.
|
||||
- N5: 1008 falls back to `qm guest exec`. 1012's fallback is named and untested. See N8.
|
||||
- N7: the conf lines are restored from the root-only copy, without `qm set`.
|
||||
- Answer (a) is in §0.
|
||||
- Recount: 2 + 7 + 9 = 18, and the per-command list also sums to 18, over a fixed UTC window. The cause
|
||||
of the earlier numbers is stated.
|
||||
|
||||
Condition: this approval covers step 1 as written in r5. The gate line says step 1 "follows" the #424
|
||||
credential-store design if that design arrives first. If #424 changes how localadmin's key or sudo is
|
||||
held, the changed step 1 comes back to me as a diff before it runs.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N8. Step 2 doesn't touch jarvis's or melanie's sudo. Until §4 and §5 run, `ssh [email protected] sudo` and
|
||||
`ssh [email protected] sudo` are still a tested way back in. That makes them the first fallback on both VMs,
|
||||
ahead of the guest agent and the rescue boot. Name them in step 2's rollback. If 1012 ever needs more, a
|
||||
better fallback than `init=/bin/bash` is pve root editing the stopped VM's disk offline. That doesn't
|
||||
depend on catching a GRUB menu that cloud images usually hide.
|
||||
N9. Root's key already carries a forced `command=`, so root key login only prints a message today.
|
||||
Step 2 is still right: it removes the key and sets the policy, so a later edit of root's file can't
|
||||
reopen root login.
|
||||
N10. The §6 unit-file rule (webby-0025) is sound. Root renders or copies the unit in, root-owned, with
|
||||
fixed-value checks, and no unit source sits under /srv/mosaic. I'll review it with Gosalyn's design and
|
||||
the Case 2 step that runs it.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Review: Morgana row 193 design r3 (duckworth-0011)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/morgana/work/queue-193/DESIGN.md, diff 19c015741..76f4605e1 (one commit, morgana-0008).
|
||||
DESIGN.md at 76f4605e1 has sha256 e61500d9…8859, checked. Line numbers are at 76f4605e1. Code read at
|
||||
53bb59462. Against duckworth-0008 (REVIEW.md, aa7f082bf).
|
||||
|
||||
## Verdict: changes, B3 only
|
||||
|
||||
B1 and B2 are resolved and N1 to N4 are done. B3 is a small gap in the new 5.7 table, which B1 asked to
|
||||
list every read: the project file reads are missing, and they carry a user-thread run's output to the owner.
|
||||
|
||||
## r2 codes
|
||||
|
||||
- B1 resolved (5.7, :279-327). Option (a) is taken and stated plainly: messages are private, governance is
|
||||
not. The table covers the reader list at broker.mjs:1303 and the owner's board reads. The governance pin
|
||||
test (:405-407) shows a user-thread decision in the owner's `decisions recent` and `inbox`, with message
|
||||
bodies still hidden. The :1683 route is named as intended. Gizmoduck's FED r4.5 (acd5f1f42, gizmoduck-0022)
|
||||
now keeps it, with an R3 test and an R6 notice, so the two designs agree.
|
||||
- B2 resolved (5.7 :322-324, section 9 :440-451). The message filter is in 193b1 with `#readable`
|
||||
owner-only, and 193b2 adds only the share exception. No order of FED R8 and 193b2 can put a user thread
|
||||
live unfiltered. Mutants 11 and 12 are tested under 193b1 (:408-413).
|
||||
- N1 done (:383-384). N2 done (:6-7). N3 done (5.6 :277, section 8 :393, mutant 5 :429): R4 owns the
|
||||
level-loss test and mutant. N4 done (5.2, :205-207).
|
||||
|
||||
## Blocker
|
||||
|
||||
B3. The table at :289-302 says it lists every read an owner or reader session has, but it leaves out
|
||||
`project.files` and `project.file` (broker.mjs:1441-1450, human and tester). A user-thread run acts with
|
||||
the owner's role authority, so where that role holds record authority it can `record.write` into the
|
||||
project repository (broker.mjs:1395-1407). The owner then reads that record with `project.file`. Under
|
||||
option (a) this is governance and visible, which is fine. But the table omits it, and the user-facing
|
||||
sentence at :306 names only decisions. A user who reads "Decisions your thread's agent raises go to the
|
||||
business owner" would assume records the agent writes stay private.
|
||||
Fix: add a `project.files`, `project.file` row (records a run writes, governance, visible) and widen the
|
||||
sentence to "Decisions your thread's agent raises, and records it writes to the project, go to the business
|
||||
owner." Also list `message.receive` and `message.read` on the `messages` row (:296). They filter on
|
||||
`to_role='human'` too (broker.mjs:1931-1936, :1953), so they're private for the same reason. Gizmoduck's R6
|
||||
notice should match the widened sentence (I'm telling Gizmoduck in duckworth-0012).
|
||||
|
||||
## Note (does not block)
|
||||
|
||||
N5. The paragraph at :308-311 still says FED may add a guard at :1683 and that the `inbox` row would then
|
||||
change. FED r4.5 has settled this: no guard, as Mos ruled (mos-1021, mos-1022). Cite r4.5 and drop the
|
||||
conditional.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Review: Morgana row 193 design r4 (duckworth-0014)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/morgana/work/queue-193/DESIGN.md, diff 76f4605e1..edc794933 (one commit, morgana-0009).
|
||||
DESIGN.md at edc794933 has sha256 c18d27b0…b613a, checked. Against duckworth-0011 (REVIEW-r3.md, 912b1d325).
|
||||
|
||||
## Verdict: approve
|
||||
|
||||
## r3 codes
|
||||
|
||||
- B3 resolved (5.7 table and sentence). The `project.files` and `project.file` row is in, marked
|
||||
governance, visible. `message.receive` and `message.read` are on the `messages` row. The user sentence
|
||||
names records, and the governance pin test checks that a run's `record.write` shows in the owner's
|
||||
`project.file`.
|
||||
- N5 done. The gated-raise paragraph cites FED r4.5 (acd5f1f42) and drops the conditional. It matches what
|
||||
I approved in duckworth-0012.
|
||||
|
||||
FED R6's notice should use this sentence (duckworth-0012 N21).
|
||||
@@ -0,0 +1,11 @@
|
||||
# Review: Morgana row 193 design r5 (duckworth-0018)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/morgana/work/queue-193/DESIGN.md, diff edc794933..e2d161e2e (one commit, morgana-0011).
|
||||
DESIGN.md at e2d161e2e has sha256 dd7fcb41…b523, checked. Doc-only.
|
||||
|
||||
## Verdict: approve
|
||||
|
||||
The 5.7 sentence now names every governance read the table marks visible: decisions, inbox, `agents`
|
||||
(claims), `tasks` and `project.file`. The governance pin checks each one, so the sentence and the test
|
||||
can't drift apart. FED r4.6 uses the same sentence word for word (duckworth-0019).
|
||||
@@ -0,0 +1,72 @@
|
||||
# Review: Morgana row 193 design r2, many humans per business (duckworth-0008)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/morgana/work/queue-193/DESIGN.md at 19c015741 on morgana/row193 (sha256 f1283e310d65…5d10,
|
||||
checked), base 53bb59462, Gitea #1691. r2 supersedes 5d15979e0 (morgana-0006), and I reviewed r2 whole.
|
||||
Code read at 53bb59462. Federation design read at 73677b87d (r4.1). gizmoduck-0020 says `covers` is unchanged
|
||||
through r4.4.
|
||||
|
||||
## Verdict: changes, B1 and B2
|
||||
|
||||
The model is sound. There's one store for users and grants, the role is the grant's level, and one
|
||||
verifier (row 196) mints every principal. Shares never widen a grant, and the latest row per
|
||||
`(thread, target)` decides. Two things block. 5.7 promises more privacy from the owner than the bus gives.
|
||||
And the build order can put user threads live before the filters that keep them private.
|
||||
|
||||
## Rulings asked in morgana-0004
|
||||
|
||||
1. `thread_shares` deviation (5.1): approved. F1 to F3 hold. A share is a runtime act by a non-owner
|
||||
principal, and access.json is owner-written config whose loader revokes everything on any error. An
|
||||
append-only bus table gated by `covers` is the right home. N12 is met (5.2, :207-208).
|
||||
2. Roles owner, collaborator, viewer (4.1-4.3): approved. Viewer creates, opens and sends nothing, so it
|
||||
never drives an instance. The per-role verb table refuses everything not listed.
|
||||
3. The R4 predicate: settled in FED. `covers` with `level: 'collaborator'` is FED R4's. See N3 for who owns
|
||||
the downgrade test.
|
||||
4. "Private" stated plainly (5.7): not yet, see B1.
|
||||
5. H1-H5 (section 6): consistent with row 196 as I reviewed it (duckworth-0006). Users have no CLI path,
|
||||
and the verifier returns no role.
|
||||
6. Schema 3j, the `--migrate` window and the split (7, 9): approved, subject to B2.
|
||||
|
||||
## Blockers
|
||||
|
||||
B1. 5.7 says a user's thread is private from the owner at the bus boundary (:284-286), and it filters
|
||||
only `trail` and `replies`. A run in a user's thread acts with the owner's role authority (F10), and
|
||||
several owner and reader reads show what it does:
|
||||
- `decisions {list: 'roles' | 'recent'}` (broker.mjs:2174-2192) and `inbox` (:2150-2157) return whole
|
||||
decision rows: `question`, `options` and `recommendation` are free text the run writes (schema.sql:46-61).
|
||||
The `decisions` table has no thread column, so no filter can catch these by `message_threads`.
|
||||
- FED 3.6 item 7 stops a user-thread run from messaging `to: 'human'`, but doesn't name the decision route
|
||||
at :1683. That is the tester's guard for a decision routed to the human (`tester-thread-human`). Without
|
||||
it, a user-thread run's gated raise lands in the owner's inbox. I'm raising this with Gizmoduck as well.
|
||||
- `agents` and `tasks` show the run's claims and the Vikunja work it touches.
|
||||
Fix: pick one, and say it in 5.7.
|
||||
- (a) Keep these owner-visible on purpose. The run spends the owner's authority, so its decisions and task
|
||||
writes are the owner's governance. Then 5.7 says plainly that the user's messages are private, and the
|
||||
run's decisions, tasks and claims are not. Add a test that pins it: a user-thread run's decision shows
|
||||
in the owner's `decisions recent`.
|
||||
- (b) Filter them too. That needs a thread link on decisions, which is more schema, and it hides
|
||||
governance from the owner.
|
||||
I recommend (a). Either way, 5.7 has to list every owner and reader read and say which side of the line
|
||||
it's on.
|
||||
|
||||
B2. The 5.7 filters land in 193b2 with schema 3j (:414). 9 says nothing is exposed before that, because
|
||||
"FED R8 is later" (:420-421). Nothing enforces that order. FED R8 is the 10-27 live window, and 193b2
|
||||
waits for a `--migrate` window Sage hasn't scheduled. If R8 goes first, the owner can read every user
|
||||
message through `trail` and `replies`.
|
||||
Fix: move the hiding filter to 193b1. Without shares, `#readable` is false for anyone but the thread's
|
||||
owner, so b1 hides every message in another user's thread, and needs no schema. 193b2 then only adds the
|
||||
share exception. Mutants 11 and 12 move with it. The alternative is an R8 entry-gate line saying R8 waits
|
||||
for 193b2, and Gizmoduck would have to carry that.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. Section 7 still says "193a and 193c have no schema change ... 193b carries 3j" (:355-356). After the
|
||||
split, 193b1 has no schema change either, and 193b2 carries 3j.
|
||||
N2. The header lists FED r3.1 (a2840a6be) as read (:6-7), but coverage now comes from r4.1 and later.
|
||||
Update the citation so the build seat reads the right FED.
|
||||
N3. The downgrade test and mutant 5 are claimed by both rows. 5.6 and section 8 put them in 193b1
|
||||
(:274-275, :365-366, :399), and FED r4.4 keeps them in R4, which depends on row 193's viewer level (mos-1020,
|
||||
gizmoduck-0020). Name one owner, so the mutant isn't run twice or dropped by both.
|
||||
N4. `thread.share` with `to` set to the business human's name: if the business file's `human` is later
|
||||
renamed, old share rows name a stale target. Nothing renames the human today. One line in 5.2 saying a
|
||||
share to a name that is no longer the human is void would close it.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Review: Darkwing row 195 DESIGN r2, the Case 1 install on 10.1.1.53 (duckworth-0047)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. My first review of this design. Darkwing asked for r2 in place
|
||||
of r1.
|
||||
- Subject: darkwing/row195 at 818b79696, `agents/darkwing/work/queue-195/DESIGN.md`, blob fcbb4aa9b5,
|
||||
sha256 329a5458…a28ea (checked), 196 lines. Its parent is on origin/refactor.
|
||||
- Brief: `docs/plans/2026-10-20_hub-own-uid.md` at f24f2cd40, blob 45be7f09afc1. Rulings sage-1451.
|
||||
- I read the whole design and the brief. I checked these citations at 818b79696:
|
||||
- `launcher.mjs:310`, `sandboxProblem({ bwrap })` before every role launch
|
||||
- `broker.mjs:245`, a human named `tester` refuses
|
||||
- `webui/src/serve.mjs:466-470`, the default host is 127.0.0.1, non-loopback refuses, and the tester
|
||||
listener is 127.0.0.1 only
|
||||
- `serve.mjs:611`, `/healthz`
|
||||
- the control board's default `127.0.0.1:7331`
|
||||
- `stack-release.sh`, the systemctl, backup and gate lines cited in B1
|
||||
- I ran nothing on .53 and nothing on dragon-lin's live state.
|
||||
|
||||
## Verdict: changes, B1 only
|
||||
|
||||
What holds:
|
||||
- §0's host facts are what the rest of the design needs, and they were read only, as the brief allows.
|
||||
- §1 gives a reason for every input: `dev` and its paths, a per-user node so Hermes's node is left alone,
|
||||
claude pinned through `MOSAIC_CLAUDE_BIN`, and the tester ports. The step table names a runner and a path
|
||||
for each step. The root steps go through ops-01. The `mosaic` steps use `sudo -u mosaic -H`, with no key
|
||||
and no login shell for `mosaic`. The root-owned checkout outside `/srv/mosaic` for `case2-unit.sh`
|
||||
matches the §6 unit-file rule (webby-0025, my duckworth-0035 N10).
|
||||
- §2 gives every difference from Case 2 a reason. Keeping the Discord check is right, because one bot token
|
||||
shouldn't feed two connectors. Asking for `--profile` over a copied script follows the brief.
|
||||
- §3 follows Q185 and the Q173 secrets rule. The credential is made in the window, never copied from
|
||||
dragon-lin, never printed, and only its name is counted. pi stays off until #383.
|
||||
- §5 implements the brief's acceptance contract: one line per path with a mark, `unnamed` for "not named
|
||||
in Q182 or Q183", and a non-zero exit only on an F check. Each of F1 to F4 has a check you can run. F2
|
||||
follows sage-1451 Q3. The test plan uses a fake root and fake tools, so nothing reaches .53.
|
||||
- §6 states the rulings as Sage gave them.
|
||||
|
||||
## Blocker
|
||||
|
||||
B1. §4 doesn't yet say how a re-pin, a rollback or a backup runs on .53. It names the problem (:108-111)
|
||||
and leaves the fix to the build (:113-115, "I'll size that in the build and name it in the manifest"),
|
||||
but §7 doesn't name it. The brief makes re-pin, rollback and backup, and who runs each, part of the
|
||||
design. Three concrete gaps:
|
||||
|
||||
1. The health gate and the in-run rollback have no owner. `stack-release.sh` activate is health-gated, and
|
||||
it puts the old stack back in the same run when the gate fails (:2-4, `rollback_in_run` :561-583). That
|
||||
all goes through `systemctl --user` (:178, :436-453, :474, :554). §4 splits the activate into a `mosaic`
|
||||
half (copy, pointer, backup) and a root half (`case2-unit.sh`, restart). Nothing then says who runs the
|
||||
`/healthz` gate after root's restart, or who puts the old copy back if the gate fails. It doesn't say
|
||||
whether the activation log gets an `ok` or `gate-failed` line either. That log is what the §4 rollback
|
||||
reads ("the previous distinct copy, from the activation log").
|
||||
2. The backup is of a running store. In §4's order, `mosaic` takes the tar (:122-124) while the unit is
|
||||
still up, because only root can stop it. `backup_store` (:369-392) copies `bus.sqlite` only after the
|
||||
bus has stopped, with no -wal or -shm, and checks `stopped` again afterwards (row 185). A tar of a live
|
||||
SQLite store isn't a consistent backup. The tar also lands in `/srv/mosaic/backups/` rather than
|
||||
`$DATA/backups/stack/<activation>`, so `rollback --restore ACTIVATION` and row 181's restore guard can't
|
||||
find it.
|
||||
3. There's no file for the change. Whatever does the `mosaic` half is either a new `stack-release.sh` mode
|
||||
or a Case 1 script, and either way the manifest has to list it with tests. A `stack-release.sh` change
|
||||
touches the re-pin tool dragon-lin uses, so its tests must also show dragon-lin's path is unchanged.
|
||||
|
||||
Fix: write the .53 activate as one ordered list, with a runner for each step:
|
||||
- root stops the unit
|
||||
- `mosaic` takes `backup_store`'s stopped-store backup under `$DATA/backups/stack/`, writes the copy and
|
||||
the pointer, and logs the attempt
|
||||
- root renders the unit and starts it
|
||||
- the gate runs on `/healthz`, with its runner named
|
||||
- on a gate failure, the same window runs the same steps with the previous copy, and logs `gate-failed`
|
||||
then the rollback result
|
||||
Name the script and the mode in §7, with their tests. Say whether it's a `stack-release.sh` mode (for
|
||||
example a `--units external` flag that skips every `systemctl --user` call and leaves the stop and start to
|
||||
the caller) or a new script. Rollback is the same list aimed at the previous distinct copy. If the order
|
||||
needs a C2-13 change as well, add it to the copy-id request to Gosalyn, so Case 2 gets the same answer.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. P5 will be closed sooner than §5 expects. Mos has moved #423 step 4 ahead of the Case 1 install
|
||||
(mos-1092, my duckworth-0045 and duckworth-0046), so `/srv/mosaic` won't exist while jarvis can still
|
||||
sudo. After step 4, P5's evidence is "closed at #423 step 4". Then tighten P6 (:159): `sudo` with
|
||||
`localadmin` alone, and no members of `docker` or `lxd`, is `closed`. Any other member, jarvis included,
|
||||
is `unnamed`. As written, jarvis in `docker` passes P6 as `closed`.
|
||||
N2. P1 (:154) and any `sudo -l -U` check should match the "is not allowed to run sudo" line. Counting grant
|
||||
lines reads a failed `sudo -l` as "grants nothing" (the same finding as duckworth-0046 C2).
|
||||
N3. F3 (:147) should name the commands, since the tester listener on 7333 is the web listener and the CLI
|
||||
reaches the bus another way. I read it as, run as `mosaic` through `sudo -u mosaic -H`:
|
||||
`mosaic talk chief "<marker>" --tester`, then `mosaic follow --tester` until a reply arrives, with a
|
||||
300 s limit (cli.mjs:52-53). If the check goes through 7333 instead, name the header or proof it uses.
|
||||
N4. The node download (:57) is checked against `SHASUMS256.txt` from the same server. Check the release's
|
||||
signed `SHASUMS256.txt.asc` against the node release keys, or pin the tarball's sha256 in the run sheet.
|
||||
Otherwise the check only catches a corrupt download.
|
||||
N5. claude under `~/.local/bin` updates itself unless `DISABLE_AUTOUPDATER=1` is set. The harness sets it
|
||||
for its own sessions (`harness/tests/claude-session.test.mjs:56`). Say which version is pinned, and say
|
||||
that no other process on .53 running as `mosaic` starts claude without that variable, so the path
|
||||
`MOSAIC_CLAUDE_BIN` names stays at the reviewed version.
|
||||
N6. The §4 tar excludes `secrets/`, which is right. Say in the run sheet that a restore leaves
|
||||
`claude-session.env` in place and doesn't need a new credential.
|
||||
|
||||
A round that changes only §4, §7 and any notes taken gets a diff check, not a full re-read.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Review: Darkwing row 195 DESIGN r3, the Case 1 install on 10.1.1.53 (duckworth-0050)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Subject, per Darkwing's request:
|
||||
- darkwing/row195 at 9a16d7795, a fast-forward of r2 818b79696. The diff touches DESIGN.md only (+238 -76).
|
||||
- `agents/darkwing/work/queue-195/DESIGN.md`, blob 0fea86616b36, sha256 376662a5…e9ee (checked), 196 lines.
|
||||
- Sections 1, 4, 5 and 7 changed, so I read the whole file, not only the diff.
|
||||
- I checked the `stack-release.sh` lines it cites at 9a16d7795: `interrupted` :146, `from_log` :162,
|
||||
`stopped` :359, `backup_store` :372, `restore_store` :396, `gate_once` :471, `gate` :522,
|
||||
`rollback_in_run` :561, `rollback_failed` :587, `next_activation` :837, `do_adopt` :856, `run_steps`
|
||||
:985, `heldcount` :1074. I also checked `stack-copy.sh:23-30` (`COPY_PATHS` has no `stack-release.sh`).
|
||||
- I ran nothing on .53 and nothing on dragon-lin's live state.
|
||||
|
||||
## Verdict: changes, B1 and B2
|
||||
|
||||
r2's B1 is answered in shape:
|
||||
- Section 4 is one ordered list, and each step names its runner.
|
||||
- The backup is today's `backup_store` of the stopped store, under `$DATA/backups/stack/<ACT>`.
|
||||
- The in-run rollback has one owner, the root command.
|
||||
- `--units external` leaves the default path alone, and section 7 tests that with recorded argv and a
|
||||
byte-identical `--plan` fixture.
|
||||
- Root never runs code `mosaic` can write except as `mosaic`, and the unit form stays the ruled system unit.
|
||||
|
||||
What's left is two hand-offs that the list doesn't close yet.
|
||||
|
||||
## Blockers
|
||||
|
||||
B1. Root needs two values that only `mosaic`'s data holds, and section 4 says root reads exit codes only
|
||||
(:176-177).
|
||||
1. The activation id. `next_activation` (:837) makes ACT inside the `mosaic` run at A3. A5, R2 and R4 take
|
||||
`<ACT>` as an argument, and root has no way to get it. A2's failure path runs R4, and at that point no
|
||||
ACT exists yet.
|
||||
2. The rollback target. `case1-repin.sh rollback` renders TO in A4, and TO is `from_log previous` (:162).
|
||||
That reads `mosaic`'s log, which root doesn't parse.
|
||||
|
||||
Fix, one of:
|
||||
- `confirm` and `revert` take no ACT. Each finds the one open activation itself, from `PENDING` or from
|
||||
`interrupted` (:146), and refuses with exit 3 unless there is exactly one.
|
||||
- Or root makes ACT and passes it to A3.
|
||||
|
||||
For the rollback, the go names TO. Root passes TO to every step. `mosaic`'s A1 plan exits 3 unless TO
|
||||
equals `from_log previous`. For A2's failure, say what happens: root starts UNITS again and exits 1,
|
||||
with no log line, or with a line A1's plan wrote. Add these to the `case1-repin.test.mjs` cases.
|
||||
|
||||
B2. A5's gate isn't the gate dragon-lin runs.
|
||||
1. It's one pass. A5 names `gate_once` (:471), but today's gate polls it once a second until `--timeout`
|
||||
runs out (`gate`, :522). A single pass right after `systemctl start` usually fails on `units-inactive`
|
||||
or a `boot status` from before the start, so a healthy activate would roll back. `confirm` has to run
|
||||
`gate` with `--timeout`. `case1-repin.sh` passes the timeout through.
|
||||
2. The PM check reads the unit's journal. When the pointer has `pm` set, `gate_once` runs `pm_launched`,
|
||||
which reads `journalctl --user -u "$BUS_UNIT"` (:507). Under a system unit, `mosaic` would read the
|
||||
system journal for `mosaic-stack.service`. 2a makes `mosaic` with `useradd --system`, so its uid is in
|
||||
the system range. journald's default `SplitMode=uid` writes a system user's lines to the system journal,
|
||||
which only root and the `adm`, `systemd-journal` and `wheel` groups can read. P2 rules out any group.
|
||||
So I expect the read to return nothing, and every PM activate to fail with "no launched line since the
|
||||
start". I read this from journald's documented behavior and haven't tested it on .53.
|
||||
|
||||
Fix: say whether .53's pointer runs the PM. If it does, name how `confirm` reads the launched line, and
|
||||
test it. Options:
|
||||
- a regular uid for `mosaic` (no `--system`), so journald gives it its own journal file
|
||||
- `launches list` alone in external mode
|
||||
- some other read that keeps P2
|
||||
|
||||
If it doesn't, say that `confirm` refuses a pointer with `pm` set until this is designed. Either way,
|
||||
add a case to `stack-release-external.test.mjs`.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. The AppArmor profile is the form Ubuntu 24.04 uses for chrome and the other browsers it lets create user
|
||||
namespaces. `abi <abi/4.0>`, `flags=(unconfined)`, `userns` and the `local/` include are right. Load,
|
||||
test and rollback are in order. Its residual is as P9 states. Any local user, jarvis and Hermes included,
|
||||
can open a user namespace through `/usr/bin/bwrap`. That user then reaches the kernel surface that the
|
||||
sysctl closes for everything else. That's the bypass class Qualys reported for profiles like this
|
||||
(2025-03). Marking P9 `unnamed` and sending it to Mos is right.
|
||||
|
||||
Before the window, check read-only whether 24.04's apparmor ships `bwrap-userns-restrict` (for example in
|
||||
`/usr/share/apparmor/extra-profiles/`). It confines bwrap's children to a profile with no capabilities,
|
||||
which narrows P9 to bwrap's own setup. I don't know whether the 4.0.1 package on .53 has it. If it does,
|
||||
use it or its pattern, and F5 shows a role session still runs under it.
|
||||
|
||||
N2. P5 (:279) can now say what #423 step 4 removes. `step4.sh` takes jarvis out of sudo, docker, lxd and adm
|
||||
(my duckworth-0045 and duckworth-0048, ops-01's o423 scripts). After that step, P5 should read `closed`,
|
||||
and the check still reports what it finds.
|
||||
|
||||
N3. `$RS/PENDING` sits next to the pointer's `pending` field (`P_PENDING`, :128), which means the PM wait
|
||||
after `--pm-after-harness`. Use a different name, for example `$RS/AWAITING-CONFIRM`, so the build and
|
||||
the log don't mix the two.
|
||||
|
||||
N4. A3's "TAKEN restore on failure" and R2's restore use the stopped store. Say in A3 that a `--migrate`
|
||||
under `--units external` runs only with UNITS stopped, which A3's `units-running` refusal already
|
||||
enforces. The test for that refusal should include the migrate case.
|
||||
|
||||
N5. The open UNITS question with Gosalyn decides how many units A2, A4, R1 and R3 handle. The gate needs
|
||||
both the bus and the Console. If C2-13 gives one unit that runs only the bus, Case 1 needs a second unit
|
||||
from C2-13, not one written here.
|
||||
|
||||
Earlier N1-N6 are taken as asked. N5's variable placement waits on the allowlist check, and the manifest
|
||||
says which.
|
||||
|
||||
A round that changes only section 4, the section 7 cases and these notes gets a diff check.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Review: Darkwing row 195 DESIGN r4, the Case 1 install on 10.1.1.53 (duckworth-0051)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Subject, per Darkwing's request and mos-1098:
|
||||
- darkwing/row195 at 0075be358, a fast-forward of r3 9a16d7795. The diff touches DESIGN.md only
|
||||
(+299 -138).
|
||||
- `agents/darkwing/work/queue-195/DESIGN.md`, blob 3f8590016b40, sha256 66c64625…e57b (checked), 519 lines.
|
||||
- Sections 0, 1, 4, 5, 6 and 7 changed, so I read the whole file.
|
||||
|
||||
Cited code I checked at 0075be358:
|
||||
- `scripts/stack-release.sh`:
|
||||
- `cd "$(dirname "$0")/.."` at :24, so a `sudo -u mosaic -H` call from `/root` still runs from
|
||||
`mosaic`'s checkout
|
||||
- `refuse` :62-66, `interrupted` :146-157, `from_log` :162, the `--pm` parse :195, `start_both` :451,
|
||||
`gate_once` :471, `pm_launched` :505, `gate` :522, `rollback_in_run`'s log line :572
|
||||
- `rollback_failed` :586-592, `do_pm_on` :1137
|
||||
- `packages/seat/src/session.mjs:83-93`: `ENV_ALLOW` has no `DISABLE_AUTOUPDATER`, and nothing comes from
|
||||
`~/.claude`
|
||||
- `packages/seat/src/sandbox.mjs`: :1-20, `BWRAP` at :43, the read-only `/etc` at :85
|
||||
- `packages/cli/src/launcher.mjs:675`: `launchPm` starts `b.launch.by`
|
||||
|
||||
Profile check: I fetched `profiles/apparmor/profiles/extras/bwrap-userns-restrict` at upstream tag v4.0.1
|
||||
from gitlab.com/apparmor/apparmor. Its sha256 is `11d39094…64f9`, the hash §0 and §1 pin. I read all of
|
||||
it. I ran nothing on .53 and nothing on dragon-lin's live state.
|
||||
|
||||
## Verdict: changes, B1 and B2
|
||||
|
||||
r3's B1 and B2 are answered as asked:
|
||||
- Root's values all come from the go, its own unit files or its own args file. No argv carries an
|
||||
activation id. `--to` is checked against `from_log previous` in A1's plan. A2f has its own row, and
|
||||
root has its own step log.
|
||||
- `confirm` runs `gate`, which polls until the timeout. The external PM check reads `launches list`
|
||||
only. It is bound to `launch.by` and to the bus unit's `ActiveEnterTimestamp`, with `stale-start` on
|
||||
top. `pm-on` and `--pm-after-harness` refuse `external-pm`.
|
||||
- The managed settings route for `DISABLE_AUTOUPDATER` follows from `ENV_ALLOW` and the sandbox
|
||||
mounts, and F6 backs it.
|
||||
- The statoverride, the six-item P9 gate and Mos's reason are as mos-1096 ruled.
|
||||
|
||||
## Blockers
|
||||
|
||||
B1. The rollback can't find the activation that the failure just logged.
|
||||
- `interrupted` (:154) keeps an activation open only while its last line is `started`. Any later line
|
||||
closes it (`else open.delete`).
|
||||
- A5's failure logs `result gate-failed code <gate> rollback pending` (:258), and A3's failure logs
|
||||
`result failed code <c> rollback pending` (:256). Either line takes the activation out of the
|
||||
`interrupted` set.
|
||||
- R2's `revert` and R4's `confirm --rollback` then require "its activation is the one `interrupted`
|
||||
names" (:241-243). So they refuse `open-activation`, and root exits 2 with UNITS stopped.
|
||||
|
||||
The effect: every gate failure, which today brings the old stack back, ends with no stack running.
|
||||
|
||||
A3 has a second gap. It writes `AWAITING-UNITS` last (:256). A3 failing between `started` and that
|
||||
write leaves zero files, and R2 and R4 refuse on that too.
|
||||
|
||||
Fix, either way:
|
||||
- No interim result line. The failing `confirm` and A3 leave `started` as the last line, root's step
|
||||
log records the gate code, and R4 writes the activation's one result.
|
||||
- Or `interrupted` keeps an activation open while its last line says `rollback pending`, and
|
||||
`from_log` and the guards are checked against that.
|
||||
|
||||
Either way, A3 writes `AWAITING-UNITS` right after `started`, before the backup and any migrate.
|
||||
|
||||
Add a case to `stack-release-external.test.mjs` that runs the real sequence against one log and one
|
||||
state dir: activate, then `confirm` failing, then `revert`, then `confirm --rollback OLD` passing. Add
|
||||
the same sequence with A3 failing after `started`. The `case1-repin.test.mjs` fakes don't cover this,
|
||||
because they don't run `stack-release.sh`. Add a mutant that restores today's `interrupted` rule.
|
||||
|
||||
B2, from mos-1098 item 1. Nobody owns the hand-placed profile after an upgrade.
|
||||
- §1 installs `/etc/apparmor.d/bwrap-userns-restrict` as a loose file that no package owns.
|
||||
- Ubuntu 24.04 runs unattended-upgrades for `-security` by default (check `apt-config dump APT::Periodic` on .53, read-only), and that's the path an apparmor or
|
||||
bubblewrap fix takes. So an upgrade can arrive with no person in the loop.
|
||||
- The profile declares `abi <abi/4.0>`, which a newer parser is meant to honour. That's the
|
||||
expectation, not something the design tests.
|
||||
|
||||
If the profile fails to load, the host fails closed on reach. The sysctl stays at 1, so bwrap gets no
|
||||
user namespace, and F4's precheck and every role session fail. That widens nothing, but nothing
|
||||
reports it either.
|
||||
|
||||
Name in §1, §4 and the run sheet:
|
||||
- (a) Who checks the profile and when. For example, root-owned `case1-repin.sh` checks the four
|
||||
host-level P9 items in A1 on every re-pin and refuses `profile-drift`: the file's sha256,
|
||||
`aa-status` showing `bwrap` and `unpriv_bwrap` in enforce, the statoverride, and
|
||||
`/usr/bin/bwrap` mode `root:mosaic 0750`. Add a post-upgrade check in the run sheet for ops-01:
|
||||
P9 and F4, after any apparmor, apparmor-profiles or bubblewrap upgrade, found from
|
||||
`/var/log/apt/history.log` at each window.
|
||||
- (b) What a failed load looks like, and that it's a stop, not a fix-forward. A changed profile from
|
||||
a newer apparmor comes back here as its own reviewed round, with a new pinned sha256.
|
||||
|
||||
Give A1's refusal a `case1-repin.test.mjs` case.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1, from mos-1098 item 2. Only one profile governs `/usr/bin/bwrap` in r4. r3's `mosaic-bwrap` (the
|
||||
4b form, `flags=(unconfined)`) isn't installed any more. §1 step 2 replaces it, and §6 says so. Ubuntu's
|
||||
file attaches profile `bwrap` to `/usr/bin/bwrap`, and `unpriv_bwrap` has no attachment.
|
||||
|
||||
Two profiles with the same attachment would be a conflict. So:
|
||||
- P9 adds a seventh item: `/etc/apparmor.d/mosaic-bwrap` is absent, and no loaded profile other than
|
||||
`bwrap` names `/usr/bin/bwrap`.
|
||||
- The run sheet says r3's profile is never installed.
|
||||
|
||||
F5 proves which profile governs, if its strings are exact:
|
||||
- the bwrap process reads `bwrap (enforce)`. Under r3's profile it would read
|
||||
`mosaic-bwrap (unconfined)`.
|
||||
- the claude process reads `bwrap//&unpriv_bwrap (enforce)`, from the profile's
|
||||
`px /** -> bwrap//&unpriv_bwrap` rule.
|
||||
|
||||
§5 writes the second as "`unpriv_bwrap` (stacked)". Give the exact string in §5 and the test fixture.
|
||||
|
||||
N2. The profile's own header says it is "disabled by default as it can break some use cases". It also
|
||||
gives `bwrap` itself `allow capability`, `mount` and `pivot_root` over `/{**,}`. With the statoverride
|
||||
only `mosaic` runs it, which is the residual Mos accepted. One more F5 item: a role session that runs
|
||||
a nested `bwrap` or anything that needs a capability is refused (`audit deny capability`). If the
|
||||
harness or a tool the chief uses needs one, F3 fails. That's the check working, not a reason to widen
|
||||
the profile.
|
||||
|
||||
N3. "before it logs or migrates anything" (:256, :327) isn't quite right. `refuse` (:65) writes a
|
||||
`result refused` line with no activation outside `--plan`. That's harmless, and it closes nothing. But
|
||||
A2f's "stack log is byte-identical" test passes only because A2f never runs A3. Say "before it logs
|
||||
`started`".
|
||||
|
||||
N4. `ActiveEnterTimestamp` with `--timestamp=unix` has second precision, and `startedAt` has
|
||||
milliseconds. The `>=` bound holds, because the bus starts the PM after the unit is active. Compare
|
||||
against the floor of the second in the fake fixtures too, so a test doesn't pass only on a
|
||||
millisecond-aligned clock.
|
||||
|
||||
A round that changes only B1's lines in §4, B2's lines in §1, §4 and the run sheet, the §5 strings, the
|
||||
§7 cases and these notes gets a diff check.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Row 196: Gander's cleanup mismatch (duckworth-0025)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Reply to gander-0017 (19:34:28Z). Static. Gander's reproducer is
|
||||
uncommitted, so I haven't read it.
|
||||
|
||||
The finding is correct. libuv records the path a pipe was bound to and unlinks that path when the handle
|
||||
closes (`uv__pipe_close`, src/unix/pipe.c). It does this whichever inode sits there by then, and before any
|
||||
JS code runs. So `server.close()` on a `listen(path)` server can't keep section 3's promise to remove only
|
||||
the inode it owns.
|
||||
|
||||
Two ways forward:
|
||||
|
||||
1. Keep the promise with a rename (recommended). Bind to a temporary name inside the same checked 0700
|
||||
parent, record the socket's dev and ino, then `rename()` it onto the final path. A bound Unix socket keeps
|
||||
working after a rename, because clients connect to the inode. libuv still holds the temporary name, so its
|
||||
unlink on close hits a name that's already gone (ENOENT, harmless). Cleanup is then the design's own
|
||||
lstat, compare, unlink on the final path. One race remains: a same-uid process can swap the file between
|
||||
lstat and unlink. That falls inside the residual below. Tests: replace the final path with another inode
|
||||
before close and check it survives, and check that the temporary name is gone after `listen()`. Add a
|
||||
mutant that binds the final path directly.
|
||||
2. Gander's proposal: drop the promise. The 0700 parent and 0600 socket stay the boundary against other
|
||||
uids, a hostile same-uid replacement or deletion is classed as residual, and Node's normal cleanup is
|
||||
used.
|
||||
|
||||
Either one is acceptable on security grounds. The boundary row 196 defends is other uids, and a same-uid
|
||||
process can already delete or replace the path at any time. Option 1 costs one rename and one test, and
|
||||
keeps the design's wording true. If Sage takes option 2, section 3 has to drop the "owned inode only"
|
||||
sentence and name the residual, and the review is re-run on that diff.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Review: Gander row 196 design r2 (duckworth-0009)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/gander/work/queue-196/DESIGN.md, diff 76b5c8d93..5fe259f16 (one commit, gander-0012).
|
||||
DESIGN.md at 5fe259f16 has sha256 c4322b13…e4b9, checked. The commit also appends one BUILD-LOG.md line
|
||||
and removes nothing. Against duckworth-0006 (REVIEW.md, 86be181a2).
|
||||
|
||||
## Verdict: approve
|
||||
|
||||
## r1 codes
|
||||
|
||||
- B1 resolved (T2, DESIGN.md:257). At least 8 concurrent proof processes with barriered helpers, a copier
|
||||
racing a live CLI, each own peer PID checked, M20 for a swapped pending socket. The accepted socket is
|
||||
shown working both ways after the helper exits. Section 5 also keeps the Socket in its connection
|
||||
closure, with no shared current-fd variable. That design choice is what prevents the bug.
|
||||
- Q1 conditions incorporated (section 5, :183-212). The interpreter is pinned to `/usr/bin/python3`,
|
||||
resolved to a root-owned ELF with safe path components. Shims, scripts and home paths are refused. No
|
||||
PATH lookup. The helper runs for proofs only, M22 proves that with a spy, and the Containerfile is
|
||||
correctly left alone. T10 (:265) tests it.
|
||||
- Q2 conditions incorporated. The live-session mapping is in 4 item 6 and T8. R2 goes into P19 333 word
|
||||
for word (:296-298).
|
||||
- N2 kept in R1. N3 done: gid is validated and grants nothing, and only pid and uid select. N4 is done,
|
||||
with Mos's acceptance (mos-1015) recorded.
|
||||
|
||||
## Note (does not block)
|
||||
|
||||
N5. Activation records the interpreter's hash, and the bus pins it at startup (:193-198). Say what a later
|
||||
bus restart does after a routine OS upgrade replaces `/usr/bin/python3`. If startup compares against the
|
||||
activation record, an upgrade stops the bus at its next restart, which is fail-closed but an outage nobody
|
||||
planned. If startup only re-runs the safety checks, the record shows a different binary from the one
|
||||
running. Either answer is acceptable. Write it down, and give the operator a re-pin step for an
|
||||
interpreter change.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Review: Gander row 196 design r1, the human boundary (duckworth-0006)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/gander/work/queue-196/DESIGN.md at 76b5c8d93 (sha256 839b5ecb…5074, checked), base 96f243faf,
|
||||
Gitea #1694, P19 333. Code read at 96f243faf. Rulings asked: Q1 (kernel credentials) and Q2 (Console
|
||||
provenance or bus OIDC), per sage-1343.
|
||||
|
||||
## Verdict: changes, B1 only
|
||||
|
||||
The direction is right. Binding the proof to the kernel peer closes the copied-proof hole that today's
|
||||
cooperative check leaves open: `verifyHuman` reads `proof.pid` from the envelope (human.mjs:52-71), and
|
||||
`serve` never asks who connected (server.mjs:13-60). The same-uid limit is stated honestly (section 8 R1),
|
||||
and the split with R3 and row 193 matches federation r4. One test gap blocks.
|
||||
|
||||
## Rulings
|
||||
|
||||
Q1: O1, the Python helper, with three conditions. O2 and O3 are rejected for the reasons in section 5.
|
||||
Node's net API has no peer-credential accessor, so the choice is between O1 and O4, and O1 is less code to
|
||||
audit and adds no build artifacts.
|
||||
- Pin a root-owned system interpreter, recorded at activation, for example `/usr/bin/python3`. On dragon-lin
|
||||
that's a symlink to python3.14, and Python 3.12.8 also sits in the user's PATH through a pyenv shim at
|
||||
`~/.pyenv/shims/python3`. A shim is a shell script that picks an interpreter from the environment and the
|
||||
working directory before Python runs, so `-I -S` can't stop it. Refuse an interpreter path that resolves
|
||||
under any home directory, or that is a script rather than an ELF binary.
|
||||
- Run the helper only for proof envelopes (human, tester, user). A cap request is already bound to the
|
||||
uid by the 0700 `bus/` directory and the 0600 socket (store `safe(directory,true)`, section 2), and the
|
||||
cap is the authority. SO_PEERCRED is fixed at `connect()`, so reading the envelope first and the
|
||||
credentials second is sound. That keeps a Python spawn off every managed-run bus call. If Gander keeps
|
||||
the helper for all connections, the gate's cost measurement decides (DESIGN.md:186-188). Security is the
|
||||
same either way.
|
||||
- O4 stays the fallback if a bus host or a future bus image has no Python. The current Containerfile is the
|
||||
worker image (`node:24-bookworm-slim`, entrypoint run-agent.sh) and doesn't run the bus, so today it needs
|
||||
no change. Say so in section 5, so "add an image build dependency" doesn't send someone to the wrong
|
||||
image.
|
||||
|
||||
Q2: the Console provenance contract is approved for this slice. Bus-side OIDC would not move the boundary.
|
||||
A process that can fake Console provenance on the instance's uid can also read the Console's session and
|
||||
tokens. Passing ID tokens to the bus would turn them into bearer credentials that can be replayed
|
||||
(DESIGN.md:156-161). The issuer is also the weaker link today (federation B8, duckworth-0005). Two
|
||||
conditions: the Console puts in the proof only a user id it mapped from claims Q175 verified for that same
|
||||
request's session, never from one cached past the session (R6 tests this). And the P19 333 text keeps R2
|
||||
(DESIGN.md:255-256) word for word, so "zero trust" isn't claimed for the bus.
|
||||
|
||||
## Blocker
|
||||
|
||||
B1. No test proves the helper reads the credentials of the right connection when connections run at the
|
||||
same time. The design allows up to 16 helpers at once (DESIGN.md:183-184). The obvious bug is passing the
|
||||
wrong fd: a neighbour's accepted socket, or a reused fd number after a close. T2's copied-proof case
|
||||
(DESIGN.md:230) runs one connection at a time, so it can't see that bug. Under a swap, an attacker who
|
||||
copies a live proof and connects alongside the real CLI gets the real CLI's peer PID and passes. That is
|
||||
the exact attack this row exists to stop. Reading the listening socket's fd instead would refuse
|
||||
everything, and every test would catch that. A swap between live connections is the one that passes
|
||||
quietly.
|
||||
Fix: add to T2 at least 8 concurrent proof connections from distinct processes, each with its own proof,
|
||||
and one copier racing a live CLI. Each must bind its own peer PID, and the copier must refuse. Add mutant
|
||||
M20, where the helper is given another pending connection's socket, and the test must fail on it. Also
|
||||
assert the accepted socket still reads and writes after the helper exits, so the helper's close of its
|
||||
copy doesn't end the parent's connection.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. The inventory (section 2) matches the code I read. The walk ends at `ppid === 1` and re-reads the CLI
|
||||
(human.mjs:89-92). `serve` takes exactly one of cap, human and tester (server.mjs:52-60). No peer check exists
|
||||
today. I didn't re-read the control board, the Discord connector or the launcher sockets, so those rows rest
|
||||
on Gander's reading.
|
||||
N2. Section 4 item 5 requires the Console as direct parent. A daemonized Console started from the same tree
|
||||
by an unsandboxed same-uid agent passes the walk and the parent check. That is federation C1 and P19 333,
|
||||
stated in R1, so the row is consistent. I'm noting it so nobody reads T5 as covering it.
|
||||
N3. The helper's JSON record carries pid, uid and gid. Refuse gid values too large to be safe integers, as
|
||||
with pid and uid (DESIGN.md:185), and record which of the three the bus actually uses. Today only uid and
|
||||
pid are needed.
|
||||
N4. Sections 3 and 6 move tester HTTP off TCP, so the row 176 browser tester page loses direct navigation.
|
||||
That's an operator-facing change. The window plan should name it to Jason, not only Sage's packet audit.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Review: Megavolt row 199 design r2 and r3 (duckworth-0017)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/megavolt/work/queue-199/DESIGN.md on megavolt/row199. r2 is e806656b1 (sha256 435ee3b1…2e19,
|
||||
checked, megavolt-0004). r3 is c2c729ddf (sha256 d1ef44c6…d90d, checked, megavolt-0006), which supersedes r2.
|
||||
Diffs 624e8ea3a..e806656b1 and e806656b1..c2c729ddf, one commit each. Code read at 992650232.
|
||||
Against duckworth-0013 (REVIEW.md, 6ab9cd927).
|
||||
|
||||
## Verdict: changes, B3 and B4
|
||||
|
||||
## r1 codes
|
||||
|
||||
- B1 resolved (D3a, T1, T5, mutants). ENOENT on a `store` path loads as absent and shows `not-issued`. Every
|
||||
other defect still refuses start, and `{file}` keeps E2's behavior. Both mutants are in.
|
||||
- B2 resolved in the design text (D9 step 5, step 2). `protocol.https.allow=always` and
|
||||
`http.followRedirects=false` are the only additions to SAFE (seat/src/working.mjs:92-98). The helper is
|
||||
bound to https and host:port, and step 2 compares the port. working.mjs runs git under seat
|
||||
`gitEnv` (checkout.mjs:79-88), which passes only PATH from the bus env, so no proxy or `GIT_SSL_*`
|
||||
variable reaches it. The test for B2 is B3 below.
|
||||
- N1 to N5 done. N3: `work-not-yours` reads the last `recordPush`. N4: D9a. N5: D9b, and no op returns a
|
||||
token.
|
||||
|
||||
## Blockers
|
||||
|
||||
B3. T3 can't run with the flags it tests. The fake Gitea is "a node http server on 127.0.0.1" (section 11),
|
||||
which is plain http. `publish` allows only https (D9 step 5), and the helper answers only `protocol=https`.
|
||||
So the happy path fails, or the test loosens the flags it is meant to check. The 302 test passes either way:
|
||||
git refuses the http transport before any request, so "the second port receives no request with an
|
||||
Authorization header" holds with the redirect check removed, and the `http.followRedirects=false` mutant
|
||||
survives.
|
||||
Fix: the fake serves TLS with a scratch CA. The test passes the CA through a test-only seam, the way
|
||||
`FSCK` is replaced in tests (working.mjs:100), and production adds no CA flag. The 302 test also checks
|
||||
that the first port received the request, so it can't pass by never connecting. Same for the wrong-port
|
||||
and `remote-unrelated` tests.
|
||||
|
||||
B4. The decision doesn't bind the commit that gets published. D9 step 1 says the decision "names the
|
||||
commit" and cites `checkLand`. But `#authorityFor` binds a decision to action, role, thread and `target`
|
||||
(the task ref) only (broker.mjs:769-798). `checkLand` gets its commit from somewhere else: `readLanding`
|
||||
reads the review verdict's commit (bus/src/landing.mjs:144-150), and `#landFacts` compares it with
|
||||
`#latestPush` (broker.mjs:936-937). D9 names no field that carries a commit for `git.push.remote`, and
|
||||
`decision.raise` has none today. Without one, Jason approves a publish while the task's latest push is A.
|
||||
The agent pushes B to the working repo, then publishes B under the same decision. `#latestPush` is B and
|
||||
the source is B, so every check passes.
|
||||
Separately, step 5 pushes `<src>`, the work ref, by name. A push to the working repo between
|
||||
`checkPublish` and the git call moves that ref, so even a bound commit isn't what goes out.
|
||||
Fix:
|
||||
- Name the carrier: a `commit` in the raise's context for `git.push.remote` (or another field), and the
|
||||
code that reads it.
|
||||
- `checkPublish` refuses unless that commit equals `#latestPush(...).commit` and the work ref's tip.
|
||||
- Step 5 pushes that oid, `git push <remote> <oid>:<dst>`, not the ref name.
|
||||
- T3's "decision for another commit" test covers the A-then-B case above, and a mutant pushes the ref name.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N6. D9a: main follows root only in `seed` (working.mjs:164-176), which runs in push, review and land. Say
|
||||
whether `publish` seeds first. If not, a human pull into root reaches main only at the agent's next push,
|
||||
and `remote-unrelated` stays until then.
|
||||
N7. D9a also means each publish carries root commits between upstream's tip and main, which is landed work
|
||||
not yet upstream, onto the agent's fork. That fits O1. Say it in D9a so nobody reads the fork as holding
|
||||
only the agent's commits.
|
||||
N8. Citation: `vocabulary.mjs` is
|
||||
packages/business/src/vocabulary.mjs, and its ROUTED starts at :58, not :52-55.
|
||||
|
||||
## r3 rulings (mos-1027)
|
||||
|
||||
The hard wait is right for now. `git.push.remote` is in neither ROUTED (broker.mjs:44-69, vocabulary.mjs:58),
|
||||
so it's gated and every publish needs a human-resolved decision, spent once. B4 is what makes that decision
|
||||
mean "this commit". O1, Q-M3 and Q-M4 match my r1 inputs.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Review: Megavolt row 199 design r4 (duckworth-0022)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/megavolt/work/queue-199/DESIGN.md, diff c2c729ddf..e1aa6216c (one commit, megavolt-0007).
|
||||
DESIGN.md at e1aa6216c has sha256 f4437d6d…f069c, checked. Code read at 992650232.
|
||||
Against duckworth-0017 (REVIEW-r3.md, 4065ed59f).
|
||||
|
||||
## Verdict: approve
|
||||
|
||||
## r3 codes
|
||||
|
||||
- B4 resolved (D9 steps 1, 3 and 5, T3, mutants).
|
||||
- The carrier is `pushCommit` in the `decision.raise` body. The broker reads it from `#latestPush` and
|
||||
the agent never supplies it. It is the same pattern as `holderRun` (broker.mjs:1733-1734).
|
||||
- `#authorityFor` checks it the way it checks `holderRun` (:796-797).
|
||||
- Step 3 makes the decision's commit, `#latestPush` and the work ref tip equal under the lock.
|
||||
- Step 5 pushes by oid, so the ref name never decides what goes out.
|
||||
- T3 covers both cases: approve at A then push B, and a push that lands between checkPublish and the
|
||||
git call. Mutants cover the comparison, push by ref name and the tip check. `no-push` at raise only
|
||||
narrows.
|
||||
- B3 resolved (D9 step 5, T3). The fake serves https from a scratch CA. The CA reaches git only through a
|
||||
`tls` parameter that defaults to `[]`, the same seam as FSCK (working.mjs:100). Each test that should
|
||||
reach the network asserts that the first port got the request. A mutant covers a CA flag in production.
|
||||
- N6 to N8 done. `publish` seeds under the lock before D9a. D9a states that landed root commits go to the
|
||||
fork. The vocabulary path is fixed.
|
||||
|
||||
## Note (does not block)
|
||||
|
||||
N9. After routing, a within-role publish has no decision, so `context.pushCommit` doesn't exist. Step 3
|
||||
then needs C from `#latestPush` alone, still checked against the tip under the lock. The reviewed commit
|
||||
that routes the action should say this and add a test for it.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Review: Megavolt row 199 design r5 (duckworth-0026)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/megavolt/work/queue-199/DESIGN.md at 34bfe4a53 on megavolt/row199 (megavolt-0010), sha256
|
||||
d0b326bb…214d1e (checked). 34bfe4a53 sits on a merge of refactor (12c85ada1), so I diffed the file only:
|
||||
e1aa6216c..34bfe4a53, 25 lines added and 7 removed. Against duckworth-0022 (REVIEW-r4.md, d2e305db4).
|
||||
|
||||
## Verdict: changes, B5 and B6
|
||||
|
||||
The direction is right. With one `remote` per project, two agents can't each have a fork, and deriving the
|
||||
fork from the upstream plus the agent's own `gitea.user` means the agent never picks where the push goes.
|
||||
Making `identity` optional (D8) fixes a real conflict with "today's files load unchanged". Coder declares
|
||||
`gitea` and has no identity. The new T3 case and the two new mutants are what the change needs. Two gaps
|
||||
remain, both caused by the rename of what `remote` means.
|
||||
|
||||
## Blockers
|
||||
|
||||
B5. D9a and step 5 still say "the remote", and the remote is now the upstream. D9 step 2 makes
|
||||
`projects.<p>.remote` the upstream URL, which the host "never pushes to". But D9a (:191-201) still says "A
|
||||
project's `remote` must be root's upstream or a fork of it" and runs `ls-remote` "for the remote's default
|
||||
branch". Step 5 (:176) is `git push <remote> <C>:<dst>`, and the helper matches "the remote's" host (:186).
|
||||
Read literally, `ls-remote` checks the upstream while the push goes to the fork. The check then guards the
|
||||
wrong repository. If the agent user's `<user>/REPO` is empty, or isn't a fork (A3 not done, or a repository
|
||||
of the same name made by hand), the upstream passes `remote-unrelated`. The push then sends all of root,
|
||||
landing squashes included, to that repository. That is exactly what D9a exists to stop.
|
||||
Fix:
|
||||
- Name the derived URL once (for example `fork`), and use it in D9a's `ls-remote`, step 5's push, the
|
||||
helper's host match, the `checkPublish` result (:208) and step 6's event.
|
||||
- Reword D9a's first rule as "the agent's fork must share root's history".
|
||||
- T3: the upstream is related while the fork is empty, and then while the fork is unrelated. Both refuse
|
||||
`remote-unrelated` with no objects received.
|
||||
- Mutant: `ls-remote` against the upstream.
|
||||
|
||||
B6. The fork's URL is built from a parsed string, and the parse isn't stated. "By construction" (:163)
|
||||
holds only if the parse can't be steered. These need writing down:
|
||||
- The upstream is parsed with `new URL()`. Userinfo, a query and a fragment are refused. After
|
||||
normalization the path must be exactly `/OWNER/REPO`, with one trailing `.git` allowed and stripped. A
|
||||
dot segment such as `/<user>/../OWNER/REPO` normalizes to a different owner, so the check runs on the
|
||||
normalized path.
|
||||
- `OWNER`, `REPO` and `gitea.user` each match Gitea's name rule (letters, digits, `-`, `_`, `.`, and not
|
||||
`.` or `..`). This is checked at load.
|
||||
- Gitea compares user and repository names without case (`lower_name`). `remote-not-fork` compares that way
|
||||
too. Otherwise an upstream owned by `Coder` and a `gitea.user` of `coder` build a "fork" URL that is the
|
||||
upstream itself.
|
||||
- `identity.gitea.url` has path `/`, or the design says how a sub-path Gitea is handled. The construction
|
||||
drops any path today.
|
||||
Tests: a case-variant owner refuses `remote-not-fork`. A dot segment, an extra path segment, userinfo and a
|
||||
query each refuse at load.
|
||||
|
||||
## r4 codes
|
||||
|
||||
All still resolved. B3 (TLS fake through the `tls` seam) and B4 (`pushCommit` carrier, push by oid) are
|
||||
unchanged. The destination is still `refs/heads/<gitea.user>/<branch>`, built by the host (:172).
|
||||
N9 (after routing, a within-role publish has no `pushCommit`) stands as a note.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N10. Name the refusal for `publish` by an agent without `identity`, or whose definition doesn't declare
|
||||
`gitea` (D8, :125-126), and give it a T3 case.
|
||||
N11. If `ls-remote` fails because the fork doesn't exist, it should refuse with a stated code rather than be
|
||||
read as "empty". Add to A3 that Gitea's push-to-create (`ENABLE_PUSH_CREATE_USER`) stays off. Otherwise a
|
||||
push to a missing `<user>/REPO` creates a plain repository instead of failing.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Review: Megavolt row 199 design r6 (duckworth-0027)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review of the design, plus one node check of D9c's
|
||||
normalization claims.
|
||||
Subject: agents/megavolt/work/queue-199/DESIGN.md at 4d3007ec9 on megavolt/row199 (megavolt-0012), sha256
|
||||
aa458dfe…dc787384 (checked). Diff 34bfe4a53..4d3007ec9, one commit, the file only. Against duckworth-0026
|
||||
(REVIEW-r5.md, 3cb6888e1).
|
||||
|
||||
## Verdict: approve
|
||||
|
||||
- B5 resolved. `fork` is named once in D9 step 2 and is the only URL the rest of `publish` uses: D9a's
|
||||
`ls-remote --symref <fork> HEAD`, step 5's push, the helper's host match, the `checkPublish` result, the
|
||||
publish result and step 6's event. D9a now checks the fork and says why a related upstream proves
|
||||
nothing. T3 puts an empty fork and an unrelated fork behind a related upstream, and the fake records every
|
||||
path, so a check against the upstream fails the test. The upstream `ls-remote` mutant is in.
|
||||
- B6 resolved (D9c). I ran the equality check in node 26 (`url.origin + url.pathname === input`). These
|
||||
each fail it: a dot segment, `%2e%2e`, an upper-case host, `:443`, backslashes, userinfo, a query and a
|
||||
fragment. A trailing slash passes it, and so does `%41`. The two-segment rule catches the first and the
|
||||
name rule (no `%`) catches the second, so both refuse. The owner and the repository are read from the
|
||||
normalized path, so the owner that gets checked is the owner that gets used. The `.git` strip followed by
|
||||
the name rule refuses `.git` and `..git`. `remote-not-fork` compares without case. A sub-path Gitea refuses
|
||||
at load. The three new mutants cover the three ways this could regress.
|
||||
- N10 resolved. Load refuses a `publish` grant without `identity` or without `gitea`, with a fixed message,
|
||||
and has a T3 case for each.
|
||||
- N11 resolved. A non-zero `ls-remote` refuses `fork-unavailable` and its stderr isn't returned. A3 keeps
|
||||
push-to-create off for users and orgs, and the mutant that reads failure as empty is in.
|
||||
- r4 codes hold. The `pushCommit` binding, the push by oid, the `tls` seam and the
|
||||
`refs/heads/<gitea.user>/<branch>` destination are unchanged. N9 is carried to BUILD.md for the routing
|
||||
commit, which is the right place for it.
|
||||
|
||||
Code can start on this revision.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Review: Megavolt row 199 design r1, per-agent credentials and remote push (duckworth-0013)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Static review, no code run.
|
||||
Subject: agents/megavolt/work/queue-199/DESIGN.md at 624e8ea3a on megavolt/row199 (sha256 3eb2df7c…1cd4,
|
||||
checked), base 992650232, Gitea #1697. Code read at 992650232.
|
||||
|
||||
## Verdict: changes, B1 and B2
|
||||
|
||||
The shape is right. The path is derived from the cap, never named in a request. No `gitea` or `vikunja`
|
||||
token enters a sandbox. The destination ref is built by the host. `publish` is no-force. E1 to E10 match the
|
||||
code where I checked them. The launcher and its git socket run in the bus process (cli.mjs:504-506), so
|
||||
`useFor` from `publish` doesn't move a token across processes. Two things block.
|
||||
|
||||
## Blockers
|
||||
|
||||
B1. A `store` reference with no file stops the whole bus at start. D13 creates an agent "with `store`
|
||||
references and no files" and says `launch` then refuses `credential-unavailable`. `revoke` (section 6)
|
||||
unlinks the file and leaves the reference. But D1 keeps E2's reader, and that reader throws
|
||||
`credential-file` when the file is missing (credentials.mjs:38-43). It runs inside `startBroker`'s try
|
||||
(runtime.mjs:89), so the bus refuses to start. The fault doesn't show at the verb. It shows at the next
|
||||
restart, for every agent of the business. Row 194's fire calls revoke before it edits the business file
|
||||
(D13), so a failed edit after a good revoke leaves exactly this state.
|
||||
Fix: say what a `store` reference with no file means. I suggest: ENOENT on a `store` path loads as an
|
||||
absent entry, `status` shows `not-issued`, and `useFor` refuses `credential-unavailable`. Every other defect
|
||||
still refuses start: wrong owner or mode, a symlink, bad format, a dir problem. `{file}` and `{env}` stay as
|
||||
they are. T1 and T5 gain a bus restart after create-without-issue and after revoke. Add a mutant that makes
|
||||
ENOENT on a `store` path fatal again, and one that makes ENOENT on a `{file}` path silent.
|
||||
|
||||
B2. D9 step 5 doesn't bind the token to the remote's host. A one-shot helper that hands out the token on
|
||||
any `get` gives it to whatever host git asks about. Git follows a redirect on the first request
|
||||
(`http.followRedirects` defaults to `initial`) and then asks the helper for credentials for the new host.
|
||||
So a redirect from the remote, or from anything that answers for its name, sends the agent's
|
||||
`write:repository` token off the Gitea host. Separately, "E8's safe env" can't push to https at all:
|
||||
working.mjs:95 sets `protocol.allow=never` with only `file` allowed.
|
||||
Fix, in D9 step 5:
|
||||
- `publish` sets `protocol.https.allow=always` and nothing else beyond SAFE.
|
||||
- It sets `http.followRedirects=false`.
|
||||
- The helper answers `get` only when `protocol` is `https` and `host`, port included, equals the remote's.
|
||||
It answers nothing for `store` and `erase`.
|
||||
- Step 2's host check compares the port too.
|
||||
- T3 gains a fake remote that 302s to a second 127.0.0.1 port, where no basic auth may arrive.
|
||||
- Add a mutant whose helper answers any host.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. Section 6 lets the tester run every lifecycle verb on the dev instance (Q173). Agents act as tester,
|
||||
so `issue` and `rotate` under the tester mean an agent session holds a live token on stdin. That cuts
|
||||
against section 7 ("Agents never mint accounts or tokens") and the no-credential-values rule. I suggest
|
||||
the tester gets `status` and `revoke` only, and `issue` and `rotate` stay with Jason. This is input to
|
||||
Q-M3.
|
||||
N2. The `issue` flag has to follow the kind. The reader takes `rotateBy` for `gitea` and `expires` for
|
||||
`vikunja` and refuses any other key (credentials.mjs:24-33). Say `--rotate-by` is gitea-only and
|
||||
`--expires` is vikunja-only.
|
||||
N3. The work ref is per task, `work/vikunja-<project>-<task>` (working.mjs:110-114), not per agent. "The
|
||||
agent's own work ref" (D9 step 3) holds only through step 4's identity check. Say so, or also check that
|
||||
the last `recordPush` for that ref was the caller's.
|
||||
N4. `publish` sends the working repo's `main` history from the project root along with the work. If the
|
||||
remote doesn't share root's history, the first publish uploads all of root, landing squashes included. Say
|
||||
that a project's `remote` must be root's upstream, and have R1 or T3 show a first publish against an empty
|
||||
fork.
|
||||
N5. E5 cites `git-socket.mjs:398-407`. It is `packages/cli/src/git-socket.mjs`. Name the host op that
|
||||
`publish` uses to reach `useFor`, since the socket today reaches the broker only through `host.op`.
|
||||
|
||||
## Q-M inputs for Mos
|
||||
|
||||
- Q-M1: O1 (forks). Gitea enforces it, and no per-agent rule can drift. O2 depends on probe P1, which hasn't run.
|
||||
- Q-M2: no objection. It's a reviewed `roles/` change, with nothing automatic.
|
||||
- Q-M3: human-only, plus N1's narrowing for the tester.
|
||||
- Q-M4: agreed. Per-agent model tokens need the proxy row.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Review: Gizmoduck row 201 FED R1 manifest, round 1 (duckworth-0033)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Subject: gizmoduck/fed-r1 at 44e36fc4c, candidate 80fe31071,
|
||||
base ae3c36f24 (gizmoduck-0032). Against the FED R1 brief (docs/plans/2026-10-20_federation-r1-r2-r5.md at
|
||||
a2af7ab49) and DESIGN.md 3.5 at a1daa3c07.
|
||||
- Manifest digests checked, all three match: access.mjs 74ac7e9e…dc4f4, index.mjs 7ee59b6e…3d20,
|
||||
access.test.mjs 3bec097f…b238. 44e36fc4c only deletes a `__pycache__` .pyc.
|
||||
- I read access.mjs in full, the test file in full, BUILD.md, and util.mjs:66-89 (`readJsonFile`).
|
||||
- In a scratch git worktree at 80fe31071, `node --test packages/business/tests/access.test.mjs` passed 24 of
|
||||
24 with exit 0. I removed the worktree afterwards. I ran no suites and no mutants.
|
||||
|
||||
## Verdict: approve
|
||||
|
||||
Each brief test item maps to a test:
|
||||
|
||||
| Brief item | Test |
|
||||
|---|---|
|
||||
| symlink, non-regular file | access.test.mjs:216, :219 |
|
||||
| another owner | :224 |
|
||||
| group or other write | :225-228 |
|
||||
| expiry | :178-186, and covers :259-266 |
|
||||
| `*` | :154-156, :165 |
|
||||
| subjects (deviation 2) | :132-135 |
|
||||
| `human`, `tester`, any business's human | :111-115 |
|
||||
| any level other than collaborator | :171-176 |
|
||||
| project not a UUID | :151-153 |
|
||||
| covers: `*` and listed ids, unlisted project, instance or business, expired, level filter | :244-274 |
|
||||
|
||||
The file checks run on one descriptor opened with `O_NOFOLLOW | O_NONBLOCK`. `fstat` comes before the read
|
||||
(util.mjs:66-89), so there's no check-then-read race, and a FIFO is refused instead of blocking. Mode
|
||||
`& 0o022` and the uid compare match 3.5's first rule. The interface (`checkAccess`, `loadAccess`, `covers`,
|
||||
`LEVELS`, `accessFilePath`, and the context with `ssoSubjects` required) is the one Morgana confirmed
|
||||
(morgana-0014, 08bb4d827) and Sage accepted (sage-1399).
|
||||
|
||||
Deviation 1, a past `expires` loads and `covers` returns false: right. 3.5 says such a grant "is treated
|
||||
as absent", not refused, and sage-1399 confirms it.
|
||||
|
||||
Deviation 2: your reading is the one I meant. The brief's phrase is about the 3.5 subject rules: a
|
||||
`(provider, sub)` pair appears once in the file and never in `console.sso.subjects`. :132-135 cover a pair
|
||||
repeated within a user, a pair shared across users, an owner subject, and the same sub under another
|
||||
provider. The other reading, a grant naming a user that isn't in the file, is covered by your added rule at
|
||||
:145.
|
||||
|
||||
What remains is Sage's gate: the business suite, every `scripts/test-*.sh`, and M01-M44 each killed.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. `covers` with no `level` lets any level cover (access.mjs:177-185, test :271). That matches 3.5 ("when
|
||||
one is asked for"), and today it is harmless because `LEVELS` holds only collaborator. Once 193a adds
|
||||
`viewer`, any broker path that calls `covers` without `level` for a write verb lets a viewer grant send.
|
||||
For 193a (Morgana) and R3: the verb gate always passes `level`, and add a test that a viewer grant refuses
|
||||
`thread.send` and `thread.create`. Making `level` required would be an interface change, so that would go
|
||||
to Sage first.
|
||||
N2. `covers` relies on the grant having passed `checkAccess`. `projects[0] === "*"` is correct only
|
||||
because `*` must stand alone (:154-156). The doc comment says "a checked grant". R5 and 193a should only
|
||||
pass grants from `checkAccess` or `loadAccess` output, never ones they built themselves.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Review: Quackerjack row 202 FED R5 manifest, round 1 (duckworth-0040)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Subject: quackerjack/fed-r5, build 030809e15, manifest
|
||||
agents/quackerjack/work/queue-202/MANIFEST.md at 959bfb275, per quackerjack-0004. Against the FED R5 brief
|
||||
(docs/plans/2026-10-20_federation-r1-r2-r5.md) and DESIGN.md 3.5 at b93f098f0.
|
||||
- The six manifest hashes match the blobs at 030809e15. 959bfb275 adds MANIFEST.md only.
|
||||
- I read cli/src/access.mjs in full, the cli.mjs and scripts/mosaic diffs, the README section, BUILD.md,
|
||||
test 10, broker.mjs:375-378 and :2318-2322, client.mjs:20-45 and transport.mjs `refuseInsideAgent`.
|
||||
- In a scratch git worktree at 030809e15, `node --test tests/access.test.mjs` from packages/cli passed 18
|
||||
of 18 with exit 0.
|
||||
- One probe of my own, in scratch: an access.json at mode 0664, then `list` and `revoke` through
|
||||
`accessCommand`. Both throw a `BusinessError`, not a `CliError` (B1). Nothing was written and the lock
|
||||
was removed.
|
||||
- I removed the worktree and the probe. I ran no suites and no mutants.
|
||||
|
||||
## Verdict: changes, B1 only
|
||||
|
||||
Your four points:
|
||||
|
||||
1. Human proof: sound. `proveHuman` makes one `project.list` call through `humanTransport` before any read
|
||||
of the file, for all three verbs. The broker runs `#human` on it (broker.mjs:2319), which needs
|
||||
`s.human`, `via` `cli` and `outsideAgent` (:377). `refuseInsideAgent` runs first, and `--tester` refuses
|
||||
before the bus is asked. One correction to the reasoning is in N1.
|
||||
2. Lock: it does what BUILD.md says. `O_EXCL` create at 0600, the pid written after, removed in a
|
||||
`finally`, and a reclaim only on ESRCH. EPERM reads as alive, and an empty or non-pid lock refuses. So
|
||||
every doubt fails closed. I agree the reclaim race (limit 1) is acceptable for a human-only verb. It
|
||||
needs a crash plus two writers at once, and it's Sage's call. See N2 and N3.
|
||||
3. Write: `checkAccess` runs on the whole new document before `writeAtomic`. The temp file is created
|
||||
`wx` at 0600 in the same directory, written, fsynced and renamed. The revision is compared inside the
|
||||
lock. See N4.
|
||||
4. Bus finding: agreed, it's outside R5. A closed socket maps to `outcome-unknown` at client.mjs:32-33.
|
||||
Pinning it in test 10 is the right call. When row 196 changes it, this test fails and must be updated
|
||||
in that row. See N5.
|
||||
|
||||
## Blocker
|
||||
|
||||
B1. A bad access file, or a bad business file, escapes as an uncaught `BusinessError`. `readCurrent`
|
||||
calls `loadAccess` (access.mjs:78) for `list`, and inside the lock for `grant` and `revoke` (:203). Only
|
||||
the `checkAccess` call at :236-241 converts `BusinessError` to a `CliError`. `run` (cli.mjs:613-614)
|
||||
rethrows anything that isn't a `CliError`, so node prints a stack trace and exits 1. My probe: an
|
||||
access.json at mode 0664 gives `BusinessError: access file must not be writable by group or other` from
|
||||
both `list` and `revoke`. The same happens for a file owned by another uid, and for a hand edit that
|
||||
breaks any 3.5 rule. DESIGN.md 3.5 says "Jason may also edit the file by hand", so this is an expected
|
||||
path, not a corner case. `accessContextFor` (:44-57) has the same gap for `loadBusiness`. It also runs in
|
||||
cli.mjs before `accessCommand`, so a broken business file reaches an agent caller ahead of the agent
|
||||
refusal.
|
||||
Fix:
|
||||
- Convert `BusinessError` to `CliError(`refused: ${message}`, 2)` around `readCurrent` too. One wrapper
|
||||
round the whole verb body does it.
|
||||
- Build the context after `refuseInsideAgent`, e.g. pass `contextFor: () => accessContextFor(...)` and call
|
||||
it inside `accessCommand`.
|
||||
- Add one test: an access file at 0664 refuses `grant` with exit 2, and the file, the receipt and the lock
|
||||
are unchanged.
|
||||
The manifest's line "BusinessError is caught and re-thrown as exit 2" is then true.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. The header comment (access.mjs:5-6) and README say the bus refusing a non-human shell is why "no agent
|
||||
and no tester can write". For a tester that's not quite right. `project.list` exempts testers
|
||||
(broker.mjs:2319, `if (!s.tester) this.#human(s)`). Testers are kept out because the CLI refuses
|
||||
`--tester` and always uses `humanTransport`. Say so in the comment. For R4, record that `access.reload`
|
||||
must call `#human` with no tester exemption, because that verb is the proof once R4 swaps it in.
|
||||
N2. The empty-lock window can go away. `bus host` already writes the pid to a temp file and hard-links it
|
||||
in as `host.lock` (packages/cli/README.md:297-299), so the lock never exists without a pid. With that
|
||||
pattern, the "empty lock, remove by hand" case disappears.
|
||||
N3. In the reclaim race, the loser's `finally` `unlinkSync(lock)` can remove the winner's lock. If its own
|
||||
lock was already taken, it throws ENOENT after the rename has happened, and no receipt is written.
|
||||
Using `rmSync(lock, { force: true })` or checking the pid before unlinking narrows that. It's part of
|
||||
limit 1, so Sage decides.
|
||||
N4. `writeAtomic` doesn't fsync the directory after `renameSync`, so a power cut can lose the rename. A
|
||||
failed write leaves the `.access-*.tmp` file behind. The receipt is appended after the lock is released
|
||||
(:246), so two writers' receipt lines can land in either order, and a crash between rename and append
|
||||
leaves a write with no receipt. Appending inside the lock, after the rename, keeps the log in revision
|
||||
order.
|
||||
N5. Test 10's `outcome-unknown` tells a human that the outcome is unknown when nothing was written. Until
|
||||
row 196 lands, the README could add: a refused proof shows as `outcome-unknown`, and no write happened.
|
||||
N6. R1's `loadAccess` checks that the file's uid matches the caller's, and the rename makes the caller
|
||||
the owner. So `mosaic access` has to run as the instance's uid, e.g. `mosaic` on .54 (Q181). Run as
|
||||
anyone else, every read refuses. The README should say which user runs it.
|
||||
N7. No verb adds or removes a `users` entry. `grant` needs the user already in the file (R1's rule), so a
|
||||
new user is a hand edit. "To cut a user off, revoke" removes that user's grants at one `--business` only.
|
||||
Say both in the help text. DESIGN.md 3.7 says "Removing the users from access.json acts at once", and the
|
||||
help should not read as if `revoke` does that.
|
||||
N8. When row 204 (193a) lands, `--level viewer` is accepted through `LEVELS` with no R5 change. The usage
|
||||
line, the help and the README name only `collaborator`. The row that lands second updates the text and
|
||||
adds a viewer grant test (my duckworth-0037 N1, carried in Morgana's BUILD.md).
|
||||
N9. `accessContextFor` calls `p.subjects.map` with no guard, so a provider with no `subjects` throws a
|
||||
`TypeError`. That goes away once R5 rebases onto row 192 and reads `console` through `checkConsole`, as
|
||||
BUILD.md says.
|
||||
|
||||
A round that changes only B1 and any notes taken gets a diff check, not a full re-read.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Review: Quackerjack row 202 FED R5, round 2 (duckworth-0041)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Subject: candidate 48a3fb96e on quackerjack/fed-r5, manifest
|
||||
agents/quackerjack/work/queue-202/MANIFEST.md at 7fd265033, per quackerjack-0005. Round 1 was
|
||||
REVIEW-r1.md (duckworth-0040, changes B1, on 030809e15).
|
||||
- The six manifest hashes match the blobs at 48a3fb96e. scripts/mosaic is unchanged since round 1.
|
||||
- I read the full diff 030809e15..48a3fb96e for packages/cli (access.mjs, cli.mjs, the test file and the
|
||||
README) and BUILD.md.
|
||||
- In a scratch git worktree at 48a3fb96e, from packages/cli, `tests/access.test.mjs` alone passed 21 of
|
||||
21 with exit 0. I removed the worktree. I ran no suites and no mutants.
|
||||
- No test pins a hash of `packages/cli/src/access.mjs` or reads it by path.
|
||||
|
||||
## Verdict: approve
|
||||
|
||||
- B1 resolved. `accessCommand` runs `refuseInsideAgent`, then wraps the whole verb body, so a
|
||||
`BusinessError` from `loadAccess`, `loadBusiness` or `checkAccess` becomes `CliError("refused: …", 3)`.
|
||||
The exit code is 3 under Sage's ruling (sage-1437). The new test sets a 0664 file and asserts exit 3,
|
||||
the path in the message, and the file, receipt and lock unchanged.
|
||||
- The context is now `contextFor()`, called after the `--tester` refusal and the human proof. The new
|
||||
test makes `contextFor` throw and the file 0664, and both the agent and the `--tester` caller get their
|
||||
own refusal first.
|
||||
- The lock no longer reclaims. A dead-pid, empty or non-pid lock refuses and names the path, and the
|
||||
dead-pid case names the pid. With no reclaim, the `finally` unlink only ever removes the caller's own
|
||||
lock, so my N3 is gone too. That trades the race for a manual clear after a crash, which is the
|
||||
fail-closed choice, and Sage made it (sage-1429).
|
||||
- `writeAtomic` removes its temp file on any failure up to the rename. The test injects a throwing
|
||||
`renameSync` through `deps.fs` and checks the old bytes, no `.access-*.tmp`, no lock and no receipt.
|
||||
M11, the direct-write mutant, is now killable.
|
||||
- `deps.fs` undefined falls back to the real functions through the default parameter, in both
|
||||
`writeAtomic` and `withLock`.
|
||||
- N6 taken in the help text and the README.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. Exit codes are now mixed. Load and check refusals are 3. `--level takes …`, `project-not-found` and
|
||||
"no grant … to revoke" stay 2. If that's intended, say so in the README. If not, it's a small follow-up.
|
||||
Sage's call.
|
||||
N2. The receipt is still appended after the lock is released (round 1 N4, second part). Receipt lines
|
||||
can land out of revision order when two writes run back to back. A crash between the rename and the
|
||||
append leaves a write with no receipt. Not required by the brief.
|
||||
N3. `writeAtomic`'s catch unlinks `tmp` even when `openSync(tmp, "wx")` failed with EEXIST, i.e. a temp
|
||||
name that belongs to someone else. With 8 random bytes this won't happen in practice.
|
||||
N4. Round 1 N1, N5, N7, N8 and N9 carry as you said. N1 is already in the R4 brief line of the
|
||||
manifest.
|
||||
|
||||
What remains is Sage's gate: the cli suite, every `scripts/test-*.sh`, and M1-M13 each killed.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Review: Morgana row 204 (193a) manifest, round 1 (duckworth-0037)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Subject: morgana/row193a at 0caf6e2f6, base 44e36fc4c (row 201
|
||||
FED R1 candidate tree), per morgana-0018. Against design `agents/morgana/work/queue-193/DESIGN.md` r5 4.2,
|
||||
my R1 N1 (duckworth-0033) and sage-1420.
|
||||
- Manifest digest d38de8c9…f75a checked, and its three file hashes match the tree: access.mjs 0c1d9a12…b2ae,
|
||||
access.test.mjs 11dc4dd3…698c, access-levels.test.mjs a0656806…d52a.
|
||||
- I read the full diff 44e36fc4c..0caf6e2f6, BUILD.md, mutants.py and mutants.log.
|
||||
- In a scratch git worktree at 0caf6e2f6, from packages/business, each file alone with the tap reporter:
|
||||
access-levels.test.mjs 5 of 5, access.test.mjs 26 of 26, both exit 0. I removed the worktree. I ran no
|
||||
suites and no mutants.
|
||||
|
||||
## Verdict: approve
|
||||
|
||||
- `LEVELS` is `["collaborator", "viewer"]`, most to least, and the comment at access.mjs:13-15 says the
|
||||
order matters.
|
||||
- `covers` (access.mjs:180-188) throws a `TypeError` for a missing or unknown `level` after the `now`
|
||||
check. That resolves my R1 N1 the way sage-1420 item 2 ruled.
|
||||
- The least-level compare is right. A grant covers when its index is at or below the asked index, so a
|
||||
viewer grant (1) fails a collaborator ask (0), and any grant passes a viewer ask.
|
||||
- The `!LEVELS.includes(grant.level)` guard at :188 is needed. Without it an unknown grant level gives
|
||||
`indexOf` -1, which would pass every ask. Mutant A6 drops it and access.test.mjs test 25 catches that.
|
||||
- `checkAccess`'s refusal path is unchanged apart from the list in its message. "Viewer", "viewer " and
|
||||
`["viewer"]` refuse.
|
||||
- The tests carry the design's matrix, the missing-level throw, an unknown grant level, and a viewer grant
|
||||
that still needs business, project, instance and expiry.
|
||||
- No other package calls `covers` (grep over packages at 0caf6e2f6), and no test pins a hash of either
|
||||
source or test path.
|
||||
|
||||
What remains is Sage's gate: the business suite, every `scripts/test-*.sh`, and A1-A6 each killed on the
|
||||
rebased tree.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. R5 (row 202, quackerjack/fed-r5 at 959bfb275) imports `LEVELS` for `access grant --level`. Once both
|
||||
land, `--level viewer` is accepted with no R5 change. R5's usage line and README still name only
|
||||
`collaborator`. Whichever row lands second should update that text and add one grant test with `viewer`.
|
||||
R5 doesn't call `covers`.
|
||||
N2. On the rebased tree, R1's M43 anchor no longer exists, as BUILD.md says. If Sage's gate for 193a
|
||||
reruns R1's mutants, M43 needs the new anchor or it aborts on an anchor count of 0.
|
||||
N3. BUILD.md's mutant table labels A5b "no level means viewer", while mutants.py and the log say "means
|
||||
any". The two are the same mutant, since every grant covers a viewer ask. Doc-only.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Review: Gizmoduck row 210 round 1 code, host.run with sudo through the credential store (duckworth-0052)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Subject, per gizmoduck-0052, which moved it from gizmoduck-0049:
|
||||
- 12ffe0b56 on `gizmoduck/row210-on-199-r2`, base 07141966e (row 199's tip, megavolt-0027).
|
||||
- `git range-diff addee664a..8ec923585 07141966e..12ffe0b56`: the DESIGN commits and the code commit
|
||||
c7958740a are `=`. 12ffe0b56 adds only BUILD.md and manifest lines.
|
||||
- `candidate-manifest.sha256` (sha256 82b701e7…): 20 files, all OK in my worktree. It lists exactly the
|
||||
non-packet files in `07141966e..12ffe0b56`.
|
||||
|
||||
What I read in full: `bus/src/host-run.mjs`, `business/src/hosts.mjs`, and the diffs of `broker.mjs`,
|
||||
`bus/src/credentials.mjs`, `credential-verbs.mjs`, `runtime.mjs`, `server.mjs`, `bus/src/business.mjs`,
|
||||
`business/src/business.mjs`, `credentials.mjs`, `resolve.mjs`, `role.mjs` and `vocabulary.mjs`. I read
|
||||
T9 and the test titles against DESIGN r3 D3-D9 and section 9.
|
||||
|
||||
## Checks I ran
|
||||
|
||||
- The 17 touched test files, each alone, in a scratch worktree at 12ffe0b56 with TMPDIR under my own scratch
|
||||
dir: 134 pass, 0 fail, from an awk sum over the TAP log. This matches gizmoduck-0052.
|
||||
- `host-run.test.mjs` alone again: 18 tests, 0 skipped. The scratch sshd ran.
|
||||
- After the runs, no sshd, ssh or ssh-agent of mine was left, and no `h-` dir was left.
|
||||
- Hash pins: no test line pairs a changed file's name with a 64-hex value.
|
||||
- I didn't run the suites or the 47 mutants. They need Sage's slot.
|
||||
|
||||
## Verdict: changes, B1
|
||||
|
||||
The design holds in the code:
|
||||
- D4 binding. `hostRun` is `{host, argvSha256, stdinSha256}` at the raise (broker.mjs `hostRun`). It's
|
||||
compared whole in `#authorityFor`. The broker appends the question text, and the 4096 cap applies to all
|
||||
of it. T9 covers each mismatch, consumed, unapproved, and the full stdin in the question.
|
||||
- D5 order. Busy is checked before `#consumeAuthority`, so a busy refusal leaves the decision usable
|
||||
(deviation 5). From the broker's `busy` check to `#inflight.set` in `use()` there's no await, so two
|
||||
calls can't both pass.
|
||||
- D6. The quoting is POSIX single-quote, with `sudo -n --` first. T10's argv list covers `$( )`, a
|
||||
backtick, `;`, a newline and glob characters. The prefix match is literal. `refusedCommand` takes the
|
||||
basename. The digits-and-dots and hex-label refusals follow my r3 note.
|
||||
- D7. ssh and ssh-agent each lead a group under `setpriv --pdeathsig KILL`. The options are all on argv
|
||||
with `-F /dev/null`, and known_hosts is pinned per call. Field 22 of `/proc/<pid>/stat` is counted after
|
||||
the last `)`. The sweep keeps a dir whose bus pid and start time are alive, kills only a live recorded
|
||||
pid whose cmdline names the dir, and refuses start on any stray entry.
|
||||
- D8 and D9. The needles cover the seed at the three base64 offsets and in hex. Revoke aborts the
|
||||
controller, and `use()` answers `credential-unavailable` on an aborted signal.
|
||||
- The kinds are closed through `credentialKind`. `resolveInstance` drops `host.*` from the session, and
|
||||
the bus puts it back on the bus side only (deviation 1).
|
||||
|
||||
## Blocker
|
||||
|
||||
B1. The ssh tests skip and pass when sshd is missing, but their skip text says the gate fails.
|
||||
- `host-run.test.mjs:22`: `skip = existsSync(SSHD) ? false : 'no /usr/bin/sshd: the gate fails on this skip'`.
|
||||
14 of the 18 tests carry `{ skip }`. That's T4 to T22 and three of the four T27 cases.
|
||||
- `node --test` exits 0 on skipped tests, and no `scripts/test-*.sh` counts skips. I grepped for `skip`. The
|
||||
only hits are each script's own docker-unavailable skip lines.
|
||||
- So on a gate machine without `/usr/bin/sshd`, the bus suite reports green and runs none of the D4-D9
|
||||
executor tests. That's the false-pass shape, and the text claims the opposite.
|
||||
|
||||
Fix, either way:
|
||||
- Make a missing sshd a failure, for example a first test that asserts `existsSync(SSHD)`, with no skip on
|
||||
the rest.
|
||||
- Or keep the skip, change the text so it doesn't claim a failure, and have the gate check `# skipped 0`
|
||||
for this file.
|
||||
|
||||
Add a mutant or a one-off check with `SSHD` pointed at a missing path, so the gate's answer is shown.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. A `host.sudo` raise checks only that the host is in the registry. It doesn't check that the raising
|
||||
role has a grant for that host, or that the argv matches a prefix. T9 raises and approves for `web2`
|
||||
(`host-run.test.mjs:235`), which that role isn't granted (`:209-211` refuses `host-not-granted`). Jason can
|
||||
be asked to approve a call that can never run. That widens nothing, because the call checks the grant.
|
||||
Checking `roles[s.role].hosts[target]` and `commandAllowed` at the raise would keep those questions out
|
||||
of his queue. Refuse with the same codes as the call.
|
||||
|
||||
N2. `addKey` (`host-run.mjs:84-92`) has no time limit and doesn't listen to the abort signal. If ssh-add
|
||||
never exits, `runHost` never returns. `#inflight` keeps the entry, because its delete is in `use()`'s
|
||||
`finally`. Every later call for that agent and host then refuses `host-busy` until the bus restarts.
|
||||
Revoke aborts the controller but can't clear the entry. ssh-add talks to a local agent on a private
|
||||
socket, so this is unlikely. Give it the `agentWaitMs` limit, or kill its group on abort.
|
||||
|
||||
N3. `host-key-mismatch` (`:177`) is chosen from exit 255 plus the stderr text. A remote command that exits
|
||||
255 and prints that line gets the same answer, and its stdout is lost. Only a command Jason approved can
|
||||
do that, so this is a reporting matter. Say so in the D6 exit-255 residual, or match the text only at the
|
||||
start of stderr.
|
||||
|
||||
N4. Deviation 3's wider runner list and deviation 11's T3 at the broker are fine. M25's CRLF mutant is
|
||||
equivalent, as BUILD.md says. With `\r` refused at :27, the line regex (:29) refuses it anyway.
|
||||
|
||||
N5. `business/tests/helpers.mjs:14` makes `mosaic-business-*` dirs that no test removes. One run of the
|
||||
17 files left 377 under TMPDIR, and `hosts.test.mjs` alone left 111. The helper is older than this row.
|
||||
It's a follow-up, not this row's to fix.
|
||||
|
||||
A round that changes only `host-run.test.mjs`'s skip, the mutant or check for B1, and these notes gets a
|
||||
diff check.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Diff check: Gizmoduck row 210 round 2 code, for duckworth-0052 (duckworth-0053)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Subject, per gizmoduck-0054:
|
||||
- 455964f23 on `gizmoduck/row210-on-199-r2`, one commit on 12ffe0b56. The base is still 07141966e.
|
||||
- The range is `12ffe0b56..455964f23`: 7 files, +163 -40. I read the whole code and test diff.
|
||||
- `candidate-manifest.sha256`: all 20 files OK in my worktree, and it lists exactly the non-packet files in
|
||||
`07141966e..455964f23`.
|
||||
|
||||
## Checks I ran
|
||||
|
||||
- The 17 touched test files, each alone, in a scratch worktree at 455964f23: 137 pass, 0 fail, 0 skipped,
|
||||
from an awk sum over the TAP log. `host-run.test.mjs` ran 21 tests, all passing, with none skipped.
|
||||
- B1 by hand. In my copy I set `SSHD` to `/nonexistent/sshd`, with a backup, and checked the sha on restore.
|
||||
The file exits 1 with 21 tests: 8 pass, 13 fail, 0 skipped. That matches gizmoduck-0054.
|
||||
- After the runs, no sshd or ssh-agent of mine was left.
|
||||
- I didn't run the suites or the 53 mutants. They need Sage's slot.
|
||||
|
||||
## Verdict: approve
|
||||
|
||||
- B1 is met. The `{ skip }` guard is gone. A first test asserts each binary, and `sshd()` throws when
|
||||
`SSHD` is missing. A missing sshd now fails the file, and the gate sees it.
|
||||
- N1 is taken. The raise runs host.run's grant and prefix check (`broker.mjs:1964-1967`). T5-T8 count 0
|
||||
`host.sudo` decisions after both refusals. T9 grants `web2` through `coderWeb2`. The `hard-waits.test.mjs`
|
||||
fixture change follows from N1 and only adds a grant.
|
||||
- N2 is taken. `addKey` kills ssh-add's group at `limitMs` (`host-failed`) or on abort
|
||||
(`credential-unavailable`). `runHost` passes `agentWaitMs` and the call's signal. The test's silent socket
|
||||
sees both connections close.
|
||||
- N3 is taken. `-E <dir>/ssh.log` keeps ssh's own messages apart from the remote command's stderr, and the pin
|
||||
refusal reads only that log on exit 255. The new test covers both a remote `fail255` and a closed port.
|
||||
|
||||
## Note (does not block)
|
||||
|
||||
N1. On an exit 255, `out.stderr += log` can take stderr up to 4096 characters past `outputMax`, and
|
||||
`truncated.stderr` stays false. That's bounded and only ssh's own text. Leave it or cap the sum.
|
||||
|
||||
The suites and the mutant batch, M36-M38 included, are Sage's gate.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Review: Gizmoduck row 210 (#1707) DESIGN.md r1, agent host access with sudo (duckworth-0042)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Subject: agents/gizmoduck/work/queue-210/DESIGN.md at 7b10bc0ff
|
||||
on gizmoduck/row210, blob 1514a36d96 (checked), base refactor 9ba31b5b3, per gizmoduck-0041. Against the
|
||||
brief docs/plans/2026-10-20_host-credentials.md (9db578ff5), REQ-CRED-3 in Honker's 1.7 draft r2 (approved
|
||||
duckworth-0039), row 199 design r6 (4d3007ec9) and row 199's code on origin/megavolt/row199 at addee664a.
|
||||
- I checked E1-E11 at 9ba31b5b3: credentials.mjs:22, :84-87, :95-103, :104-117, host.mjs:31 and :270,
|
||||
broker.mjs:145-147, :790-797 and :1490-1532, server.mjs:87-95, business.mjs:89, vocabulary.mjs:101-103,
|
||||
sandbox.mjs:29-31, instance-settings.mjs:27. They hold. E12 is stale, see N1.
|
||||
- 199's `useFor` at addee664a (credentials.mjs:228-256) passes a BusError through and maps anything else to
|
||||
`service-failed`, so D5's own codes (`host-key-mismatch`, `credential-leak`) reach the caller as designed.
|
||||
- I ran nothing. The probe in section 2 is the author's, and I didn't repeat it.
|
||||
|
||||
## Verdict: changes, B1-B4
|
||||
|
||||
What holds:
|
||||
- The four grant layers, checked on every call, meet REQ-CRED-3's "a grant exists for that agent and that
|
||||
host before the credential is used". The agent never names another agent: the key comes from the cap
|
||||
through `useFor`.
|
||||
- No agent and not the chief can write layers 2-4. `hosts` is outside ORGANIZATION_KEYS and INSTANCE_KEYS,
|
||||
and `instances.set` refuses credentialed definitions.
|
||||
- `host.sudo` lands as a hard wait under REQ-WAIT-3, and T25 enforces REQ-WAIT-2.
|
||||
- The key handoff keeps the key off disk, argv and env (T19). `-F /dev/null`, a bus-written known_hosts
|
||||
under `HostKeyAlias` and `StrictHostKeyChecking=yes` give no trust on first use. Forwarding is off and
|
||||
`restrict` is on the host side.
|
||||
- The command is built from argv with POSIX single quotes and `sudo -n --`, never taken as a string.
|
||||
- The executor runs in the bus child as a broker.sock verb. The 10 s serial IPC (E5) is a sound reason not
|
||||
to use a host op. It matches where 199 put the push (199 BUILD.md deviation 11), see N1.
|
||||
- The brief's two named mutants are there: pin skip (M12, M13) and key leak (M15, M16).
|
||||
- Section 6 states the limits plainly, including that `"any"` is root and can outlive the grant.
|
||||
|
||||
## Blockers
|
||||
|
||||
B1. The event log doesn't say what ran as root. D5 step 6 records `action.allowed` with "host, argv hash,
|
||||
stdin size and hash", and T22 checks that the bodies carry hashes. After routing, a within-role call has no
|
||||
decision, so no record anywhere in the stack names the command, only its sha256. The existing verbs log the
|
||||
thing done in clear: `requestWeb` records the url or query (broker.mjs:1521), and 1.7's push line says
|
||||
"Every push is logged with the remote, the ref, the commit and, when one was given, the decision" (draft
|
||||
:105). argv has already passed `#secretCheck` (step 1), so it holds no known secret, and it's capped at
|
||||
32 KiB. The design gives no reason to hash it. Fix: record `host`, `user` and `argv` in clear in the
|
||||
`requested` event, and keep output as sizes and hashes. Change T22 to assert argv is present and stdout,
|
||||
stderr and the key are not, and M24's partner: a mutant that drops argv from the event.
|
||||
|
||||
B2. A pre-routing decision binds stdin that Jason never sees. D4 binds `stdinSha256`, and the question
|
||||
shows only "the host id and the argv". `stdin` can be 64 KiB (D6). So `argv: ["bash"]` with a script on
|
||||
stdin shows Jason the question "… bash", and he approves a script he can't read. Binding a hash he can't
|
||||
check doesn't give him a decision about it. Fix, either:
|
||||
- the question shows stdin in full when present (it passed `#secretCheck`), or
|
||||
- a gated call refuses non-empty stdin, and stdin is allowed only for a routed within-role call.
|
||||
Add a T9 case for the chosen rule and a mutant that drops it.
|
||||
|
||||
B3. If the bus child dies, the key stays loaded and the call can't be revoked. D7 step 5 cleans up in a
|
||||
`finally`, and T18 covers success, refusal, timeout and a thrown error. A bus child that is SIGKILLed or
|
||||
crashes never runs the `finally`. `ssh-agent -D` has no lifetime of its own, so it keeps the key loaded on
|
||||
its socket until the machine reboots. A detached ssh keeps running the remote command. After the restart
|
||||
the new bus's kill map is empty, so D9's "revoke also kills a `host.run` in flight" no longer holds for
|
||||
that call. 199's push leans on git's own time limit for the same case (deviation 11, "git's own time limit
|
||||
ends it"). ssh-agent has no such limit unless one is given. Fix:
|
||||
- `ssh-add -t <timeout + grace>`, so the agent drops the key on its own.
|
||||
- At bus start, sweep `<configDir>/secrets/run/`: kill what each leftover dir's recorded pids name (check
|
||||
they are still that ssh-agent or ssh first) and remove the dir.
|
||||
- Consider `setpriv --pdeathsig SIGKILL` around ssh-agent and ssh, which the sandbox already uses
|
||||
(sandbox.mjs:25-27). Probe it from node first.
|
||||
Add a test that SIGKILLs the bus child mid-call and then asserts the key is gone from the agent by the
|
||||
bound and the next start leaves no run dir. Add a mutant that drops `-t`.
|
||||
|
||||
B4. Nothing routes section 4 to Jason. REQ-CRED-3 says "Who grants a host credential, and how, is set by
|
||||
the #424 design, which Jason approves", and section 4 ends "The table above is that answer". Section 10's
|
||||
only Jason question, Q-J1, is about the first grants at the live window. My approval of the design doesn't
|
||||
stand in for his. Fix: add a Q-J0 to section 10, sent through Mos before the build lands: approve the
|
||||
section 4 table. Name where his answer is recorded.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. 199 is on origin now: origin/megavolt/row199 at addee664a carries code and BUILD.md. The header's "199
|
||||
has no code on origin yet" and E12 are stale. The bus child already spawns git there: runtime.mjs:245 calls
|
||||
`publishRemote`, which runs `runGit` (seat/src/checkout.mjs:95-138) as a detached group leader with
|
||||
SIGTERM, a grace period and SIGKILL to the group. Reuse that shape, or the helper, for ssh rather than
|
||||
writing a second one. Q-S2: deviation 11 keeps the token and the push in the bus child, and the host side
|
||||
only polls. D5's assumption holds, and since the agent reaches broker.sock directly, 210 needs no poll.
|
||||
Sage confirms.
|
||||
N2. Section 6 should say that a registry entry for the stack's own machine, or any host where the stack's
|
||||
`<configDir>` lives, gives the agent with `"any"` every agent's keys and tokens, not just root there.
|
||||
Consider refusing a loopback `address` at load.
|
||||
N3. D5 says every spawn is `detached: true`, so each gets its own group. D7 step 3 says ssh-agent runs "in
|
||||
the same process group". Say which group ssh-agent is in, and make sure the timeout and revoke kill reach
|
||||
both the agent and ssh.
|
||||
N4. Prefix lists plus stdin: `[["tee"]]` or `[["dd"]]` writes any file as root from stdin. D6 already says
|
||||
a prefix list limits mistakes, not a determined agent. Say in the doc that stdin goes to the command, so
|
||||
some prefixes are root on their own.
|
||||
N5. Pass the user with `-l <user>` and the address as its own argument rather than `<user>@<address>`.
|
||||
Then nothing depends on how ssh splits on `@`, or on an IPv6 literal.
|
||||
N6. D8 is defense in depth. The remote never holds the private key, and forwarding is off, so output can
|
||||
carry the key only if the agent already had it. It's still right to keep. Also note that a `host.*` entry
|
||||
in the shared token map makes today's `assertClean` loop over a multi-line value that never matches
|
||||
(E3), which is harmless.
|
||||
N7. My #423 r5 condition (duckworth-0035) applies. If ops-01 changes step 1 to fit this design, for
|
||||
example per-agent `from=…,restrict` lines for localadmin, that changed step 1 comes to me as a diff before
|
||||
it runs. That's the live part, not this row.
|
||||
N8. Q-S1 (route `host.sudo` in 1.7) is Sage's and Jason's call. Without it, B2's rule matters on every
|
||||
call.
|
||||
|
||||
A round with only B1-B4 and any notes taken gets a diff check, not a full re-read.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Review: Gizmoduck row 210 (#1707) DESIGN.md r2, diff check (duckworth-0043)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Subject: DESIGN.md at b726e10f4 on gizmoduck/row210, blob
|
||||
8c6c6a8c5e (checked), per gizmoduck-0044. Diff 7b10bc0ff..b726e10f4, +153 -60, one file. I read the
|
||||
whole diff, including sage-1450's D6 change, which goes past my B items. Round 1 was REVIEW-r1.md
|
||||
(duckworth-0042). I checked the new cites at 9ba31b5b3:
|
||||
- broker.mjs:126 `text()` caps a string at 4096, and :1654 runs `string(a.question)` on the raise.
|
||||
- sandbox.mjs:44 is `KILL_WITH_PARENT = ["/usr/bin/setpriv", "--pdeathsig", "KILL", "--"]`.
|
||||
- E12 and 199's cites at addee664a match what I read in round 1.
|
||||
I ran nothing.
|
||||
|
||||
## Verdict: approve on b726e10f4, with one condition (C1)
|
||||
|
||||
- B1 resolved. `requested` and `completed` carry host, address, user and argv in clear, after
|
||||
`#secretCheck`. stdin and output stay as size and sha256. T22 asserts both sides, and M30 drops argv.
|
||||
- B2 resolved. The broker appends host, user, JSON-quoted argv and the full stdin to the question itself,
|
||||
inside the existing 4096 cap. A raise that doesn't fit refuses `invalid-request`. T9 checks the stored
|
||||
question and the cap, and M31 omits stdin. With sage-1455 (every call a Jason decision under 1.7, per
|
||||
mos-1086), this was the rule that mattered most, and it now holds.
|
||||
- B3 resolved.
|
||||
- `setpriv --pdeathsig KILL --`, the sandbox's own prefix, wraps every spawn.
|
||||
- `ssh-add -t <timeout+30>` bounds the key even if the agent survives.
|
||||
- A pids file and a sweep before `broker.sock` listens. The sweep kills only a pid whose cmdline names
|
||||
the dir, and leaves alone a dir whose bus pid is alive, which also covers two buses sharing
|
||||
`<configDir>`.
|
||||
- T27 covers all three, and M32-M34 drop each one.
|
||||
- B4 resolved. Q-J0 is in section 10 in standalone form, routed through Mos before landing, and recorded in
|
||||
DECISIONS.md. See C1.
|
||||
- sage-1450's D6 holds together. There's no wildcard grant. Load refuses a string, empty, duplicate and
|
||||
glob-character `commands`, and a shell or runner as `argv[0]` by basename (so `/bin/bash` too). T26 and
|
||||
M6, M28 and M29 cover each one. Section 6's known limit names what closes it (`bwrap --unshare-net`) and
|
||||
doesn't claim the allowlist closes it.
|
||||
- Notes N1-N7 were taken as asked.
|
||||
|
||||
## Condition
|
||||
|
||||
C1. Q-J0 overstates revoke. Its line "You, Mos or the tester can revoke at once. The stack drops the key
|
||||
and stops a running command" goes to Jason word for word. Section 6 says "Revoke stops the stack's path,
|
||||
not remote processes. Killing ssh may leave a remote process running." He should approve on the accurate
|
||||
version. Replace the line with something like: "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." That's a
|
||||
one-line change that needs only my diff check, and it must be in before Mos sends Q-J0.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. The runner list is open by design (D6 says so), and it's Sage's ruling. Commands that run another
|
||||
command as root as directly as `env` or `nohup` do: `systemd-run`, `runuser`, `chroot`, `nsenter`,
|
||||
`unshare`, `setpriv`, `capsh`, `doas`, `pkexec`, `flock`, `stdbuf`, `ionice`, `chrt`, `taskset`, `strace`,
|
||||
`script`, `watch`, and the interpreters (`python3`, `perl`, `ruby`, `node`, `awk`). Adding them costs one
|
||||
array line each. `systemd-run` is the one I'd add first.
|
||||
N2. The loopback check names `127.0.0.0/8`, `::1` and `localhost`. getaddrinfo also takes these to the
|
||||
local machine: `0.0.0.0` (and `0`), `::`, `::ffff:127.0.0.1`, short or numeric IPv4 forms such as
|
||||
`127.1` or `2130706433`, which pass the DNS-name regex, and `*.localhost`, which systemd-resolved maps to
|
||||
loopback. Refuse a name made only of digits and dots unless it's a full four-part IPv4 literal, and run
|
||||
the address check on `net.isIP` output. Section 6 already says load can't know every address of the
|
||||
stack's machine, so this narrows the gap without closing it.
|
||||
N3. The sweep trusts that a live pid is still the bus that wrote the file. A reused pid leaves that dir in
|
||||
place until the next start. The cmdline check already stops a wrong kill. Recording each pid's start time
|
||||
(`/proc/<pid>/stat` field 22) beside it removes the reuse case. Low stakes, because `-t` and pdeathsig
|
||||
have already dropped the key.
|
||||
N4. T26 is listed after T27, and M28/M29 after M35. Cosmetic.
|
||||
N5. On a gated call the 4096 cap counts the agent's own question, the JSON-quoted argv and the stdin, so
|
||||
stdin's real room is smaller than 4 KiB. D4 says this. The tool help should say it too.
|
||||
|
||||
Q-S3 (the host_run harness tool) is Sage's call. What remains: C1, then the build, manifest review and
|
||||
Sage's gate. The row lands after 199 and after the PRD version holding REQ-CRED-3.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Review: Gizmoduck row 210 DESIGN r3, C1 diff check (duckworth-0044)
|
||||
|
||||
Reviewer Duckworth (Opus 5.5), 2026-10-20. Subject: gizmoduck/row210 at e021dfa26, DESIGN.md blob
|
||||
ece332e306 (checked), per gizmoduck-0047. e021dfa26 is a fast-forward of b726e10f4, and the diff touches
|
||||
DESIGN.md only (+51 -26). I read the whole diff.
|
||||
|
||||
## Verdict: C1 met, approve stands
|
||||
|
||||
C1. Q-J0 now says "The stack drops the key and cuts its connection. A command already started on the machine
|
||||
may keep running." That matches section 6 and D9's revoke line. The quote is still 10 lines, and the yes/no
|
||||
question is last. Dropping "on the machine" from the ops-01 sentence loses nothing. Mos can send Q-J0 as it
|
||||
stands at e021dfa26.
|
||||
|
||||
The rest of r3 matches the rulings and my r2 notes:
|
||||
- Q-S3 (sage-1468): the `host_run` row is gone from D10, and the scope line says no `packages/harness` file
|
||||
changes. My r2 N5 goes to Sage's follow-up row.
|
||||
- N1 (sage-1473): D6 carries the whole runner list, five interpreters, and the line that the list can't be
|
||||
complete and Jason's per-call decision is the control. T26 has one case per name.
|
||||
- N2: I checked `net.isIP` on node here. It returns 0 for `127.1`, `2130706433`, `0177.0.0.1`,
|
||||
`127.000.000.001` and `127.0.0.01`, and 4 for `10.1.1.53`. glibc resolves `0177.0.0.1` and `127.0.0.01`
|
||||
to 127.0.0.1. The rule "a name of only digits and dots refuses unless `net.isIP` reads it as IPv4"
|
||||
refuses all of them, octal included.
|
||||
- N3: the pids file records the start time (field 22), the sweep treats a mismatch as dead, and T27 and M34
|
||||
test it.
|
||||
- N4: M28 and M29 are in order.
|
||||
- The 1.7 approval line and the Q-S1 line agree with sage-1468.
|
||||
|
||||
## Notes (do not block)
|
||||
|
||||
N1. D6's interpreter list has `python3` but not `python`, `python3.12` or other versioned names, and has
|
||||
`awk` but not `gawk`, `mawk` or `nawk`. `sed` (GNU `e` command), `vi`/`vim`, `less` and `php` also start a
|
||||
shell. D6 already says the list isn't the control, so these are cheap additions to T26 if Gizmoduck wants
|
||||
them, not a requirement. A basename match on `python3*` and `*awk` would cover the versioned forms.
|
||||
|
||||
The build can start on 199's candidate.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Honker
|
||||
|
||||
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 194 (agent lifecycle, #1692) under mos-0974 and mos-0977.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `honker/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Launchpad
|
||||
|
||||
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 192 (Q175 SSO phase 1, #1690) under mos-0974 and mos-0977.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `launchpad/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -1,178 +0,0 @@
|
||||
# Row 192: Q175 phase 1, the Console verifies sign-in itself (`--sso`)
|
||||
|
||||
Launchpad, 2026-10-20. Author Launchpad, reviewer Webby, gate owner Sage. Issue #1690. Brief
|
||||
`docs/plans/2026-10-20_sso-phase1.md`. Design `agents/sage/work/q175-sso/DESIGN.md` (blob 9e3eadb15c80).
|
||||
Rulings sage-1340 (F1 to F6) and sage-1347 (F7, F8). Plan launchpad-0001.
|
||||
|
||||
Base 53bb59462 (`base.txt`). Candidate: branch `launchpad/row192`, r1 83b32d8d8 and the round 2 commit on it.
|
||||
`candidate-manifest.sha256` lists the 18 changed code, test and doc paths; the packet files are not in it. Drift
|
||||
check at the r2 commit: `git log 53bb59462..origin/refactor` (origin/refactor ae3c36f24) touches none of the 18 paths.
|
||||
|
||||
No request in any test goes to auth.diversecanvas.com, mosaic.woltje.com or 127.0.0.1:7332. Every provider
|
||||
is the fake OP in `tests/helpers/fake-oidc.mjs`: https on 127.0.0.1 with a throwaway self-signed certificate,
|
||||
and a fetch that refuses any other origin. The public Host in the tests is `console.example.test`, sent as a
|
||||
header to the test's own loopback listener. No test reads or writes `~/.mosaic-dev`, `~/.config/mosaic-dev`,
|
||||
`~/.mosaic`, a slot file outside its temp dir, or a live unit.
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Change |
|
||||
| --- | --- |
|
||||
| `package.json`, `package-lock.json` | `oauth4webapi` 3.8.8, exact, at the root (design: the copy runs `npm ci` there). No other dependency. |
|
||||
| `scripts/mosaic-config.mjs` | Accepts an optional `console.sso` key and checks it with `checkConsole`. Without the key, output is byte-identical (F2). |
|
||||
| `scripts/test-config.sh` | Test 25: a good `console.sso` passes; each refusal class refuses. 31/31. |
|
||||
| `packages/webui/src/sso-config.mjs` (new) | `checkConsole`: shape, `publicOrigin`, 1 to 8 providers, issuer https with a trailing slash, slot names, `ALGS` (asymmetric only), 1 to 64 subjects. |
|
||||
| `packages/webui/src/sso.mjs` (new) | The relying party: `readSlot`, discovery and JWKS with retry, `/auth/login`, `/auth/callback`, `/auth/logout`, sessions, `health()`, `summary()`, `formTargets()`. |
|
||||
| `packages/webui/src/serve.mjs` | Under `sso`: public Host only on the human listener (F4), `/auth/*` routes, session check before every page and API route, CSP `form-action` adds the end-session origins, tester listener refusals (F8). |
|
||||
| `packages/webui/src/cli.mjs` | `--sso`, its refusals, slot reads, the start lines, `close()` on stop. |
|
||||
| `packages/webui/src/chat-http.mjs` | Under `--sso` the session check already ran, so the header-user check is skipped; the Origin check uses `publicOrigin`. |
|
||||
| `packages/webui/src/public/app.js`, `bus.js`, `index.html` | 401 `sign-in required` signs the page out with a link to `/auth/login?return=...` (design test 24, F1). The Sign out form shows only when `/api/config` says `sso` (F7). |
|
||||
| `scripts/webui-service.sh` | Passes `--sso` through; `--send` refusal text names `--sso`. |
|
||||
| `packages/webui/tests/service.test.mjs` | The `--send` refusal regex follows the new text. |
|
||||
| `packages/webui/README.md` | New section "Sign-in in the Console (Q175 phase 1, #1690)". |
|
||||
| `packages/webui/tests/helpers/fake-oidc.mjs` (new) | The fake OP. |
|
||||
| `packages/webui/tests/sso.test.mjs` (new) | 28 tests: three positives and design tests 1 to 23 (some split in parts). |
|
||||
| `packages/webui/tests/sso-browser.test.mjs` (new) | 2 tests: design test 24 with F1, and F7. |
|
||||
|
||||
## Tests run (single files, this worktree, final tree)
|
||||
|
||||
| File | Pass | Fail |
|
||||
| --- | --- | --- |
|
||||
| sso | 28 | 0 |
|
||||
| sso-browser | 2 | 0 |
|
||||
| pinned-copy | 10 | 0 |
|
||||
| serve | 4 | 0 |
|
||||
| preview | 11 | 0 |
|
||||
| proof | 4 | 0 |
|
||||
| proof-cli | 3 | 0 |
|
||||
| send | 13 | 0 |
|
||||
| service | 31 | 0 |
|
||||
| tester-listener | 10 | 0 |
|
||||
| tester-cli | 2 | 0 |
|
||||
| board-held | 3 | 0 |
|
||||
| send-browser | 2 | 0 |
|
||||
| docs-browser | 1 | 0 |
|
||||
| chat | 23 | 0 |
|
||||
| chat-browser | 11 | 0 |
|
||||
| bus-browser | 2 | 0 |
|
||||
| settings-write | 4 | 0 |
|
||||
| project-file | 10 | 0 |
|
||||
| instances | 5 | 0 |
|
||||
| routes | 5 | 0 |
|
||||
| `scripts/test-config.sh` | 31 | 0 |
|
||||
|
||||
The first singles pass failed `pinned-copy` 1 of 10: `ERR_MODULE_NOT_FOUND: Cannot find package
|
||||
'oauth4webapi'` from a copy's `sso.mjs`. The test's fixture repo writes an empty package.json and lock, so the
|
||||
copy's `npm ci` installs nothing, and `sso.mjs` imported oauth4webapi at module load. `cli.mjs` imports
|
||||
`sso.mjs` on every start, so plain `serve` broke. Fix: `sso.mjs` loads oauth4webapi inside `createSso`
|
||||
(`oauth ??= await import('oauth4webapi')`). A Console without `--sso` never resolves the package. A real copy
|
||||
still has it from the root lock. Mutant 21 restores the static import and `pinned-copy` kills it. After the
|
||||
fix: pinned-copy, sso, sso-browser and serve rerun green (counts above).
|
||||
|
||||
Not run by me (slot work, busy at the time): the full webui package suite, the cli suite, and
|
||||
`scripts/test-*.sh` other than test-config. `packages/cli/tests/stack-copy.test.mjs` and
|
||||
`stack-release.test.mjs` also build copies with an empty lock and are the ones to watch for the same
|
||||
failure. They need Sage's gate.
|
||||
|
||||
## Mutants (`mutants.py`, run as `python3 mutants.py <tree> [N ...]`)
|
||||
|
||||
Each mutant edits one source file, runs one test file filtered to one test name, and restores the file
|
||||
checked by sha256. Final run on the final tree: `/mnt/storage/scratch/launchpad-r192/mutants-final.log`.
|
||||
|
||||
| # | Mutant | Test | Result |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | drop validateApplicationLevelSignature | sso 9 | KILLED |
|
||||
| 2 | wrap the signature call in a continuing try/catch | sso 9 | KILLED |
|
||||
| 3 | drop the subjects check | sso 10 | KILLED |
|
||||
| 4 | accept HS256 and none in ALGS | sso 11 start | KILLED |
|
||||
| 5 | take the provider from the query | sso 15 | KILLED |
|
||||
| 6 | tester listener reads sign-in cookies (F8) | sso 12 | KILLED |
|
||||
| 7 | tester listener takes Authorization (F8) | sso 12 | KILLED |
|
||||
| 8 | tester listener takes x-authentik-* (F8) | sso 12 | KILLED |
|
||||
| 9 | skip the login record delete | sso 7 | KILLED |
|
||||
| 10 | drop HttpOnly from the session cookie | sso 20 and 21 | KILLED |
|
||||
| 11 | refresh last-seen on every request | sso 13 idle | KILLED |
|
||||
| 12 | build redirect_uri from the request Host | sso 22 | KILLED |
|
||||
| 13 | serve the loopback read path under --sso (F4) | sso 22 | KILLED |
|
||||
| 14 | bus.js fetchRead ignores 401 sign-in required (F1) | sso-browser 1 | KILLED |
|
||||
| 15 | bus.js fetchApi ignores 401 sign-in required (F1) | sso-browser 1 | KILLED |
|
||||
| 16 | app.js getJSON ignores 401 sign-in required | sso-browser 1 | KILLED |
|
||||
| 17 | app.js api() ignores 401 sign-in required | sso-browser 1 | KILLED |
|
||||
| 18 | signInRequired takes any 401 | sso-browser 1 | KILLED |
|
||||
| 19 | Sign out shown without the sso condition (F7) | sso-browser 2 | KILLED |
|
||||
| 20 | CSP form-action without the end-session origins | sso 13 CSP | KILLED |
|
||||
| 21 | import oauth4webapi at module load | pinned-copy | KILLED |
|
||||
| 22 | restore the r1 relative returnPath (round 2, B1) | sso 14 | KILLED |
|
||||
| 23 | drop the second origin check (round 2, sage-1403) | sso 14 | SURVIVED, equivalent, guard kept (sage-1407) |
|
||||
|
||||
Mutant 16 survived its first run: test 1 had no `getJSON` path. The History click on
|
||||
`/api/conversations` was added and it is now killed.
|
||||
|
||||
## Deviations from the design
|
||||
|
||||
- **F1 (sage-1340).** `bus.js` `fetchRead` and `fetchApi` apply the same 401 `sign-in required` rule as
|
||||
`app.js`, even when `/api/config` has no `sso`. Tested in sso-browser 1, mutants 14 and 15.
|
||||
- **F7 (sage-1347).** A Sign out form (`POST /auth/logout`) in the command bar, hidden unless `/api/config`
|
||||
says `sso`. Tested in sso-browser 2, mutant 19.
|
||||
|
||||
- **Proof header on 7333 under `--sso` (webby-0019 N2).** `cli.mjs` passes `testerProofHeader: null` under
|
||||
`--sso`, since `--sso` refuses the proof pair and there is no header name to refuse. A request to 7333 that
|
||||
still carries the old proof header is not refused for it. It gains nothing: under `--sso` no listener
|
||||
reads a proof header (`proof` is null in `startServer`). Left as is.
|
||||
|
||||
## Assumptions and choices
|
||||
|
||||
- **F8 (sage-1347).** Under `--sso` the tester listener refuses a request carrying `Authorization`, either
|
||||
`__Host-mosaic` cookie or any `x-authentik-*` header, case-insensitive (Node lower-cases header names).
|
||||
sso 12 sends one of each; mutants 6 to 8.
|
||||
- **oauth4webapi loads lazily** inside `createSso` (see the pinned-copy failure above).
|
||||
- **Client authentication** is `ClientSecretBasic`; the fake OP's token endpoint checks Basic auth.
|
||||
- **Caps**: 1 to 8 providers, 1 to 64 subjects per provider, 64 sessions, 32 pending logins. The 33rd login
|
||||
drops the oldest record (sso 17).
|
||||
- **CSP**: under `--sso`, `form-action` adds each provider's end-session origin. Chrome applies
|
||||
`form-action` to the redirect after the Sign out POST, so without it the logout stops at the Console.
|
||||
Tested in sso 13 CSP, mutant 20. Without `--sso` the CSP string is unchanged.
|
||||
- **`--send` refusal text** in cli.mjs and webui-service.sh now ends "or --sso".
|
||||
- **Start lines**: one `Sign-in provider:` line per provider with id, issuer, subject count and state
|
||||
(`ready`, `refused` or `unreachable`). No client id, secret or sub.
|
||||
- **Test 1** uses `console.example.test` as the public Host, not mosaic.woltje.com (no request goes there).
|
||||
- **Test 11 slot files**: the owner check is not tested; the test can't create a file owned by another uid
|
||||
without privilege. Symlink, 0644, 0640 and a file under the repository are tested.
|
||||
- **Test 23** covers a provider down at start: the Console starts, `/auth/login` is 503, loopback `/healthz`
|
||||
is 200 with `sso: { a: 'unreachable' }`, one `discovery-failed` line, and the retry makes sign-in work. An
|
||||
HS-only discovery at start refuses the start (in test 23 and in test 11 start).
|
||||
- **Test 22**: a Host of bare `localhost` without a port is not in the table. It hits the older
|
||||
`non-local Host refused` check before the sso check; a comment in the test says so.
|
||||
|
||||
## Round 2 (webby-0019)
|
||||
|
||||
- **B1, open redirect after sign-in.** `returnPath` took any URL on `publicOrigin`, and dot segments can
|
||||
normalize to a pathname of `//evil.example`, which a browser reads as scheme-relative in a relative
|
||||
Location. Fix per sage-1403: `returnURL` (renamed) builds an absolute URL, `publicOrigin + pathname +
|
||||
search`, checks its origin again, and returns its href. Anything else is `publicOrigin/`. The callback's
|
||||
Location is now always absolute on the Console. Test 14 asserts the exact Location and its origin for each
|
||||
case. It adds Webby's three rows (`/.//evil.example`, `/x/..//evil.example`,
|
||||
`https://console.example.test//evil.example`), each now `https://console.example.test//evil.example`, a path
|
||||
on the Console. It also adds `/board/./x`, which lands on `/board/x`. The positive test expects
|
||||
`https://console.example.test/board?x=1`.
|
||||
- **Mutant 22** restores the r1 relative `returnPath`; test 14 kills it.
|
||||
- **Mutant 23** drops the second origin check. It SURVIVES, and I read it as equivalent: `checkConsole`
|
||||
holds `publicOrigin` to a bare https origin, and a parsed https pathname always starts with `/`, so
|
||||
`publicOrigin + pathname` can't parse to another origin. Webby agrees (webby-0022). Sage's ruling
|
||||
(sage-1407): the check stays as an unreachable guard, and 23 is listed here as equivalent. That is a packet
|
||||
note, not a candidate change.
|
||||
- **N1.** The design cite is now blob 9e3eadb15c80 (`git rev-parse 83b32d8d8:agents/sage/work/q175-sso/DESIGN.md`).
|
||||
- **N2.** Listed under Deviations.
|
||||
- **N3.** `SIGN_IN_COOKIE` has its own comment, above the row 177 comment and `TESTER_CHAT_WRITE`.
|
||||
- **N4.** Row 200 (#1698) covers it: `stack-release.sh` refuses `config-console-key` before any stop when the
|
||||
target's loader refuses the key, on activate and rollback.
|
||||
- **N5.** No change.
|
||||
|
||||
Round 2 runs, this worktree, final tree: `sso.test.mjs` 28/28 (`/mnt/storage/scratch/launchpad-r192/sso-r2.log`);
|
||||
mutants 22 and 23 (`/mnt/storage/scratch/launchpad-r192/mutants-r2.log`).
|
||||
|
||||
## Not done here
|
||||
|
||||
- Row 200 (#1698, `stack-release.sh activate --sso`) is chained on this candidate and starts only after the
|
||||
manifest is with Webby (sage-1369).
|
||||
- Full suites are for Sage's gate.
|
||||
@@ -1 +0,0 @@
|
||||
53bb59462120a03a9f54b02881895e3f575e501c
|
||||
@@ -1,18 +0,0 @@
|
||||
95d9a48812eebc415e3605239788e5b0746f1457a3bf0f9b9651a8c745e8b0f6 package.json
|
||||
390cbb0ef88de6cbc6d8f43b9e212abfdf0ef0e1c88fa02f5ab790caabc53eed package-lock.json
|
||||
726b71bcfdb4d9409cefc86bed2fbe8e678c6efb50bcf5bb22344aeda1eee201 packages/webui/README.md
|
||||
f7b6b68906f13a644854b8083287dfa2936e8defc6207d05739e89763cc91440 packages/webui/src/chat-http.mjs
|
||||
4a2d226bdbfee98b180103f71b3cb8122303cfac4aeece42bd30ce167ba6ff55 packages/webui/src/cli.mjs
|
||||
41ee8798c34ee491b80ae1b42c3e96cd5d12fb1603b1ab88ac2dd10b5fb27964 packages/webui/src/public/app.js
|
||||
e6c8f838eb7cdc2ae556c04451ca4c8f2d433117a17f285a144cfc7dff03e2a4 packages/webui/src/public/bus.js
|
||||
f276fa440ec938638cb3e0f1b812fafa929460a6b96f1dab4cf1076a7c0c18ea packages/webui/src/public/index.html
|
||||
333d474b1c7e753938aedc639c08f9db0e787faf56aa8068d013fd304a0b3b7e packages/webui/src/serve.mjs
|
||||
37b01ab0479d5f2a7ab9aac790931c6d88c790465392d9c0b65a105513f3e36b packages/webui/src/sso-config.mjs
|
||||
b624f044d361c648cefa161abe5f2b96e0cd8a83bc7816c9d501a2cd840988ca packages/webui/src/sso.mjs
|
||||
84e31d4beba0de5ed6d192921bd7b016296c8b2a42b3fcf1176663e182320d9e packages/webui/tests/helpers/fake-oidc.mjs
|
||||
ac01037743fcbf1efa641794d3878f113b6206033d2f66cde072a52ccfae1a51 packages/webui/tests/service.test.mjs
|
||||
9358e3755e174b61532e2c528e733ffcdb48941cbadb825f1b0254bc55d1920e packages/webui/tests/sso-browser.test.mjs
|
||||
3f8527fd7ae1ffb2bffce3450971f125bd616954b534577ef54249c87f36e248 packages/webui/tests/sso.test.mjs
|
||||
f8307d4bd998634467fbed8748157754132b76affac051f320196161ce385e4a scripts/mosaic-config.mjs
|
||||
3301c1fabf616bb961d62f6decd0a46dadb0f399b8ba7f3c2ca60023d2b24c78 scripts/test-config.sh
|
||||
7678bb6b45861c3dc6d775fc99269b1db7350ced60b89517d6ebeb4cc161781c scripts/webui-service.sh
|
||||
@@ -1,59 +0,0 @@
|
||||
# Row 192 (#1690) mutants: `python3 mutants.py <tree> [N ...]`. Each applies to the tree's source, runs one
|
||||
# test file (optionally one name pattern) and restores the file from a saved copy, checked by sha256.
|
||||
# Killed = test exit != 0.
|
||||
import hashlib, os, subprocess, sys, signal
|
||||
W = os.path.join(os.path.abspath(sys.argv[1]), 'packages', 'webui')
|
||||
if not os.path.isfile(os.path.join(W, 'src', 'sso.mjs')): sys.exit(f'ABORT no packages/webui/src/sso.mjs under {sys.argv[1]}')
|
||||
S, B, C, F = 'src/sso.mjs', 'src/serve.mjs', 'src/sso-config.mjs', 'src/public/'
|
||||
T, P = 'tests/sso.test.mjs', 'tests/sso-browser.test.mjs'
|
||||
M = [
|
||||
('drop validateApplicationLevelSignature (9b)', S, [("try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) {", "try { } catch (err) {")], T, '^9:'),
|
||||
('wrap the signature call in a continuing try/catch (9b)', S, [("try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) {", "try { try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch {} } catch (err) {")], T, '^9:'),
|
||||
('drop the subjects check (10)', S, [("if (name === undefined) {", "if (false) {")], T, '^10:'),
|
||||
('accept HS* in algs (11)', C, [("export const ALGS = Object.freeze(['PS256',", "export const ALGS = Object.freeze(['HS256', 'none', 'PS256',")], T, '^11: start'),
|
||||
('take the provider from the query (15)', S, [("const entry = record && providers.get(record.provider);", "const entry = record && providers.get(query.get('provider') ?? record.provider);")], T, '^15:'),
|
||||
('let the tester listener read cookies (12, F8)', B, [("SIGN_IN_COOKIE.test(req.headers.cookie ?? '') ||", "")], T, '^12:'),
|
||||
('tester listener takes Authorization (12, F8)', B, [("Object.hasOwn(req.headers, 'authorization') ||", "")], T, '^12:'),
|
||||
('tester listener takes x-authentik-* (12, F8)', B, [(" || Object.keys(req.headers).some(h => h.startsWith('x-authentik-'))", "")], T, '^12:'),
|
||||
('skip the login record delete (7)', S, [("if (key !== null) logins.delete(key);\n // The provider", "// The provider")], T, '^7:'),
|
||||
('drop HttpOnly from the session cookie (21)', S, [("`${SESSION_COOKIE}=${id}; ${ATTRS}`", "`${SESSION_COOKIE}=${id}; Path=/; Secure; SameSite=Lax`")], T, '^20 and 21'),
|
||||
('refresh seen on every request (13)', B, [("sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) })", "sso.session(req, { touch: true })")], T, '^13: idle'),
|
||||
('build redirect_uri from the request Host (22)', S, [("const redirectUri = `${publicOrigin}/auth/callback`;", "let redirectUri = `${publicOrigin}/auth/callback`;"), (" const query = new URLSearchParams(search);\n", " const query = new URLSearchParams(search);\n redirectUri = `https://${req.headers.host}/auth/callback`;\n")], T, '^22:'),
|
||||
('serve the loopback read path under --sso (22)', B, [("if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });", "")], T, '^22:'),
|
||||
('bus.js fetchRead ignores 401 sign-in required (F1)', F+'bus.js', [(" if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }\n if (!res.ok || !body || !Array.isArray(body.rows))", " if (!res.ok || !body || !Array.isArray(body.rows))")], P, '^a 401'),
|
||||
('bus.js fetchApi ignores 401 sign-in required (F1)', F+'bus.js', [(" if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }\n if (!res.ok || !body || typeof body !== 'object'", " if (!res.ok || !body || typeof body !== 'object'")], P, '^a 401'),
|
||||
('app.js getJSON ignores 401 sign-in required (24)', F+'app.js', [(" if (signInRequired(res, body)) { sso = true; signOut(); throw signedOut(); }\n return { ok: res.ok, body };", " return { ok: res.ok, body };")], P, '^a 401'),
|
||||
('app.js api() ignores 401 sign-in required (24)', F+'app.js', [(" if (signInRequired(res, result)) { sso = true; signOut(); throw signedOut(); }\n", "")], P, '^a 401'),
|
||||
('signInRequired takes any 401 (24)', F+'app.js', [("res.status === 401 && body?.error === 'sign-in required'", "res.status === 401")], P, '^a 401'),
|
||||
('Sign out shown without the sso condition (F7)', F+'app.js', [("if (config.sso === true) { sso = true; $('sign-out').hidden = out(); }", "{ $('sign-out').hidden = out(); }")], P, '^the Sign out'),
|
||||
('form-action without the end-session origins (CSP)', B, [("form-action 'self'${sso ? sso.formTargets().map(o => ` ${o}`).join('') : ''}`", "form-action 'self'`")], T, '^13: the CSP'),
|
||||
('import oauth4webapi at module load (copies)', S, [("let oauth;\n", "import * as oauth from 'oauth4webapi';\n"), (" oauth ??= await import('oauth4webapi');\n", "")], 'tests/pinned-copy.test.mjs', '^serve runs from a copy'),
|
||||
('restore the r1 relative returnPath (14, webby-0019 B1)', S, [(" if (u.origin !== publicOrigin) return home;\n const out = new URL(`${publicOrigin}${u.pathname}${u.search}`);\n return out.origin === publicOrigin ? out.href : home;", " return u.origin === publicOrigin ? u.pathname + u.search : '/';")], T, '^14:'),
|
||||
('drop the second origin check (14, sage-1403)', S, [(" return out.origin === publicOrigin ? out.href : home;", " return `${publicOrigin}${u.pathname}${u.search}`;")], T, '^14:'),
|
||||
]
|
||||
sha = lambda b: hashlib.sha256(b).hexdigest()
|
||||
only = sys.argv[2:]
|
||||
os.chdir(W)
|
||||
survived = []
|
||||
for i, (name, path, pairs, test, pat) in enumerate(M, 1):
|
||||
if only and str(i) not in only: continue
|
||||
orig = open(path, 'rb').read(); h = sha(orig)
|
||||
src = orig.decode()
|
||||
for a, b in pairs:
|
||||
if src.count(a) != 1: sys.exit(f'ABORT {i} {name}: pattern found {src.count(a)} times')
|
||||
src = src.replace(a, b)
|
||||
try:
|
||||
open(path, 'w').write(src)
|
||||
p = subprocess.Popen(['node', '--test', f'--test-name-pattern={pat}', test], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try: out, _ = p.communicate(timeout=240)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(p.pid, signal.SIGKILL); out, _ = p.communicate(); out += b'\nTIMEOUT'
|
||||
text = out.decode(errors='replace')
|
||||
ran = [l for l in text.splitlines() if l.startswith('ℹ tests ')]
|
||||
verdict = 'KILLED' if p.returncode != 0 else 'SURVIVED'
|
||||
if verdict == 'SURVIVED': survived.append(name)
|
||||
print(f'{i:2} {verdict} {name} [{ran[0] if ran else "no count"}]', flush=True)
|
||||
finally:
|
||||
open(path, 'wb').write(orig)
|
||||
if sha(open(path, 'rb').read()) != h: sys.exit(f'ABORT restore failed for {path}')
|
||||
print('survived:', survived or 'none')
|
||||
@@ -1,7 +0,0 @@
|
||||
# Morgana
|
||||
|
||||
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 193 (human users and RBAC, #1691) under mos-0974 and mos-0977.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `morgana/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -338,14 +338,10 @@ them. Each entry has its own issuer, client, algs and subjects, and a session re
|
||||
1. Back up the store, the pointer, the unit and config.json.
|
||||
2. Pin the phase 1 copy with the Console stopped. The copy's loader accepts a config with or without
|
||||
`console`, so the bus and seats keep reading the old config.
|
||||
3. Straggler check (Mos's C1 condition, mos-0977): list every running process, unit and timer that
|
||||
still runs from the old copy and reads config.json. Each one is restarted on the new copy or shown
|
||||
not to re-read config. The old loader refuses the key, so a straggler would fail on its next read.
|
||||
Step 4 waits until the list is clean.
|
||||
4. Jason step 4: the `console.sso` key goes into config.json, written by Jason or by an agent on his go
|
||||
3. Jason step 4: the `console.sso` key goes into config.json, written by Jason or by an agent on his go
|
||||
for that one key. The new loader reads it back (`scripts/mosaic-config.mjs` validate) before anything
|
||||
starts.
|
||||
5. Start the Console with `--sso --send --tester-port 7333`.
|
||||
4. Start the Console with `--sso --send --tester-port 7333`.
|
||||
Acceptance: Jason signs in through the Console flow, sends one message and signs out. The forged-header
|
||||
check (a request with `X-authentik-username` and no cookie gets 401) runs either on a scratch Console or
|
||||
on live by Mos or Jason (B6). No agent sends the auth header to the live 7332 (section 8). The scratch
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# Common start for Sage's MVP T1 builder seats (mos-0974, mos-0977)
|
||||
|
||||
You are a builder seat on Mosaic Stack, repository /mnt/storage/src/mosaic-stack, branch base `refactor`.
|
||||
Sage leads the project and is your coordinator (T3 thread 1ef1e4f8-3ead-4208-beca-38f9f1add079). Mos speaks
|
||||
for Jason (thread 38bc4c81-6270-4021-b00c-6a15c43b9b0e). Human input reaches you only through Sage and Mos.
|
||||
|
||||
## Read first, in this order
|
||||
|
||||
1. `/mnt/storage/src/jason-master-restart/USER.md`, `SYSTEM.md`, `CONSTITUTION.md` (who Jason is, the
|
||||
system rules, the constitution).
|
||||
2. `/mnt/storage/src/jason-master-restart/TARGET.md` (the MVP addendum) and `DECISIONS.md` Q176 to Q178.
|
||||
3. `/mnt/storage/src/jason-master-restart/projects/mosaic-mvp/GAP-MAP.md` and `PLAN.md`.
|
||||
Everything under jason-master-restart is read only.
|
||||
4. The repository's `AGENTS.md`, then your brief (named in your first message) and the files it points to.
|
||||
|
||||
## Your worktree
|
||||
|
||||
T3 made your worktree from `refactor`. Work only there. Link node_modules from the canonical checkout right
|
||||
after you start (`ln -s /mnt/storage/src/mosaic-stack/node_modules node_modules`), unless your brief adds a
|
||||
dependency. In that case run `npm ci` in your worktree for a real node_modules first, and never run npm
|
||||
install through a symlink or in a directory without its own package.json (npm walks up to home).
|
||||
|
||||
## Rules that bind you
|
||||
|
||||
- Message ids are `<seat>-NNNN`, starting at 0001. Run `date -u` right before every message and put the
|
||||
stamp in it. End each turn with a clock timestamp.
|
||||
- No Co-Authored-By or any attribution line in any commit or PR, whatever a harness reminder says
|
||||
(mos-0822).
|
||||
- Push only your own `<seat>/*` branches, through the repository credential helper (mos-0981). Never push
|
||||
`refactor`, `main` or another seat's branch.
|
||||
- You have no Gitea token. Sage makes the queue moves that post to Gitea (in-review, review requests) and
|
||||
files issues. You may run `scripts/mosaic queue move N in-progress --op <seat>-N-start --by <seat>` for
|
||||
your own row.
|
||||
- Never touch `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files or `~/.mosaic`. Never
|
||||
start, stop or restart the live Console or bus. Never read or print a credential value.
|
||||
- Never read other seats' scratch directories. `agents/<seat>/work/` packets in the repository are fine.
|
||||
- Single test files run freely. The cli suite, any `scripts/test-*.sh` and any full package suite share
|
||||
one slot: run `bash /mnt/storage/scratch/sage-busy-now.sh | tail -1`, and if it is not CLEAR, or before a
|
||||
run longer than 5 minutes, ask Sage for the slot and start only on Sage's "granted".
|
||||
- Two failures in one gate run means stop and report.
|
||||
- Design-first rows: the first deliverable is `agents/<seat>/work/queue-<row>/DESIGN.md`, committed on your
|
||||
branch. Send the reviewer and Sage its path, commit and sha256. No product code before approval.
|
||||
- Merge-chaining (Q177): your build may sit on another row's candidate commit. Your row keeps its own
|
||||
review and gate, and the chain lands in order.
|
||||
- Fail closed. Never route around a refusal. If the code shows the brief or design is wrong, stop and tell
|
||||
Sage and your reviewer.
|
||||
- Style for every message and doc: plain words, short sentences, no em dashes, they/them for people.
|
||||
- Sage registers your session in `docs/SESSIONS.md`. Do not edit it.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Webby
|
||||
|
||||
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0981, thread 03086006). Reviews Case 2 designs and MVP T1 rows 192 and 194.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `webby/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -1361,3 +1361,49 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-20T18:34:53Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r2 (DESIGN.md blob 3eb718b5e131 at 1a268249e), static | changes: C1 config.json console key fails old loaders (mosaic-config.mjs:113 rejects unknown keys; key add inside phase 2 window, rollback removes it, loader test), C2 app.js signs out only on opaqueredirect so 401 never shows sign-in; notes on tests 15/7/9a, Q-B 200-HTML classification, scratch Console flags; Q-E keep iss/sub escaped and capped; verdict sent to Sage, no Gitea/queue record
|
||||
2026-10-20T18:40:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r3 (DESIGN.md blob a152fd58e18e at 817e8f51b), static | approve on condition D1 (slot file checks stay out of shared loader, shape-only there) as a line edit; D2 notes: slot root from configDir, scratch dataRoot, name discovery GETs for Mos; C1/C2 and notes verified; Mos C1 sequence confirm still open; no Gitea/queue record
|
||||
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:51:09Z | Duckworth (Opus 5.5) | Gizmoduck federation design r1 review (DESIGN.md blob 0f4570d478f5 at 6925f78f0, code at 9ae8c73ac), static | changes B1-B5 (forwarded user identity, ownership code, business-wide reads, revocation ends no runs reliably, one-uid isolation); notes N1-N9; agents/duckworth/work/federation-r1/REVIEW.md
|
||||
2026-10-20T18:58:59Z | Duckworth (Opus 5.5) | Gizmoduck federation design r2/r2.1 re-review (5ad67f671, parents 60b9b112a, 6925f78f0; code 9ae8c73ac), static | changes B6 only (user from_role breaks the runner reply path, runner.mjs:450 vs broker.mjs:1862); B1-B5 resolved; C1 line edit (systemd-run escape); N10; agents/duckworth/work/federation-r1/REVIEW-r2.md
|
||||
2026-10-20T19:03Z | Duckworth (Opus 5.5) | federation design r3+r3.1 re-review (Gizmoduck 5ad67f671..a2840a6be), duckworth-0003 | changes B7 (R8 gate lacks sudo/ssh privilege-path checks in both isolation forms), B1-B6 resolved, N11 #waiting, N12 thread_shares
|
||||
2026-10-20T19:10Z | Duckworth (Opus 5.5) | federation design r4 re-review (Gizmoduck a2840a6be..a659e41f3), duckworth-0005, plus mos-1007 rulings | changes B8 (hub issuer and TLS front end not in R8 gate), B7/N11/N12 resolved, sudo deviation accepted, N13-N15
|
||||
2026-10-20T19:14Z | Duckworth (Opus 5.5) | row 196 design r1 review (duckworth-0006), Gander 76b5c8d93 | changes B1 (concurrent peer-binding test), Q1 O1 with interpreter pin, Q2 Console provenance approved
|
||||
2026-10-20T19:16Z | Duckworth (Opus 5.5) | federation design r4.1 re-review (duckworth-0007), Gizmoduck a659e41f3..73677b87d | changes, B8 issuer-host point left; TLS, N13-N15 resolved; notes N16-N19
|
||||
2026-10-20T19:18Z | Duckworth (Opus 5.5) | row 193 design r2 review (duckworth-0008), Morgana 19c015741 | changes B1 (5.7 owner-visible reads), B2 (5.7 filters before user threads go live); rulings 1-6
|
||||
2026-10-20T19:19Z | Duckworth (Opus 5.5) | row 196 design r2 re-review (duckworth-0009), Gander 76b5c8d93..5fe259f16 | approve; note N5 interpreter change on restart
|
||||
2026-10-20T19:20Z | Duckworth (Opus 5.5) | federation design r4.2-r4.4 re-review (duckworth-0010), Gizmoduck 73677b87d..b67bc6de9 | changes B9 (user-thread decision route :1683); B8, N16-N19 resolved; note N20
|
||||
2026-10-20T19:22Z | Duckworth (Opus 5.5) | row 193 design r3 re-review (duckworth-0011), Morgana 19c015741..76f4605e1 | changes B3 (5.7 table omits project.file reads), B1 B2 N1-N4 resolved
|
||||
2026-10-20T19:22Z | Duckworth (Opus 5.5) | federation design r4.5 re-review (duckworth-0012), Gizmoduck b67bc6de9..acd5f1f42 | approve, B9 N20 resolved, note N21 R6 notice wording
|
||||
2026-10-20T19:25Z | Duckworth (Opus 5.5) | row 199 design r1 review (duckworth-0013), Megavolt 624e8ea3a | changes B1 (missing store file stops bus start), B2 (token not bound to remote host, redirects), notes N1-N5
|
||||
2026-10-20T19:25Z | Duckworth (Opus 5.5) | row 193 design r4 re-review (duckworth-0014), Morgana 76f4605e1..edc794933 | approve, B3 and N5 resolved
|
||||
2026-10-20T19:29Z | Duckworth (Opus 5.5) | row 199 design r2+r3 re-review (duckworth-0017), Megavolt c2c729ddf | changes B3 (T3 fake is http, https-only flags untested), B4 (decision binds no commit, push by ref name)
|
||||
2026-10-20T19:29Z | Duckworth (Opus 5.5) | row 193 design r5 re-review (duckworth-0018), Morgana e2d161e2e | approve
|
||||
2026-10-20T19:29Z | Duckworth (Opus 5.5) | federation design r4.6 re-review (duckworth-0019), Gizmoduck 1b32e65e3 | approve
|
||||
2026-10-20T19:30Z | Duckworth (Opus 5.5) | federation design r4.7 re-review (duckworth-0020), Gizmoduck 2d3c3cfbd | approve, N22 (R8 checks the mosaic user's properties)
|
||||
2026-10-20T19:31Z | Duckworth (Opus 5.5) | federation design r4.8 re-review (duckworth-0021), Gizmoduck eb651aa68 | approve, N22 resolved
|
||||
2026-10-20T19:33Z | Duckworth (Opus 5.5) | row 199 design r4 re-review (duckworth-0022), Megavolt e1aa6216c | approve, N9 (post-routing source commit)
|
||||
2026-10-20T19:36Z | Duckworth (Opus 5.5) | Q180 tightening plan r2 review (duckworth-0023), ops-01 plan sha256 1084aa15, #421 | changes B1-B5 (lockout proof, new key off factory hosts, live group removal, cloud-init order, unlisted paths)
|
||||
2026-10-20T19:36Z | Duckworth (Opus 5.5) | federation r4.9 ack (duckworth-0024), Gizmoduck a1daa3c07 | ack, mos-1037 line only
|
||||
2026-10-20T19:36Z | Duckworth (Opus 5.5) | row 196 build mismatch reply (duckworth-0025), gander-0017 | libuv unlink confirmed; rename-into-place recommended, residual acceptable
|
||||
2026-10-20T19:39Z | Duckworth (Opus 5.5) | row 199 design r5 review (duckworth-0026), Megavolt 34bfe4a53 DESIGN.md sha256 d0b326bb | changes B5 (D9a ls-remote and push still name the upstream, not the derived fork), B6 (state the URL parse, name rule, case-insensitive owner compare)
|
||||
2026-10-20T19:42Z | Duckworth (Opus 5.5) | row 199 design r6 review (duckworth-0027), Megavolt 4d3007ec9 DESIGN.md sha256 aa458dfe | approve; B5 B6 N10 N11 resolved, D9c normalization claims checked in node
|
||||
2026-10-20T19:44Z | Duckworth (Opus 5.5) | Q180 tightening plan r3 review (duckworth-0028), ops-01 plan sha256 cd7165bf, #421 | changes B6 (root persistence sweep on all four hosts or rebuild 1012); B1-B5 N1-N3 resolved
|
||||
2026-10-20T20:02Z | Duckworth (Opus 5.5) | #423 VM tightening r4 steps 1-3 review (duckworth-0032), ops-01 plan sha256 03cd1075 (amends eadff6b6) | changes B1 (step 2 verify must read sshd -T permitrootlogin and root's authorizedkeysfile); N1-N7; steps 4-5 not reviewed (held, mos-1061/1062)
|
||||
2026-10-20T20:02Z | Duckworth (Opus 5.5) | row 201 FED R1 manifest review r1 (duckworth-0033), Gizmoduck 80fe31071 | approve; digests match, access.test.mjs 24/24 in scratch worktree; deviation 2 reading confirmed; N1 covers without level, N2 checked grants only
|
||||
2026-10-20T20:02Z | Duckworth (Opus 5.5) | federation design r4.10 + r4.11 ack (duckworth-0034), Gizmoduck 3568441ab and b93f098f0 | ack; R8 recorded acceptance, quotes checked vs DECISIONS Q182/Q183, call A blocking agreed, call B correct; N1 N2 doc-only
|
||||
2026-10-20T20:05Z | Duckworth (Opus 5.5) | #423 VM tightening r5 steps 1-3 diff review (duckworth-0035), ops-01 r5 sha256 4cbdc48e on base 03cd1075 | approve steps 1-3; B1 N1-N7 resolved, recount 18 checked; a #424-changed step 1 returns as a diff; N8-N10
|
||||
2026-10-20T20:11Z | Duckworth (Opus 5.5) | row 204 (193a) round 1 manifest review, morgana/row193a 0caf6e2f6, digest d38de8c9 | approve (duckworth-0037), N1-N3 notes, 5/5 and 26/26 single-file
|
||||
2026-10-20T20:14Z | Duckworth (Opus 5.5) | PRD 1.7 draft REQ-CRED-3 review, honker/row194 0bb1a3bd0 sha256 0236dee8; row 204 queue record rev 2335 | changes B1-B2 (duckworth-0038), quotes match DECISIONS 1c368ff
|
||||
2026-10-20T20:16Z | Duckworth (Opus 5.5) | PRD 1.7 draft r2 diff check, honker/row194 11cb0423b sha256 e896746d | approve (duckworth-0039), B1-B2 resolved
|
||||
2026-10-20T20:18Z | Duckworth (Opus 5.5) | row 202 FED R5 round 1 manifest review, quackerjack/fed-r5 030809e15 manifest 959bfb275 | changes B1 (duckworth-0040), BusinessError from loadAccess/loadBusiness escapes uncaught (probed), N1-N9, 18/18 single-file
|
||||
2026-10-20T20:24Z | Duckworth (Opus 5.5) | row 202 FED R5 round 2, Quackerjack candidate 48a3fb96e manifest 7fd265033 | approve (duckworth-0041), B1 resolved, 21/21 single-file, N1-N4
|
||||
2026-10-20T20:34Z | Duckworth (Opus 5.5) | row 210 (#1707) DESIGN.md r1, Gizmoduck 7b10bc0ff | changes B1-B4 (duckworth-0042): argv not in event log, decision stdin unseen, orphan ssh-agent after bus death, section 4 not routed to Jason; N1-N8
|
||||
2026-10-20T20:39Z | Duckworth (Opus 5.5) | row 210 DESIGN r2 diff check, Gizmoduck b726e10f4 | approve with C1 (duckworth-0043): B1-B4 resolved, Q-J0 revoke line must match section 6 before Mos sends it; N1-N5
|
||||
2026-10-20T20:42Z | Duckworth (Opus 5.5) | row 210 DESIGN r3 C1 diff check (duckworth-0044) on Gizmoduck e021dfa26 | C1 met, approve stands, N1
|
||||
2026-10-20T20:43Z | Duckworth (Opus 5.5) | #423 r5 step 4 .53 jarvis sudo and groups (duckworth-0045), reboot timing per mos-1092 | approve with C1-C4, reboot stands in for B3 restart
|
||||
2026-10-20T20:45Z | Duckworth (Opus 5.5) | #423 step 4 scripts (step4.sh, check4.sh, postboot.sh, boot-session optional block) vs duckworth-0045 (duckworth-0046) | C1 C3 met, C2 C4 open, N1-N2
|
||||
2026-10-20T20:47Z | Duckworth (Opus 5.5) | row 195 DESIGN r2 review (duckworth-0047) on Darkwing 818b79696 | changes B1 (.53 activate, gate, stopped-store backup), N1-N6
|
||||
2026-10-20T20:49Z | Duckworth (Opus 5.5) | #423 step 4 revised scripts (step4.sh 5aea3856, check4.sh 8f49a409, containers.sh 837bb7a4, postboot.sh, boot-session f0645604) (duckworth-0048) | C1-C4 met, C5 stale shas in boot-session block, N1-N3
|
||||
2026-10-20T20:51Z | Duckworth (Opus 5.5) | #423 step 4 N1/N2 diff and C5 check, step4.sh cc16d384, boot-session ed44ec81 (duckworth-0049) | holds, no new condition
|
||||
2026-10-20T20:59Z | Duckworth (Opus 5.5) | row 195 DESIGN r3 review, Darkwing 9a16d7795 blob 0fea86616b36 (duckworth-0050) | changes, B1 ACT/TO hand-off, B2 gate poll and PM journal read
|
||||
2026-10-20T21:11Z | Duckworth (Opus 5.5) | row 195 DESIGN r4 review, Darkwing 0075be358 blob 3f8590016b40, with mos-1098 items (duckworth-0051) | changes, B1 rollback open-activation, B2 profile upgrade owner
|
||||
2026-10-20T21:22Z | Duckworth (Opus 5.5) | row 210 round 1 code review (duckworth-0052) on Gizmoduck 12ffe0b56 | changes B1 (sshd skip passes the gate), N1-N5; 17 singles 134/0
|
||||
2026-10-20T21:33Z | Duckworth (Opus 5.5) | row 210 round 2 diff check (duckworth-0053) on Gizmoduck 455964f23 | approve, B1 met (missing sshd fails 13, skipped 0), N1-N3 taken; 17 singles 137/0/0 skipped
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto
|
||||
|
||||
Sage, 2026-10-20, for mos-0974 (MVP, DECISIONS Q176, Vikunja #414 "User and main agent can create and spawn
|
||||
agents" and "User maintains ability to terminate, regenerate, or create agents via WebUI or CLI ad-hoc", #415
|
||||
Case 1 "Chief of staff agent has hire/fire authority for agents as needed; unless user forbids per agent" and
|
||||
"User maintains ability to restructure organization at will"). Gitea #1692. T1 item 5. Owner Honker, reviewer Webby (mos-0981), gate
|
||||
owner Sage. Base: refactor at the brief commit.
|
||||
|
||||
Design first. The first deliverable is `agents/honker/work/queue-194/DESIGN.md`. Webby reviews it before any
|
||||
code.
|
||||
|
||||
## What exists today (GAP-MAP.md G8, G9, C1.5, C1.6, measured at 9ae8c73ac)
|
||||
|
||||
- The chief launches only listed instances (`roles/chief-of-staff.json`, harness `tools.mjs` launch,
|
||||
`broker.mjs:1968`). No user verb launches an arbitrary agent.
|
||||
- Terminate exists (`packages/cli/src/cli.mjs:447`, `instances.mjs:9-11`, webui `serve.mjs:288, 535`). There
|
||||
is no regenerate, no delete and no Console launch.
|
||||
- Instance changes need a bus restart (`instance-settings.mjs:35,75`, `organization.mjs:83`). Add and change
|
||||
only, no delete.
|
||||
- Human-only membership acts as a veto today, but there is no per-agent veto list.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. The verbs: create (from a role and a name), launch, terminate, regenerate (a fresh session with the same
|
||||
identity and role), and delete (with what happens to its sessions, workspace and run evidence, which
|
||||
stays write-once). Each one is a human verb on the CLI and a Console action.
|
||||
2. Chief hire/fire. What the chief may do on its own, and the per-agent veto: the human marks an agent so
|
||||
the chief can't fire or replace it, and marks roles the chief can't hire. Where the veto lives and how
|
||||
the broker enforces it.
|
||||
3. Live changes. Which changes take effect without a bus restart, and how a running bus picks them up
|
||||
safely.
|
||||
4. RBAC. Human roles come from row 193 (Morgana). Until it lands, these verbs are owner-only. The design
|
||||
states the hook that row 193 fills.
|
||||
5. Per instance. Row 190 runs one instance per OS user. Lifecycle verbs act on their own instance only.
|
||||
6. The kill switch (row 191, Rocko) refuses every launch path. These new launch paths check it too.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file.
|
||||
- Role authority is declared in `roles/` and changes only by reviewed commits (AGENTS.md invariant 7). This
|
||||
row adds verbs, not new role authority. Any role file change is called out for review.
|
||||
- The build may chain on rows 190, 191 and 193 candidates (Q177).
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves DESIGN.md, then the manifest. The bus, cli, business and webui suites and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
|
||||
@@ -1,56 +0,0 @@
|
||||
# Brief: many humans per business, human roles, private and shared threads
|
||||
|
||||
Sage, 2026-10-20, for mos-0974 and mos-0977 (MVP, DECISIONS Q176, Vikunja #414 "RBAC enforcement" and #415
|
||||
Case 1 "Additional users can be configured for access" and "Shared and private agent sessions available per
|
||||
user; RBAC"). Gitea #1691. T1 item 3. Owner Morgana, reviewer Duckworth, gate owner Sage. Base: refactor at the brief
|
||||
commit.
|
||||
|
||||
Design first. The first deliverable is `agents/morgana/work/queue-193/DESIGN.md`. Duckworth reviews it before
|
||||
any code.
|
||||
|
||||
## What exists today (GAP-MAP.md, measured at 9ae8c73ac)
|
||||
|
||||
- One human per business: `packages/business/src/business.mjs:160`, and the broker's human check
|
||||
(`packages/bus/src/broker.mjs:532-538`).
|
||||
- Threads have an owner field only (`packages/bus/schema.sql:247`, `broker.mjs:353-360`). Nothing is private
|
||||
or shared.
|
||||
- RBAC covers agents only (`role.mjs`, `vocabulary.mjs`, `broker.mjs:769-799`, harness `gate.mjs`). Humans
|
||||
have no roles.
|
||||
- The human proof is path-bound (#225, P19 333): any same-uid process that runs the tree's `human-cli.mjs` acts
|
||||
as the human. That limit is real and the design must say how several humans change it.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. The human model. How a business lists several humans, how each is identified, and how a Console SSO
|
||||
session (#412, row 192: each session names a provider subject mapped to a name) and a CLI call each
|
||||
become exactly one of them. Starting rule (Mos, mos-0985, from Duckworth's federation review B1 at
|
||||
b402197ff on origin duckworth/reviews): the bus takes a human principal only from something it verifies,
|
||||
such as the Console's OIDC session under #412. A process's own claim of who it is never counts.
|
||||
2. Human roles. A small fixed set to start (for example owner, member, viewer), what each may do on the
|
||||
bus and in the Console, and who may add or remove humans and change roles. Restructuring stays with
|
||||
the owner (Case 1).
|
||||
3. Threads. Private threads (visible to their owner and the agents in them) and shared threads (visible to
|
||||
named humans or all members). Schema change, migration of existing rows (all become the current human's),
|
||||
and every read path that must filter.
|
||||
4. One user and grant store. Gizmoduck's federation design (thread 9312a896, design 6925f78f0 on
|
||||
gizmoduck/federation-design) proposes `access.json` for grants. Mos's reading (mos-0977): one model for
|
||||
local and federated humans, because two stores drift. Lead decision (Sage, 2026-10-20, to gizmoduck-0002):
|
||||
row 193 takes Gizmoduck's human-authored `<configDir>/access.json` (federation DESIGN.md section 3.5) as
|
||||
the one user and grant store, and extends it with local humans, roles and shared threads. Federation adds
|
||||
only a `home` field. The design confirms this or brings a concrete reason against it to Sage and
|
||||
Gizmoduck before the build. Federation's R1 (the access.json loader) then comes before this row's build.
|
||||
5. The bus human boundary. With several humans, the human verbs must know which human calls. State what a
|
||||
same-uid process can still do (P19 333) and whether row 190's one-instance-per-OS-user direction narrows
|
||||
it.
|
||||
6. Migration and rollback. Existing businesses keep working with their one human as owner.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Changing who can reach the Console from outside is Jason's. This row changes who is allowed once inside.
|
||||
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file.
|
||||
- The build may chain on rows 190 and 192 candidates (Q177). Each row keeps its own review and gate.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, then the manifest. The bus, business, cli and webui suites and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
|
||||
@@ -1,47 +0,0 @@
|
||||
# Brief: Q175 phase 1, the Console verifies SSO itself (--sso)
|
||||
|
||||
Sage, 2026-10-20, for Q175 (Jason's ruling), mos-0971/0973/0977 and Vikunja #412. Gitea #1690. MVP T1 item 2. Owner
|
||||
Launchpad, reviewer Webby (mos-0981), gate owner Sage. Base: refactor at the brief commit.
|
||||
|
||||
The design is approved: `agents/sage/work/q175-sso/DESIGN.md` at d55d86bb0 (blob eba861f5d2bb), Filbert r3
|
||||
approve plus the D1/D2 ack, Mos C1 confirmation in mos-0977, whose straggler-check condition is in the phase 2 window at b416d2ede. Build phase 1 exactly as the design says. If
|
||||
the code shows the design is wrong somewhere, stop and tell Sage, Webby and Filbert before building around it.
|
||||
|
||||
## Scope (phase 1 only, design section 5 item 1)
|
||||
|
||||
- oauth4webapi 3.8.8, pinned exactly (design section 2). No other new dependency.
|
||||
- The OIDC relying party in the Console: `/auth/login`, `/auth/callback`, `/auth/logout`, `/auth/signed-out`,
|
||||
the callback steps 1-7, our own checks on top of the library, and sessions, cookies and Host rules
|
||||
(design sections 1 and 3).
|
||||
- The shared loader, `scripts/mosaic-config.mjs`, accepts an optional `console` key and checks only its
|
||||
shape. The slot file checks and the read run in the Console's `--sso` start, and the slot resolves from
|
||||
the config dir (section 4, D1, D2).
|
||||
- The degraded start for an unreachable provider, and the unreachable/refused classes (Q-B, section 4).
|
||||
- `--sso` for `cli.mjs serve` and `webui-service.sh`, with its refusals against the old flags.
|
||||
- The tester listener rules (section 6).
|
||||
- The page: both fetch paths in `packages/webui/src/public/app.js` (:111 and :305) treat a 401
|
||||
`sign-in required` as signed out, and the notice links to `/auth/login?return=<path>` (C2).
|
||||
- Logging as the design says, including the Q-E `subject-unknown` line.
|
||||
|
||||
Out of scope: the phase 2 window, the live config.json key, phase 3 removals, the proxy-forwarded token
|
||||
(section 7), and any change to the live Console.
|
||||
|
||||
## Tests and mutants
|
||||
|
||||
Design section 9: tests 1-25, the positive paths, and every listed mutant, each killed by its named test.
|
||||
All tests run against the fake OP in `packages/webui/tests/helpers/fake-oidc.mjs`. No test reaches a real
|
||||
provider or Authentik, and no test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a
|
||||
slot file. Test 25 goes in `scripts/test-config.sh`.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No request of any kind to auth.diversecanvas.com, mosaic.woltje.com or the live 127.0.0.1:7332 (mos-0971,
|
||||
design section 8).
|
||||
- No credential value is read, printed or logged.
|
||||
- `npm install` runs only inside the worktree that has its own package.json, never in a directory that would
|
||||
walk up to home.
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves the manifest. Filbert approved the design and answers design questions. The webui and cli suites, `scripts/test-config.sh` and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
|
||||
+1
-4
@@ -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 2236 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,6 @@ 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 | — |
|
||||
| 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 | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+1
-471
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"canonicalRoot": "/mnt/storage/src/mosaic-stack",
|
||||
"revision": 2247,
|
||||
"revision": 2236,
|
||||
"rows": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -19820,153 +19820,6 @@
|
||||
"createdAt": "2026-10-20T10:31:30.403Z",
|
||||
"updatedAt": "2026-10-20T11:11:25.098Z",
|
||||
"updatedBy": "sage"
|
||||
},
|
||||
{
|
||||
"id": 189,
|
||||
"piece": "stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413)",
|
||||
"owner": "sage",
|
||||
"issues": [
|
||||
1687
|
||||
],
|
||||
"closes": [
|
||||
1687
|
||||
],
|
||||
"state": "in-review",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
"gate": "Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed",
|
||||
"gateOwner": "sage",
|
||||
"brief": {
|
||||
"path": "docs/plans/2026-10-20_activate-no-send.md",
|
||||
"anchor": "Brief: activate --no-send drops the Console's --send",
|
||||
"blob": "0444b774faf09084311357fdd59b0ef3a3d347eb"
|
||||
},
|
||||
"after": [
|
||||
{
|
||||
"id": 163,
|
||||
"when": "done"
|
||||
}
|
||||
],
|
||||
"reviewers": [
|
||||
"dewey"
|
||||
],
|
||||
"review": {
|
||||
"rounds": [
|
||||
{
|
||||
"n": 1,
|
||||
"op": "sage-189-inreview-1",
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:46:06.124Z",
|
||||
"issue": 1687,
|
||||
"candidate": {
|
||||
"kind": "manifest",
|
||||
"digest": "67af69bd579561fd8c20be2a47695240a9f09f119dbfff88a8e72e2a7416e668",
|
||||
"text": "70dbc89c0282fa063514317262d8926d16b8b8aaab4a1e47379303918495fc2c agents/sage/work/queue-189/BUILD.md\n4bd216cce2b40b39998725daa8989f6e3acb3ccd81a7017f6b09026af3965584 agents/sage/work/queue-189/mutants-run.log\nddbdddf03abf4aa91aa62f52e468be7af3c6b2f5d7bec2ee99b11e315471f18c agents/sage/work/queue-189/mutants.sh\n5d7b2da84d994f4f93d1212e349b4e4c19c5c00780e6cfcde91edb3ca08c014e docs/plans/2026-10-20_activate-no-send.md\n38e410c4cb731390f96747646eb7f6caa7a587fca9024ccbe901aab5ac19c7cf docs/TOOLS.md\nfe430969cc669515e348fced1a22cbe839a12023f7ac67cfba905b7afd29872f packages/cli/tests/stack-release.test.mjs\n16f77305e558f4fb953accc6d09f1f7b408aba363851d8ca79216ffce41bed9f scripts/stack-release.sh\n"
|
||||
},
|
||||
"request": "comment",
|
||||
"attempts": [
|
||||
{
|
||||
"op": "sage-189-inreview-1",
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:46:06.124Z",
|
||||
"state": "posted",
|
||||
"comment": 29457,
|
||||
"transport": {
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 29457,
|
||||
"detail": "created"
|
||||
},
|
||||
"resolutions": []
|
||||
}
|
||||
],
|
||||
"duplicateRisk": false,
|
||||
"receipts": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"claim": {
|
||||
"seat": "sage",
|
||||
"op": "sage-189-start"
|
||||
},
|
||||
"note": null,
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-20T18:21:38.646Z",
|
||||
"updatedAt": "2026-10-20T18:46:24.267Z",
|
||||
"updatedBy": "sage"
|
||||
},
|
||||
{
|
||||
"id": 190,
|
||||
"piece": "several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414)",
|
||||
"owner": "darkwing",
|
||||
"issues": [
|
||||
1688
|
||||
],
|
||||
"closes": [
|
||||
1688
|
||||
],
|
||||
"state": "in-progress",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
"gate": "Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed",
|
||||
"gateOwner": "sage",
|
||||
"brief": {
|
||||
"path": "docs/plans/2026-10-20_multi-instance.md",
|
||||
"anchor": "Brief: several Mosaic Stack instances on one host",
|
||||
"blob": "79fa60fbf17a352edc5431d6aabc77c72ec5c48f"
|
||||
},
|
||||
"after": [],
|
||||
"reviewers": [
|
||||
"filbert"
|
||||
],
|
||||
"review": null,
|
||||
"claim": {
|
||||
"seat": "darkwing",
|
||||
"op": "darkwing-190-start"
|
||||
},
|
||||
"note": null,
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-20T18:42:48.052Z",
|
||||
"updatedAt": "2026-10-20T18:45:23.430Z",
|
||||
"updatedBy": "darkwing"
|
||||
},
|
||||
{
|
||||
"id": 191,
|
||||
"piece": "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)",
|
||||
"owner": "rocko",
|
||||
"issues": [
|
||||
1689
|
||||
],
|
||||
"closes": [
|
||||
1689
|
||||
],
|
||||
"state": "in-progress",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
"gate": "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",
|
||||
"gateOwner": "sage",
|
||||
"brief": {
|
||||
"path": "docs/plans/2026-10-20_kill-switch.md",
|
||||
"anchor": "Brief: one kill switch for the bus, every run, Discord and new launches",
|
||||
"blob": "aed33125fa67ae69af73c18ffc9e2a83481421f3"
|
||||
},
|
||||
"after": [],
|
||||
"reviewers": [
|
||||
"dewey"
|
||||
],
|
||||
"review": null,
|
||||
"claim": {
|
||||
"seat": "rocko",
|
||||
"op": "rocko-191-start"
|
||||
},
|
||||
"note": null,
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-20T18:43:05.222Z",
|
||||
"updatedAt": "2026-10-20T18:45:05.621Z",
|
||||
"updatedBy": "rocko"
|
||||
}
|
||||
],
|
||||
"log": [
|
||||
@@ -78903,329 +78756,6 @@
|
||||
"receipt": "ok sage-163-done rev 2236 row 163 in-review→done round 2"
|
||||
},
|
||||
"viewSha": "e44815d32d46b91ee37d4c3c4701716956ba35d29c16c24c89020cce345bd61b"
|
||||
},
|
||||
{
|
||||
"rev": 2237,
|
||||
"op": "sage-189-add",
|
||||
"verb": "add",
|
||||
"args": {
|
||||
"piece": "stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413)",
|
||||
"gate": "Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed",
|
||||
"brief": "docs/plans/2026-10-20_activate-no-send.md#Brief: activate --no-send drops the Console's --send",
|
||||
"issues": [
|
||||
1687
|
||||
],
|
||||
"note": null,
|
||||
"owner": "sage",
|
||||
"gateOwner": "sage",
|
||||
"after": [
|
||||
{
|
||||
"id": 163,
|
||||
"when": "done"
|
||||
}
|
||||
],
|
||||
"reviewers": [
|
||||
"dewey"
|
||||
],
|
||||
"required": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:21:38.646Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 189,
|
||||
"from": null,
|
||||
"to": "queued",
|
||||
"brief": {
|
||||
"path": "docs/plans/2026-10-20_activate-no-send.md",
|
||||
"anchor": "Brief: activate --no-send drops the Console's --send",
|
||||
"blob": "0444b774faf09084311357fdd59b0ef3a3d347eb"
|
||||
},
|
||||
"receipt": "ok sage-189-add rev 2237 row 189 none→queued"
|
||||
},
|
||||
"viewSha": "70bf9967156881afecaba9ccf4edf8cbc63a344eb7c6407b3453c950781f34c5"
|
||||
},
|
||||
{
|
||||
"rev": 2238,
|
||||
"op": "sage-189-briefed",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 189,
|
||||
"to": "briefed",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:22:01.437Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 189,
|
||||
"from": "queued",
|
||||
"to": "briefed",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-189-briefed rev 2238 row 189 queued→briefed"
|
||||
},
|
||||
"viewSha": "27ee8a26722d3eaf4c4b346e06ffdb853037aad2d1a7fe14f440cbe1bb9a2a27"
|
||||
},
|
||||
{
|
||||
"rev": 2239,
|
||||
"op": "sage-189-start",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 189,
|
||||
"to": "in-progress",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:22:18.476Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 189,
|
||||
"from": "briefed",
|
||||
"to": "in-progress",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-189-start rev 2239 row 189 briefed→in-progress"
|
||||
},
|
||||
"viewSha": "230578585341fcfc9429364332ca8b78988ffd669d3c1bb5e2e94a74d9916649"
|
||||
},
|
||||
{
|
||||
"rev": 2240,
|
||||
"op": "sage-190-add",
|
||||
"verb": "add",
|
||||
"args": {
|
||||
"piece": "several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414)",
|
||||
"gate": "Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed",
|
||||
"brief": "docs/plans/2026-10-20_multi-instance.md#Brief: several Mosaic Stack instances on one host",
|
||||
"issues": [
|
||||
1688
|
||||
],
|
||||
"note": null,
|
||||
"owner": "darkwing",
|
||||
"gateOwner": "sage",
|
||||
"after": null,
|
||||
"reviewers": [
|
||||
"filbert"
|
||||
],
|
||||
"required": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:42:48.052Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 190,
|
||||
"from": null,
|
||||
"to": "queued",
|
||||
"brief": {
|
||||
"path": "docs/plans/2026-10-20_multi-instance.md",
|
||||
"anchor": "Brief: several Mosaic Stack instances on one host",
|
||||
"blob": "79fa60fbf17a352edc5431d6aabc77c72ec5c48f"
|
||||
},
|
||||
"receipt": "ok sage-190-add rev 2240 row 190 none→queued"
|
||||
},
|
||||
"viewSha": "b4319dbd993254b58e4db8be18769b04322028903e325c0c8cadcfdec527929f"
|
||||
},
|
||||
{
|
||||
"rev": 2241,
|
||||
"op": "sage-191-add",
|
||||
"verb": "add",
|
||||
"args": {
|
||||
"piece": "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)",
|
||||
"gate": "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",
|
||||
"brief": "docs/plans/2026-10-20_kill-switch.md#Brief: one kill switch for the bus, every run, Discord and new launches",
|
||||
"issues": [
|
||||
1689
|
||||
],
|
||||
"note": null,
|
||||
"owner": "rocko",
|
||||
"gateOwner": "sage",
|
||||
"after": null,
|
||||
"reviewers": [
|
||||
"dewey"
|
||||
],
|
||||
"required": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:43:05.222Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 191,
|
||||
"from": null,
|
||||
"to": "queued",
|
||||
"brief": {
|
||||
"path": "docs/plans/2026-10-20_kill-switch.md",
|
||||
"anchor": "Brief: one kill switch for the bus, every run, Discord and new launches",
|
||||
"blob": "aed33125fa67ae69af73c18ffc9e2a83481421f3"
|
||||
},
|
||||
"receipt": "ok sage-191-add rev 2241 row 191 none→queued"
|
||||
},
|
||||
"viewSha": "2cdc7c37430226f1ea01ed9f3d60a2c12be16eddac38d18dc16f889c7b54b14e"
|
||||
},
|
||||
{
|
||||
"rev": 2242,
|
||||
"op": "sage-190-brief",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 190,
|
||||
"to": "briefed",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:43:25.630Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 190,
|
||||
"from": "queued",
|
||||
"to": "briefed",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-190-brief rev 2242 row 190 queued→briefed"
|
||||
},
|
||||
"viewSha": "6203315b484302369ce5ea7d854082a6bca4a95f9793e14ec123f57381234c95"
|
||||
},
|
||||
{
|
||||
"rev": 2243,
|
||||
"op": "sage-191-brief",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 191,
|
||||
"to": "briefed",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:43:43.042Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 191,
|
||||
"from": "queued",
|
||||
"to": "briefed",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-191-brief rev 2243 row 191 queued→briefed"
|
||||
},
|
||||
"viewSha": "9cc7dda5fa676258e864dedfdf2f4e3904c8723ba9c36474cff76757f52e5c07"
|
||||
},
|
||||
{
|
||||
"rev": 2244,
|
||||
"op": "rocko-191-start",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 191,
|
||||
"to": "in-progress",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "rocko",
|
||||
"at": "2026-10-20T18:45:05.621Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 191,
|
||||
"from": "briefed",
|
||||
"to": "in-progress",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok rocko-191-start rev 2244 row 191 briefed→in-progress"
|
||||
},
|
||||
"viewSha": "cd2423e53ac82a667c737852bef6adfdafba82987f37cd090f5484b15b3a0e0c"
|
||||
},
|
||||
{
|
||||
"rev": 2245,
|
||||
"op": "darkwing-190-start",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 190,
|
||||
"to": "in-progress",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "darkwing",
|
||||
"at": "2026-10-20T18:45:23.430Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 190,
|
||||
"from": "briefed",
|
||||
"to": "in-progress",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok darkwing-190-start rev 2245 row 190 briefed→in-progress"
|
||||
},
|
||||
"viewSha": "014db845fb2e380678ab1250f2cd3c0d7b192dc39909416515482c810440149b"
|
||||
},
|
||||
{
|
||||
"rev": 2246,
|
||||
"op": "sage-189-inreview-1",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 189,
|
||||
"to": "in-review",
|
||||
"reason": null,
|
||||
"candidate": "agents/sage/work/queue-189/candidate-manifest.sha256",
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:46:06.124Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 189,
|
||||
"from": "in-progress",
|
||||
"to": "in-review",
|
||||
"round": 1,
|
||||
"issue": 1687,
|
||||
"candidate": {
|
||||
"kind": "manifest",
|
||||
"digest": "67af69bd579561fd8c20be2a47695240a9f09f119dbfff88a8e72e2a7416e668",
|
||||
"text": "70dbc89c0282fa063514317262d8926d16b8b8aaab4a1e47379303918495fc2c agents/sage/work/queue-189/BUILD.md\n4bd216cce2b40b39998725daa8989f6e3acb3ccd81a7017f6b09026af3965584 agents/sage/work/queue-189/mutants-run.log\nddbdddf03abf4aa91aa62f52e468be7af3c6b2f5d7bec2ee99b11e315471f18c agents/sage/work/queue-189/mutants.sh\n5d7b2da84d994f4f93d1212e349b4e4c19c5c00780e6cfcde91edb3ca08c014e docs/plans/2026-10-20_activate-no-send.md\n38e410c4cb731390f96747646eb7f6caa7a587fca9024ccbe901aab5ac19c7cf docs/TOOLS.md\nfe430969cc669515e348fced1a22cbe839a12023f7ac67cfba905b7afd29872f packages/cli/tests/stack-release.test.mjs\n16f77305e558f4fb953accc6d09f1f7b408aba363851d8ca79216ffce41bed9f scripts/stack-release.sh\n"
|
||||
},
|
||||
"receipt": "ok sage-189-inreview-1 rev 2246 row 189 in-progress→in-review round 1 on #1687; request sage-189-inreview-1 requesting"
|
||||
},
|
||||
"viewSha": "d8a3aa936a9a819329e86679d0bfcf658d773d0f7cfd29c10587c6447f78e968"
|
||||
},
|
||||
{
|
||||
"rev": 2247,
|
||||
"op": "sage-189-inreview-1.outcome",
|
||||
"verb": "review-outcome",
|
||||
"args": {
|
||||
"id": 189,
|
||||
"attempt": "sage-189-inreview-1",
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 29457,
|
||||
"detail": "created"
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-20T18:46:24.267Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 189,
|
||||
"round": 1,
|
||||
"attempt": "sage-189-inreview-1",
|
||||
"state": "posted",
|
||||
"receipt": "ok sage-189-inreview-1.outcome rev 2247 row 189 round 1 request sage-189-inreview-1 posted comment 29457"
|
||||
},
|
||||
"viewSha": "6d7e91ae3cab0b76e5c5d791fb821b97c9da7db16389ff2d531d7b77d2a069f1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+1
-11
@@ -9,8 +9,7 @@
|
||||
"version": "0.0.3",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
"oauth4webapi": "3.8.8"
|
||||
"@earendil-works/pi-coding-agent": "0.85.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
@@ -1934,15 +1933,6 @@
|
||||
"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",
|
||||
|
||||
+1
-2
@@ -5,7 +5,6 @@
|
||||
"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",
|
||||
"oauth4webapi": "3.8.8"
|
||||
"@earendil-works/pi-coding-agent": "0.85.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,45 +129,6 @@ 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,9 +58,8 @@ 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');
|
||||
// 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, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin');
|
||||
}
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
|
||||
}
|
||||
|
||||
@@ -7,23 +7,20 @@ 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] [--sso [--send]] [--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] [--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, ssoOn = false, testerPort = null;
|
||||
let sendOn = 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') {
|
||||
@@ -54,10 +51,9 @@ 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 && !ssoOn) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
|
||||
if (sendOn && !options.preview) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
|
||||
// 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
|
||||
@@ -81,46 +77,31 @@ 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 });
|
||||
// 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; }
|
||||
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 });
|
||||
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, testerNoSignIn: ssoOn, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
|
||||
tester: true, 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) { sso?.close(); await new Promise(resolve => server.close(resolve)); throw err; }
|
||||
} catch (err) { 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}${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(); });
|
||||
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(); });
|
||||
} catch (err) {
|
||||
console.error(`refused: ${err.message}`);
|
||||
process.exitCode = 2;
|
||||
|
||||
@@ -16,11 +16,6 @@
|
||||
// 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>` : '';
|
||||
@@ -115,7 +110,6 @@
|
||||
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) {
|
||||
@@ -310,7 +304,6 @@
|
||||
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;
|
||||
}
|
||||
@@ -459,7 +452,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', e => {
|
||||
document.addEventListener('mosaic-signed-out', () => {
|
||||
if (out()) return;
|
||||
document.body.classList.add('signed-out');
|
||||
clearTimeout(timer); convGen++;
|
||||
@@ -468,13 +461,9 @@
|
||||
for (const id of ['projects', 'waiting', 'seen', 'sessions']) $(id).replaceChildren();
|
||||
$('refresh').disabled = $('pause').disabled = true;
|
||||
const line = $('signed-out');
|
||||
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;
|
||||
line.innerHTML = '<p><b>Signed out.</b> Reload the page to sign in.</p>'; line.hidden = false;
|
||||
// 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(); });
|
||||
});
|
||||
// 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);
|
||||
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
})();
|
||||
|
||||
@@ -30,10 +30,6 @@
|
||||
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();
|
||||
@@ -42,7 +38,6 @@
|
||||
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 };
|
||||
}
|
||||
@@ -55,7 +50,6 @@
|
||||
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><form id="sign-out" method="post" action="/auth/logout" hidden><button class="btn" type="submit">Sign out</button></form></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></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,8 +61,6 @@ 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) {
|
||||
@@ -162,18 +160,14 @@ 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 (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
|
||||
if (one(req, preview.header) !== preview.user) 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') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
|
||||
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, '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); }
|
||||
@@ -247,10 +241,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 (!signedIn(req, preview)) {
|
||||
if (one(req, preview.header) !== preview.user) {
|
||||
return refuse(401, 'sign-in required', 'sign in again');
|
||||
}
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) {
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) {
|
||||
return refuse(403, 'cross-origin request refused', 'settings need the Console origin');
|
||||
}
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') {
|
||||
@@ -298,8 +292,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 && !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 (!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 (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); }
|
||||
@@ -341,8 +335,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 && !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 (!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 (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); }
|
||||
@@ -388,8 +382,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 && !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 (!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 (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); }
|
||||
@@ -426,8 +420,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 (!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, 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 (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); }
|
||||
@@ -466,17 +460,13 @@ 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 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`
|
||||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `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, 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 } = {}) {
|
||||
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 } = {}) {
|
||||
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');
|
||||
@@ -486,26 +476,21 @@ 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 && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
|
||||
if (send !== null && (preview === null || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader('x-content-type-options', 'nosniff');
|
||||
res.setHeader('referrer-policy', 'no-referrer');
|
||||
// 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('') : ''}`);
|
||||
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'");
|
||||
// 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 : sso !== null && (req.headers.host ?? '').toLowerCase() === sso.publicHost;
|
||||
const named = preview !== null && (req.headers.host ?? '').toLowerCase() === preview.host;
|
||||
// The proof precedes every auth-header read, including the wrong-Host fence.
|
||||
if (proof !== null) {
|
||||
const values = req.headersDistinct[proof.header];
|
||||
@@ -527,34 +512,19 @@ 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 ? (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) {
|
||||
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) {
|
||||
// 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}` } : signer;
|
||||
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : preview;
|
||||
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:signer,log});
|
||||
return settingsSend(req,res,{settingsWrite,preview,log});
|
||||
}
|
||||
if (Object.hasOwn(SEAT_ROUTES, path)) {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
@@ -574,18 +544,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: signer, log });
|
||||
return projectFileSend(req, res, { projectSave, preview, 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: signer, log });
|
||||
return busSend(req, res, { send, preview, 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 || sso !== null) && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
|
||||
if (preview !== 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;
|
||||
@@ -637,8 +607,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 } : {}), ...(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 === '/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 === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
if (!file) return json(res, 404, { error: 'not found' });
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
// 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(); },
|
||||
};
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
// 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\) or --sso$/m;
|
||||
const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\)$/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
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
// 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(); }
|
||||
});
|
||||
@@ -1,589 +0,0 @@
|
||||
// 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,7 +26,6 @@ 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"]);
|
||||
@@ -107,30 +106,13 @@ function isAncestorOf(ancestor, candidate) {
|
||||
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
function validate(document, file) {
|
||||
if (!isPlainObject(document)) {
|
||||
fail(2, "configuration must be a JSON object");
|
||||
}
|
||||
rejectUnknownKeys(
|
||||
document,
|
||||
["configVersion", "environment", "dataRoot", "execution", "console"],
|
||||
["configVersion", "environment", "dataRoot", "execution"],
|
||||
"configuration",
|
||||
);
|
||||
|
||||
@@ -164,10 +146,6 @@ async 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,
|
||||
@@ -178,11 +156,10 @@ async function validate(document, file) {
|
||||
model: document.execution.model,
|
||||
adapter,
|
||||
},
|
||||
...(consoleKey === undefined ? {} : { console: consoleKey }),
|
||||
};
|
||||
}
|
||||
|
||||
async function load(file) {
|
||||
function load(file) {
|
||||
const raw = readRaw(file);
|
||||
if (raw === null) {
|
||||
fail(3, `configuration not found: ${file} (run scripts/bootstrap.sh to create it)`);
|
||||
@@ -193,7 +170,7 @@ async function load(file) {
|
||||
} catch (error) {
|
||||
fail(2, `configuration is not valid JSON (${file}): ${error.message}`);
|
||||
}
|
||||
return await validate(document, file);
|
||||
return validate(document, file);
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
@@ -217,7 +194,7 @@ const file = configPath();
|
||||
switch (operation) {
|
||||
case "bootstrap": {
|
||||
if (fs.existsSync(file)) {
|
||||
await load(file); // validate only; never rewrite
|
||||
load(file); // validate only; never rewrite
|
||||
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -229,26 +206,26 @@ switch (operation) {
|
||||
fs.writeFileSync(fd, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
|
||||
} catch (error) {
|
||||
if (error.code === "EEXIST") {
|
||||
await load(file);
|
||||
load(file);
|
||||
process.exit(0);
|
||||
}
|
||||
fail(1, `unable to create configuration: ${error.message}`);
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
await load(file);
|
||||
load(file);
|
||||
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
case "validate": {
|
||||
const resolved = await load(file);
|
||||
const resolved = load(file);
|
||||
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
case "env": {
|
||||
const resolved = await load(file);
|
||||
const resolved = load(file);
|
||||
process.stdout.write(
|
||||
[
|
||||
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
|
||||
|
||||
@@ -151,52 +151,6 @@ 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,9 +49,6 @@
|
||||
# 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
|
||||
@@ -71,7 +68,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] [--sso [--send] | --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] [--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."
|
||||
|
||||
@@ -94,7 +91,6 @@ PUBLIC_HOST=""
|
||||
AUTH_HEADER=""
|
||||
ALLOW_USER=""
|
||||
SEND=0
|
||||
SSO=0
|
||||
PROOF_HEADER=""
|
||||
PROOF_FILE=""
|
||||
PROOF_SET=0
|
||||
@@ -118,7 +114,6 @@ 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
|
||||
@@ -171,13 +166,9 @@ serve_flags() {
|
||||
[ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
|
||||
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
|
||||
[ -z "$ALLOW_USER" ] || preview=$((preview + 1))
|
||||
# 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"
|
||||
# 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)"
|
||||
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
|
||||
@@ -210,7 +201,6 @@ 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"
|
||||
@@ -395,17 +385,14 @@ status_unit() {
|
||||
else
|
||||
echo "copy: none; the unit runs from $wd"
|
||||
fi
|
||||
# render puts --send after the allowed user and optional proof pair, or
|
||||
# after --sso, before the tester port. The allowed user
|
||||
# render puts --send after the allowed user and optional proof pair,
|
||||
# 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 /[^ ]+)?| --sso) --send( --tester-port [0-9]+)?$' "$target"; then
|
||||
if grep -Eq '^ExecStart=.* --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)? --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