Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b008eed728 | ||
|
|
7fd265033b | ||
|
|
48a3fb96e0 | ||
|
|
959bfb2752 | ||
|
|
030809e15a | ||
|
|
35aaceac97 | ||
|
|
44e36fc4c6 | ||
|
|
80fe310713 | ||
|
|
2d0aa29374 | ||
|
|
ae3c36f248 | ||
|
|
a2af7ab495 | ||
|
|
aa1bd209e3 | ||
|
|
ace27dc5eb | ||
|
|
a4a42dbac2 | ||
|
|
a31f1a0f8b | ||
|
|
992650232f | ||
|
|
a7661ada06 | ||
|
|
f871cb3096 | ||
|
|
0bbd23c619 | ||
|
|
0217b71c8f | ||
|
|
c6f528a32f |
@@ -5174,9 +5174,3 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
|
||||
2026-10-20 04:44:54 UTC | Row 178 round 2 preparation | Before: Dewey C1 five render guards lacked killing tests. Added independent refusal fixtures, a temporary uid+1 id stub with cleanup, and empty loopback proof case; activation ordering recorded in r2 BUILD. Product unchanged; syntax/text preflight only, no test or mutant execution. Pending row 181 rebase and Sage slot.
|
||||
|
||||
2026-10-20 05:10:34 UTC | Row 178 #1676 round 2 verified | Before: C1 render refusals lacked killing tests and row 181 base was pending. Rebased onto 931651f2d, kept all COUNTED/count code and tests, appended T178 tail; added independent owner/collision/absolute/charset/header and empty-loopback proof tests. Four files singly 156/156, all 24 mutants killed, metadata and sandbox fixture probes pass; scratch sweep empty. Packet agents/rocko/work/queue-178/r2/, candidate 9300a670, inventory d0dcde7b (59/59), scan 58 files no findings; in-review revs 2166-2167, expected no-token request failure. No live actions or commits.
|
||||
|
||||
2026-10-20 19:09:44 UTC | Gander row 196 #1694 design phase | Before: tester TCP reaches every local uid and human proofs do not bind the socket peer. After: design draft at agents/gander/work/queue-196/DESIGN.md inventories listeners, proposes private tester HTTP and SO_PEERCRED binding, aligns the per-request principal contract with Morgana and Gizmoduck, and lists migration, tests, mutants and same-uid limits. Python versus other credential options and Console provenance require Duckworth ruling. Documentation only. No product code, suite, live probe, secret access or deployment.
|
||||
|
||||
2026-10-20 19:16:20 UTC | Gander row 196 #1694 design r2 | Before: Duckworth r1 requested concurrent helper-fd evidence (B1) and selected O1 with interpreter, proof-only and image conditions. After: design adds eight concurrent genuine proof processes plus a racing copier, M20 swapped socket and post-helper socket survival, root-owned system ELF pinning and activation evidence, safe gid validation, cap traffic without helpers, and unchanged P19 R2 text. Mos accepted socket-only tester access (mos-1015 via sage-1363); any authenticated browser relay is a separate row. Documentation coverage checks passed. No product code, suites or live action.
|
||||
|
||||
2026-10-20 19:21:19 UTC | Gander row 196 #1694 design approval and N5 | Before: Duckworth approved r2 (duckworth-0009, cb6d840df) with a nonblocking interpreter-upgrade note. After: r3 chooses startup safety revalidation, appends actual interpreter identity to bus/peercred-starts.jsonl and retains old activation evidence. Operator re-pin uses the next reviewed copy, respecting already-active refusal. Added task-by-task PLAN.md for inline implementation and independent manifest review. Documentation checks passed. No product code, suites or live action.
|
||||
|
||||
@@ -20,6 +20,11 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
|
||||
| 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 |
|
||||
| Gander | Builder, the human boundary across uids (P19 333, #1694) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
|
||||
| Herb | Builder, a second gate lane (#1695) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
|
||||
| Bushroot | Builder, retire the control board writer on 7331 (#1696) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1008 | none, T3 only |
|
||||
| Megavolt | Builder, MVP T1 item 6 (agent credentials and git identity, #1697) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Quackerjack | Builder, federation R5 and R2 (#414) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1023 | none, T3 only |
|
||||
| Gizmoduck | Federation design (#414) and builder of FED R1 | T3 session (Claude, Opus 5.5, thread 9312a896) | 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` |
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Bushroot
|
||||
|
||||
Builder seat, Claude Haiku 5.5, started by Sage under mos-1008 item 2 (a low-level model under Q177) for the row
|
||||
that retires the control board writer on 7331 (#1696).
|
||||
|
||||
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 the credentials row settles per-agent identity, it pushes only its own `bushroot/*`
|
||||
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -1,332 +0,0 @@
|
||||
# Row 196: the human boundary across uids and humans
|
||||
|
||||
Gander, 2026-10-20. Revision 3, recording approved r2 and N5. Gitea #1694, P19 333.
|
||||
Base: `96f243faf`, branch `gander/row196`. Design approval latest-by 2026-10-22, landing
|
||||
latest-by 2026-10-24 and before federation R3. This document authorizes no live action.
|
||||
|
||||
## 1. Outcome and authority
|
||||
|
||||
Other Linux users must not reach the tester's data or writes. A bus human proof must describe
|
||||
the process that actually connected. Console user calls must also pass the human walk and
|
||||
come directly from this bus tree's Console. Row 193 and federation R3 use that one verifier.
|
||||
|
||||
This is not isolation from a hostile process on the instance's own uid. Keeping the human
|
||||
terminal CLI means that an unmarked process on that uid still has the owner's authority.
|
||||
Process ancestry cannot prove that a process was never an agent. Sections 4 and 8 state this
|
||||
limit without treating it as an enforcement guarantee.
|
||||
|
||||
Inputs: COMMON at `53bb59462`, the row brief at `96f243faf`, Q175 sections 1, 6 and 8,
|
||||
federation r2 at `60b9b112a` section 3.6, Duckworth's B1-B5 at `b402197ff`, row 190's
|
||||
DESIGN.md and the row 190b brief. Q175-Q178 and the MVP plan set the order. Row 190b is on
|
||||
hold pending the host decision. This row does not assume that a separate uid is already live.
|
||||
|
||||
Sage's `sage-1343` accepts the design direction and requires Duckworth to rule on the peer
|
||||
credential implementation. `sage-1345` keeps the control board inventory-only. Morgana's
|
||||
`morgana-0003` supplies the principal contract in section 4. Sage registers this session.
|
||||
Mos accepted removal of direct tester browser access in mos-1015, relayed by sage-1363.
|
||||
Agents test through the private socket. Any later browser view is an authenticated relay
|
||||
behind SSO, designed and reviewed in its own row. This row adds no relay.
|
||||
|
||||
## 2. Inventory from code, not a live probe
|
||||
|
||||
"Same uid" includes untrusted local processes. "Other uid" excludes root, which is outside
|
||||
this boundary. SSO below means Q175 is active on that instance, not merely installed.
|
||||
|
||||
| Entry | Same uid today | Other uid today | Effect of SSO and this row |
|
||||
|---|---|---|---|
|
||||
| Main Console, default 7332, bare serve default 7330 | Loopback reads. With preview/send, matching Host, header and origin can write as owner | Same TCP reach and header forgery | Q175 SSO verifies login and refuses non-public Host except GET /healthz. This row does not change public ingress |
|
||||
| Tester TCP, default installation 7333, optional per instance | Reads and the tester instance, route, name and chat writes | Identical reach, no uid check | Q175 deliberately leaves tester separate. This row replaces TCP with a private filesystem Unix socket |
|
||||
| `<dataRoot>/bus/broker.sock` | Connects, then needs cap or proof. Claimed human PID is not the socket peer | Normally refused by bus/ 0700 and socket 0600 | SSO changes nothing. Add peer uid and PID binding, retain file checks |
|
||||
| Human wrapper to tree's human-cli.mjs | Owner if CLI path, nonce and ancestry pass. Agent ancestors refuse | Cannot connect to protected socket, and proof uid refuses | Preserve terminal owner path, add peer binding. SSO does not authenticate terminal users |
|
||||
| Tester CLI to broker.sock | Tester if its proof passes and bus.tester is exactly true. T3 agents are allowed, managed runs are not | Filesystem modes normally refuse | Same tester policy, plus peer binding. No owner/user upgrade |
|
||||
| `<dataRoot>/bus-host/launch.sock` | Requires run cap and launcher checks | bus-host/ 0700, socket 0600 | No human proof endpoint. Unchanged |
|
||||
| `<dataRoot>/bus-host/git.sock` | Git capability protocol and broker authorization | bus-host/ 0700, socket 0600 | No human proof endpoint. Unchanged |
|
||||
| Discord connector | Outbound Discord gateway/REST, no inbound local listener. Local process could tamper with same-uid files/process | No local port to call. File separation depends on uid and modes | Existing guild, user, channel, bot and webhook checks remain. Discord is not a new bus human transport |
|
||||
| Row 190 additions | Separate configs, roots, unit names, wrappers and Console ports, on one uid | Console/tester TCP still reachable | Adds no new class of listener. Row 196 derives its socket per dataRoot. Row 190 alone supplies no hostile-process isolation |
|
||||
| Control board, default 7331 | Unauthenticated transcript/board reads, POST /api/reply and /api/seen after request checks | Same loopback reach. A local client can supply matching request headers | Independent development service, outside instance SSO. Inventory only per sage-1345. Its fix needs another row |
|
||||
|
||||
Evidence: `packages/webui/src/{cli,serve,bus}.mjs`, `packages/bus/src/{server,runtime,human,
|
||||
tester,store}.mjs`, `packages/cli/src/{launcher,git-socket,transport}.mjs`,
|
||||
`packages/discord/src/{gateway,authorize}.mjs`, `packages/control-board/src/serve.mjs`.
|
||||
Store checks bus/ with `safe(directory,true)`, including owner, symlink and mode checks.
|
||||
The dataRoot check itself does not require 0700 today. Do not describe that as a checked mode.
|
||||
|
||||
## 3. D1: private tester HTTP socket
|
||||
|
||||
Add `serve --tester-socket`, a boolean selecting `<dataRoot>/console/tester.sock`. No arbitrary
|
||||
path or new system config field. Create console/ as 0700. Refuse a symlink, wrong owner,
|
||||
non-directory or any group/other bits on an existing console/. Refuse a pre-existing socket
|
||||
or other entry rather than deleting it blindly. A stale socket needs an operator's diagnosis.
|
||||
Refuse a path longer than Linux's 107-byte pathname capacity before binding.
|
||||
|
||||
Bind HTTP on the Unix path, set socket mode 0600, then verify type, owner and mode before
|
||||
declaring ready. The private parent covers the bind-to-chmod interval. Cleanup only the owned
|
||||
device/inode, as broker.sock does. Never open a TCP compatibility bridge or an abstract socket.
|
||||
Readiness failure closes the main listener too, rather than leaving a partially started Console.
|
||||
|
||||
The tester handler keeps its fixed principal, header/cookie refusals and exact write list.
|
||||
Use a fixed logical origin `http://mosaic-tester.invalid` for URL and same-origin checks. It
|
||||
does not resolve that name. Clients send that Host and Origin plus the existing Fetch Metadata
|
||||
when required. A Host string is not authentication. Unix socket access supplies that boundary.
|
||||
The main listener still refuses x-mosaic-tester. No tester /auth route exists.
|
||||
|
||||
`bus.tester` remains Jason's switch. Socket reach never enables tester authority by itself.
|
||||
Turning it off must refuse every tester bus call. This row adds no user or owner capability
|
||||
to the tester. Tester reads retain today's scope, including any local Console reads that are
|
||||
not bus calls. This row does not turn bus.tester into a separate HTTP read switch.
|
||||
|
||||
### Consumers and migration paths
|
||||
|
||||
| Consumer of 7333 | New path |
|
||||
|---|---|
|
||||
| Agent tester HTTP tools and curl probes in authorized window packets | `curl --unix-socket <dataRoot>/console/tester.sock http://mosaic-tester.invalid/...`, with the fixed Origin for writes. Add one repository helper for bounded tester HTTP requests so callers do not copy path/origin logic |
|
||||
| `scripts/mosaic ... --tester` | Already uses TESTER_CLI and broker.sock, not TCP. Keep its verbs, output and switch |
|
||||
| Browser tester page in row 176 and later window checks | Ordinary browsers cannot navigate a Unix HTTP socket. Replace automated tester HTTP checks with the socket helper. Browser visual QA uses scratch fixtures only. No local TCP relay is added |
|
||||
| `webui-service.sh` render/install and installed unit | Carry --tester-socket for new copies. Refuse a target without support. Units running old copies retain their old flags until a window |
|
||||
| `stack-release.sh` activate, plan, rehearsal and rollback | Add --tester-socket. Replace inherited --tester-port N only when explicitly requested. Refuse incompatible inherited flags before stopping units. Plan names the transport and every dropped pair. Rehearsal uses a scratch socket |
|
||||
| Gate/window Console checks, including agents' console-check scripts | Extend checks to use scratch Unix HTTP and expect no tester TCP bind. Sage owns live packet updates, including Q175 phase 2's old --tester-port 7333 instructions |
|
||||
| `docs/TOOLS.md`, webui/cli/bus READMEs and examples | Document socket invocation, fixed origin, loss of direct browser navigation and per-instance path. Historical window receipts stay unchanged |
|
||||
|
||||
Repository search found no non-test hardcoded 7333 caller in tools/ or the harness code. The
|
||||
active windows and external seat commands still need Sage's consumer audit before re-pin.
|
||||
The build manifest will record the full search, rather than treating this draft as that audit.
|
||||
|
||||
## 4. D2: one verified principal per bus request
|
||||
|
||||
Internal interface: `verifyPrincipal(request, connection) -> {kind,business,id}` or refusal.
|
||||
Kinds are owner, user and tester. The connection metadata is supplied by the trusted server,
|
||||
never deserialized from JSON. No role or authority is returned. The broker looks up live policy.
|
||||
|
||||
1. Read and validate the bounded envelope first. For human, tester and user proofs, read Linux
|
||||
SO_PEERCRED on that accepted Unix connection before dispatch. Require a valid peer PID
|
||||
and the bus uid. Refuse unavailable credentials, unsupported platform and errors. Cap
|
||||
traffic keeps its capability protocol behind the validated 0700 directory and 0600 socket.
|
||||
2. Require exactly one cap, human, tester or future user envelope. Human/tester/user proof.pid
|
||||
must equal the kernel peer PID. Verify startTime, nonce and CLI path against that process.
|
||||
Keep the second process read after the walk. Missing, changed or exited processes refuse.
|
||||
3. Owner terminal: run the full existing human walk. Map the business to businesses[b].human.
|
||||
No supplied human id, role or uid selects the principal. Keep today's terminal invocation.
|
||||
4. Tester: run verifyTester, retaining its deliberate difference from the human walk. An agent
|
||||
seat may be tester. A managed-run marker or registered managed ancestor refuses. Then check
|
||||
bus.tester. The full human walk must never be weakened to admit a session user.
|
||||
5. Session user: use the full human walk with the path-bound user-cli entry and its own nonce
|
||||
variable, MOSAIC_BUS_USER_NONCE. Parameterize the nonce reader without admitting a human
|
||||
or tester nonce as a user proof. Require its immediate parent to be the same tree's resolved webui/src/cli.mjs,
|
||||
argv[2] `serve`. Check parent uid, startTime and ppid again before binding. Both child and
|
||||
parent remain alive for the call. A different checkout or wrapper parent refuses.
|
||||
6. For user calls, the Console puts its mapped access-file user id in the verified proof's
|
||||
user field, as federation 3.6 specifies. The id comes only from claims Q175 verified for
|
||||
this request's live session, never from an identity cached past that session. It never
|
||||
takes that id from a form field or verb
|
||||
args. Return kind user, proof.business and proof.user as id only after the provenance
|
||||
checks. R3 then validates that mapping and the live grant. Bad access.json denies users,
|
||||
not the terminal owner. The verifier does not implement access-file policy.
|
||||
7. Owner Console calls keep the verified Q175 owner subject path. Under SSO only a subject
|
||||
in console.sso.subjects reaches owner bus calls. They use human-cli and the same peer/walk
|
||||
checks. A terminal owner still needs no SSO token. No anonymous main-listener fallback.
|
||||
|
||||
The bus verifies which process supplied the session identity. It relies on that Console's
|
||||
Q175 implementation to have verified OIDC. It does not independently verify an ID token in
|
||||
this option. A provider/sub field alone, from another process, is not enough.
|
||||
|
||||
The Console must spawn user-cli directly with its nonce already in the launch environment.
|
||||
It must not use human-cli's existing self-respawn pattern, which makes the CLI's parent a CLI.
|
||||
Apply the same initial-nonce spawn to Console human calls for consistent tests. Terminal
|
||||
human-cli can retain its self-respawn since no Console-parent condition applies there.
|
||||
Never strip agent markers from inherited environments to make a walk pass.
|
||||
|
||||
The server binds an internal cap for this request, dispatches, and disconnects in every
|
||||
success/error/timeout path, including asynchronous task and web calls. It never returns the
|
||||
cap or a reusable principal handle. A proof copied by another process has a different kernel
|
||||
peer PID and fails. One verified CLI may make only its one request on its one connection.
|
||||
The session may hold s.user for that request. No PID or principal cache survives the connection.
|
||||
|
||||
### Fit with rows 193 and R3
|
||||
|
||||
Row 196 owns peer metadata, proof/walk verification and the internal principal interface.
|
||||
It supplies the Console provenance verifier and tests before R3 adds user-cli and user sessions.
|
||||
R3 owns access.json mapping and the bindUser integration, using this interface. It must not
|
||||
add a second ancestry implementation. Row 193 adds viewer and share policy to the same live
|
||||
grant check. An owner id is the business human. A user id is the access-file id, with stored
|
||||
owner string `user:<id>`. Neither row takes role from the proof.
|
||||
|
||||
No live grant, unknown subject, duplicate owner/user mapping and invalid access file all
|
||||
deny user binding through R3's policy. Per-request authorization, access.reload and the
|
||||
30-second recheck remain R3's responsibility. Share visibility uses that check in row 193.
|
||||
Binding provenance is not a grant and never widens one. R3's user dispatch must run before
|
||||
names falls through to #agent. R3 must not land before this contract and its tests are green.
|
||||
Gizmoduck's gizmoduck-0015 and Gander's gander-0007 agree on this split. R3 keeps the reserved
|
||||
user-name mutant. Row 196 tests kind separation even if a user id resembles the owner name.
|
||||
|
||||
### OIDC alternative
|
||||
|
||||
The bus could verify the signed ID token itself and map its issuer/sub. That reduces reliance
|
||||
on the Console's subject assertion, but requires JWKS/provider handling, nonce or challenge
|
||||
binding, replay handling, login and logout/session lifetimes in the bus. Reusing an ID token
|
||||
on every request either creates a replay credential or expires a Console session with the
|
||||
token. A bus-issued session needs a separate lifecycle design. It also leaves the terminal
|
||||
owner and hostile same-uid limit intact. Recommend the Console provenance contract for this
|
||||
slice. Duckworth approved this option in duckworth-0006, with the live-session condition above
|
||||
and the requirement to carry section 8 R2 into P19 333 word for word.
|
||||
|
||||
## 5. D3: obtaining kernel credentials, O1 approved with conditions
|
||||
|
||||
All options must bind the accepted connection, not a PID a caller picked. None gets a silent
|
||||
fallback to the current proof. Sage required this comparison. Duckworth selected O1 in
|
||||
duckworth-0006, with the interpreter, proof-only and image conditions below. O4 remains the
|
||||
fallback if Python is unavailable, and needs a revised build manifest before use.
|
||||
|
||||
| Option | What it proves and costs | Recommendation |
|
||||
|---|---|---|
|
||||
| O1 Python stdlib helper on inherited accepted socket | Node passes the accepted net.Socket as fd 3 using documented spawn stdio stream passing. Python socket(fileno=3) reads SO_PEERCRED and returns only pid,uid,gid on stdout. No socket reads, no shell, no credential values. Adds Python to bus hosts and any future bus image, with a process per admitted proof connection. Copies carry the helper and depend on the system interpreter | Selected by Duckworth, with conditions below |
|
||||
| O2 /proc/net/unix plus inode and /proc/PID/fd scans | The table identifies Unix socket entries, not an authenticated peer PID for each accepted connection. Finding a process holding an inode is racy, may have several holders and can be unreadable. The server endpoint's inode is not the client's inode | Reject as a replacement for SO_PEERCRED. No claimed proof PID may choose the scan target |
|
||||
| O3 plain Node fd passing or abstract sockets | A private inherited IPC channel can authenticate a child that this parent spawned. The bus did not spawn the independently started Console or terminal CLI. Moving the bus into the Console's child tree changes lifecycle and ownership. An abstract name supplies no filesystem mode boundary and no peer uid check by itself. Node's public net API supplies no SO_PEERCRED accessor | Reject as a drop-in. A larger bus-spawned transport design could use fd capabilities, but is outside this slice |
|
||||
| O4 small compiled helper or Node-API addon | Calls getsockopt directly, with no Python runtime. A helper can use the same inherited fd. Adds compiler/build closure and platform artifacts to stack-copy and containers. An addon also needs a supported way to obtain the accepted socket fd and Node version coverage | Viable alternative if Python is refused. Prefer a small helper over undocumented Node handles |
|
||||
|
||||
For O1, pin the system interpreter `/usr/bin/python3`, resolving its system symlink to an
|
||||
absolute ELF binary. Require root ownership and no group/other write on that executable and
|
||||
its path components. Refuse scripts, PATH shims and any resolved path under an OS account's
|
||||
home, including /home and /root. A pyenv shim can choose an interpreter before -I -S applies.
|
||||
No PATH lookup, caller-selected interpreter or helper. Invoke the resolved binary with
|
||||
`-I -S`, a minimal environment and the absolute helper path in the same pinned copy.
|
||||
|
||||
Activation preflight records the interpreter's resolved path, version and file identity/hash
|
||||
in its gate evidence. The bus validates and pins the resolved executable at each startup
|
||||
before opening broker.sock. The activation gate must report the same interpreter used by
|
||||
that bus. Missing, unsafe or changed resolution during that activation refuses it, never
|
||||
falling back to PATH. No secret values are recorded. Test system-owned interpreter checks
|
||||
through injected metadata.
|
||||
|
||||
N5 decision: a later bus restart repeats the safety/version/support checks and accepts a
|
||||
safe system interpreter installed by an OS upgrade. It does not compare against an old
|
||||
activation hash. Before opening the listener, append a startup receipt with timestamp,
|
||||
copy path, resolved interpreter path, version and file identity/hash to
|
||||
`<dataRoot>/bus/peercred-starts.jsonl` (0600 in the checked 0700 bus directory). Refuse an
|
||||
unsafe receipt file or failed append. Never edit the old activation evidence. The startup
|
||||
receipt identifies the binary now in use, so the old receipt remains a historical fact.
|
||||
If an upgraded interpreter fails the checks, startup fails closed until a safe supported
|
||||
system interpreter is restored by the operator. There is no shim or O4 auto-fallback.
|
||||
|
||||
Operator re-pin after an interpreter change: in the next authorized window, prepare the
|
||||
next reviewed copy with `scripts/stack-copy.sh copy NEW`, then run
|
||||
`scripts/stack-release.sh activate NEW BUSINESS --tester-socket` with that instance's existing
|
||||
flags and selector, first with --plan. NEW must differ from the active copy because activate
|
||||
refuses already-active. Record the interpreter in the new activation evidence and compare it
|
||||
with the bus startup receipt. A routine OS update alone does not authorize a re-pin or unit
|
||||
restart. A bus restart does not rewrite config, pointer or activation history.
|
||||
|
||||
Preflight helper and SO_PEERCRED support too. The helper lives under packages/bus/src so
|
||||
stack-copy's closure includes it. Copies remain immutable. Document Python >=3.10 on bus
|
||||
hosts. Today's Containerfile builds the worker image and runs run-agent.sh, not the bus.
|
||||
It needs no Python change for this row. A future bus-host image must explicitly include
|
||||
the pinned system Python or use reviewed O4. No install through the node_modules symlink.
|
||||
|
||||
Pause each proof connection while reading credentials. Limit outstanding helpers
|
||||
to 16, timeout to one second, output to one strict JSON record of at most 256 bytes. Refuse
|
||||
nonzero exit, extra output, malformed integers, missing fd and timeout. Validate pid, uid and
|
||||
gid as safe integers, pid >=2 and uid/gid >=0. Only pid and uid select the principal. gid is
|
||||
validated but grants no authority. Kill and reap helpers on shutdown. Cap envelopes never
|
||||
spawn this helper. Reading the proof envelope first is sound because SO_PEERCRED describes
|
||||
the peer at connect time. Keep the accepted Socket object owned by its connection closure,
|
||||
not a shared current-fd variable. Only the helper's duplicate fd is closed when it exits.
|
||||
|
||||
References: [Node stdio stream passing](https://nodejs.org/api/child_process.html#optionsstdio),
|
||||
[Node Unix socket support](https://nodejs.org/api/net.html#ipc-support),
|
||||
[Python socket and getsockopt](https://docs.python.org/3/library/socket.html),
|
||||
[Linux Unix credentials and permissions](https://man7.org/linux/man-pages/man7/unix.7.html),
|
||||
[Linux proc network table fields](https://man7.org/linux/man-pages/man5/proc_pid_net.5.html).
|
||||
These support the APIs. The connection-binding behavior still needs T2 on supported Node versions.
|
||||
|
||||
## 6. Migration and rollback
|
||||
|
||||
No config schema or store schema changes. Old copies and units keep working until each
|
||||
instance re-pins. No new verifier is loaded into a running old broker. Do not pair a new
|
||||
Console with an old bus and call it protected. Same-tree paths intentionally refuse mixtures.
|
||||
|
||||
The authorized window must pin bus, Console and human wrapper to one candidate. New tools
|
||||
offer `activate ID BUSINESS --tester-socket`. Plan must show the old tester-port pair removed
|
||||
and the new socket flag added, and tell Jason that direct tester browser navigation is lost.
|
||||
Mos accepted that change in mos-1015 (sage-1363). Agents test through the socket. A later
|
||||
authenticated browser relay behind SSO belongs to its own row. Without the explicit
|
||||
conversion, a candidate that no longer
|
||||
serves tester TCP refuses inherited --tester-port before stopping anything. New serve refuses
|
||||
--tester-port with a migration error. It never silently changes that flag's meaning.
|
||||
|
||||
Rollback to a copy lacking --tester-socket drops tester enablement and reports it, as today's
|
||||
tools drop unsupported flags. It does not turn the socket back into TCP. The old main Console
|
||||
and owner CLI can run with their known old limits. A failed activation's recovery may restore
|
||||
the old saved unit, including its TCP listener. Report that recovery as restoration of the old
|
||||
security state, never as a hardened deployment. After rollback, R3 and second-human access are
|
||||
not accepted until prerequisites are restored. Older operator tools cannot manage the new
|
||||
flag safely. The window must use the reviewed candidate tool blob for rollback as well.
|
||||
|
||||
The Q173 window supplies real uid-denial evidence and the final consumer audit. No second uid,
|
||||
live socket, unit, config or secret is touched in this row's tests. Row 190b or a separate host
|
||||
still owns cross-instance file isolation. It is not replaced by SSO or this socket change.
|
||||
|
||||
## 7. Acceptance tests and mutants
|
||||
|
||||
All fixtures use scratch roots and ephemeral sockets, no live 7332/7333, public origin or
|
||||
provider. No real second uid. Foreign uid checks use injected metadata and name that limit.
|
||||
Use short TMPDIR paths so the longest Unix socket path stays under 108 bytes.
|
||||
|
||||
| Test | Required evidence | Mutant killed |
|
||||
|---|---|---|
|
||||
| T1 private tester | HTTP over scratch socket reads and writes as tester only. parent 0700, socket 0600. No tester TCP listen. Wrong Host/origin and auth cookies refuse | M1 bind loopback TCP as well, M2 remove socket mode check, M3 accept owner cookie |
|
||||
| T2 real peer binding | At least 8 concurrent proof connections from distinct scratch client processes, each with its own live proof, plus a copier racing one live CLI. Barrier the helpers so connections overlap. Each genuine call must bind its own peer PID and pass, the copier must refuse. Repeat on Node 24 and 26. A lower-level helper fixture exchanges bytes both ways on the accepted socket after the helper exits, and each real bus call receives its response after helper exit. Helper failure cases all refuse | M4 use proof.pid as peer PID, M5 continue after helper error, M20 give the helper another pending connection's socket |
|
||||
| T3 uid/path refusal | Inject foreign peer uid, wrong file owner/modes, symlink parent, existing socket and long path. Inject interpreter script, user-owned ELF, home path, writable ancestor and missing executable. Refuse before dispatch, no repaired paths. Unsafe pid/uid/gid and extra helper output refuse | M6 ignore uid, M7 accept unsafe parent |
|
||||
| T4 ancestry | Agent env, binary and registered launch above owner/user refuse. Missing/reused PID, changed parent and cycles refuse. Clean terminal owner works | M8 omit walk, M9 omit identity re-read |
|
||||
| T5 Console parent | Direct CLI child from exact tree Console fixture works. Same CLI under shell, other copy, wrapper, agent ancestor or CLI respawn refuses | M10 remove direct-parent check, M11 accept any Console basename |
|
||||
| T6 principal shape | No human id or role from request body. Exactly one proof. No returned cap. Disconnect on synchronous and async failure, timeout and close. Tester agent allowed, managed tester refused | M12 accept extra identity, M13 omit disconnect, M14 use tester walk for user |
|
||||
| T7 consumer migration | Render/plan/activate/rehearse fixture carries socket flag in unit and pointer. Explicit conversion removes port. Unsupported/inherited mismatch refuses before unit calls. Rollback disables unsupported tester, never opens TCP | M15 keep inherited tester port, M16 restore TCP on downgrade |
|
||||
| T8 gates and identity | bus.tester false refuses tester calls. SSO owner/user wiring uses only claims verified for that request's live session. Expired/logged-out or replaced session cannot reuse an earlier mapped id. User kind never becomes owner from its id. R3 integration maps subject, denies missing/invalid grants and owner overlap, broken access file leaves terminal owner working | M17 bypass tester switch, M18 turn user kind into owner by id |
|
||||
| T9 isolation limits | Two scratch instances have different sockets and matching tree paths. Foreign-uid stand-in refuses. Record that no real uid boundary was exercised | M19 choose default instance socket |
|
||||
| T10 interpreter and cap path | Activation evidence records the same pinned root-owned system ELF that the bus uses. Scripts, home paths and unsafe owners/modes refuse. Simulated OS replacement followed by startup accepts a safe replacement and appends its actual identity without changing the old receipt, refuses an unsafe replacement or unsafe/unwritable receipt. Cap calls complete without invoking the helper, proven with a helper spy that fails if called | M21 bypass interpreter safety, M22 invoke helper for cap traffic, M23 retain stale interpreter identity after restart |
|
||||
|
||||
Tests for new policy stay in the bus, WebUI and CLI packages. R3 provides the real user/grant
|
||||
half of T8 in its candidate, with its own gate. This row tests the shared verifier's user
|
||||
provenance without shipping placeholder user access. Mutants run on scratch copies,
|
||||
restore the original bytes and verify hashes. Two failures in a gate run stop and are reported.
|
||||
Single test files run freely. Ask Sage for the slot before any suite. Final acceptance is
|
||||
Duckworth's approved manifest plus Sage's bus, WebUI, CLI and scripts/test-*.sh gate.
|
||||
|
||||
## 8. Closure, limits and review decisions
|
||||
|
||||
R1. Same uid remains trusted OS authority. A hostile process may detach, clear its markers,
|
||||
modify argv, read same-uid pipes or capabilities, edit policy files, or interfere with the
|
||||
Console where host policy permits. SO_PEERCRED proves the connecting process identity at
|
||||
connection time. It does not prove human intent, protect memory, or track a socket passed to
|
||||
another process. Ordinary agent children fail the walk. Hostile detached agents are not
|
||||
claimed contained. Separate uids narrow cross-instance reach, not reach within one instance.
|
||||
|
||||
R2. The Console provenance option trusts verified code and process metadata, not an independent
|
||||
bus-side OIDC exchange. That trade must be approved, not hidden behind the phrase zero trust.
|
||||
|
||||
R3. A Unix tester endpoint removes direct browser navigation. No bridge is in this design.
|
||||
The 7331 development board remains a separately reported reachability and impersonation risk.
|
||||
|
||||
P19 333 can record the following after the implementation gate and authorized re-pin:
|
||||
"Tester ingress is private to the instance uid. Bus human proofs are tied to the actual
|
||||
Unix peer and retain the human walk. Session-user calls require the same-tree Console as
|
||||
direct parent. Q175 phase 2 separately protects main Console ingress with verified OIDC.
|
||||
Hostile processes on the instance uid, root, and the independent development control board
|
||||
remain outside these guarantees. Cross-instance file isolation requires the uid/host gate."
|
||||
|
||||
Duckworth requires that P19 333 also carry R2 word for word:
|
||||
"The Console provenance option trusts verified code and process metadata, not an independent
|
||||
bus-side OIDC exchange. That trade must be approved, not hidden behind the phrase zero trust."
|
||||
|
||||
Do not mark the historical task fully closed on a design or commit. Closure needs live
|
||||
Q175 phase 2, this row's re-pin and the recorded residual accepted through Mos. A promise of
|
||||
hostile same-uid isolation would require separate OS authority for agents and a new design.
|
||||
|
||||
Review receipt: duckworth-0006, notes at `86be181a2:agents/duckworth/work/row196/REVIEW.md`.
|
||||
Q1 selects O1 with the conditions incorporated in section 5. Q2 approves Console provenance
|
||||
with the live-session and unchanged P19 R2 conditions. B1 adds concurrent T2, M20 and socket
|
||||
survival checks. N2's detached-Console limit remains in R1. N3's gid validation and N4's
|
||||
operator window warning are included. Mos's tester-access ruling is recorded above. No
|
||||
product code begins before design approval.
|
||||
Duckworth approved revision 2 in duckworth-0009, review at
|
||||
`cb6d840df:agents/duckworth/work/row196/REVIEW-r2.md`. N5 permits either restart policy.
|
||||
Revision 3 selects safety revalidation with a new append-only startup receipt, and gives the
|
||||
operator a re-pin path that respects the existing already-active refusal.
|
||||
@@ -1,121 +0,0 @@
|
||||
# Row 196 Human Boundary Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking.
|
||||
|
||||
**Goal:** Bind human proofs to their actual Unix peers and make tester HTTP private to the instance uid.
|
||||
|
||||
**Architecture:** The bus reads kernel credentials through a pinned system Python helper only for proof envelopes. One principal verifier keeps the human walk and supplies Console provenance for federation R3. Tester HTTP moves to a private filesystem socket. Release tools migrate the flags explicitly.
|
||||
|
||||
**Tech Stack:** Linux, Node 24 and 26, Python >=3.10 standard library, existing Node test runner and shell release tools.
|
||||
|
||||
**Spec:** `agents/gander/work/queue-196/DESIGN.md`, approved r2 at 5fe259f16, with N5 restart policy added in r3.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Work in the existing gander/row196 worktree. Plain node_modules symlink. No new package dependency.
|
||||
- No live files, credentials, listeners, units, second uid or external authentication service.
|
||||
- Helpers: proof envelopes only, at most 16 concurrent, one second timeout, 256-byte strict output.
|
||||
- Root-owned system Python ELF at resolved /usr/bin/python3, never PATH or a home/shim path.
|
||||
- console/ 0700, tester.sock 0600, Unix pathname at most 107 bytes.
|
||||
- One principal and transient cap per request. R3 owns user access policy and user-cli integration.
|
||||
- Mos accepted socket-only tester access. No TCP relay or control-board change.
|
||||
- Single test files run freely. Ask Sage before any suite. Stop and report two failures in a gate run.
|
||||
- Commit code only after applicable suites pass. Sage owns the final gate and manifest queue moves.
|
||||
|
||||
## Review Focus
|
||||
|
||||
- Connection overlap and disconnect: helpers must retain the correct Socket object and release capacity on every exit.
|
||||
- OS interpreter replacement: restart records the actual safe binary, while old receipts remain unchanged.
|
||||
- Long or unsafe instance paths: refuse before binding or stopping any service.
|
||||
- Rollback/recovery: distinguish intentional tester disablement from restoration of an old saved TCP unit.
|
||||
- CLI nonce respawn and session expiry: direct-parent provenance must survive correct Console spawning and refuse stale identity.
|
||||
|
||||
## Task 1: kernel credentials and interpreter evidence
|
||||
|
||||
Files: create `packages/bus/src/peercred.mjs`, `packages/bus/src/peercred.py`,
|
||||
`packages/bus/src/interpreter.mjs`; tests in `packages/bus/tests/peercred.test.mjs` and
|
||||
`packages/bus/tests/interpreter.test.mjs`, with subprocess fixtures under `packages/bus/fixtures/`.
|
||||
|
||||
Interfaces: `inspectInterpreter()` returns a validated immutable record of path, version,
|
||||
device, inode and sha256. `recordInterpreterStart(directory, record)` appends the actual startup
|
||||
identity safely. `createPeerReader({interpreter})` returns `{read(socket),close()}`. read resolves
|
||||
to `{pid,uid,gid}` from that exact socket, or refuses. Test-only injected OS/process operations
|
||||
do not become product flags or environment overrides.
|
||||
|
||||
- [ ] Write interpreter safety, receipt replacement and cap-independent helper tests (T3/T10).
|
||||
- [ ] Run each new test file alone and record the expected missing-module failures.
|
||||
- [ ] Implement system interpreter validation and startup receipt append with no-follow, owner/type/mode checks.
|
||||
- [ ] Write T2's eight-process barrier plus racing copier, wrong-fd case and post-helper socket exchange.
|
||||
- [ ] Implement fd-3 helper, strict result validation, bounded async reader and helper shutdown.
|
||||
- [ ] Run `node --test packages/bus/tests/interpreter.test.mjs` and separately `node --test packages/bus/tests/peercred.test.mjs`.
|
||||
- [ ] Confirm process/socket cleanup, eight distinct kernel PIDs, copier refusal and original receipt preservation.
|
||||
|
||||
## Task 2: principal provenance and bus dispatch
|
||||
|
||||
Files: modify `packages/bus/src/{human,tester,server,runtime}.mjs`, create
|
||||
`packages/bus/src/principal.mjs`, add `packages/bus/tests/principal.test.mjs`, and extend
|
||||
`packages/bus/tests/human.test.mjs`. Extend server/runtime test files after inspecting their fixtures.
|
||||
|
||||
Interfaces: `verifyPrincipal(request, {peer,...trustedPathsAndProcessReaders})` returns
|
||||
`{kind,business,id}`. Human maps to the business human, tester to tester, user to its verified
|
||||
Console proof id. No roles or grants. Server obtains peer metadata from Task 1 only after
|
||||
envelope validation and only for proofs. Existing cap dispatch never invokes the helper.
|
||||
Future user binding stays refused without R3's trusted integration.
|
||||
|
||||
- [ ] Write T4/T5/T6/T8 process-table tests, including changed ancestry, distinct nonce namespaces and kind confusion.
|
||||
- [ ] Run the focused new file and confirm expected failure before implementing the verifier.
|
||||
- [ ] Parameterize the nonce/path portion of the existing human walk and retain all marker/binary/launch checks.
|
||||
- [ ] Wire peer metadata, interpreter startup receipt and one-request cleanup into runtime/server.
|
||||
- [ ] Test cap requests with a failing helper spy (M22), and test async refusal/close cleanup.
|
||||
- [ ] Run each affected bus test file alone. Record any base fixture changes needed for real peer identity.
|
||||
|
||||
## Task 3: tester Unix HTTP and Console nonce spawning
|
||||
|
||||
Files: modify `packages/webui/src/{cli,serve,bus}.mjs`, create
|
||||
`packages/webui/src/tester-socket.mjs` and `packages/cli/src/tester-http.mjs`; add focused
|
||||
`packages/webui/tests/tester-socket.test.mjs` and `packages/cli/tests/tester-http.test.mjs`.
|
||||
Extend existing WebUI transport tests.
|
||||
|
||||
Interfaces: `startTesterSocket({dataRoot,...handlerOptions})` owns the private listener and
|
||||
cleanup. `testerRequest({dataRoot,method,path,body})` uses the fixed logical origin and bounded
|
||||
Unix HTTP. Console human child spawning supplies its initial nonce, preserving agent markers.
|
||||
R3 reuses that spawn pattern for its user-cli.
|
||||
|
||||
- [ ] Write T1/T3/T9 socket permission, unsafe-path, wrong-origin and instance separation tests.
|
||||
- [ ] Confirm the new focused files fail, then implement --tester-socket and refusal of --tester-port.
|
||||
- [ ] Keep tester principal, write allowlist and cookie/header refusals intact. Add no browser relay.
|
||||
- [ ] Write and implement the initial-nonce Console spawn test and the helper request timeout test.
|
||||
- [ ] Run each new or changed test file alone, verifying cleanup and no tester TCP bind.
|
||||
|
||||
## Task 4: release migration and operator evidence
|
||||
|
||||
Files: modify `scripts/{webui-service,stack-release}.sh`, inspect
|
||||
`scripts/stack-copy.sh` closure, extend `packages/cli/tests/stack-release.test.mjs` and its
|
||||
render fixtures. Update `docs/TOOLS.md` and package READMEs with the implementation.
|
||||
|
||||
Interfaces: `activate ID BUSINESS --tester-socket` explicitly replaces inherited tester-port
|
||||
flags. Pointer and unit argv agree. Candidate support and interpreter preflight run before
|
||||
any unit stop. Activation gate evidence records the same interpreter as the bus startup receipt.
|
||||
|
||||
- [ ] Write T7 explicit conversion, inherited mismatch and unsupported-target refusal fixtures.
|
||||
- [ ] Run only the affected test file, then implement flag rendering, plan output and rollback disabling unsupported tester.
|
||||
- [ ] Preserve old-copy recovery behavior and report restored old security state accurately.
|
||||
- [ ] Add rehearsal scratch socket and T10 interpreter gate identity comparison.
|
||||
- [ ] Verify same-copy activation refusal is preserved. Document N5's next-reviewed-copy re-pin path.
|
||||
- [ ] Run the affected file alone, shell syntax checks, and inspect remaining tester-port references by purpose.
|
||||
|
||||
## Task 5: mutants, approved suites and manifest
|
||||
|
||||
Files: add the row's mutation runner and receipts under `agents/gander/work/queue-196/`.
|
||||
Append BUILD-LOG phase receipts. No SESSIONS or queue file edits.
|
||||
|
||||
- [ ] Exercise M1-M23 against isolated scratch copies. Record killer test, restored hash and cleanup for each.
|
||||
- [ ] Run focused tests on Node 24 and 26, including the concurrent T2 case and socket lifetime checks.
|
||||
- [ ] Send Sage the scope and estimated duration, obtain the suite slot, then run approved bus/WebUI/CLI suites.
|
||||
- [ ] Let Sage own the scripts/test-*.sh gate. Stop on the repository's two-failure rule.
|
||||
- [ ] Build the exact changed-path manifest with acceptance evidence, search results, limits and consumer audit.
|
||||
- [ ] Commit/push the verified candidate only after applicable suites pass. Send Duckworth and Sage path, commit and hashes for manifest review.
|
||||
|
||||
Execution stays with Gander in this worktree. No new agent delegation. Duckworth reviews
|
||||
the manifest independently. Push is only to gander/row196. No merge, re-pin or deployment
|
||||
is authorized by this plan.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Gizmoduck
|
||||
|
||||
Design and builder seat, Claude Opus 5.5, a T3 thread (9312a896). Author of the federation design
|
||||
(`agents/gizmoduck/work/federation/DESIGN.md` on `gizmoduck/federation-design`, #414). Builds FED R1, the
|
||||
access.json loader and validator, under Mos's order mos-1023 item 1.
|
||||
|
||||
No launcher in this directory. The seat works in its own worktree and has no credentials of its own; until
|
||||
the credentials row settles per-agent identity, it pushes only its own `gizmoduck/*` branches through the
|
||||
repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Row 201 round 1 (#1699): FED R1, the access.json loader and validator
|
||||
|
||||
Gizmoduck, 2026-10-20. Reviewer Duckworth, gate owner Sage. Federation design #414.
|
||||
|
||||
- Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, section "Brief: FED R1", at a2af7ab49.
|
||||
- Design: `agents/gizmoduck/work/federation/DESIGN.md` section 3.5, r4.9 a1daa3c07 on
|
||||
`gizmoduck/federation-design`.
|
||||
- Base: ae3c36f24 (`origin/refactor` when I branched, contains the brief). Branch `gizmoduck/fed-r1`.
|
||||
- Candidate: the commit that adds this file. `candidate-manifest.sha256` lists the three code files.
|
||||
|
||||
## Interface
|
||||
|
||||
This is what R5 and row 193a build against. Any change to it in review goes to Sage before it lands.
|
||||
Morgana confirmed it (morgana-0014) and Sage accepted it with `ssoSubjects` required (sage-1399). Sage
|
||||
relays it to Quackerjack.
|
||||
|
||||
| Export | Shape |
|
||||
|--------|-------|
|
||||
| `LEVELS` | frozen `["collaborator"]`, the one allowed level set. 193a widens it with the single line `["collaborator", "viewer"]` |
|
||||
| `accessFilePath(dir = configDir())` | `<dir>/access.json` |
|
||||
| `checkAccess(doc, { providers, peers, businesses, ssoSubjects })` | pure, returns a frozen deep copy, throws `BusinessError` exit 2 naming the key path |
|
||||
| `loadAccess(file, context, { uid = process.getuid() } = {})` | one no-follow read through `readJsonFile`, then the stat checks, then `checkAccess`. `uid` is a test seam |
|
||||
| `covers(grant, { business, project, instance }, now, { level } = {})` | boolean. `now` must be a valid `Date`, otherwise TypeError |
|
||||
|
||||
The context: `providers` is an array of provider ids, `peers` an array of peer ids (never `local`),
|
||||
`businesses` maps a business id to `{ human, instances }`, and `ssoSubjects` is an array of
|
||||
`{ provider, sub }`, the owner's `console.sso.subjects`. A missing or malformed context refuses.
|
||||
All four are exported from `src/index.mjs` too.
|
||||
|
||||
## Rules, code and tests
|
||||
|
||||
DESIGN 3.5 rule, where `access.mjs` enforces it, and the test that shows the refusal names the key path.
|
||||
|
||||
| 3.5 rule | access.mjs | access.test.mjs test | Mutant |
|
||||
|----------|-----------|----------------------|--------|
|
||||
| regular file, not a symlink | 167 (`readJsonFile`, util.mjs:66-89) | "loadAccess refuses a missing file, a symlink and a directory" | M03 |
|
||||
| owned by the instance's uid | 168 | "loadAccess refuses another owner and group or other write" | M01 |
|
||||
| no group or other write | 169 | same | M02 |
|
||||
| unknown keys refuse | 147, 71, 83, 121 | "top level", "users: unknown keys", "users: subjects", "grants: unknown keys" | M05 M10 M16 M22 |
|
||||
| `accessVersion` is 1 | 148 | "top level" | M06 |
|
||||
| `revision` positive integer | 149 | "top level" | M07 |
|
||||
| user id matches the id rule | 72 | "users: unknown keys and the id rule" | M11 |
|
||||
| never `human` or `tester` | 73 | "users: human, tester and any business's human" | M12 |
|
||||
| never any business's `human` | 74 | same, both businesses | M13 |
|
||||
| provider is a given provider | 84-86 | "users: subjects, provider, sub" | M17 |
|
||||
| a pair appears once in the file | 90 | same, within one user and across two | M20 |
|
||||
| a pair is never in `console.sso.subjects` | 89 | same | M19 |
|
||||
| `home` is a given peer or `local` | 75-77 | "users: home is a given peer or local" | M14 |
|
||||
| grant names a declared business | 126-128 | "grants: unknown keys, id, user and business" | M26 |
|
||||
| `projects` is `["*"]` or UUIDs | 98-107, 130-132 | "grants: projects" | M27 M28 M29 M30 |
|
||||
| `instances` is `["*"]` or declared instances | 98-107, 133-135 | "grants: instances" | M31 |
|
||||
| `level` is `collaborator` | 136 | "grants: any level other than collaborator" (missing, null, wrong case) | M32 |
|
||||
| `expires` optional ISO time | 111-117, 137 | "grants: expires is optional ISO time" | M33 M34 |
|
||||
| past `expires` is absent | covers 179 | "covers: an expired grant doesn't match, at and after the instant" | M38 M39 |
|
||||
|
||||
`covers`: `*` and listed ids match (180-182), an unlisted project, instance or business doesn't, an
|
||||
expired grant doesn't (179), and a level filter rejects another level (183). Mutants M38 to M44.
|
||||
|
||||
## Deviations from the brief
|
||||
|
||||
1. Expiry. The brief's test list says expiry refuses. DESIGN 3.5 line 257 says a grant past `expires` is
|
||||
treated as absent. Sage ruled the design wins (sage-1399), so a past `expires` passes `checkAccess`
|
||||
and `covers` returns false at and after the instant. A malformed `expires` still refuses.
|
||||
2. The brief lists "a grant disjoint from the given subjects". 3.5 has no rule that ties a grant to a
|
||||
subject, and a grant names a user, not a subject. I read it as the 3.5 lines 248-249 subject rules:
|
||||
a `(provider, sub)` pair that is also in `ssoSubjects` refuses, and so does a pair that appears twice.
|
||||
Duckworth, say if you meant something else.
|
||||
|
||||
## Rules I added beyond 3.5
|
||||
|
||||
Each one is fail closed and has a test and a mutant.
|
||||
|
||||
- User ids and grant ids are unique (154, 123; M21, M24). A grant's `user` names a user in the file
|
||||
(125; M25). Without these, a grant could point at nobody or two users could share an id.
|
||||
- `projects` and `instances` are non-empty, distinct, and `*` never mixes with ids (98-106).
|
||||
- `expires` must be UTC with a `Z` and must round-trip (`2026-02-30T00:00:00Z` refuses). `Date.parse`
|
||||
accepts that date and rolls it to March 2, so the regex alone wasn't enough.
|
||||
- The context is checked too (43-67; M35 to M37). `peers` can't name `local`, because that would make a
|
||||
peer user look local.
|
||||
- `covers` throws a TypeError for a `now` that isn't a valid Date (M44), since a bad clock would
|
||||
otherwise make every grant look unexpired.
|
||||
|
||||
## Checks
|
||||
|
||||
- `node --test packages/business/tests/access.test.mjs`: 24 pass, 0 fail (single file, no slot).
|
||||
- Morgana's `access-levels.test.mjs` at 08bb4d827, as a cross-check, not part of the candidate. On this
|
||||
candidate it passes 3 of 4, and the one failure is the viewer case 193a adds. In a scratch copy with
|
||||
`LEVELS` widened to `["collaborator", "viewer"]` it passes 4 of 4.
|
||||
- `mutants.py`: 44 mutants, one per refusal and covers branch. All 44 anchors match once and every
|
||||
mutant passes `node --check`. **Not run yet**: mutants need Sage's slot. Run as
|
||||
`python3 -I agents/gizmoduck/work/queue-201/mutants.py <checkout> <empty scratch dir>`. It runs a control
|
||||
first, puts each run in its own process group with a 60 s timeout, and exits 1 if any mutant survives.
|
||||
- `node tools/unslop-hook/unslop-check.js` is clean on all three code files and this file.
|
||||
- No test reads or pins `src/index.mjs` by hash (grep of packages/*/tests and scripts/test-*.sh).
|
||||
- Not run: the business suite and `scripts/test-*.sh`. Both need Sage's slot.
|
||||
|
||||
## Boundaries kept
|
||||
|
||||
Nothing touched `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any access.json. Tests write
|
||||
only under `mkdtemp` scratch dirs.
|
||||
@@ -0,0 +1 @@
|
||||
ae3c36f24883e870541158d3ade1f5d3ef2ab856
|
||||
@@ -0,0 +1,3 @@
|
||||
74ac7e9ed38c230e60e7f7ece373104446c362b6fce0a4f92ca2210a981dc4f4 packages/business/src/access.mjs
|
||||
7ee59b6e792fb47048fd06d227b65467ef71dabfcbdabdc0527b780656323d20 packages/business/src/index.mjs
|
||||
3bec097f55909b31faf2bbc62c81e1d4d0290f50e696d3d3d367e77d82c5b238 packages/business/tests/access.test.mjs
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Row 201 (FED R1) mutants: one per refusal in packages/business/src/access.mjs.
|
||||
|
||||
Usage: mutants.py <checkout> <scratch-dir>
|
||||
|
||||
Copies packages/business and packages/plugins from <checkout> into
|
||||
<scratch-dir>, applies one mutant at a time (each anchor must match exactly
|
||||
once), runs `node --test tests/access.test.mjs` in its own process group with
|
||||
a timeout, and expects it to fail. Prints one line per mutant and exits 1 if
|
||||
any survives, an anchor misses, or the unmutated control fails. Needs Sage's
|
||||
slot. Writes nothing outside <scratch-dir>.
|
||||
"""
|
||||
import os, shutil, signal, subprocess, sys
|
||||
|
||||
MUTANTS = [
|
||||
# loadAccess file checks
|
||||
("M01 owner check", 'if (stat.uid !== uid) refuse(', 'if (false) refuse('),
|
||||
("M02 group/other write check", 'if ((stat.mode & 0o022) !== 0) refuse(`access file', 'if (false) refuse(`access file'),
|
||||
("M03 one-fd nofollow read", 'const document = readJsonFile(file, "access file", (stat) => {',
|
||||
'const document = JSON.parse(readFileSync(file, "utf8")); ((stat) => {'),
|
||||
# top level
|
||||
("M04 document object", 'if (!isPlainObject(document)) refuse("access file must be a JSON object");', ''),
|
||||
("M05 top unknown keys", 'rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");', ''),
|
||||
("M06 accessVersion", 'if (document.accessVersion !== 1) refuse(', 'if (false) refuse('),
|
||||
("M07 revision", 'requirePositiveInt(document.revision, "revision");', ''),
|
||||
("M08 users array", 'requireArray(document.users, "users").forEach(', '(document.users ?? []).forEach('),
|
||||
("M09 grants array", 'requireArray(document.grants, "grants").forEach(', '(document.grants ?? []).forEach('),
|
||||
# users
|
||||
("M10 user unknown keys", 'rejectUnknownKeys(user, ["id", "home", "subjects"], at);', ''),
|
||||
("M11 user id rule", 'requireId(user.id, `${at}.id`);', ''),
|
||||
("M12 human/tester", 'if (RESERVED_USERS.includes(user.id)) refuse(', 'if (false) refuse('),
|
||||
("M13 business human", 'if (humans.has(user.id)) refuse(', 'if (false) refuse('),
|
||||
("M14 home", 'if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {', 'if (false) {'),
|
||||
("M15 subjects non-empty", 'if (subjects.length === 0) refuse(', 'if (false) refuse('),
|
||||
("M16 subject unknown keys", 'rejectUnknownKeys(s, ["provider", "sub"], sat);', ''),
|
||||
("M17 provider", 'if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {', 'if (false) {'),
|
||||
("M18 sub", 'requireText(s.sub, `${sat}.sub`);', ''),
|
||||
("M19 owner subject", 'if (ctx.owners.has(key)) refuse(', 'if (false) refuse('),
|
||||
("M20 duplicate subject", 'if (seenSubjects.has(key)) refuse(', 'if (false) refuse('),
|
||||
("M21 duplicate user id", 'if (users.has(user.id)) refuse(', 'if (false) refuse('),
|
||||
# grants
|
||||
("M22 grant unknown keys", 'rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);', ''),
|
||||
("M23 grant id rule", 'requireId(grant.id, `${at}.id`);', ''),
|
||||
("M24 duplicate grant id", 'if (seenGrants.has(grant.id)) refuse(', 'if (false) refuse('),
|
||||
("M25 grant user", 'if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(', 'if (false) refuse('),
|
||||
("M26 grant business", 'if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {', 'if (false) {'),
|
||||
("M27 scope non-empty", 'if (list.length === 0) refuse(`${where} must not be empty`);', ''),
|
||||
("M28 scope * alone", 'if (list.length !== 1) refuse(', 'if (false) refuse('),
|
||||
("M29 scope duplicate", 'if (seen.has(entry)) refuse(', 'if (false) refuse('),
|
||||
("M30 project UUID", 'if (typeof p !== "string" || !UUID.test(p)) refuse(', 'if (false) refuse('),
|
||||
("M31 declared instance", 'if (typeof x !== "string" || !business.instances.has(x)) refuse(', 'if (false) refuse('),
|
||||
("M32 level", 'if (!LEVELS.includes(grant.level)) refuse(', 'if (false) refuse('),
|
||||
("M33 expires format", 'if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {', 'if (typeof value !== "string") {'),
|
||||
("M34 expires real time", 'if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(', 'if (false) refuse('),
|
||||
# context
|
||||
("M35 context unknown keys", 'rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);', ''),
|
||||
("M36 context peers local", 'if (peers.has(LOCAL_HOME)) refuse(', 'if (false) refuse('),
|
||||
("M37 context ssoSubjects array", 'requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach(', '(context.ssoSubjects ?? []).forEach('),
|
||||
# covers
|
||||
("M38 covers expiry", 'if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;', ''),
|
||||
("M39 covers expiry boundary", 'now.getTime() >= Date.parse(grant.expires)', 'now.getTime() > Date.parse(grant.expires)'),
|
||||
("M40 covers business", 'if (grant.business !== business) return false;', ''),
|
||||
("M41 covers project", 'if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;', ''),
|
||||
("M42 covers instance", 'if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;', ''),
|
||||
("M43 covers level", 'if (level !== undefined && grant.level !== level) return false;', ''),
|
||||
("M44 covers now check", 'if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw', 'if (false) throw'),
|
||||
]
|
||||
|
||||
TIMEOUT = 60
|
||||
|
||||
|
||||
def run(cwd):
|
||||
proc = subprocess.Popen(["node", "--test", "tests/access.test.mjs"], cwd=cwd,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try:
|
||||
out, _ = proc.communicate(timeout=TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
proc.communicate()
|
||||
return None, "timeout"
|
||||
return proc.returncode, out.decode(errors="replace")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
sys.exit("usage: mutants.py <checkout> <scratch-dir>")
|
||||
checkout, scratch = (os.path.abspath(a) for a in sys.argv[1:])
|
||||
if not os.path.isfile(os.path.join(checkout, "packages/business/src/access.mjs")):
|
||||
sys.exit(f"no packages/business/src/access.mjs under {checkout}")
|
||||
if os.path.exists(scratch) and os.listdir(scratch):
|
||||
sys.exit(f"scratch dir not empty: {scratch}")
|
||||
for pkg in ("business", "plugins"):
|
||||
shutil.copytree(os.path.join(checkout, "packages", pkg), os.path.join(scratch, "packages", pkg))
|
||||
pkg = os.path.join(scratch, "packages/business")
|
||||
target = os.path.join(pkg, "src/access.mjs")
|
||||
original = open(target).read()
|
||||
|
||||
code, out = run(pkg)
|
||||
if code != 0:
|
||||
print(out)
|
||||
sys.exit(f"control failed (exit {code}): the unmutated tests must pass")
|
||||
print("control: pass")
|
||||
|
||||
bad = 0
|
||||
for name, old, new in MUTANTS:
|
||||
n = original.count(old)
|
||||
if n != 1:
|
||||
print(f"{name}: ANCHOR {n}")
|
||||
bad += 1
|
||||
continue
|
||||
mutated = original.replace(old, new)
|
||||
if name.startswith("M03"):
|
||||
mutated = 'import { readFileSync } from "node:fs";\n' + mutated
|
||||
open(target, "w").write(mutated)
|
||||
code, _ = run(pkg)
|
||||
open(target, "w").write(original)
|
||||
if code == 0:
|
||||
print(f"{name}: SURVIVED")
|
||||
bad += 1
|
||||
else:
|
||||
print(f"{name}: killed ({'timeout' if code is None else f'exit {code}'})")
|
||||
print(f"mutants {len(MUTANTS)}, not killed {bad}")
|
||||
sys.exit(1 if bad else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
# Herb
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Sage under mos-0977 item 5 (more Codex Sol rows) for a second gate lane
|
||||
design row (#1695, mos-0977 item 6).
|
||||
|
||||
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 the credentials row settles per-agent identity, it pushes only its own `herb/*`
|
||||
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Megavolt
|
||||
|
||||
Builder seat, Claude Opus 5.5, started by Sage under mos-0974 and mos-0977 item 5 for MVP T1 item 6, the
|
||||
credential store per agent with its own git identity (#1697).
|
||||
|
||||
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 the credentials row settles per-agent identity, it pushes only its own `megavolt/*`
|
||||
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Quackerjack
|
||||
|
||||
Builder seat, Claude Haiku 5.5 at high effort, started by Sage under mos-1023 item 1 and Q177 for federation
|
||||
rows R5 (`mosaic access list, grant, revoke`) and R2 (the `federation` config key), per Gizmoduck's design
|
||||
section 7 (#414).
|
||||
|
||||
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 the credentials row settles per-agent identity, it pushes only its own
|
||||
`quackerjack/*` branches through the repository credential helper (mos-0981), never `refactor`, `main` or
|
||||
another seat's branch.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Row 202 BUILD: FED R5, `mosaic access list|grant|revoke`
|
||||
|
||||
Quackerjack, 2026-10-20. Plan: `PLAN.md` (commit 2d0aa2937). Manifest: `MANIFEST.md` in this directory.
|
||||
Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, "Brief: FED R5". Reviewer Duckworth. Gate owner Sage.
|
||||
|
||||
## What was built
|
||||
|
||||
- `packages/cli/src/access.mjs`: the three verbs, the human proof, the context (`accessContextFor`), the
|
||||
atomic write, the lock and the receipt.
|
||||
- `packages/cli/src/cli.mjs`: routes `access` to `accessCommand`.
|
||||
- `scripts/mosaic`: sends `access` to the CLI.
|
||||
- `packages/cli/README.md`: the verbs, the human proof, the lock, the `instances` limit, the removal order,
|
||||
and the limits on peers and subjects.
|
||||
- `packages/cli/tests/access.test.mjs`: 21 tests (round 2; 18 in round 1). Single file, green on this branch.
|
||||
|
||||
R1 (`packages/business/src/access.mjs`) is merged in unchanged at 80fe31071, the Duckworth-approved
|
||||
candidate. 44e36fc4c, the next commit on that branch, only removes a stray pyc file.
|
||||
|
||||
## The lock file (not in PLAN.md; added at Sage's request)
|
||||
|
||||
- Path: `<configDir>/access.json.lock`, the access file's path plus `.lock`. Mode 0600.
|
||||
- Created with `O_EXCL` (`wx`). The holder writes its pid and a newline into it, then holds it for the
|
||||
read, the check, the temp write and the rename. It is removed in a `finally`.
|
||||
- A second writer that finds the lock reads the pid. If the pid is alive (or the pid is reused by a live
|
||||
process), it refuses with `refused: ... being written by another command; try again` (exit 3). Nothing
|
||||
is written.
|
||||
- A lock whose pid is gone (`kill(pid, 0)` returns ESRCH) also refuses, and nothing reclaims it. The
|
||||
refusal names the path and the pid. A human checks that no write is running and removes the file. This
|
||||
is the crash case: the holder died mid-write, and a human clears it.
|
||||
- An empty lock, or one that is not a pid, refuses. Its holder may still be between create and write. A
|
||||
holder that died in that gap leaves an empty lock; a human removes it.
|
||||
|
||||
Reclaim was removed at Sage's ruling (sage-1429, 10-20). It was the one design with a race, so the
|
||||
race below is gone, not accepted.
|
||||
|
||||
Former limit, now removed: reclaim race. The old design let a writer take a stale lock by unlinking it
|
||||
and retaking it. The case that lost: writers A and B both read the same dead pid N. A unlinks the lock
|
||||
and takes it. B then unlinks A's live lock and takes its own. Both read the same revision, both write
|
||||
it, and the second rename silently replaces the first. The revision check did not catch it, because both
|
||||
had read the old revision before either wrote.
|
||||
|
||||
Known limits, stated plainly:
|
||||
1. A dead holder's lock blocks every write until a human removes it. That is the fail-closed price of
|
||||
no reclaim. The refusal says which pid and which path.
|
||||
2. A reused pid reads as alive, so a stale lock refuses. The fix is to remove the file by hand.
|
||||
3. The lock is per access file. It does not cover the bus's own reads.
|
||||
4. Crash-safety of the write path: the temp file is removed on any failure before or at the rename. The
|
||||
directory is not fsynced after the rename, so a power loss right after a rename can lose the rename.
|
||||
The bytes are never mixed.
|
||||
|
||||
Tests for the lock (all in `access.test.mjs`):
|
||||
- "a live holder's lock refuses and leaves the file and the lock alone": pid = this process.
|
||||
- "an empty or unreadable lock refuses": empty file, and `not-a-pid`.
|
||||
- "a lock left by a dead pid refuses, names the pid, and is not reclaimed": a child that has exited. The
|
||||
file, the lock and the receipt are unchanged.
|
||||
- "the lock is mode 0600 while held and removed after a refused write".
|
||||
|
||||
Tests for the write path and the refusals (added in this round):
|
||||
- "a failed rename leaves the old bytes, removes the temp file, and writes no receipt": the fs seam
|
||||
(`deps.fs`) makes `renameSync` throw. Checks the old bytes, no `.access-*.tmp`, no lock, no receipt.
|
||||
- "a group-writable access file refuses with exit 3, names the file, and changes nothing": a 0664 file.
|
||||
Exit 3, the file path in the message, nothing written.
|
||||
- "an agent or tester caller is refused before the context or the access file is read": `contextFor`
|
||||
throws if called, and the file is 0664. The refusal must come first.
|
||||
|
||||
## The human proof, and the bus behavior behind test 10
|
||||
|
||||
The proof is one `project.list` call through `humanTransport`. The bus gates it with `#human`
|
||||
(`packages/bus/src/broker.mjs`, the `project.list` case at :2319, and `#human` at :377). The call is in
|
||||
one function, `proveHuman` in `access.mjs`, for R4 to swap.
|
||||
|
||||
Test 10 runs the real `human-cli.mjs` on a scratch bus from agent ancestry (`CLAUDECODE=1` in the child's
|
||||
env). The bus refuses. The client reports `outcome-unknown`, not `human-required`, because the bus closes
|
||||
the socket on a failed proof and `packages/bus/src/client.mjs:32-33` maps a closed socket to
|
||||
`outcome-unknown`. Exit 1 (`busExit`). The test pins that code and asserts the access file and the
|
||||
receipt are unchanged.
|
||||
|
||||
This is a bus behavior outside R5. It is not changed here. Gizmoduck and Gander own the verifier (row 196).
|
||||
Until it changes, a human who hits the agent refusal sees `outcome-unknown`.
|
||||
|
||||
## Context
|
||||
|
||||
`accessContextFor(system, env)`:
|
||||
- `providers`: `system.console.sso.providers[].id`. Row 192's shape (packages/webui/src/sso-config.mjs on
|
||||
row 192's candidate).
|
||||
- `ssoSubjects`: each provider's `subjects[].sub`, paired with the provider id. Gizmoduck's design says
|
||||
`console.sso.subjects`; Sage ruled that is shorthand for this (erratum).
|
||||
- `peers`: `[]` until row 203 adds the federation key. A grant that names a peer home refuses.
|
||||
- `businesses`: `loadBusiness` for each `<configDir>/businesses/*.json`; `human` and the role names as
|
||||
`instances`. A business file that fails to load fails the command.
|
||||
|
||||
No `console` key gives empty providers and subjects, so every grant naming a subject refuses. The
|
||||
`checkConsole` check from row 192 is not on this branch yet. R5 rebases onto row 192's landing, and then
|
||||
reads `console` through `checkConsole`.
|
||||
|
||||
## Mutant list (one per refusal; run on Sage's go, before review)
|
||||
|
||||
| # | Mutant | Expected kill |
|
||||
|---|---|---|
|
||||
| M1 | remove the `refuseInsideAgent` call | "an agent caller is refused before the bus is asked" |
|
||||
| M2 | remove the `--tester` check | "--tester is refused on every verb" |
|
||||
| M3 | the proof's error is swallowed | "a bus that refuses the human proof blocks the write" |
|
||||
| M4 | the revision compare is removed | "a stale revision refuses with revision-conflict" |
|
||||
| M5 | `takeLock` unlinks a dead-pid lock and retakes it (the reclaim this round removed) | "a lock left by a dead pid refuses, names the pid, and is not reclaimed" |
|
||||
| M6 | `pidAlive` always returns true (a dead pid reads as live) | "a lock left by a dead pid refuses, names the pid, and is not reclaimed" |
|
||||
| M11 | `writeAtomic` writes straight to access.json, no temp file or rename | "a failed rename leaves the old bytes, removes the temp file, and writes no receipt" |
|
||||
| M12 | the BusinessError catch in `accessCommand` is removed | "a group-writable access file refuses with exit 3, names the file, and changes nothing" |
|
||||
| M13 | `contextFor()` is called before the agent and tester refusals | "an agent or tester caller is refused before the context or the access file is read" |
|
||||
| M7 | `checkAccess` is skipped before the write | "a grant naming an unknown instance is refused before any write" |
|
||||
| M8 | the receipt append is removed | "grant writes ... a receipt line" |
|
||||
| M9 | a project name is written as given (no lookup) | "a project name resolves to its id" |
|
||||
| M10 | the lock is not taken | "a live holder's lock refuses" |
|
||||
|
||||
Round 2 closed the direct-write gap: `writeAtomic` takes the fs calls through `deps.fs` (default: the real
|
||||
`node:fs` functions), and a rename that throws is tested (M11 is the direct-write mutant). Not yet run:
|
||||
the mutant batch, the cli suite and `scripts/test-*.sh`. All wait on Sage's slot.
|
||||
|
||||
Round 2 also changed the file's refusal path: a `BusinessError` from `loadAccess` or `loadBusiness` now
|
||||
exits 3 with the file path and key path on stderr, no stack trace (Duckworth B1, Sage sage-1437).
|
||||
|
||||
## Not done in this row
|
||||
|
||||
- `--peers` and the federation key: row 203.
|
||||
- `access.reload`: row R4.
|
||||
- Gizmoduck's `console.sso.subjects` name: erratum, the flattened shape is used.
|
||||
- The suite (`packages/cli` and every `scripts/test-*.sh`) and the mutant batch: on Sage's gate.
|
||||
@@ -0,0 +1,6 @@
|
||||
e2d3e613a8d6f04544904acb2af8aec44dedf9ba6820f1936e1f7d6547be2028 packages/cli/src/access.mjs
|
||||
e956ac5b54844af30ac7717e85b5b60282b2ccf00dfc134d1d5c1d9f3cb44f32 packages/cli/src/cli.mjs
|
||||
de7d15dc6989379a47b1efd4f449e9707004978198f01d704b2d61c8411ed6ca packages/cli/tests/access.test.mjs
|
||||
f50760a67337147e50af032969ea65ca313ac7b76f6239e23e5c0d95431bd4b7 packages/cli/README.md
|
||||
aeddef32c28c883eedf166bcac96d1e6828b71c862e683977e097706339baf86 scripts/mosaic
|
||||
79f2c5344c0d73754804ab58c68edfb82bad7bfef50718550c698f61ae2a8ab5 agents/quackerjack/work/queue-202/BUILD.md
|
||||
@@ -0,0 +1,64 @@
|
||||
# Row 202 manifest: FED R5, mosaic access list, grant, revoke (round 2)
|
||||
|
||||
Candidate: 48a3fb96e on quackerjack/fed-r5 (round 2). Supersedes the round-1 manifest at 959bfb275, which
|
||||
pointed at build 030809e15 and was reviewed by Duckworth as round 1 (changes, B1; REVIEW-r1.md, a76767e87).
|
||||
Base: R1 at 80fe31071, merged; 44e36fc4c and 35aaceac9 on the branch before the build.
|
||||
Owner Quackerjack, reviewer Duckworth, gate owner Sage. Brief: docs/plans/2026-10-20_federation-r1-r2-r5.md, Brief: FED R5.
|
||||
|
||||
## Files (sha256 of the blob at 48a3fb96e)
|
||||
|
||||
```
|
||||
e2d3e613a8d6f04544904acb2af8aec44dedf9ba6820f1936e1f7d6547be2028 packages/cli/src/access.mjs
|
||||
e956ac5b54844af30ac7717e85b5b60282b2ccf00dfc134d1d5c1d9f3cb44f32 packages/cli/src/cli.mjs
|
||||
de7d15dc6989379a47b1efd4f449e9707004978198f01d704b2d61c8411ed6ca packages/cli/tests/access.test.mjs
|
||||
f50760a67337147e50af032969ea65ca313ac7b76f6239e23e5c0d95431bd4b7 packages/cli/README.md
|
||||
aeddef32c28c883eedf166bcac96d1e6828b71c862e683977e097706339baf86 scripts/mosaic
|
||||
79f2c5344c0d73754804ab58c68edfb82bad7bfef50718550c698f61ae2a8ab5 agents/quackerjack/work/queue-202/BUILD.md
|
||||
```
|
||||
|
||||
scripts/mosaic is unchanged since round 1.
|
||||
|
||||
## What changed in round 2
|
||||
|
||||
- Lock: no reclaim. A dead-pid, empty or unreadable lock refuses and names the path (and pid); a human
|
||||
removes it. The reclaim race is gone (Sage sage-1429).
|
||||
- Atomic write: fs calls go through `deps.fs` (default: the real `node:fs` functions). A failed write
|
||||
removes its temp file and leaves the old bytes (Sage sage-1429, the direct-write gap).
|
||||
- BusinessError from loadAccess or loadBusiness: exit 3, file and key path on stderr, no stack trace
|
||||
(Duckworth B1, Sage sage-1437). Sage's ruling is exit 3, so the checkAccess refusals move from exit 2 to
|
||||
exit 3 too.
|
||||
- `contextFor` is a function, built after the agent and tester refusals and the human proof. A refused
|
||||
caller never reads access.json or the business files (Sage sage-1437).
|
||||
- The help text and README say the command runs as the instance's uid (Duckworth N6, Sage sage-1437).
|
||||
|
||||
## Interface used from R1 (80fe31071)
|
||||
|
||||
checkAccess(doc, { providers, peers, businesses, ssoSubjects }), loadBusiness, loadAccess (via readCurrent),
|
||||
accessFilePath is not used (the CLI takes configDir), covers is not used by R5.
|
||||
|
||||
## Named in review
|
||||
|
||||
- Human proof: one project.list call (proveHuman, access.mjs). Gate: packages/bus/src/broker.mjs:2319
|
||||
(project.list case) and :377 (#human). Agent and --tester refusals come before the proof.
|
||||
- Lock: <configDir>/access.json.lock, 0600, pid stamp. Live pid refuses (BUSY); dead, empty or unreadable
|
||||
refuses and names the path. No reclaim. See BUILD.md.
|
||||
- Write: checkAccess on the new document, then temp file, fsync, rename; revision compared inside the lock.
|
||||
- Pinned bus behavior: a failed proof reads as outcome-unknown (client.mjs:32-33). Outside R5.
|
||||
- Context: accessContextFor, built lazily; peers [] until row 203; subjects flattened from
|
||||
console.sso.providers[].subjects.
|
||||
|
||||
## Tests and mutants
|
||||
|
||||
- packages/cli/tests/access.test.mjs: 21 tests, single file green on 48a3fb96e (21/21, run on the candidate).
|
||||
- Mutants M1-M13 in BUILD.md. M11 (direct write) and M12 (no BusinessError catch) and M13 (context built
|
||||
early) are new. Run on Sage's go and slot only.
|
||||
- Not run in this round: the mutant batch, the cli suite and scripts/test-*.sh.
|
||||
|
||||
## Not in this row
|
||||
|
||||
- R2 peers and the federation key (row 203).
|
||||
- R4 access.reload (its brief takes N1: project.list exempts testers, so access.reload needs #human).
|
||||
- row 192 checkConsole (the context reads console.sso directly until 192 lands).
|
||||
- Viewer level (Sage's carry, item 5): whichever of 202 and 204 lands second adds it. Row 204 is not on
|
||||
refactor yet as of 10-20 20:23Z.
|
||||
- Cli suite and scripts/test-*.sh (Sage's gate).
|
||||
@@ -0,0 +1,106 @@
|
||||
# Row 202, FED R5: `mosaic access list`, `grant`, `revoke`
|
||||
|
||||
Quackerjack, 2026-10-20. Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, "Brief: FED R5". Design:
|
||||
`agents/gizmoduck/work/federation/DESIGN.md` at a1daa3c07 (sections 3.5, 3.7, 7 row R5). Reviewer Duckworth.
|
||||
Chained on row 201 (R1, Gizmoduck). Interface as Sage relayed it (sage-1400, gizmoduck-0030).
|
||||
|
||||
## Status before build
|
||||
|
||||
- Claim: `scripts/mosaic queue move 202 in-progress --op quackerjack-202-start --by quackerjack`, run from
|
||||
the canonical checkout, exits 2: "row 202 waits on 201 (done; now briefed)". Row 201 is `briefed`, so the
|
||||
claim is not made. I will not build past the plan until Sage says how to proceed. This plan is a file in
|
||||
my own worktree; it changes no queue row.
|
||||
- `gizmoduck/fed-r1` is not on origin yet. R5 imports `packages/business/src/access.mjs` only after R1 lands.
|
||||
Until then, code and tests run against the names in sage-1400, with the R1 import stubbed in one place.
|
||||
|
||||
## Interface I build against (R1, as relayed)
|
||||
|
||||
- `checkAccess(doc, { providers, peers, businesses, ssoSubjects })`: pure. Returns the checked document,
|
||||
frozen. Throws `BusinessError` (exit 2) naming the key path. `ssoSubjects` is required; a missing one fails
|
||||
closed.
|
||||
- `covers(grant, { business, project, instance }, now, { level } = {})`.
|
||||
- `accessFilePath(dir = configDir())`: `<configDir>/access.json`.
|
||||
- `loadAccess(file, context)`: one read, refuses a symlink, a non-regular file, another owner, or group or
|
||||
other write, then runs `checkAccess`. An expired grant is not refused here.
|
||||
|
||||
## Files I will change
|
||||
|
||||
New:
|
||||
- `packages/cli/src/access.mjs`: argument parsing for `list`, `grant`, `revoke`; the human check; the write;
|
||||
the receipt; the usage text. Exports a `main`-style function the way `thread.mjs` and `project.mjs` do.
|
||||
- `packages/cli/tests/access.test.mjs`: all R5 tests below.
|
||||
|
||||
Edited:
|
||||
- `scripts/mosaic`: add `access` to the dispatch case that sends a verb to `packages/cli/src/cli.mjs`.
|
||||
- `packages/cli/src/cli.mjs`: route `access` to `access.mjs`.
|
||||
- `packages/cli/README.md`: the three verbs, the `instances` limit, and the provider-removal order.
|
||||
|
||||
Not touched: `packages/business/src/access.mjs` (R1 owns it), `scripts/mosaic-config.mjs` (R2), any bus
|
||||
broker code (R3/R4), `~/.mosaic-dev`, `~/.config/mosaic-dev`, the units, any live `access.json`.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- `list`: prints the users, grants and revision of `accessFilePath()`. Reads through `loadAccess`, so a bad
|
||||
file refuses here too. Shows each grant's project as the id (and the name, when `project.list` resolves
|
||||
it). Shows `["*"]` as "all projects, now and later".
|
||||
- `grant`: `--user`, `--business`, `--projects`, `--instances`, `--level collaborator`, optional
|
||||
`--expires`, `--revision N`. Builds the new document in memory, runs `checkAccess` on it, then writes. A
|
||||
bad grant refuses before any write. `--project <name>` resolves the name through `project.list` at grant
|
||||
time and writes the id (design 3.5).
|
||||
- `revoke`: removes the user's grants, or one grant with `--grant ID`, same flow.
|
||||
- Write: a temp file in the same directory, mode 0600, fsync, rename over `access.json`. Revision goes up by
|
||||
one. The caller passes the revision it read, and a mismatch refuses with `revision-conflict` before the
|
||||
rename.
|
||||
- Receipt: one line appended to `<dataRoot>/state/access-log.jsonl`. Append-only, same shape as
|
||||
`activation-log.jsonl` (`packages/runs/src/state.mjs`). It records the time, the verb, the actor, the
|
||||
revision before and after, and the grant ids touched. No secret or token goes in it.
|
||||
- Help text states two things. First, a grant's `instances` is the real limit: a collaborator drives the
|
||||
granted instance with the owner's role (design 3.5). Second, the removal order: take a user out of
|
||||
`access.json` first, which acts at once. Removing a provider from `console.sso` takes effect only after a
|
||||
Console restart (design 3.7).
|
||||
|
||||
## Human proof
|
||||
|
||||
The check runs as the human transport does now: `humanTransport` (`packages/cli/src/transport.mjs`) runs
|
||||
`packages/bus/src/human-cli.mjs` against the bus socket, and `refuseInsideAgent` refuses first in any agent
|
||||
ancestry. Open point, for Sage or Gizmoduck to settle before build: the design names no bus verb for the
|
||||
grant check. My reading is that R5 makes one call through `humanTransport` to a read the bus already gates
|
||||
as human-only, and uses its success as the proof, with no new bus verb (R5 must not widen the bus).
|
||||
If that is wrong, I stop and report it.
|
||||
|
||||
Tests use two stand-ins, the pattern `packages/cli/tests/helpers.mjs` already has:
|
||||
- Stand-in human CLI: `fakeCli(root, body)` replaces `human-cli.mjs` as the `cli` argument of
|
||||
`humanTransport`, so the answer comes from a table. Used for the caller-refusal tests and most of the rest.
|
||||
- Scratch bus: `startBroker` from `packages/bus/src/runtime.mjs` on a scratch socket and scratch data root.
|
||||
One integration test runs the real `HUMAN_CLI` against it, so the proof path is checked end to end. The
|
||||
bus accepts only its own `human-cli.mjs` path (memory #225), so that test uses the real file, not a copy.
|
||||
- Tester caller: a `tester` proof, through `testerTransport` with `TESTER_CLI`. Refused with `tester-refused`
|
||||
or `tester-required` as the bus returns it.
|
||||
- Agent caller: env with `MOSAIC_RUN_ID` (and the other markers in `AGENT_MARKERS`) set. Refused by
|
||||
`refuseInsideAgent` (exit 3) before any transport call.
|
||||
|
||||
## Test list (`packages/cli/tests/access.test.mjs`) and one mutant per refusal
|
||||
|
||||
Each test uses a scratch `MOSAIC_CONFIG` dir and a scratch data root. None reads `~/.config/mosaic-dev`.
|
||||
|
||||
| # | Refusal or behaviour | Test | Mutant (must be killed) |
|
||||
|---|---|---|---|
|
||||
| 1 | Agent caller refused | env with `MOSAIC_RUN_ID` runs `grant`; exit 3, no file change | `refuseInsideAgent` call removed from the access path |
|
||||
| 2 | Tester caller refused | `tester` proof runs `grant`; `tester-refused` (exit 3), no write | tester branch lets a tester write |
|
||||
| 3 | Human proof required | stand-in answers `human-required`; exit 3, no write | proof result ignored |
|
||||
| 4 | Concurrent write refused | two writers read revision 3; second with `--revision 3` gets `revision-conflict`; file keeps revision 4 | revision compare removed |
|
||||
| 5 | Receipt appended | after `grant`, one new line in `access-log.jsonl` with both revisions; a second write adds a second line, the first is unchanged | receipt write removed, or the write truncates the log |
|
||||
| 6 | Bad grant refused before write | grant with a non-UUID project, unknown instance, level `viewer`; exit 2, file bytes unchanged | `checkAccess` runs after the rename |
|
||||
| 7 | Atomic write | the write is a temp file and a rename; a failed rename leaves the old file whole | direct write to `access.json` |
|
||||
| 8 | Revoke removes the grant | `revoke` removes the user; `list` no longer shows it; revision +1 | revoke leaves the grant |
|
||||
| 9 | `--project <name>` resolves to an id | name resolves through `project.list`; the file holds the id | name written as-is |
|
||||
| 10 | Real proof path | scratch `startBroker` plus real `HUMAN_CLI` with a valid proof; grant succeeds | (control: the success path must pass with no mutant) |
|
||||
|
||||
The mutant batch needs Sage's slot. Single test files run freely.
|
||||
|
||||
## Gate
|
||||
|
||||
- Duckworth approves this manifest and the code.
|
||||
- The cli suite and every `scripts/test-*.sh` green on Sage's gate.
|
||||
- Each mutant above is killed.
|
||||
- Rebase onto R1's landing; re-run every touched test file singly before in-review.
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
n=0
|
||||
for p in /proc/[0-9]*; do
|
||||
c=$(tr '\0' ' ' < $p/cmdline 2>/dev/null) || continue
|
||||
case "$c" in *shell-snapshots*|*chrome-devtools-mcp*|*sage-busy-now*) continue;; esac
|
||||
if echo "$c" | grep -qE 'node --test|gate\.sh|mutant|scripts/test-[a-z-]+\.sh|docker compose'; then echo "BUSY ${p#/proc/}: ${c:0:160}"; n=$((n+1)); fi
|
||||
done
|
||||
[ $n -eq 0 ] && echo CLEAR
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
set -o pipefail
|
||||
# Landing gate template (2026-10-14): sed the G= and OUT= lines and this comment.
|
||||
# Sequential suites, each teed to its own log; stops at the first failure.
|
||||
G=/mnt/storage/scratch/SET-ME-wt
|
||||
OUT=/mnt/storage/scratch/SET-ME-out
|
||||
cd "$G" || exit 9
|
||||
mkdir -p "$OUT" || exit 9 # a missing OUT failed the row 142 gate at step 1 (2026-10-15)
|
||||
# test-task.sh live cases run `docker compose` from $G, so without a name the project is "wt"
|
||||
# and leaves wt_default behind (row 142 gate, 2026-10-15). Name it per gate dir and remove it on exit.
|
||||
export COMPOSE_PROJECT_NAME="$(basename "$(dirname "$G")")"
|
||||
trap 'docker network rm "${COMPOSE_PROJECT_NAME}_default" >/dev/null 2>&1 && echo "removed network ${COMPOSE_PROJECT_NAME}_default"' EXIT
|
||||
# A gate tree without the node_modules link fails cli tests with ENOENT (2026-10-13).
|
||||
[ -d "$G/node_modules" ] || { echo "STOP no node_modules in $G"; exit 8; }
|
||||
echo "base $(git rev-parse --short HEAD) dirty $(git status --short | grep -vc node_modules) DOCKER_HOST=${DOCKER_HOST:-unset}"
|
||||
run() { local name="$1" dir="$2" cmd="$3"
|
||||
(cd "$G/$dir" && bash -c "$cmd") > "$OUT/$name.log" 2>&1; local rc=$?
|
||||
echo "$name rc=$rc $(grep -E '^ℹ (pass|fail) [0-9]+' "$OUT/$name.log" | tr '\n' ' ') | $(tail -n1 "$OUT/$name.log")"
|
||||
[ $rc -eq 0 ] || { echo "STOP $name"; exit $rc; }; }
|
||||
# No pkg-queue step: scripts/test-queue.sh runs packages/queue/tests/ itself (same 156 tests; CI debate item 2a, Vikunja #274).
|
||||
for d in packages/*/; do p=$(basename "$d"); [ -d "$d/tests" ] || continue; [ "$p" = queue ] && continue
|
||||
run "pkg-$p" "packages/$p" 'node --test tests/*.test.mjs'; done
|
||||
for s in scripts/test-*.sh; do n=$(basename "$s" .sh); run "script-${n#test-}" . "bash $s"; done
|
||||
echo GATE_PASSED
|
||||
@@ -0,0 +1,36 @@
|
||||
# Row 189 gate timing (for row 197, Herb)
|
||||
|
||||
Sage, 2026-10-20T19:25:04Z. Gate on 0c177e9ef (base 0217b71c8), one lane. The slot was mine. Seats may have run single test files at the same time, which the slot rules allow, and I did not record that.
|
||||
Start 2026-10-20T19:09:37Z, end 2026-10-20T19:20:36Z. Total wall time: 659 s.
|
||||
|
||||
Wall time per suite is the gap between one suite's log mtime and the one before it (the first suite starts at start.txt), so it includes node start-up. node's duration_ms is listed where the suite prints it. Scripts print no duration.
|
||||
|
||||
| Suite | Wall s | duration_ms s | Ended (UTC) |
|
||||
|---|---|---|---|
|
||||
| pkg-bus | 3 | 3.8 | 19:09:40 |
|
||||
| pkg-business | 3 | 2.2 | 19:09:43 |
|
||||
| pkg-cli | 388 | 388.5 | 19:16:11 |
|
||||
| pkg-control-board | 1 | 0.8 | 19:16:12 |
|
||||
| pkg-conversation | 35 | 35.4 | 19:16:47 |
|
||||
| pkg-discord | 3 | 2.6 | 19:16:50 |
|
||||
| pkg-harness | 12 | 11.6 | 19:17:02 |
|
||||
| pkg-ledger | 13 | 13.5 | 19:17:15 |
|
||||
| pkg-mosaic | 3 | 2.3 | 19:17:18 |
|
||||
| pkg-plugins | 0 | 0.3 | 19:17:18 |
|
||||
| pkg-project | 1 | 1.2 | 19:17:19 |
|
||||
| pkg-runs | 1 | 0.8 | 19:17:20 |
|
||||
| pkg-seat | 6 | 6.2 | 19:17:26 |
|
||||
| pkg-tasks | 1 | 1.2 | 19:17:27 |
|
||||
| pkg-webui | 58 | 57.9 | 19:18:25 |
|
||||
| script-auth | 1 | - | 19:18:26 |
|
||||
| script-conductor | 5 | - | 19:18:31 |
|
||||
| script-config | 2 | - | 19:18:33 |
|
||||
| script-discord | 8 | - | 19:18:41 |
|
||||
| script-extension-package | 1 | - | 19:18:42 |
|
||||
| script-foundation | 40 | - | 19:19:22 |
|
||||
| script-land-work | 11 | - | 19:19:33 |
|
||||
| script-queue | 21 | - | 19:19:54 |
|
||||
| script-release | 11 | - | 19:20:05 |
|
||||
| script-task | 31 | - | 19:20:36 |
|
||||
|
||||
Source: /mnt/storage/scratch/sage-g189/out/*.log mtimes and gate.log. The cli suite is about 59% of the run, and webui plus conversation add about 14%.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Row 189: `stack-release.sh activate --no-send`
|
||||
|
||||
Gitea #1687. Vikunja P19 #413 (Q175 interim, #412). Brief: docs/plans/2026-10-20_activate-no-send.md.
|
||||
Rulings: mos-0972 (the Console runs without `--send` until Q175 phase 2), mos-0973 (go; Sage builds,
|
||||
Dewey reviews; gate is the cli suite plus every scripts/test-*.sh). Base: def2e7932 (refactor, the
|
||||
brief commit on 9ae8c73ac).
|
||||
|
||||
## What it does
|
||||
|
||||
`activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE` in a new `send_for_target`, which
|
||||
`console_for_target` calls after `proof_for_target`. `install_stack` renders the unit from
|
||||
`RUN_CONSOLE` and `write_pointer` writes the pointer from it, so both lose the flag and `flags_drift`
|
||||
stays quiet afterwards. Only `activate` takes the flag; the parser sends every other op to usage
|
||||
(exit 4).
|
||||
|
||||
`check_target` lets a `--no-send` activate name the pointer's copy only when the pointer's Console
|
||||
flags hold `--send`. That run is a live-copy re-pin, the same path row 187 uses for the proof pair:
|
||||
the plan prints the live-copy note, the run stops both units, backs up the stopped store and starts
|
||||
them again. A plain same-copy activate, or a same-copy `--no-send` once `--send` is gone, still
|
||||
refuses `already-active`.
|
||||
|
||||
`--plan` prints one `send:` line after the `proof:` line, for activate and rollback:
|
||||
|
||||
- `send: dropped; --no-send was given`
|
||||
- `send: off; --no-send was given and the pointer holds no --send`
|
||||
- `send: carried` (the pointer's `--send` goes on unchanged)
|
||||
- `send: off`
|
||||
|
||||
The steps log gets `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
|
||||
--send, so there is nothing to drop`.
|
||||
|
||||
Rollback and pm-on carry the pointer's flags as they are, so once `--no-send` has run, nothing adds
|
||||
`--send` back. There is no re-enable flag (mos-0973). Q175 phase 2 changes the Console flags in its
|
||||
own row.
|
||||
|
||||
docs/TOOLS.md: `[--no-send]` in the usage block and one paragraph after the proof paragraph.
|
||||
|
||||
## Tests (packages/cli/tests/stack-release.test.mjs, T55)
|
||||
|
||||
1. Seeded preview pointer with `--send --tester-port 7333`: a same-copy `activate B acme --no-send`
|
||||
passes. The pointer and the unit ExecStart lack `--send` and keep the other flags in order, status
|
||||
says the unit flags match, the steps log has the drop line, the log line has from = to = B and the
|
||||
backup sum equals the stopped store's, and the units stop and start in order.
|
||||
2. Plain same-copy activate, with `--tester-port` and with `--no-proof`, refuses `already-active`. So does
|
||||
a same-copy `--no-send` once `--send` is gone, and on a pointer that never held it.
|
||||
3. The four `send:` lines, the live-copy note on the same-copy plan only, and the Console line right
|
||||
after `send:` without `--send`. The run on a pointer with no `--send` logs the nothing-to-drop line
|
||||
and no drop line.
|
||||
4. After `--no-send` on C1, a rollback to B leaves `--send` out of the pointer and the unit, the steps
|
||||
log never names `--send`, and status matches. pm-on after `activate --pm-after-harness --no-send`
|
||||
keeps it off.
|
||||
5. `--no-send` on rollback (with and without `--plan`), pm-on, status, adopt and rehearse is a usage
|
||||
error with the usage text, nothing written, started or logged. The usage text shows `[--no-send]`.
|
||||
6. A pointer holding both the proof pair and `--send`: the plans for `--no-proof`, `--no-send` and both
|
||||
name what each drops and carries. `--no-send` keeps the pair, `--no-send` with a new pair replaces it,
|
||||
`--no-proof` keeps `--send`, and both together leave neither. Status matches after each.
|
||||
|
||||
`planTail` gains `'send: off'` after `'proof: off'`, because every plan now prints the line. T36 takes
|
||||
`out.slice(-n)` around `planTail`, so its four slice counts grow by one.
|
||||
|
||||
## Mutants
|
||||
|
||||
mutants.sh in this packet, run on the candidate. Each mutates scripts/stack-release.sh and runs T55
|
||||
plus T1 and T2.
|
||||
|
||||
- M1: the same-copy exception ignores `--no-send`.
|
||||
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
|
||||
- M3: with `--no-send`, the pointer is written from `P_CONSOLE`, so it keeps `--send` while the unit
|
||||
drops it.
|
||||
- M4: rollback re-adds `--send`.
|
||||
- M5: the parser accepts `--no-send` for rollback.
|
||||
|
||||
Results: mutants-run.log, all five killed (M1 5 failures, M2 2, M3 7, M4 2, M5 2).
|
||||
|
||||
## Deviations from the brief
|
||||
|
||||
- The brief's line numbers were taken before row 163 landed. The candidate corrects them to the
|
||||
9ae8c73ac lines (check_target :249-256, flags_drift :646-657, console_for_target :664-693, adopt
|
||||
:828).
|
||||
- Test 5 also covers rehearse and `rollback --plan`, which the brief did not list.
|
||||
|
||||
## Outbound calls
|
||||
|
||||
None. The change edits an array before the existing unit render and pointer write. Every test uses
|
||||
the stand-in systemctl, units, stores and data root the file already builds; no test touches
|
||||
`~/.mosaic-dev`, the live units or a slot file.
|
||||
@@ -0,0 +1,5 @@
|
||||
M1 KILLED fail=5
|
||||
M2 KILLED fail=2
|
||||
M3 KILLED fail=7
|
||||
M4 KILLED fail=2
|
||||
M5 KILLED fail=2
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Row 189 mutants M1-M5: each mutates scripts/stack-release.sh in the sage-b189 worktree, runs T55 and
|
||||
# the plan-tail tests, and restores the file from a checked backup.
|
||||
set -uo pipefail
|
||||
WT=/mnt/storage/scratch/sage-b189
|
||||
OUT=/mnt/storage/scratch/sage-b189-mut/out
|
||||
S=$WT/scripts/stack-release.sh
|
||||
mkdir -p "$OUT" || exit 1
|
||||
cp -p "$S" "$OUT/stack-release.sh.orig" || { echo "backup failed"; exit 1; }
|
||||
ORIG=$(sha256sum "$S" | cut -d' ' -f1)
|
||||
[ "$(sha256sum "$OUT/stack-release.sh.orig" | cut -d' ' -f1)" = "$ORIG" ] || { echo "backup differs"; exit 1; }
|
||||
restore() { cp -p "$OUT/stack-release.sh.orig" "$S"; [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ] || { echo "RESTORE FAILED"; exit 1; }; }
|
||||
trap restore EXIT
|
||||
|
||||
mutate() {
|
||||
case "$1" in
|
||||
M1) sed -i '/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/d' "$S" ;;
|
||||
M2) sed -i 's/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/{ [ "$NO_SEND" = 1 ]; } ||/' "$S" ;;
|
||||
M3) sed -i 's/^ write_pointer "\$TO" "\$RUN_PM" "\$RUN_PENDING" "\$P_CLAUDE" "\$T_SCHEMA" "\${RUN_CONSOLE\[@\]}"$/ if [ "$NO_SEND" = 1 ]; then write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${P_CONSOLE[@]}"; else write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${RUN_CONSOLE[@]}"; fi/' "$S" ;;
|
||||
M4) sed -i 's/^ SEND_PLAN="send: off"$/ SEND_PLAN="send: off"; [ "$OP" != rollback ] || RUN_CONSOLE+=(--send)/' "$S" ;;
|
||||
M5) sed -i 's/--no-send) \[ "\$OP" = activate \] || usage;/--no-send) { [ "$OP" = activate ] || [ "$OP" = rollback ]; } || usage;/' "$S" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
for m in M1 M2 M3 M4 M5; do
|
||||
restore
|
||||
mutate "$m"
|
||||
if [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ]; then echo "$m NOT-APPLIED"; continue; fi
|
||||
bash -n "$S" || { echo "$m SYNTAX"; continue; }
|
||||
diff "$OUT/stack-release.sh.orig" "$S" > "$OUT/$m.diff"
|
||||
(cd "$WT" && TMPDIR=/mnt/storage/scratch/t189 timeout 900 node --test --test-reporter=tap --test-name-pattern='T55|T1 |T2 ' packages/cli/tests/stack-release.test.mjs) > "$OUT/$m.log" 2>&1
|
||||
fails=$(awk '/^# fail /{print $3}' "$OUT/$m.log")
|
||||
if [ "${fails:-0}" -gt 0 ]; then echo "$m KILLED fail=$fails"; else echo "$m SURVIVED"; fi
|
||||
done
|
||||
@@ -1363,3 +1363,4 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-20T18:41:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design D1/D2 check (DESIGN.md blob eba861f5d2bb at d55d86bb0), static git diff 817e8f51b..d55d86bb0 | ack: D1 and D2 match; design approved; Mos C1 sequence confirm still open; no Gitea/queue record
|
||||
2026-10-20T18:57:10Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r2 (#1688, DESIGN.md sha256 5493c9df3ec0) | CHANGES to Darkwing, cc Sage: B1 claim check on MOSAIC_CONFIG-only selection, B2 shared prefix drop-in dirs; N1-N7 notes; static read, no slot, no Gitea or queue record
|
||||
2026-10-20T19:00:04Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r3 (#1688, DESIGN.md sha256 98236a1aff98) | APPROVE to Darkwing, cc Sage: B1 and B2 closed with M13 and M14, N7 settled by Sage's 48ebee0ff; static read, no slot; manifest round gets a queue review record (sage-1337)
|
||||
2026-10-20T19:57:00Z | Gizmoduck (Opus 5.5, 9312a896) | row 201 FED R1 (#1699, #414) round 1: packages/business/src/access.mjs, index export, tests/access.test.mjs, packet agents/gizmoduck/work/queue-201 | 24/24 single file; 44 mutants written, not run (needs Sage's slot); expiry per sage-1399; to Duckworth for manifest review
|
||||
|
||||
+9
-1
@@ -368,7 +368,7 @@ reports checkout or copy identity and refactor drift, and writes nothing.
|
||||
```
|
||||
scripts/stack-release.sh adopt BUSINESS
|
||||
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
|
||||
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
|
||||
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
|
||||
scripts/stack-release.sh status BUSINESS
|
||||
@@ -480,6 +480,14 @@ copies. It checks the file with `stat` and `realpath` and never reads the value.
|
||||
or `off`. On the live copy it also notes that the run re-pins the pointer's copy
|
||||
and only the Console flags change.
|
||||
|
||||
`activate ... --no-send` (row 189, mos-0972) drops `--send` from the pointer's
|
||||
Console flags and the unit, so the Console serves reads and no human sends. It
|
||||
may name the live copy while the pointer holds `--send`; once `--send` is gone a
|
||||
same-copy `--no-send` refuses `already-active`. Later activates, `pm-on` and
|
||||
rollbacks carry the pointer, so send stays off. No flag puts `--send` back.
|
||||
`--plan` prints one `send:` line: `dropped`, `off` with or without the
|
||||
`--no-send` note, or `carried`.
|
||||
|
||||
#### A target past c216f47db: the PM after the harness kind
|
||||
|
||||
From c216f47db on, the bus launches the PM only with a `harness` kind in the
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Brief: activate --no-send drops the Console's --send
|
||||
|
||||
Sage, 2026-10-20, for Mos's ruling mos-0972 (Q175 interim, Vikunja #412). Owner Sage, reviewer Dewey, gate owner
|
||||
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit.
|
||||
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit. The candidate corrects them;
|
||||
the first draft took them before row 163.
|
||||
|
||||
This row builds the tool only. The live restart without `--send` is a separate step afterwards, a same-copy
|
||||
activate under Q173 with a backup first and a STATE report after. No test here touches `~/.mosaic-dev`, the
|
||||
@@ -14,11 +15,11 @@ Mos ruled that the Console runs without `--send` until Q175 phase 2, because wit
|
||||
process can forge `X-authentik-username` on 127.0.0.1:7332 and send as Jason (design section 8). On the pinned
|
||||
stack, the Console flags live in the pointer (`~/.mosaic-dev/state/stack-active.json`):
|
||||
|
||||
- `console_for_target` (scripts/stack-release.sh:658-687) carries the pointer's flags unchanged. Only
|
||||
- `console_for_target` (scripts/stack-release.sh:664-693) carries the pointer's flags unchanged. Only
|
||||
`--tester-port` and the row 187 proof pair can change.
|
||||
- `check_target` (:247-249) lets an activate name the pointer's copy only for a proof-pair change.
|
||||
- `adopt` refuses once a pointer exists (:822).
|
||||
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:640-651) refuse
|
||||
- `check_target` (:249-256) lets an activate name the pointer's copy only for a proof-pair change.
|
||||
- `adopt` refuses once a pointer exists (:828).
|
||||
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:646-657) refuse
|
||||
`unit-flags-drift` on every later activate, pm-on and rollback.
|
||||
|
||||
So the tool can't drop `--send` today. This row adds that one path.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Brief: a credential store per agent, own git identity, pushes to a remote
|
||||
|
||||
Sage, 2026-10-20, for mos-0974 (MVP T1 item 6) and the MVP plan
|
||||
(`jason-master-restart/projects/mosaic-mvp/PLAN.md` item 6, GAP-MAP G11 and G13; TARGET.md: "credentials store
|
||||
available for all agents; secrets protected with RLS equivalent" and "Agent uses own credentials for git and
|
||||
other actions"). Gitea #1697. Owner Megavolt (Opus 5.5, a new builder seat), reviewer Duckworth, gate owner
|
||||
Sage. Base: refactor at the brief commit. Design first. The first deliverable is
|
||||
`agents/megavolt/work/queue-ROW/DESIGN.md`.
|
||||
|
||||
This row draws a security boundary. Sage reviews the design's authority model, and Mos rules on it before any
|
||||
code is written.
|
||||
|
||||
## What exists today (GAP-MAP, measured at 9ae8c73ac)
|
||||
|
||||
- G11. Credential references exist per instance for Gitea and Vikunja only
|
||||
(`packages/business/src/credentials.mjs`, `packages/bus/src/credentials.mjs`, `vocabulary.mjs:103`). One
|
||||
Claude env file is shared by every seat.
|
||||
- G13. Agents use their own Vikunja token (`packages/tasks/src/adapter.mjs`). Git goes through the host
|
||||
socket (`packages/cli/src/git-socket.mjs`). Gitea tokens are loaded but never used.
|
||||
- The development repository's helper (`scripts/git-credential-mosaic.mjs`, wired in `.git/config` for
|
||||
git.mosaicstack.dev) pushes as one identity. Until this row lands, seats push only their own `<seat>/*`
|
||||
branches through it (mos-0981).
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. The store. Where each agent's credentials live, per instance, under the row 190 config canon. File modes,
|
||||
ownership and the read path. Which process may read which entry, and how the bus enforces that (the "RLS
|
||||
equivalent"). A process asking for another agent's entry is refused and logged.
|
||||
2. Kinds. Gitea (git over HTTPS, and the API), Vikunja, and the model provider. Say which kinds 10-27 needs
|
||||
and which wait. The model provider entry must not copy jwoltje's `claude-session.env`. If it needs a new
|
||||
provider account or subscription, flag it for Jason and don't pick one.
|
||||
3. Git identity and push. Each agent commits with its own name and email and pushes with its own token to a
|
||||
remote it is granted. Which refs an agent may push (its own branches, never the default branch or another
|
||||
agent's), and where that's enforced: the Gitea side (branch protection, token scopes) and our side. How
|
||||
this fits the host git socket, or replaces it.
|
||||
4. Lifecycle. Issue, rotate (`rotateBy`, `expires`), revoke and delete, and what row 194 (agent lifecycle) calls
|
||||
when an agent is hired or fired. A fired agent's credentials stop working at once.
|
||||
5. Who does what. Creating a Gitea user or token, or any account outside the fleet, is Jason's (Q173
|
||||
exclusions). The design lists each such step as a command or UI action for Jason. Agents never mint
|
||||
accounts.
|
||||
6. Migration. Today's seats (jarvis helper, seat tokens) keep working until each moves. The mos-0981 push
|
||||
rule ends only when Mos says so.
|
||||
7. What this row closes in G11 and G13, and what stays open.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No credential value is read, printed, logged or committed. Tests use fake tokens in scratch roots and a
|
||||
fake Gitea.
|
||||
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, `~/.mosaic` or the live
|
||||
`.git/config` of the canonical checkout.
|
||||
- No request to git.mosaicstack.dev, tasks.woltje.com or any provider from a test.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, Mos rules on the authority model, then Duckworth approves the manifest. The bus,
|
||||
business, cli and tasks suites and every `scripts/test-*.sh` are green on Sage's gate, and the design's
|
||||
mutants are killed.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Federation build rows R1, R2 and R5 (#414)
|
||||
|
||||
Sage, 2026-10-20, for Mos's orders mos-1023 item 1, mos-1028 item 2 and mos-1034 item 1. The design is
|
||||
Gizmoduck's `agents/gizmoduck/work/federation/DESIGN.md` at a1daa3c07 on `gizmoduck/federation-design` (r4.9,
|
||||
sha256 a31cbebd070f9e3a5ca4ccb158b1747c6f5a83c3469855705b0026a581fb5c6a). Duckworth approved r4.5
|
||||
(duckworth-0012), r4.6, r4.7 (duckworth-0020) and r4.8 (duckworth-0021, 8a22d0897 on `duckworth/reviews`),
|
||||
and acked r4.9 (duckworth-0024). r4.7 applies Jason's Q181 hosts, r4.8 adds N22's R8 checks and r4.9 names who
|
||||
runs them. None of them changes R1, R2 or R5. Section 7's table
|
||||
sets each row's scope and test gate. Vikunja P19 #414. Seats push only their own `<seat>/*` branches
|
||||
(mos-0981).
|
||||
|
||||
The other R rows are filed when their parents have candidates: R3 after R1 and row 196, R4 after R3, R5 and
|
||||
row 193's viewer level (193a, mos-1019), R6 after #412 phase 1, R1, R2 and R3, R7 after R1-R6 and row 190.
|
||||
R8 is a live window, not a code row. Its brief is the last section here.
|
||||
|
||||
## Brief: FED R1, the access.json loader and validator
|
||||
|
||||
Owner Gizmoduck, reviewer Duckworth, gate owner Sage. Base: refactor at this brief's commit. No parent.
|
||||
|
||||
`packages/business/src/access.mjs`: the access.json loader and validator from DESIGN.md section 3.5, and the
|
||||
pure `covers`. Providers, peers and businesses come in as arguments. One read plus the file checks. The
|
||||
exported interface is what R5 and row 193a build against, so the manifest names it, and any change to it in
|
||||
review goes to Sage before it lands. Section 7 names `covers` but not the loader and validator exports or
|
||||
the context shape. Morgana's 193a (508ee4d61 on `morgana/row193a`) assumes `checkAccess(doc, {providers,
|
||||
peers, businesses})`, with `businesses` mapping an id to `{human, instances}`, throwing a `BusinessError` that
|
||||
names the key path. Use those names, or tell Morgana and Quackerjack yours before the manifest goes to
|
||||
Duckworth. Row 193a later widens the level check to `viewer` on R1's candidate (mos-1019).
|
||||
|
||||
Tests, in `packages/business/tests/`: every rule in 3.5 refuses with the key path named. That covers a
|
||||
symlink, a non-regular file, another owner, group or other write, expiry, `*`, a grant disjoint from the given
|
||||
subjects, `human` and `tester` ids, any level other than `collaborator`, and a project entry that isn't a
|
||||
UUID. For `covers`: `*` and listed ids match, while an unlisted project, instance or business doesn't, an
|
||||
expired grant doesn't, and a level filter rejects another level. One mutant per refusal, each killed.
|
||||
|
||||
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. Tests
|
||||
use scratch roots. Single test files run freely, and suites need Sage's slot.
|
||||
|
||||
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, and the mutants are killed.
|
||||
|
||||
## Brief: FED R5, mosaic access list, grant and revoke
|
||||
|
||||
Owner Quackerjack, reviewer Duckworth, gate owner Sage. Chained on R1 (Q177). The build starts now against
|
||||
R1's exported interface as the R1 manifest names it, and R5 lands after R1 and rebases onto R1's landing.
|
||||
|
||||
`mosaic access list`, `grant` and `revoke`: human proof, atomic write, revision bump, a receipt line. The help
|
||||
text states the `instances` limit and the provider-removal order (DESIGN.md section 7, row R5). R4 later makes
|
||||
these verbs call `access.reload`, so R5 leaves no other reload path.
|
||||
|
||||
Tests, in `packages/cli/tests/`: tester and agent callers are refused, a concurrent write refuses on a
|
||||
revision conflict, the receipt line is appended, and a bad grant is refused before anything is written. One
|
||||
mutant per refusal, each killed.
|
||||
|
||||
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. The
|
||||
human proof in tests uses the scratch stand-in pattern (a scratch bus verifies its own human-cli path).
|
||||
Single test files run freely, and suites need Sage's slot.
|
||||
|
||||
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutants are killed.
|
||||
|
||||
## Brief: FED R2, the federation key in the system config
|
||||
|
||||
Owner Quackerjack, reviewer Webby, gate owner Sage. Chained on row 192 (#412 phase 1). The build starts when
|
||||
row 192 has a candidate, on that candidate.
|
||||
|
||||
A `federation` key in `scripts/mosaic-config.mjs` with the shape in DESIGN.md section 3. A config with no
|
||||
`federation` key still loads as it does today. This changes the config schema, so the manifest lists every
|
||||
reader of the key.
|
||||
|
||||
Tests, in `scripts/test-config.sh`: a valid key loads, an unknown sub-key and a bad origin refuse, and a
|
||||
config without the key loads. One mutant per refusal, each killed.
|
||||
|
||||
Boundaries: `~/.config/mosaic-dev/config.json` is user-authored and is not touched. Tests use scratch config
|
||||
dirs only.
|
||||
|
||||
Gate: Webby approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and
|
||||
the mutants are killed.
|
||||
|
||||
## Brief: FED R8, the hub window on 10.1.1.54
|
||||
|
||||
A live window, not a code row (DESIGN.md section 7, row R8, r4.9). Run by Jason and Mos. Nothing here
|
||||
authorizes a step on any host; each step waits for the window's go.
|
||||
|
||||
Order (mos-1042 item 2). R8 runs after Bean and Chet are live in Case 2 and both Hermes gateways on 10.1.1.54
|
||||
are stopped, because R8 requires that the hub host runs no other instance or agent and Q181 keeps Melanie's
|
||||
Hermes on .54 until then. Case 2 go-live doesn't wait on R8. The risk, stated for 10-27: federation between
|
||||
the two instances now depends on Melanie's switch-over date.
|
||||
|
||||
Entry checks, all before anything starts:
|
||||
- R7, #412 phase 2, the hub host live (Vikunja #418) and Jason's #419 answer, as section 7 lists.
|
||||
- The four hub user checks on 10.1.1.54 (N22, mos-1035 item 1): `sudo -l -U mosaic` grants nothing, `id
|
||||
mosaic` shows only its own group, `~mosaic/.ssh/authorized_keys` is absent or empty, and no file under
|
||||
`/etc/sudoers.d` names `mosaic`. Jason runs them as localadmin with his new key (#421 step 1), and the output
|
||||
goes into the R8 record. No factory uid runs them, because R8 requires every factory root path to .54 to
|
||||
fail (r4.9, mos-1037).
|
||||
- The factory privilege checks, the key fingerprint comparison and the section 8 privilege path table, each
|
||||
as section 7's R8 row states them.
|
||||
|
||||
Done when every entry check has its output recorded, Jason signs in to the hub and sends one message, Melanie
|
||||
revokes, and his next request is refused.
|
||||
@@ -1,46 +1,77 @@
|
||||
# Brief: Melanie's hub instance under its own Linux user (row 190b)
|
||||
# Brief: Melanie's hub instance on its own host (row 195, was 190b)
|
||||
|
||||
Sage, 2026-10-20, from Darkwing's draft (agents/darkwing/work/queue-190b/BRIEF-DRAFT.md) for Mos's ruling
|
||||
mos-0986 (2026-10-20T18:53:48Z) on row 190 R1. Mos ruled a separate uid per instance a hard precondition of
|
||||
federation R8 (mos-0984, mos-0985) and of Case 2 row C2-13. The finding was federation review B5
|
||||
(duckworth-0001): on one uid any factory agent can read, write and control Melanie's hub directly, and SSO and
|
||||
RBAC don't stop a local process with filesystem access. On the 10-27 MVP path. Gitea #1693.
|
||||
Sage, 2026-10-20. Rewritten for Mos's rulings mos-0998 and mos-1005 (Vikunja #418, #419). The first version
|
||||
put the hub under its own Linux user on dragon-lin. That form can't pass Duckworth's B7 there, because the
|
||||
factory uid's `sudo -n` succeeds on dragon-lin (Mos's recon, brain-redux
|
||||
`projects/mosaic-mvp/MELANIE-HOST-RECON.md`). Melanie's own server is ruled out too: it has 1.2 GiB RAM free
|
||||
with her two Hermes bots running, and co-tenancy would share her user and her passwordless sudo. The hub runs
|
||||
on a separate host. Which host is Jason's decision (#419): hardware outside the cluster, a cluster VM with
|
||||
factory PVE root removed, or a cluster VM with that residual accepted. This row is written so the host choice
|
||||
doesn't change it. Gitea #1693. On the 10-27 MVP path. Federation R8 (Gizmoduck's design r4, 04a6112be) needs
|
||||
this row or nothing.
|
||||
|
||||
Owner Darkwing (Opus 5.5), reviewer Duckworth (author of B5; mos-0981 keeps new work off Filbert and Dewey),
|
||||
gate owner Sage. Merge-chained on row 190 (Q177): it builds on row 190's candidate and keeps its own review and
|
||||
gate. Design first. The first deliverable is `agents/darkwing/work/queue-190b/DESIGN.md`.
|
||||
Owner Darkwing (Opus 5.5), reviewer Duckworth (author of B5 and B7), gate owner Sage. Chained on row 190
|
||||
(Q177): the hub runs under row 190's config canon. Design first. The first deliverable is
|
||||
`agents/darkwing/work/queue-190b/DESIGN.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
Melanie's hub instance runs under its own Linux user. No process of the factory instance can read or write the
|
||||
hub's config, data root or secrets, or control the hub's units. That covers the bus, role seats and their
|
||||
sessions, task runs, the Discord connector and the Console.
|
||||
Melanie's hub instance (Case 2, C2-13) runs on its own host. No factory agent can read or write the hub's
|
||||
config, data root or secrets, reach its bus socket, or log in to the hub host. Her writing reaches the hub only
|
||||
through the existing git.mosaicstack.dev remotes, books repo only.
|
||||
|
||||
## Provision (blank until Jason answers #419)
|
||||
|
||||
Who provisions the host and how is left open. Sizing from Mos (mos-0998): 4 vCPU, 8 GB RAM, 64 GB disk,
|
||||
internal network only. Each agent process measures 300 to 390 MB RSS on dragon-lin. The design takes the host
|
||||
as given: a fresh Linux host with a named admin path, reachable by the deploying seat while the row runs.
|
||||
|
||||
## The design must state
|
||||
|
||||
1. How operators (Mos, Sage, Jason) deploy, re-pin and roll back the hub. Which commands they run, as which
|
||||
user, and what each one needs.
|
||||
2. Each root step Jason runs: useradd, the home and its modes, `loginctl enable-linger`, and any sudoers rule,
|
||||
with the exact command lines.
|
||||
3. How the hub reaches the Claude proxy and gets its session env without sharing jwoltje's
|
||||
`claude-session.env`.
|
||||
4. Whether real isolation needs the factory instance's seats to run apart from the operators' uid. If yes, say
|
||||
so plainly with its cost. Don't design around it.
|
||||
|
||||
It must also cover the copies (shared read-only trees under jwoltje's home, or the hub's own), the human CLI
|
||||
wrapper and human proof for the hub's Console, and how the stack-release pointer, log and backups work under
|
||||
the second user.
|
||||
1. The deploy. Exactly what Darkwing installs on the hub host and how: the stack copy, node, the units, the
|
||||
config dir and data root under row 190's canon (O1: `~/.config/mosaic-NAME`, and "`~` is the home of the OS
|
||||
user that runs the instance"). Which commands run as which user on the hub host, and which need its root.
|
||||
2. Re-pin, rollback and backup on the hub host, and who runs them after the deploy key is gone.
|
||||
3. How the hub gets model access without a copy of jwoltje's `claude-session.env` or any factory credential.
|
||||
If that needs the credentials row (T1 item 6), say so and name the interim. If it needs a new subscription
|
||||
or a new provider account, flag it for Jason and don't pick one (mos-1008).
|
||||
4. The books repo path. Which git.mosaicstack.dev remote, which credential the hub holds for it, and that it
|
||||
can't read or push any other repository.
|
||||
5. The admin path and the end state. The host ends with no key the factory uid can read. The design names the
|
||||
admin path that remains (for example the hypervisor console, or a key kept off the factory uid) and who
|
||||
holds it.
|
||||
6. The acceptance script, as code Duckworth can read. It takes Gizmoduck's list of infrastructure gate items
|
||||
as its input (mos-1011), not a fixed set of checks. Each item names a path, its closed form and its
|
||||
accepted form, and the script reports which form holds for each, or fails. The list covers at least:
|
||||
- federation R8's separate-host form: the hub's bus socket, data root and config dir don't exist on the
|
||||
factory host, the factory host mounts nothing of the hub's, and no factory agent holds a login or key
|
||||
for the hub host
|
||||
- B7: from the factory uid, `sudo -n`, `doas -n`, `su`, polkit, and `ssh -o BatchMode=yes` to the hub
|
||||
host all fail
|
||||
- the hypervisor and backup paths ops-01 measured (mos-1011: one PVE node the factory key has root on,
|
||||
PBS on that node with unencrypted backups, a flat /16 LAN), each closed or accepted by Jason
|
||||
7. The identity provider and TLS front end (B8, mos-1008, mos-1011). Either the factory holds no credential
|
||||
that can create users, change groups, issue tokens or impersonate in the issuer the hub trusts, or the hub
|
||||
uses an issuer the factory can't administer. A brand, tenant or app inside the same Authentik does not
|
||||
count as separate. The same holds for the TLS front end that serves the hub. The proof is a failed admin
|
||||
call from every token the factory's root can read. No credential value is printed in that proof.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Row 190 lands first, as designed: one uid, scratch two-instance tests without root.
|
||||
- No agent runs a root step. Every one goes to Jason as an exact command list.
|
||||
- No real hub user, unit or data root is created in this row's tests. Tests use scratch roots and stand-ins as
|
||||
row 190 does. Anything that needs a second real uid is a Q173 window with a backup first and a STATE report
|
||||
after.
|
||||
- No tunnel or Traefik exposure. That waits for Jason, after Q175 SSO.
|
||||
- Provisioning, hypervisor access and any infra or k3s change are Jason's (Q173 exclusions). Mos takes the
|
||||
hypervisor residual (factory root on the PVE nodes, Q172) to Jason. It doesn't block this row.
|
||||
- Melanie's server (`[email protected]`) is reference only, read only, under Jason's authorization in
|
||||
mos-0995. Nothing on it changes, and her Hermes bots keep running.
|
||||
- No tunnel, Traefik or other exposure outside the internal network. That waits for Jason, after Q175 SSO.
|
||||
- No credential value is printed, logged or committed. Keys made for the deploy are removed at the end, and
|
||||
the acceptance script proves it.
|
||||
- The admin-call proof in point 7 and any call to the issuer or the TLS front end run only in a window Mos
|
||||
approves, never as a test or an anonymous probe (Q175).
|
||||
- Tests in the repository use scratch roots and stand-ins. The live deploy and the acceptance run on the hub
|
||||
host are a window with a backup first (where there is state) and a STATE report after.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, then the manifest. The cli suite and every `scripts/test-*.sh` are green on
|
||||
Sage's gate. The design's mutants are killed.
|
||||
Duckworth approves DESIGN.md, then the manifest. Any repository change is green on Sage's gate (cli suite and
|
||||
every `scripts/test-*.sh`), with the design's mutants killed. The row is done when the acceptance script passes
|
||||
on the hub host from the factory uid, Duckworth reads its output, and Mos confirms.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Row 193 build rows: 193a, 193b1, 193b2, 193c and 193d (#1691)
|
||||
|
||||
Sage, 2026-10-20, for Mos's go mos-1034 item 4. Row 193's design is Morgana's
|
||||
`agents/morgana/work/queue-193/DESIGN.md` at e2d161e2e on `morgana/row193` (r5, sha256
|
||||
dd7fcb419ed8510e9d8d74b569183386d0942af8b68eecc0bbb5e182e677b523), approved by Duckworth (duckworth-0018).
|
||||
Section 8 lists the tests and mutants, and section 9 the split. The brief behind them is
|
||||
`docs/plans/2026-10-20_human-users.md`. Vikunja P19 #414/#415, Gitea #1691.
|
||||
|
||||
Owner Morgana for every row, reviewer Duckworth, gate owner Sage. Each row chains on its parent's candidate
|
||||
(Q177) and rebases onto the parent's landing. Seats push only their own `<seat>/*` branches (mos-0981).
|
||||
Single test files run freely, and suites and mutant batches need Sage's slot.
|
||||
|
||||
Boundaries for every row: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, any
|
||||
live access.json or `~/.mosaic`. No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
|
||||
No user thread goes live before 193b1's message filter has landed (mos-1021 B2, FED R8).
|
||||
|
||||
The owner-visible records are fixed by Mos (mos-1023 item 2) and named in one user sentence, which FED R6
|
||||
reuses word for word: "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks
|
||||
and project files, but not your messages."
|
||||
|
||||
## Brief: 193a, the access level check widened to viewer
|
||||
|
||||
Chained on FED R1. Started early on `morgana/row193a` (508ee4d61) against R1's interface as the FED brief
|
||||
names it, with Sage's go (sage-1373). R1's level check accepts `viewer` and still refuses any other new
|
||||
level. Tests in `packages/business/tests/` (section 8, Business), with the four mutants in Morgana's
|
||||
BUILD.md. FED R4 chains on this row.
|
||||
|
||||
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, and the mutants are killed.
|
||||
|
||||
## Brief: 193b1, viewer verbs, board refusals and the message filter
|
||||
|
||||
Chained on FED R3, FED R4 and row 196's verifier. The bus gets the viewer verb list, per-grant level
|
||||
checks, board reads refused to users, and the section 5.7 `trail` and `replies` filter with `#readable`
|
||||
owner-only. No schema change. The governance pin test names each item in the user sentence above. Bus tests
|
||||
and mutants 1, 3, 6, 10, 11 and 12 from section 8.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutants are killed.
|
||||
|
||||
## Brief: 193b2, thread shares (schema 3j)
|
||||
|
||||
Chained on 193b1. `thread_shares`, the share verbs, the share rules of `#readable` and `#threadRead`, and the
|
||||
section 5.5 share filters. Schema 3i to 3j. The table is append-only, and row 181's restore guard and
|
||||
`COUNTED` include it. Bus tests for shares and the migration, and mutants 2, 4, 7, 8 and 9 from section 8.
|
||||
Going live needs a `--migrate` window with Jason. Sage takes it to Mos when this row is near.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus suite, the cli suite and every `scripts/test-*.sh` are green on
|
||||
Sage's gate, and the mutants are killed.
|
||||
|
||||
## Brief: 193c, mosaic access user verbs and --level viewer
|
||||
|
||||
Chained on FED R5. `mosaic access user add/remove` and `grant --level viewer`, only where R5 lacks them. They
|
||||
refuse tester and agent callers, write a receipt line and call `access.reload` (section 8, CLI). If R5
|
||||
already has all of them, this row records that and closes with no code.
|
||||
|
||||
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutants are killed.
|
||||
|
||||
## Brief: 193d, the Console's shared list, share controls and viewer read-only
|
||||
|
||||
Chained on FED R6, row 192 and 193b2 (for share controls). A viewer has no composer or share control, and
|
||||
with each UI check removed the bus still refuses (section 8, Webui).
|
||||
|
||||
Gate: Duckworth approves the manifest. The webui suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, and the mutants are killed.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Brief: retire the control board writer on 7331
|
||||
|
||||
Sage, 2026-10-20, for Mos's ruling mos-1008 item 2. Gitea #1696. MVP 10-27 work. Owner Bushroot (Haiku 5.5, a
|
||||
low-level model under Q177), reviewer Webby, gate owner Sage. Base: refactor at the brief commit.
|
||||
|
||||
## Why
|
||||
|
||||
`packages/control-board/src/serve.mjs` is a loopback HTTP service on 7331 with no authentication. It reads
|
||||
transcripts and writes through `/api/reply`. Any host process that reaches the port can do both. That fails
|
||||
Q175 ("zero trust with verification"). Gander found it in the row 196 inventory (gander-0004). Nothing listens
|
||||
on 7331 now (`ss -ltnp`, 2026-10-20T19:05:09Z), so retiring it costs the least.
|
||||
|
||||
## Steps, in order
|
||||
|
||||
1. Inventory first. List every reference to the control board and to 7331: scripts, units, `scripts/mosaic`
|
||||
and other CLI dispatch, docs (AGENTS.md, READMEs, TOOLS.md), package.json scripts, tests, and the Console
|
||||
(`packages/webui`). For each, say whether it starts the writer, reads its code, or only mentions it.
|
||||
Commit the list as `agents/bushroot/work/queue-ROW/INVENTORY.md` and send it to Sage and Webby.
|
||||
2. Stop if something depends on it. If a reference shows a live dependency (the Console or another package
|
||||
imports code that only the control board has, or a unit or script still starts it), stop. Sage takes it to
|
||||
Mos before anything is removed.
|
||||
3. Otherwise retire the writer. Either remove the `serve` entry point, or make it refuse to start with a
|
||||
message that points to the Console. Keep any read-only code another package imports, and say which.
|
||||
Update the docs that tell people to start it. The AGENTS.md text about the control-board reply convention
|
||||
is a separate matter. Change it only if it tells someone to start 7331.
|
||||
|
||||
## Mos's call on the Console dependency (mos-1019)
|
||||
|
||||
The inventory (004f133bd) found that the Console proxies `/api/board`, `/api/seen`, `/api/reply` and the
|
||||
conversation routes to `DEFAULT_BOARD` on 7331 (`packages/webui/src/serve.mjs:12`), so the board view returns
|
||||
502 today. Mos took Sage's recommendation: retire the 7331 listener and its README start commands, and have
|
||||
the Console call the board's read code in-process, behind the Console's own auth (Q175 SSO on 7332).
|
||||
`/api/reply` stays only under `--send`. The DESIGN.md for that change comes before code, and Webby approves
|
||||
it.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Nothing in the stack can start a writer on 7331. A test proves it: the entry point refuses (or is gone), no
|
||||
script or unit starts it, and a grep check finds no starter. Each change gets a mutant that brings the writer
|
||||
back and the test kills it. Mos added three items (mos-1019):
|
||||
|
||||
- The board view answers from the Console with nothing listening on 7331.
|
||||
- Every board route sits behind the Console's auth.
|
||||
- A test proves `/api/reply` refuses without `--send`.
|
||||
|
||||
Test fixtures that bind port 0 may stay if they only test the code in-process.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files or `~/.mosaic`.
|
||||
- No request to any live port.
|
||||
- Single test files run freely. Suites need Sage's slot.
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves INVENTORY.md, then DESIGN.md, then the manifest. The control-board and webui package suites, the cli suite and
|
||||
every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Brief: a second gate lane, two full gates at once on dragon-lin
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-0977 item 6 ("put the design for a second gate lane on the list now")
|
||||
and the MVP push (mos-0974). Gitea #1695. Owner Herb (Codex gpt-6.1-sol, a new builder seat), reviewer
|
||||
Webby, gate owner Sage. Base: refactor at the brief commit. Design first. The first deliverable is
|
||||
`agents/herb/work/queue-ROW/DESIGN.md`.
|
||||
|
||||
## Why
|
||||
|
||||
Every full run shares one slot today. A full gate (cli suite plus every `scripts/test-*.sh`) takes about 13
|
||||
minutes or more, reviewers' runs and mutants take the same slot, and with eight or more seats building for
|
||||
10-27 the slot is the bottleneck. The slot exists because concurrent runs broke each other. The busy check is
|
||||
`agents/sage/work/gate-lane/busy-now.sh` (a copy of Sage's), and the gate is
|
||||
`agents/sage/work/gate-lane/gate-template.sh` (a copy of Sage's template). The host has 16 cores and 62 GB.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. Every shared resource two concurrent runs can collide on, found from the code and tests, not guessed:
|
||||
Docker compose project names and networks (`scripts/test-task.sh` and others), fixed TCP ports in tests,
|
||||
unix socket paths and their 107-byte limit under TMPDIR, the sandbox's private `/tmp`, shared caches,
|
||||
scratch and worktree paths, the queue lock, and any test that reads host-wide state (process lists,
|
||||
`pgrep`, `/proc`). For each: how a lane gets its own copy, or why it is safe to share.
|
||||
2. Timing. Which tests have time limits that a doubled CPU load could break, measured, and what changes (a
|
||||
bound, or a lane rule that keeps those suites apart).
|
||||
3. The lane mechanism: lane ids, a lock per lane that a run takes and releases even on a crash, the busy
|
||||
check that reports per lane, and how a seat asks for a lane. Sage still grants it.
|
||||
4. The gate template takes a lane id and isolates everything from point 1 by it.
|
||||
5. Proof: two full gates on different candidates at once, three times, with zero cross-failures, plus the
|
||||
wall time of each against a single-lane run. Any flake found is named with its cause.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev` or slot files. The proof runs use
|
||||
scratch roots only, and it needs Sage's grant of the whole slot while it runs.
|
||||
- Test code changes that only isolate a resource are in scope. A change to what a test checks is not, and
|
||||
goes to its owner.
|
||||
- Docker networks and containers a run makes are removed by the run (trap), and nothing else is touched.
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves DESIGN.md, then the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, both singly and in the two-lane proof.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite)
|
||||
|
||||
Sage, 2026-10-20, for Launchpad's finding F3 on row 192 (launchpad-0001) and Q175 (#412). Gitea #1698. Owner
|
||||
Launchpad, reviewer Webby, gate owner Sage. Chained on row 192's candidate (Q177): it lands after row 192 and
|
||||
before the phase 2 window. Base: row 192's candidate.
|
||||
|
||||
## Why
|
||||
|
||||
The phase 2 window re-pins the Console to the phase 1 commit with `--sso --send --tester-port 7333`
|
||||
(`agents/sage/work/q175-sso/DESIGN.md`, section 5). `scripts/stack-release.sh` doesn't know `--sso` today.
|
||||
Its `proof_checks` (`:751`) requires `--public-host`, `--auth-header` and `--allow-user` whenever a proof pair is
|
||||
carried, and the pointer's Console flags still hold the forward-auth pair (`--auth-header X-authentik-username
|
||||
--allow-user jason.woltje`) that `--sso` refuses to start with. So the window can't run through the script, and
|
||||
a hand-edited pointer is not an option.
|
||||
|
||||
## What the row does
|
||||
|
||||
1. `activate ID BUSINESS --sso` writes Console flags with `--sso` and without `--auth-header`, `--allow-user`
|
||||
and any proof pair, and `--plan` names each flag it drops and carries, in the style of row 189's `send:`
|
||||
line. The design names the full flag set the phase 2 Console starts with and checks it against row 192's
|
||||
refusals.
|
||||
2. Before anything stops, the script refuses a target copy whose Console doesn't support `--sso`, and refuses
|
||||
when the config's `console` key or its slot file fails the checks row 192's `--sso` start runs. It reads
|
||||
neither value and only uses the same stat and shape checks.
|
||||
3. Rollback from an `--sso` pointer to a pre-phase-1 copy follows the design's phase 2 rollback order (remove
|
||||
the `console` key first). The script refuses a rollback that would start a Console on a config it can't
|
||||
read, and says which step comes first.
|
||||
4. `docs/TOOLS.md` gets the flag.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No change to the live pointer, units, `~/.mosaic-dev` or `~/.config/mosaic-dev`. Tests use scratch roots
|
||||
and seeded pointers, as rows 187 and 189 do.
|
||||
- No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and the
|
||||
mutants are killed (one per refusal, and one that keeps `--auth-header` in the written flags).
|
||||
@@ -0,0 +1,185 @@
|
||||
// The access file, <configDir>/access.json (federation design section 3.5,
|
||||
// FED R1, #414). It names the extra users who may sign in to this instance
|
||||
// and the grants that let them reach a business's threads. The validator is
|
||||
// pure: providers, peers, businesses and the owner's sign-in subjects come in
|
||||
// as arguments. Any failed rule refuses the whole file, which leaves every
|
||||
// user with no grant (fail closed).
|
||||
|
||||
import { join } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { configDir } from "./business.mjs";
|
||||
import { isPlainObject, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, readJsonFile, deepFreeze } from "./util.mjs";
|
||||
|
||||
// The grant levels. Row 193a widens this to collaborator and viewer.
|
||||
export const LEVELS = Object.freeze(["collaborator"]);
|
||||
// Ids a user may never take: the bus's own principals.
|
||||
const RESERVED_USERS = Object.freeze(["human", "tester"]);
|
||||
const LOCAL_HOME = "local";
|
||||
const ALL = "*";
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
||||
const SUB_MAX = 255;
|
||||
|
||||
export function accessFilePath(dir = configDir()) {
|
||||
return join(dir, "access.json");
|
||||
}
|
||||
|
||||
function requireArray(value, where) {
|
||||
if (!Array.isArray(value)) refuse(`${where} must be an array`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireText(value, where) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > SUB_MAX || value.includes("\0")) {
|
||||
refuse(`${where} must be a non-empty string of at most ${SUB_MAX} characters`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function subjectKey(provider, sub) {
|
||||
return JSON.stringify([provider, sub]);
|
||||
}
|
||||
|
||||
function checkContext(context) {
|
||||
const where = "access context";
|
||||
requireObject(context, where);
|
||||
rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);
|
||||
const providers = new Set(requireArray(context.providers, `${where}.providers`).map((p, i) => requireText(p, `${where}.providers[${i}]`)));
|
||||
const peers = new Set(requireArray(context.peers, `${where}.peers`).map((p, i) => requireId(p, `${where}.peers[${i}]`)));
|
||||
if (peers.has(LOCAL_HOME)) refuse(`${where}.peers must not name ${JSON.stringify(LOCAL_HOME)}`);
|
||||
requireObject(context.businesses, `${where}.businesses`);
|
||||
const businesses = new Map();
|
||||
for (const [id, business] of Object.entries(context.businesses)) {
|
||||
const at = `${where}.businesses.${id}`;
|
||||
requireId(id, `${where}.businesses key`);
|
||||
requireObject(business, at);
|
||||
requireId(business.human, `${at}.human`);
|
||||
const instances = requireArray(business.instances, `${at}.instances`).map((x, i) => requireId(x, `${at}.instances[${i}]`));
|
||||
businesses.set(id, { human: business.human, instances: new Set(instances) });
|
||||
}
|
||||
const owners = new Set();
|
||||
requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach((s, i) => {
|
||||
const at = `${where}.ssoSubjects[${i}]`;
|
||||
requireObject(s, at);
|
||||
owners.add(subjectKey(requireText(s.provider, `${at}.provider`), requireText(s.sub, `${at}.sub`)));
|
||||
});
|
||||
return { providers, peers, businesses, owners };
|
||||
}
|
||||
|
||||
function checkUser(user, at, ctx, humans, seenSubjects) {
|
||||
requireObject(user, at);
|
||||
rejectUnknownKeys(user, ["id", "home", "subjects"], at);
|
||||
requireId(user.id, `${at}.id`);
|
||||
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
|
||||
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
|
||||
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
|
||||
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)} (got ${JSON.stringify(user.home)})`);
|
||||
}
|
||||
const subjects = requireArray(user.subjects, `${at}.subjects`);
|
||||
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
|
||||
subjects.forEach((s, i) => {
|
||||
const sat = `${at}.subjects[${i}]`;
|
||||
requireObject(s, sat);
|
||||
rejectUnknownKeys(s, ["provider", "sub"], sat);
|
||||
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
|
||||
refuse(`${sat}.provider must be a configured provider (got ${JSON.stringify(s.provider)})`);
|
||||
}
|
||||
requireText(s.sub, `${sat}.sub`);
|
||||
const key = subjectKey(s.provider, s.sub);
|
||||
if (ctx.owners.has(key)) refuse(`${sat} is an owner sign-in subject (console.sso.subjects)`);
|
||||
if (seenSubjects.has(key)) refuse(`${sat} appears more than once in the file`);
|
||||
seenSubjects.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
// Either ["*"] alone or a non-empty list of distinct entries, each checked.
|
||||
function checkScope(value, where, check) {
|
||||
const list = requireArray(value, where);
|
||||
if (list.length === 0) refuse(`${where} must not be empty`);
|
||||
if (list.includes(ALL)) {
|
||||
if (list.length !== 1) refuse(`${where} must be ["*"] alone or list entries without "*"`);
|
||||
return;
|
||||
}
|
||||
const seen = new Set();
|
||||
list.forEach((entry, i) => {
|
||||
check(entry, `${where}[${i}]`);
|
||||
if (seen.has(entry)) refuse(`duplicate entry in ${where}: ${JSON.stringify(entry)}`);
|
||||
seen.add(entry);
|
||||
});
|
||||
}
|
||||
|
||||
function checkExpires(value, where) {
|
||||
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
|
||||
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
|
||||
}
|
||||
const iso = new Date(value).toISOString();
|
||||
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
|
||||
}
|
||||
|
||||
function checkGrant(grant, at, ctx, users, seenGrants) {
|
||||
requireObject(grant, at);
|
||||
rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);
|
||||
requireId(grant.id, `${at}.id`);
|
||||
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
|
||||
seenGrants.add(grant.id);
|
||||
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file (got ${JSON.stringify(grant.user)})`);
|
||||
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
|
||||
refuse(`${at}.business must name a declared business (got ${JSON.stringify(grant.business)})`);
|
||||
}
|
||||
const business = ctx.businesses.get(grant.business);
|
||||
checkScope(grant.projects, `${at}.projects`, (p, where) => {
|
||||
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
|
||||
});
|
||||
checkScope(grant.instances, `${at}.instances`, (x, where) => {
|
||||
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
|
||||
});
|
||||
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
|
||||
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
|
||||
}
|
||||
|
||||
// Check a parsed access document against the instance's context. Returns the
|
||||
// document frozen. Every refusal is a BusinessError naming the key path. An
|
||||
// expired grant is not refused: covers() treats it as absent.
|
||||
export function checkAccess(document, context) {
|
||||
const ctx = checkContext(context);
|
||||
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
|
||||
if (!isPlainObject(document)) refuse("access file must be a JSON object");
|
||||
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
|
||||
if (document.accessVersion !== 1) refuse(`accessVersion must be 1 (got ${JSON.stringify(document.accessVersion)})`);
|
||||
requirePositiveInt(document.revision, "revision");
|
||||
const users = new Set();
|
||||
const seenSubjects = new Set();
|
||||
requireArray(document.users, "users").forEach((user, i) => {
|
||||
checkUser(user, `users[${i}]`, ctx, humans, seenSubjects);
|
||||
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id: ${user.id}`);
|
||||
users.add(user.id);
|
||||
});
|
||||
const seenGrants = new Set();
|
||||
requireArray(document.grants, "grants").forEach((grant, i) => checkGrant(grant, `grants[${i}]`, ctx, users, seenGrants));
|
||||
return deepFreeze(structuredClone(document));
|
||||
}
|
||||
|
||||
// Read and check the access file. One descriptor, opened without following a
|
||||
// link, serves the stat checks and the read (readJsonFile). The file holds
|
||||
// authority, so it must belong to this uid and not be writable by group or
|
||||
// other, as the business file is.
|
||||
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
|
||||
const document = readJsonFile(file, "access file", (stat) => {
|
||||
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`);
|
||||
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
|
||||
});
|
||||
return checkAccess(document, context);
|
||||
}
|
||||
|
||||
// Does a checked grant cover a thread? `target` is the thread's business, bus
|
||||
// project id and instance. With `level`, the grant's level must be that one;
|
||||
// without it, any level covers. An expired grant covers nothing.
|
||||
export function covers(grant, { business, project, instance }, now, { level } = {}) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
|
||||
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
|
||||
if (grant.business !== business) return false;
|
||||
if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;
|
||||
if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;
|
||||
if (level !== undefined && grant.level !== level) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -10,5 +10,6 @@ export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
|
||||
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
|
||||
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
|
||||
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
|
||||
export { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "./access.mjs";
|
||||
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
|
||||
export { systemVars, resolveInstance, classify } from "./resolve.mjs";
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// FED R1 (#414, row 201): the access file loader and validator, DESIGN.md
|
||||
// section 3.5, and covers(). Every test works in a scratch directory.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, mkdirSync, symlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { BusinessError } from "../src/errors.mjs";
|
||||
import { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "../src/access.mjs";
|
||||
import { tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const P1 = "0f8b6c1e-2a3d-4e5f-8a9b-0c1d2e3f4a5b";
|
||||
const P2 = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
function context(over = {}) {
|
||||
return {
|
||||
providers: ["authentik"],
|
||||
peers: ["factory"],
|
||||
businesses: {
|
||||
"creative-hub": { human: "melanie", instances: ["researcher", "writer"] },
|
||||
acme: { human: "jason", instances: ["coder"] },
|
||||
},
|
||||
ssoSubjects: [{ provider: "authentik", sub: "owner-sub" }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function doc() {
|
||||
return {
|
||||
accessVersion: 1,
|
||||
revision: 3,
|
||||
users: [
|
||||
{ id: "bean", home: "factory", subjects: [{ provider: "authentik", sub: "s-1" }] },
|
||||
{ id: "chet", home: "local", subjects: [{ provider: "authentik", sub: "s-2" }] },
|
||||
],
|
||||
grants: [
|
||||
{ id: "g-1", user: "bean", business: "creative-hub", projects: ["*"], instances: ["researcher"], level: "collaborator", expires: "2026-12-31T00:00:00Z" },
|
||||
{ id: "g-2", user: "chet", business: "creative-hub", projects: [P1], instances: ["*"], level: "collaborator" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Refuses with a BusinessError, exit 2 unless given, and a message matching `pattern`.
|
||||
function refuses(fn, pattern, exitCode = 2) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `not a BusinessError: ${error}`);
|
||||
assert.equal(error.exitCode, exitCode);
|
||||
assert.match(error.message, pattern);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function mutated(change) {
|
||||
const d = doc();
|
||||
change(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
test("a valid document passes and comes back frozen", () => {
|
||||
const checked = checkAccess(doc(), context());
|
||||
assert.deepEqual(checked, doc());
|
||||
assert.ok(Object.isFrozen(checked));
|
||||
assert.ok(Object.isFrozen(checked.grants[0].projects));
|
||||
});
|
||||
|
||||
test("checkAccess does not freeze or alias the caller's document", () => {
|
||||
const d = doc();
|
||||
const checked = checkAccess(d, context());
|
||||
assert.ok(!Object.isFrozen(d));
|
||||
assert.notEqual(checked.grants, d.grants);
|
||||
});
|
||||
|
||||
test("LEVELS is the single allowed level set, collaborator only", () => {
|
||||
assert.deepEqual([...LEVELS], ["collaborator"]);
|
||||
assert.ok(Object.isFrozen(LEVELS));
|
||||
});
|
||||
|
||||
test("accessFilePath is access.json in the config directory", () => {
|
||||
assert.equal(accessFilePath("/x/y"), join("/x/y", "access.json"));
|
||||
const dir = tmp();
|
||||
const saved = process.env.MOSAIC_CONFIG;
|
||||
process.env.MOSAIC_CONFIG = join(dir, "config.json");
|
||||
try {
|
||||
assert.equal(accessFilePath(), join(dir, "access.json"));
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.MOSAIC_CONFIG;
|
||||
else process.env.MOSAIC_CONFIG = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test("top level: object, unknown keys, accessVersion, revision, arrays", () => {
|
||||
refuses(() => checkAccess([], context()), /access file must be a JSON object/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.extra = 1; }), context()), /unsupported access file key: "extra"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.accessVersion = 2; }), context()), /accessVersion must be 1/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.accessVersion; }), context()), /accessVersion must be 1/);
|
||||
for (const bad of [0, -1, 1.5, "3", undefined]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.revision = bad; }), context()), /revision must be a positive integer/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { d.users = {}; }), context()), /^users must be an array/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.grants; }), context()), /^grants must be an array/);
|
||||
});
|
||||
|
||||
test("users: unknown keys and the id rule", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].role = "x"; }), context()), /unsupported users\[1\] key: "role"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0] = "bean"; }), context()), /users\[0\] must be a JSON object/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "Bean"; }), context()), /users\[0\]\.id must match/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id: bean/);
|
||||
});
|
||||
|
||||
test("users: human, tester and any business's human are refused", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human \("melanie"\)/);
|
||||
// The human of a business the grant doesn't name is refused too.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human \("jason"\)/);
|
||||
});
|
||||
|
||||
test("users: home is a given peer or local", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local" \(got "elsewhere"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.users[1].home; }), context()), /users\[1\]\.home must be a configured peer/);
|
||||
refuses(() => checkAccess(doc(), context({ peers: [] })), /users\[0\]\.home must be a configured peer/);
|
||||
});
|
||||
|
||||
test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects = []; }), context()), /users\[0\]\.subjects must not be empty/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.users[0].subjects; }), context()), /users\[0\]\.subjects must be an array/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].extra = 1; }), context()), /unsupported users\[0\]\.subjects\[0\] key: "extra"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider \(got "google"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = ""; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = 7; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
|
||||
// A pair twice in one user, and across two users.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects.push({ provider: "authentik", sub: "s-1" }); }), context()), /users\[0\]\.subjects\[1\] appears more than once/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "s-1"; }), context()), /users\[1\]\.subjects\[0\] appears more than once/);
|
||||
// The owner's own sign-in subject can't be an extra user.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "owner-sub"; }), context()), /users\[1\]\.subjects\[0\] is an owner sign-in subject/);
|
||||
// The same sub under another provider is a different pair.
|
||||
const twoProviders = context({ providers: ["authentik", "google"] });
|
||||
assert.ok(checkAccess(mutated((d) => { d.users[1].subjects[0] = { provider: "google", sub: "owner-sub" }; }), twoProviders));
|
||||
});
|
||||
|
||||
test("grants: unknown keys, id, user and business", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].role = "x"; }), context()), /unsupported grants\[0\] key: "role"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id: g-1/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].id = "G 1"; }), context()), /grants\[0\]\.id must match/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file \(got "nobody"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business \(got "other"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "constructor"; }), context()), /grants\[1\]\.business must name a declared business/);
|
||||
});
|
||||
|
||||
test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID \(got "stack"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1.toUpperCase()]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, 7]; }), context()), /grants\[1\]\.projects\[1\] must be "\*" or a bus project id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", P1]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, P1]; }), context()), /duplicate entry in grants\[1\]\.projects/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = []; }), context()), /grants\[1\]\.projects must not be empty/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = "*"; }), context()), /grants\[1\]\.projects must be an array/);
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[1].projects = [P1, P2]; }), context()));
|
||||
});
|
||||
|
||||
test("grants: instances is [\"*\"] or distinct declared instances of that business", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of creative-hub \(got "coder"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["*", "writer"]; }), context()), /grants\[0\]\.instances must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["writer", "writer"]; }), context()), /duplicate entry in grants\[0\]\.instances/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = []; }), context()), /grants\[0\]\.instances must not be empty/);
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[0].instances = ["researcher", "writer"]; }), context()));
|
||||
});
|
||||
|
||||
test("grants: any level other than collaborator refuses, naming grants[i].level", () => {
|
||||
for (const bad of ["viewer", "owner", "Collaborator", "COLLABORATOR", "", null, 1]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].level = bad; }), context()), /grants\[1\]\.level must be one of collaborator/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator \(got undefined\)/);
|
||||
});
|
||||
|
||||
test("grants: expires is optional ISO time, and a past expiry is not refused", () => {
|
||||
for (const bad of ["2026-12-31", "2026-12-31 00:00:00Z", "2026-12-31T00:00:00+01:00", "tomorrow", 1798675200000, null]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = bad; }), context()), /grants\[0\]\.expires must be an ISO time/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-02-30T00:00:00Z"; }), context()), /grants\[0\]\.expires is not a real time/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-13-01T00:00:00Z"; }), context()), /grants\[0\]\.expires must be an ISO time/);
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2026-12-31T00:00:00.500Z"; }), context()));
|
||||
// DESIGN 3.5: a grant past expires is treated as absent, not refused (sage-1399).
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2001-01-01T00:00:00Z"; }), context()));
|
||||
});
|
||||
|
||||
test("the context is required and checked", () => {
|
||||
refuses(() => checkAccess(doc()), /access context must be a JSON object/);
|
||||
for (const key of ["providers", "peers", "ssoSubjects"]) {
|
||||
const c = context();
|
||||
delete c[key];
|
||||
refuses(() => checkAccess(doc(), c), new RegExp(`access context\\.${key} must be an array`));
|
||||
}
|
||||
refuses(() => checkAccess(doc(), context({ businesses: [] })), /access context\.businesses must be a JSON object/);
|
||||
refuses(() => checkAccess(doc(), context({ extra: 1 })), /unsupported access context key: "extra"/);
|
||||
refuses(() => checkAccess(doc(), context({ peers: ["local"] })), /access context\.peers must not name "local"/);
|
||||
refuses(() => checkAccess(doc(), context({ ssoSubjects: [{ provider: "authentik" }] })), /access context\.ssoSubjects\[0\]\.sub must be a non-empty string/);
|
||||
refuses(() => checkAccess(doc(), context({ businesses: { acme: { human: "jason" } } })), /access context\.businesses\.acme\.instances must be an array/);
|
||||
});
|
||||
|
||||
// loadAccess: the file checks, then checkAccess.
|
||||
|
||||
test("loadAccess reads a valid file and checks it", () => {
|
||||
const file = writeJson(join(tmp(), "access.json"), doc());
|
||||
assert.deepEqual(loadAccess(file, context()), doc());
|
||||
});
|
||||
|
||||
test("loadAccess refuses a missing file, a symlink and a directory with exit 4", () => {
|
||||
const dir = tmp();
|
||||
refuses(() => loadAccess(join(dir, "access.json"), context()), /access file not found/, 4);
|
||||
const real = writeJson(join(dir, "real.json"), doc());
|
||||
const link = join(dir, "access.json");
|
||||
symlinkSync(real, link);
|
||||
refuses(() => loadAccess(link, context()), /access file must be a regular, non-symbolic-link file/, 4);
|
||||
const sub = join(dir, "d");
|
||||
mkdirSync(sub);
|
||||
refuses(() => loadAccess(sub, context()), /access file must be a regular, non-symbolic-link file/, 4);
|
||||
});
|
||||
|
||||
test("loadAccess refuses another owner and group or other write", () => {
|
||||
const file = writeJson(join(tmp(), "access.json"), doc());
|
||||
refuses(() => loadAccess(file, context(), { uid: process.getuid() + 1 }), /access file must belong to uid/);
|
||||
for (const mode of [0o620, 0o602, 0o666]) {
|
||||
chmodSync(file, mode);
|
||||
refuses(() => loadAccess(file, context()), /access file must not be writable by group or other/);
|
||||
}
|
||||
chmodSync(file, 0o644);
|
||||
assert.ok(loadAccess(file, context()));
|
||||
});
|
||||
|
||||
test("loadAccess refuses bad JSON and a bad document with exit 2", () => {
|
||||
const dir = tmp();
|
||||
refuses(() => loadAccess(writeJson(join(dir, "a.json"), "{"), context()), /access file is not valid JSON/);
|
||||
refuses(() => loadAccess(writeJson(join(dir, "b.json"), mutated((d) => { d.grants[0].level = "owner"; })), context()), /grants\[0\]\.level/);
|
||||
});
|
||||
|
||||
// covers.
|
||||
|
||||
const NOW = new Date("2026-10-20T12:00:00Z");
|
||||
const target = (over = {}) => ({ business: "creative-hub", project: P1, instance: "researcher", ...over });
|
||||
|
||||
test("covers: * and listed ids match", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g1, target({ project: P2 }), NOW), true);
|
||||
assert.equal(covers(g2, target({ instance: "writer" }), NOW), true);
|
||||
assert.equal(covers(g2, target(), NOW), true);
|
||||
});
|
||||
|
||||
test("covers: an unlisted project, instance or business doesn't match", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g2, target({ project: P2 }), NOW), false);
|
||||
assert.equal(covers(g1, target({ instance: "writer" }), NOW), false);
|
||||
assert.equal(covers(g2, target({ business: "acme" }), NOW), false);
|
||||
assert.equal(covers(g1, target({ business: "acme" }), NOW), false);
|
||||
});
|
||||
|
||||
test("covers: an expired grant doesn't match, at and after the instant", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g1, target(), new Date("2026-12-30T23:59:59.999Z")), true);
|
||||
assert.equal(covers(g1, target(), new Date("2026-12-31T00:00:00Z")), false);
|
||||
assert.equal(covers(g1, target(), new Date("2027-01-01T00:00:00Z")), false);
|
||||
// No expires never expires.
|
||||
assert.equal(covers(g2, target(), new Date("2099-01-01T00:00:00Z")), true);
|
||||
});
|
||||
|
||||
test("covers: a level filter rejects another level, and no filter accepts any", () => {
|
||||
const g = checkAccess(doc(), context()).grants[0];
|
||||
assert.equal(covers(g, target(), NOW, { level: "collaborator" }), true);
|
||||
assert.equal(covers(g, target(), NOW, {}), true);
|
||||
assert.equal(covers(g, target(), NOW, { level: "viewer" }), false);
|
||||
assert.equal(covers({ ...g, level: "viewer" }, target(), NOW, { level: "collaborator" }), false);
|
||||
});
|
||||
|
||||
test("covers: now must be a valid Date", () => {
|
||||
const g = checkAccess(doc(), context()).grants[0];
|
||||
assert.throws(() => covers(g, target(), Date.now()), TypeError);
|
||||
assert.throws(() => covers(g, target(), new Date("nope")), TypeError);
|
||||
});
|
||||
@@ -1009,6 +1009,35 @@ names no one; the restart itself needs Mos's go or Jason's hand. A vars or launc
|
||||
prints `no restart`, because the launcher reads it at its next launch check.
|
||||
The Console Settings page has the same read and write (`packages/webui`).
|
||||
|
||||
## Access (FED R5, #414)
|
||||
|
||||
```
|
||||
mosaic access list --business ID [--json]
|
||||
mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator [--expires ISO] --revision N
|
||||
mosaic access revoke --business ID --user ID [--grant GID] --revision N
|
||||
```
|
||||
|
||||
These edit `<configDir>/access.json` (design section 3.5). The human proof is one `project.list`
|
||||
call through the human transport: the bus gates it with `#human` (`packages/bus/src/broker.mjs`,
|
||||
the `project.list` case and `#human`). An agent, or `--tester`, is refused before any write. Each
|
||||
write runs the file's checks on the new document first, bumps the revision by one, and renames
|
||||
atomically. A `--revision` that is not the file's current revision refuses with `revision-conflict`.
|
||||
A writer that finds the lock refuses at once. A lock whose pid is gone, or that is empty or
|
||||
unreadable, also refuses, and nothing reclaims it: check that no write is running, then remove
|
||||
`<configDir>/access.json.lock` by hand. Each write appends one receipt line to
|
||||
`<dataRoot>/state/access-log.jsonl`.
|
||||
|
||||
The command runs as the instance's uid. `access.json` must belong to that uid and must not be
|
||||
writable by group or other. If it is not, or a business file fails to load, the command refuses
|
||||
with exit 3, names the file and the key path, and writes nothing.
|
||||
|
||||
A grant's `instances` is the real limit. The granted instance runs with the owner's role, so a
|
||||
collaborator's message drives it with that authority. To cut a user off, revoke first: it acts at
|
||||
once. Removing a provider from `console.sso` takes effect only after a Console restart.
|
||||
|
||||
Peer homes are not read yet, so a grant that names a peer home refuses until row 203 adds the
|
||||
federation key. A grant that names a sign-in subject needs `console.sso` in the system config.
|
||||
|
||||
## Token usage
|
||||
|
||||
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// `mosaic access list|grant|revoke` (federation design section 3.5, FED R5, #414).
|
||||
// The human edits <configDir>/access.json here. Each write is checked against the
|
||||
// instance's context before it lands, bumps the revision by one, and appends one
|
||||
// receipt line to <dataRoot>/state/access-log.jsonl. The human proof is one call
|
||||
// through humanTransport: a bus that does not see a human shell refuses it, so
|
||||
// no agent and no tester can write. The context (providers, peers, businesses,
|
||||
// ssoSubjects) comes in as a function; the caller decides where it comes from.
|
||||
|
||||
import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, openSync, writeSync, fsyncSync, closeSync, existsSync, unlinkSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { join, dirname } from "node:path";
|
||||
import { BusinessError, accessFilePath, checkAccess, configDir, loadAccess, loadBusiness, LEVELS } from "../../business/src/index.mjs";
|
||||
import { rolesDir } from "./config.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
import { refuseInsideAgent } from "./transport.mjs";
|
||||
|
||||
export const ACCESS_USAGE = [
|
||||
"mosaic access list --business ID [--json]",
|
||||
"mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator [--expires ISO] --revision N",
|
||||
"mosaic access revoke --business ID --user ID [--grant GID] --revision N",
|
||||
].join("\n");
|
||||
|
||||
// The help text. A grant's `instances` is the real limit: the granted instance
|
||||
// runs with the owner's role, so a collaborator drives it with that authority.
|
||||
// Removing a user acts at once. Removing a provider from console.sso waits for a
|
||||
// Console restart, so take the users out of access.json first. Peer homes are
|
||||
// empty until the federation key lands (row 203), so a grant naming a peer
|
||||
// home refuses until then. A grant naming a subject needs console.sso.
|
||||
export const ACCESS_HELP = `${ACCESS_USAGE}
|
||||
|
||||
A grant gives a user exactly what the granted instances can do. The instances
|
||||
list is the real limit: a collaborator's message drives a granted instance with
|
||||
the owner's role.
|
||||
To cut a user off, revoke them here first. It acts at once. Removing a provider
|
||||
from console.sso takes effect only after a Console restart, so do that second.
|
||||
Peer homes are not read yet (row 203 adds the federation key), so a grant that
|
||||
names a peer home refuses. A grant that names a sign-in subject needs console.sso
|
||||
in the system config; without it every such grant refuses.
|
||||
The command runs as the instance's uid: access.json must belong to it and must
|
||||
not be writable by group or other, or the command refuses (exit 3) and writes nothing.`;
|
||||
|
||||
// The instance's context for checkAccess, read from the system config and the business
|
||||
// files. providers and ssoSubjects come from console.sso (row 192's shape: each provider
|
||||
// has an id and a subjects list). peers is empty until row 203. A business file that
|
||||
// fails to load fails the whole command.
|
||||
export function accessContextFor(system, env) {
|
||||
const providers = system.console?.sso?.providers ?? [];
|
||||
const ssoSubjects = providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, sub: s.sub })));
|
||||
const dir = join(configDir(env), "businesses");
|
||||
const businesses = {};
|
||||
if (existsSync(dir)) {
|
||||
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) {
|
||||
const id = file.slice(0, -".json".length);
|
||||
const doc = loadBusiness(id, { dir: configDir(env), rolesDir: rolesDir(env) });
|
||||
businesses[id] = { human: doc.human, instances: Object.keys(doc.roles) };
|
||||
}
|
||||
}
|
||||
return { providers: providers.map((p) => p.id), peers: [], businesses, ssoSubjects };
|
||||
}
|
||||
|
||||
// Every verb needs --business: the human proof runs through that business's bus.
|
||||
// --tester is refused outright on every verb, before any bus call.
|
||||
function parse(args) {
|
||||
const out = { positional: [], flags: new Set(), values: {} };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === "--json" || a === "--tester") out.flags.add(a);
|
||||
else if (a.startsWith("--")) {
|
||||
if (i + 1 >= args.length || args[i + 1].startsWith("--")) throw new CliError(`${a} needs one value\n${ACCESS_USAGE}`, 4);
|
||||
out.values[a] = args[++i];
|
||||
} else out.positional.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const list = (value) => (value === "*" ? ["*"] : value.split(",").filter(Boolean));
|
||||
|
||||
function readCurrent(file, context) {
|
||||
if (!existsSync(file)) return { document: { accessVersion: 1, revision: null, users: [], grants: [] }, revision: 0 };
|
||||
const document = loadAccess(file, context);
|
||||
return { document, revision: document.revision };
|
||||
}
|
||||
|
||||
// The file calls the write path makes. A test passes a stand-in through deps.fs.
|
||||
const realFs = { openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync };
|
||||
|
||||
// Atomic write: a temp file in the same directory, 0600, fsynced, then renamed over
|
||||
// the old file. A reader sees the old bytes or the new bytes, never a mix. A failure
|
||||
// before or at the rename removes the temp file and leaves the old bytes in place.
|
||||
function writeAtomic(file, text, fs = realFs) {
|
||||
const tmp = join(dirname(file), `.access-${randomBytes(8).toString("hex")}.tmp`);
|
||||
try {
|
||||
const fd = fs.openSync(tmp, "wx", 0o600);
|
||||
try {
|
||||
fs.writeSync(fd, text);
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
fs.renameSync(tmp, file);
|
||||
} catch (error) {
|
||||
try {
|
||||
fs.unlinkSync(tmp);
|
||||
} catch {
|
||||
// Already gone: the temp file was never created.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function receipt(dataRoot, line) {
|
||||
const dir = join(dataRoot, "state");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(join(dir, "access-log.jsonl"), `${JSON.stringify(line)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
// Two writers never interleave: the read, the check and the rename happen under an
|
||||
// exclusive lock file, `<access.json>.lock`, mode 0600. The file holds the holder's
|
||||
// pid and a newline. A lock whose holder is alive refuses. A lock that is empty,
|
||||
// unreadable, or whose pid is gone also refuses, and nothing reclaims it: a human
|
||||
// removes it once no write is running. Fail closed, with no reclaim race.
|
||||
const BUSY = "refused: the access file is being written by another command; try again";
|
||||
|
||||
function lockPid(lock) {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(lock, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const pid = Number(text.trim());
|
||||
return Number.isSafeInteger(pid) && pid >= 1 ? pid : null;
|
||||
}
|
||||
|
||||
function pidAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error.code !== "ESRCH";
|
||||
}
|
||||
}
|
||||
|
||||
function lockRefusal(lock) {
|
||||
const pid = lockPid(lock);
|
||||
if (pid !== null && pidAlive(pid)) return BUSY;
|
||||
if (pid === null) return `refused: lock ${lock} is unreadable or not a pid; if no write is running, remove it by hand and try again`;
|
||||
return `refused: lock ${lock} is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
|
||||
}
|
||||
|
||||
function takeLock(lock, fs) {
|
||||
try {
|
||||
return fs.openSync(lock, "wx", 0o600);
|
||||
} catch (error) {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
}
|
||||
throw new CliError(lockRefusal(lock), 3);
|
||||
}
|
||||
|
||||
export function withLock(file, fn, fs = realFs) {
|
||||
const lock = `${file}.lock`;
|
||||
const fd = takeLock(lock, fs);
|
||||
try {
|
||||
fs.writeSync(fd, `${process.pid}\n`);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
fs.unlinkSync(lock);
|
||||
}
|
||||
}
|
||||
|
||||
// The one human-proof call: project.list, which the bus gates with #human
|
||||
// (broker.mjs:2319, and #human at :377). It also returns the rows that resolve
|
||||
// project names. Row R4 swaps this call for access.reload.
|
||||
async function proveHuman(transport, business) {
|
||||
return transport(business)("project.list", {});
|
||||
}
|
||||
|
||||
// Resolve a --projects item: "*" and UUIDs pass through, anything else is a project
|
||||
// name looked up in project.list at grant time (the id is what gets written).
|
||||
function resolveProjects(items, rows) {
|
||||
return items.map((item) => {
|
||||
if (item === "*" || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(item)) return item;
|
||||
const hits = rows.filter((r) => r.name === item);
|
||||
if (hits.length !== 1) throw new CliError(`refused: project-not-found: no project named ${item} in this business (or the name is ambiguous)`, 2);
|
||||
return hits[0].id;
|
||||
});
|
||||
}
|
||||
|
||||
// deps: { system: { dataRoot }, transport(business) -> call(verb, args), contextFor() -> context, configDir?, now?, fs? }
|
||||
export async function accessCommand(args, io, deps) {
|
||||
refuseInsideAgent(io.env);
|
||||
try {
|
||||
return await accessVerb(args, io, deps);
|
||||
} catch (error) {
|
||||
// A file or business doc the checks refuse to load (group-writable, a wrong uid, a
|
||||
// hand edit) is a refusal with exit 3 and the file and key path, not a crash.
|
||||
if (error instanceof BusinessError) throw new CliError(`refused: ${error.message}`, 3);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function accessVerb(args, io, deps) {
|
||||
const parsed = parse(args);
|
||||
const sub = parsed.positional[0];
|
||||
if (!["list", "grant", "revoke"].includes(sub) || parsed.positional.length !== 1) throw new CliError(ACCESS_HELP, 4);
|
||||
if (parsed.flags.has("--tester")) throw new CliError("refused: access writes take the human's shell, not --tester", 3);
|
||||
const business = parsed.values["--business"];
|
||||
if (!business) throw new CliError(`--business is required\n${ACCESS_USAGE}`, 4);
|
||||
|
||||
const rows = await proveHuman(deps.transport, business);
|
||||
const file = deps.file ?? accessFilePath(deps.configDir ?? configDir(io.env));
|
||||
// Built after the human proof, so a refused caller never reads the business files.
|
||||
const context = deps.contextFor();
|
||||
|
||||
if (sub === "list") {
|
||||
const { document } = readCurrent(file, context);
|
||||
io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(document, null, 2)}\n` : formatList(document));
|
||||
return;
|
||||
}
|
||||
|
||||
const expected = parsed.values["--revision"];
|
||||
if (!/^\d+$/.test(expected ?? "")) throw new CliError(`--revision takes the revision you read\n${ACCESS_USAGE}`, 4);
|
||||
const user = parsed.values["--user"];
|
||||
if (!user) throw new CliError(`${sub} needs --user\n${ACCESS_USAGE}`, 4);
|
||||
|
||||
const result = withLock(file, () => {
|
||||
const { document, revision } = readCurrent(file, context);
|
||||
if (Number(expected) !== revision) {
|
||||
throw new CliError(`refused: revision-conflict: the access file is at revision ${revision}, not ${expected}; run list again`, 3);
|
||||
}
|
||||
let next;
|
||||
let touched;
|
||||
if (sub === "grant") {
|
||||
const level = parsed.values["--level"];
|
||||
if (!parsed.values["--projects"] || !parsed.values["--instances"] || level === undefined) {
|
||||
throw new CliError(`grant needs --projects, --instances and --level\n${ACCESS_USAGE}`, 4);
|
||||
}
|
||||
if (!LEVELS.includes(level)) throw new CliError(`--level takes ${LEVELS.join(", ")}`, 2);
|
||||
const id = `g-${randomBytes(4).toString("hex")}`;
|
||||
const grant = {
|
||||
id,
|
||||
user,
|
||||
business,
|
||||
projects: resolveProjects(list(parsed.values["--projects"]), rows),
|
||||
instances: list(parsed.values["--instances"]),
|
||||
level,
|
||||
};
|
||||
if (parsed.values["--expires"] !== undefined) grant.expires = parsed.values["--expires"];
|
||||
touched = [id];
|
||||
next = { ...document, revision: revision + 1, grants: [...document.grants, grant] };
|
||||
} else {
|
||||
const only = parsed.values["--grant"];
|
||||
const drop = (g) => g.user === user && g.business === business && (only === undefined || g.id === only);
|
||||
touched = document.grants.filter(drop).map((g) => g.id);
|
||||
if (touched.length === 0) throw new CliError(`refused: no grant for ${user} at ${business} to revoke`, 2);
|
||||
next = { ...document, revision: revision + 1, grants: document.grants.filter((g) => !drop(g)) };
|
||||
}
|
||||
// Every check runs on the new document before a byte is written.
|
||||
const checked = checkAccess(next, context);
|
||||
writeAtomic(file, `${JSON.stringify(checked, null, 2)}\n`, deps.fs);
|
||||
return { checked, revision, touched };
|
||||
}, deps.fs);
|
||||
|
||||
receipt(deps.system.dataRoot, {
|
||||
at: (deps.now ?? (() => new Date()))().toISOString(),
|
||||
verb: sub,
|
||||
business,
|
||||
user,
|
||||
actor: "human",
|
||||
revisionBefore: result.revision,
|
||||
revisionAfter: result.checked.revision,
|
||||
grants: result.touched,
|
||||
});
|
||||
io.stdout.write(`access ${sub}: ${result.touched.join(", ")} at ${business}, revision ${result.checked.revision}\n`);
|
||||
}
|
||||
|
||||
function formatList(doc) {
|
||||
const lines = [`access file revision ${doc.revision ?? "none"}`];
|
||||
for (const u of doc.users) lines.push(`user ${u.id} home ${u.home}`);
|
||||
for (const g of doc.grants) {
|
||||
lines.push(`grant ${g.id} user ${g.user} business ${g.business} projects ${g.projects.join(",")} instances ${g.instances.join(",")} level ${g.level}${g.expires ? ` expires ${g.expires}` : ""}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TAL
|
||||
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
|
||||
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
|
||||
import { instancesCommand, INSTANCES_USAGE } from "./instances.mjs";
|
||||
import { accessCommand, accessContextFor } from "./access.mjs";
|
||||
import { createChannelCaller } from "../../plugins/src/channel.mjs";
|
||||
import { PluginError } from "../../plugins/src/kinds.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
@@ -573,6 +574,15 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
|
||||
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
|
||||
if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
|
||||
if (verb === "access") {
|
||||
const system = deps.system ?? loadSystem({ env: io.env });
|
||||
return accessCommand(rest, io, {
|
||||
system,
|
||||
transport: (b) => humanTransport({ socket: socketPath(system.dataRoot), business: b, env: io.env }),
|
||||
contextFor: () => deps.accessContext ?? accessContextFor(system, io.env),
|
||||
now: deps.now,
|
||||
});
|
||||
}
|
||||
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
|
||||
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
|
||||
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
// `mosaic access` (FED R5, #414). Every write goes to a scratch config dir and a
|
||||
// scratch data root. The success paths run on a stand-in human CLI (fakeCli).
|
||||
// The one test that runs the real human-cli.mjs does it on a scratch bus from
|
||||
// agent ancestry, so the bus must refuse, and nothing is written.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as nodeFs from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { accessCommand, accessContextFor, withLock } from "../src/access.mjs";
|
||||
import { CliError } from "../src/errors.mjs";
|
||||
import { socketPath } from "../src/config.mjs";
|
||||
import { HUMAN_CLI, humanTransport } from "../src/transport.mjs";
|
||||
import { fakeCli, humanEnv, io, tmp, fixture } from "./helpers.mjs";
|
||||
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
|
||||
|
||||
const PROJECT = "3f1c2a64-0d8e-4b7a-9c55-1a2b3c4d5e6f";
|
||||
const ROWS = [{ id: PROJECT, name: "hub", repo: null }];
|
||||
const CONTEXT = {
|
||||
providers: ["authentik"],
|
||||
peers: [],
|
||||
businesses: { acme: { human: "jason", instances: ["coder"] } },
|
||||
ssoSubjects: [{ provider: "authentik", sub: "jason-sub" }],
|
||||
};
|
||||
const DOC = {
|
||||
accessVersion: 1,
|
||||
revision: 1,
|
||||
users: [{ id: "alice", home: "local", subjects: [{ provider: "authentik", sub: "alice-sub" }] }],
|
||||
grants: [],
|
||||
};
|
||||
|
||||
// The stand-in human CLI. It answers project.list with ROWS, or refuses like a bus
|
||||
// that sees no human shell when FAKE_MODE is "refuse".
|
||||
const BODY = `
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
const input = JSON.parse(readFileSync(0, "utf8"));
|
||||
writeFileSync(process.env.FAKE_LOG, JSON.stringify(input));
|
||||
if (process.env.FAKE_MODE === "refuse") { process.stderr.write("human-required\\n"); process.exit(3); }
|
||||
process.stdout.write(JSON.stringify(${JSON.stringify(ROWS)}));
|
||||
`;
|
||||
|
||||
function setup(t, { mode = "ok", doc = DOC } = {}) {
|
||||
const root = tmp(t, "mosaic-access-");
|
||||
const config = join(root, "config");
|
||||
mkdirSync(config, { recursive: true });
|
||||
const dataRoot = join(root, "data");
|
||||
const log = join(root, "fake.log");
|
||||
const cli = fakeCli(root, BODY);
|
||||
const env = humanEnv({ FAKE_LOG: log, FAKE_MODE: mode });
|
||||
const file = join(config, "access.json");
|
||||
if (doc) writeFileSync(file, JSON.stringify(doc, null, 2), { mode: 0o600 });
|
||||
const deps = {
|
||||
system: { dataRoot },
|
||||
configDir: config,
|
||||
contextFor: () => CONTEXT,
|
||||
now: () => new Date("2026-10-20T20:00:00Z"),
|
||||
transport: (business) => humanTransport({ socket: join(root, "bus.sock"), business, env, cli, timeoutMs: 5000 }),
|
||||
};
|
||||
const receipts = () => (existsSync(join(dataRoot, "state", "access-log.jsonl")) ? readFileSync(join(dataRoot, "state", "access-log.jsonl"), "utf8") : "");
|
||||
return { root, config, file, deps, env, log, receipts, read: () => JSON.parse(readFileSync(file, "utf8")) };
|
||||
}
|
||||
|
||||
const GRANT_BASE = ["grant", "--business", "acme", "--user", "alice"];
|
||||
const GRANT = [...GRANT_BASE, "--projects", PROJECT, "--instances", "coder", "--level", "collaborator"];
|
||||
|
||||
async function run(args, deps, env = humanEnv()) {
|
||||
const i = io(env);
|
||||
await accessCommand(args, i, deps);
|
||||
return i.out.text;
|
||||
}
|
||||
|
||||
async function refusal(promise, pattern) {
|
||||
await assert.rejects(promise, (e) => e instanceof CliError && pattern.test(e.message));
|
||||
return promise.catch((e) => e);
|
||||
}
|
||||
|
||||
test("list prints the revision and the users", async (t) => {
|
||||
const s = setup(t);
|
||||
const out = await run(["list", "--business", "acme"], s.deps);
|
||||
assert.match(out, /access file revision 1/);
|
||||
assert.match(out, /user alice home local/);
|
||||
});
|
||||
|
||||
test("grant writes one revision up, a receipt line, and the id in the file", async (t) => {
|
||||
const s = setup(t);
|
||||
const out = await run([...GRANT, "--revision", "1"], s.deps);
|
||||
assert.match(out, /access grant: g-[0-9a-f]{8} at acme, revision 2/);
|
||||
const doc = s.read();
|
||||
assert.equal(doc.revision, 2);
|
||||
assert.equal(doc.grants.length, 1);
|
||||
assert.deepEqual(doc.grants[0].projects, [PROJECT]);
|
||||
const [line] = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.equal(line.verb, "grant");
|
||||
assert.equal(line.revisionBefore, 1);
|
||||
assert.equal(line.revisionAfter, 2);
|
||||
assert.equal(line.actor, "human");
|
||||
});
|
||||
|
||||
test("a second write appends a second receipt and leaves the first intact", async (t) => {
|
||||
const s = setup(t);
|
||||
await run([...GRANT, "--revision", "1"], s.deps);
|
||||
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
|
||||
const lines = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.deepEqual(lines.map((l) => l.verb), ["grant", "revoke"]);
|
||||
assert.equal(lines[0].revisionAfter, 2);
|
||||
});
|
||||
|
||||
test("a stale revision refuses with revision-conflict and writes nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
await run([...GRANT, "--revision", "1"], s.deps);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), /revision-conflict/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts().trim().split("\n").length, 1);
|
||||
});
|
||||
|
||||
test("a live holder's lock refuses and leaves the file and the lock alone", async (t) => {
|
||||
const s = setup(t);
|
||||
writeFileSync(`${s.file}.lock`, `${process.pid}\n`, { mode: 0o600 });
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), /being written by another command/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${process.pid}\n`);
|
||||
assert.equal(readdirSync(s.config).filter((f) => f.startsWith(".access-")).length, 0);
|
||||
});
|
||||
|
||||
test("an empty or unreadable lock refuses, since its holder may still be writing", async (t) => {
|
||||
const s = setup(t);
|
||||
writeFileSync(`${s.file}.lock`, "");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), /is unreadable or not a pid/);
|
||||
writeFileSync(`${s.file}.lock`, "not-a-pid\n");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), /is unreadable or not a pid/);
|
||||
assert.equal(existsSync(`${s.file}.lock`), true);
|
||||
});
|
||||
|
||||
test("a lock left by a dead pid refuses, names the pid, and is not reclaimed", async (t) => {
|
||||
const s = setup(t);
|
||||
const dead = spawnSync(process.execPath, ["-e", ""]);
|
||||
writeFileSync(`${s.file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`${s.file}\\.lock is left by pid ${dead.pid}, which is not running`));
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${dead.pid}\n`);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("a failed rename leaves the old bytes, removes the temp file, and writes no receipt", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const fs = { ...nodeFs, renameSync: () => { throw Object.assign(new Error("injected rename failure"), { code: "EXDEV" }); } };
|
||||
await assert.rejects(run([...GRANT, "--revision", "1"], { ...s.deps, fs }), /injected rename failure/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.deepEqual(readdirSync(s.config).filter((f) => f.startsWith(".access-")), []);
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("a group-writable access file refuses with exit 3, names the file, and changes nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
chmodSync(s.file, 0o664);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /must not be writable by group or other/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
assert.match(err.message, new RegExp(s.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
});
|
||||
|
||||
test("an agent or tester caller is refused before the context or the access file is read", async (t) => {
|
||||
const s = setup(t);
|
||||
chmodSync(s.file, 0o664);
|
||||
const deps = { ...s.deps, contextFor: () => { throw new Error("context was read"); } };
|
||||
const env = { ...humanEnv(), MOSAIC_RUN_ID: "run-1" };
|
||||
await refusal(run([...GRANT, "--revision", "1"], deps, env), /human shell/);
|
||||
await refusal(run(["list", "--business", "acme", "--tester"], deps), /not --tester/);
|
||||
});
|
||||
|
||||
test("the lock is mode 0600 while held and removed after a refused write", async (t) => {
|
||||
const s = setup(t);
|
||||
let mode;
|
||||
withLock(s.file, () => {
|
||||
mode = statSync(`${s.file}.lock`).mode & 0o777;
|
||||
});
|
||||
assert.equal(mode, 0o600);
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
await refusal(run([...GRANT, "--revision", "9"], s.deps), /revision-conflict/);
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
});
|
||||
|
||||
test("a grant naming an unknown instance is refused before any write", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const bad = [...GRANT_BASE, "--projects", PROJECT, "--instances", "nobody", "--level", "collaborator", "--revision", "1"];
|
||||
await refusal(run(bad, s.deps), /refused: /);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("a project name resolves to its id at grant time", async (t) => {
|
||||
const s = setup(t);
|
||||
const args = [...GRANT_BASE, "--projects", "hub", "--instances", "coder", "--level", "collaborator", "--revision", "1"];
|
||||
await run(args, s.deps);
|
||||
assert.deepEqual(s.read().grants[0].projects, [PROJECT]);
|
||||
});
|
||||
|
||||
test("an unknown project name refuses before any write", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const args = [...GRANT_BASE, "--projects", "nope", "--instances", "coder", "--level", "collaborator", "--revision", "1"];
|
||||
await refusal(run(args, s.deps), /project-not-found/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
});
|
||||
|
||||
test("revoke removes the user's grants and leaves a revision behind", async (t) => {
|
||||
const s = setup(t);
|
||||
await run([...GRANT, "--revision", "1"], s.deps);
|
||||
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
|
||||
const doc = s.read();
|
||||
assert.equal(doc.revision, 3);
|
||||
assert.deepEqual(doc.grants, []);
|
||||
});
|
||||
|
||||
test("revoke of a user with no grant refuses and writes nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run(["revoke", "--business", "acme", "--user", "alice", "--revision", "1"], s.deps), /no grant/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
});
|
||||
|
||||
test("an agent caller is refused before the bus is asked", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const env = { ...humanEnv(), MOSAIC_RUN_ID: "run-1" };
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps, env), /human shell/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
assert.equal(existsSync(s.log), false);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
});
|
||||
|
||||
test("--tester is refused on every verb before the bus is asked", async (t) => {
|
||||
const s = setup(t);
|
||||
for (const verb of [["list"], [...GRANT, "--revision", "1"], ["revoke", "--user", "alice", "--revision", "1"]]) {
|
||||
const args = [...verb, "--business", "acme", "--tester"];
|
||||
const err = await refusal(run(args, s.deps), /not --tester/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
}
|
||||
assert.equal(existsSync(s.log), false);
|
||||
});
|
||||
|
||||
test("a bus that refuses the human proof blocks the write", async (t) => {
|
||||
const s = setup(t, { mode: "refuse" });
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /human-required/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("the context reads console.sso subjects and the business files, and peers stay empty", async (t) => {
|
||||
const root = tmp(t, "mosaic-access-ctx-");
|
||||
const f = fixture(root, "acme");
|
||||
const system = { console: { sso: { providers: [{ id: "authentik", subjects: [{ sub: "jason-sub" }] }] } } };
|
||||
const ctx = accessContextFor(system, { MOSAIC_CONFIG: join(root, "config", "config.json") });
|
||||
assert.deepEqual(ctx.providers, ["authentik"]);
|
||||
assert.deepEqual(ctx.ssoSubjects, [{ provider: "authentik", sub: "jason-sub" }]);
|
||||
assert.deepEqual(ctx.peers, []);
|
||||
assert.equal(ctx.businesses.acme.human, "jason");
|
||||
assert.ok(Array.isArray(ctx.businesses.acme.instances) && ctx.businesses.acme.instances.length > 0);
|
||||
const bare = accessContextFor({}, { MOSAIC_CONFIG: join(root, "config", "config.json") });
|
||||
assert.deepEqual(bare.ssoSubjects, []);
|
||||
assert.deepEqual(bare.providers, []);
|
||||
});
|
||||
|
||||
// Real human-cli.mjs on a scratch bus, run from agent ancestry. The proof must fail.
|
||||
test("the real human-cli, run from agent ancestry, refuses and writes nothing", async (t) => {
|
||||
const root = tmp(t, "mosaic-access-real-");
|
||||
const f = fixture(root, "acme");
|
||||
const config = join(root, "config");
|
||||
const bus = await startBroker({
|
||||
dataRoot: f.dataRoot,
|
||||
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir: config, rolesDir: f.roles, system: systemFor(root) },
|
||||
});
|
||||
t.after(() => bus.close());
|
||||
writeJson(join(config, "access.json"), DOC);
|
||||
const file = join(config, "access.json");
|
||||
const before = readFileSync(file, "utf8");
|
||||
const agentEnv = { ...humanEnv(), CLAUDECODE: "1" };
|
||||
const deps = {
|
||||
system: { dataRoot: f.dataRoot },
|
||||
configDir: config,
|
||||
contextFor: () => CONTEXT,
|
||||
transport: (business) => humanTransport({ socket: socketPath(f.dataRoot), business, env: agentEnv, cli: HUMAN_CLI, timeoutMs: 10000 }),
|
||||
};
|
||||
// The bus closes the socket on a failed human proof, so the client reports
|
||||
// outcome-unknown (client.mjs:32-33), not human-required. That is a bus behavior
|
||||
// outside R5 (BUILD.md). The test pins it, and nothing is written.
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], deps), /^outcome-unknown/);
|
||||
assert.equal(err.code, "outcome-unknown");
|
||||
assert.equal(err.exitCode, 1);
|
||||
assert.equal(readFileSync(file, "utf8"), before);
|
||||
assert.equal(existsSync(join(f.dataRoot, "state", "access-log.jsonl")), false);
|
||||
});
|
||||
@@ -381,7 +381,7 @@ async function seed(e,{copy=B,pm=false,store='3g',pointer:how='adopt'}={}){
|
||||
const rolledBack=(code,to=B)=>`stack-release: ${code}; rolled back to ${to}, which passed the gate\n`;
|
||||
const planBackup=(e)=>`backup: the run backs up the stopped store to ${e.data}/backups/stack/<activation>/ before any migration`;
|
||||
const planNoBackup='backup: none; the run rolls back before the backup';
|
||||
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
|
||||
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off','send: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
|
||||
|
||||
// --- The T table ---
|
||||
|
||||
@@ -1030,17 +1030,17 @@ test('T36 --plan shows the pending state and lists the row 169 refusals it can s
|
||||
return r.out.split('\n');
|
||||
};
|
||||
let out=await quiet(0,'activate',C1,'acme','--pm-after-harness','--plan');
|
||||
assert.deepEqual(out.slice(-6),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
|
||||
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
|
||||
biz(e,HARNESS);
|
||||
out=await quiet(3,'activate',A,'acme','--pm-after-harness','--plan');
|
||||
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
|
||||
assert.deepEqual(out.slice(-8),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
|
||||
out=await quiet(3,'activate',C1,'acme','--pm-after-harness','--plan');
|
||||
assert.equal(out.at(-2),'refuses pm-after-harness-set: the business file already sets harness ["claude-code"]; use --pm');
|
||||
biz(e);assert.equal((await run(e,'activate',C1,'acme','--pm-after-harness')).code,0);
|
||||
out=await quiet(3,'activate',C,'acme','--plan');
|
||||
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
|
||||
assert.deepEqual(out.slice(-8),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
|
||||
out=await quiet(0,'rollback','acme','--plan');
|
||||
assert.deepEqual(out.slice(-6),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
|
||||
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
|
||||
});
|
||||
|
||||
// Row 176 test 11: supported and older Console copies deliberately differ from the checkout.
|
||||
@@ -1706,6 +1706,113 @@ test('T54 row 188: a carried pair gets the proof file checks, and a proof file i
|
||||
});
|
||||
});
|
||||
|
||||
// Row 189 (#1687, mos-0972/0973): activate --no-send drops the pointer's --send; nothing turns it back on.
|
||||
const sendLines=(out)=>out.filter((l)=>l.startsWith('send:'));
|
||||
const noSend=(c)=>c.filter((x)=>x!=='--send');
|
||||
const LIVE_NOTE=`note: ${B} is the pointer's copy; the run re-pins it and only the Console flags change`;
|
||||
test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry it off',async(t)=>{
|
||||
await t.test('test 1: a live-copy --no-send drops --send from the pointer and the unit, keeps the rest in order, logs the step and backs up the stopped store',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']),want=noSend(base);
|
||||
assert.deepEqual(want.slice(-2),['--tester-port','7333']);
|
||||
const sum=createHash('sha256').update(readFileSync(e.store)).digest('hex'),n=calls(e).length;
|
||||
let r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,want);
|
||||
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));assert.doesNotMatch(conExec(e),/ --send( |$)/);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
|
||||
const act=pointer(e).activation,l=logLines(e).at(-1);
|
||||
assert.deepEqual([l.op,l.from,l.to,l.result,l.backup],['activate',B,B,'ok',act]);assert.equal(backupSum(e,act),sum);
|
||||
assert.deepEqual(startsStops(e,n),[`stop ${CON}`,`stop ${BUS}`,`start ${BUS}`,`start ${CON}`]);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 2: a plain live-copy activate still refuses already-active, and so does --no-send once --send is gone',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--send']);
|
||||
await refused(e,['activate',B,'acme'],'already-active',`${B} is the pointer's copy`);
|
||||
await refused(e,['activate',B,'acme','--tester-port','7333'],'already-active',`${B} is the pointer's copy`);
|
||||
await refused(e,['activate',B,'acme','--no-proof'],'already-active',`${B} is the pointer's copy`);
|
||||
const r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
await refused(e,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
|
||||
const g=await mk(t);await seedPreview(g);
|
||||
await refused(g,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
|
||||
for(const x of [e,g])await noLeak(x);
|
||||
});
|
||||
await t.test('test 3: --plan prints one send: line in each case, with the live-copy note on a same-copy run',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
|
||||
let out=await planOnly(e,0,'activate',B,'acme','--no-send');
|
||||
assert.deepEqual(sendLines(out),['send: dropped; --no-send was given']);
|
||||
assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[LIVE_NOTE]);
|
||||
assert.equal(out[out.indexOf('send: dropped; --no-send was given')+1],`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${noSend(base).join(' ')}`);
|
||||
out=await planOnly(e,0,'activate',C1,'acme');
|
||||
assert.deepEqual(sendLines(out),['send: carried']);assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[]);
|
||||
assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(' --send'));
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C1,'acme','--no-send')),['send: dropped; --no-send was given']);
|
||||
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,noSend(base));assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme','--no-send')),['send: off; --no-send was given and the pointer holds no --send']);
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme')),['send: off']);
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'rollback','acme')),['send: off']);
|
||||
r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.match(stepText(e),/Z --no-send was given and the pointer holds no --send, so there is nothing to drop\n/);
|
||||
assert.doesNotMatch(stepText(e),/drop --send/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 4: after --no-send, a rollback to the earlier copy leaves --send out of the pointer and the unit',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
|
||||
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
|
||||
assert.doesNotMatch(stepText(e),/--send/);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 4: pm-on after --no-send keeps --send off',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,{pm:true},['--send']);
|
||||
let r=await prun(e,'activate',C1,'acme','--pm-after-harness','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
biz(e,HARNESS);
|
||||
r=await prun(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).pm,true);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 5: --no-send on rollback, pm-on, status, adopt or rehearse is a usage error',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--send']);
|
||||
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
|
||||
for(const args of [['rollback','acme','--no-send'],['rollback','acme','--plan','--no-send'],['pm-on','acme','--no-send'],['status','acme','--no-send'],['adopt','acme','--no-send'],
|
||||
['rehearse','acme','--backup','/tmp/x','--port','7400','--no-send']]){
|
||||
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
|
||||
}
|
||||
assert.match(usageText,/\| --no-proof\] \[--no-send\]/);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 6: with both a pair and --send in the pointer, --no-send and --no-proof each drop what they name and keep the rest',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),pre=noSend(base),f=await proofFile(e),g=await proofFile(e,'proof2.189',0o400);
|
||||
let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send']);
|
||||
let out=await planOnly(e,0,'activate',C1,'acme','--no-proof');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: carried']);
|
||||
out=await planOnly(e,0,'activate',C1,'acme','--no-send');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: carried, header x-console-proof','send: dropped; --no-send was given']);
|
||||
out=await planOnly(e,0,'activate',B,'acme','--no-send','--no-proof');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: dropped; --no-send was given']);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,[...pre,...pair(f)]);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send',...pair(g,'X-Proof-Two'));assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,[...pre,...pair(g,'X-Proof-Two')]);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
const h=await mk(t),hbase=await seedPreview(h,undefined,['--send']),hpre=noSend(hbase),k=await proofFile(h);
|
||||
r=await prun(h,'activate',B,'acme',...pair(k));assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(h,'activate',C1,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(h).console,[...hpre,'--send']);
|
||||
r=await prun(h,'activate',C1,'acme','--no-send','--no-proof');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(h).console,hpre);assert.doesNotMatch(conExec(h),/--proof-| --send( |$)/);
|
||||
const steps=stepText(h);
|
||||
assert.match(steps,/Z --no-proof was given and the pointer holds no proof pair, so there is nothing to drop\n/);assert.match(steps,/Z drop --send; --no-send was given\n/);
|
||||
r=await prun(h,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
for(const x of [e,h])await noLeak(x);
|
||||
});
|
||||
});
|
||||
|
||||
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
|
||||
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
|
||||
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
|
||||
|
||||
+1
-1
@@ -35,6 +35,6 @@ if [ "${1:-}" = business ]; then
|
||||
exec node "$REPO/packages/business/src/cli.mjs" "$@"
|
||||
fi
|
||||
case "${1:-}" in
|
||||
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
|
||||
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|name|names|follow|threads|thread|access) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
|
||||
esac
|
||||
exec node "$REPO/packages/seat/src/cli.mjs" "$@"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Moves the bus, the Console and the human CLI from the pointer's copy to another copy together,
|
||||
# behind a health gate, and puts the old stack back inside the same run when the gate fails.
|
||||
# Console tester listener flags follow target support (row 176, #1674); so does the row 178 proof
|
||||
# pair, which activate sets or drops (row 187, #1685).
|
||||
# pair, which activate sets or drops (row 187, #1685). activate --no-send drops --send (row 189, #1687).
|
||||
# Design and tests: agents/darkwing/work/queue-162/BUILD.md.
|
||||
#
|
||||
# Who runs it. Decision 96 (Mos, mos-0716) lets this script stop and start the live units under
|
||||
@@ -30,13 +30,13 @@ BIN="$HOME/.npm-global/bin"
|
||||
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
CONSOLE_UNIT=mosaic-webui.service
|
||||
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
|
||||
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 R_BACKUP="" R_PORT=""
|
||||
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 R_BACKUP="" R_PORT=""
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'USAGE'
|
||||
usage: stack-release.sh adopt BUSINESS
|
||||
stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
|
||||
stack-release.sh pm-on BUSINESS [--timeout S]
|
||||
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
|
||||
stack-release.sh status BUSINESS
|
||||
@@ -85,6 +85,7 @@ parse_opts() {
|
||||
--proof-header) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_HEADER="$2"; shift 2 ;;
|
||||
--proof-file) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_FILE="$2"; shift 2 ;;
|
||||
--no-proof) [ "$OP" = activate ] || usage; NO_PROOF=1; shift ;;
|
||||
--no-send) [ "$OP" = activate ] || usage; NO_SEND=1; shift ;;
|
||||
--migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;;
|
||||
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || usage; RESTORE="$2"; shift 2 ;;
|
||||
--backup) [ "$OP" = rehearse ] && [ $# -ge 2 ] && { [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || [[ "$2" =~ ^/[A-Za-z0-9/._+-]*$ ]]; } || usage; R_BACKUP="$2"; shift 2 ;;
|
||||
@@ -244,14 +245,16 @@ target_schema() {
|
||||
in_chain() { local v; for v in "${T_CHAIN[@]}"; do [ "$v" != "$1" ] || return 0; done; return 1; }
|
||||
|
||||
# The item 3 checks on the target copy, in order: copy, schema, already-active. An activate that
|
||||
# sets a proof pair, or drops the pointer's pair with --no-proof, may name the pointer's copy: it
|
||||
# re-pins the live copy with new Console flags (row 187, Part B's B3 and B-R1).
|
||||
# sets a proof pair, drops the pointer's pair with --no-proof, or drops the pointer's --send with
|
||||
# --no-send, may name the pointer's copy: it re-pins the live copy with new Console flags (row 187,
|
||||
# Part B's B3 and B-R1; row 189, mos-0972).
|
||||
check_target() {
|
||||
copy_exists "$TO" 2>/dev/null || refuse copy-missing "no copy $TO in $COPIES"
|
||||
copy_ok "$TO" 2>/dev/null || refuse copy-unreachable "$TO is not reachable from refactor and origin/refactor"
|
||||
target_schema "$TO" || refuse copy-schema-unknown "cannot read SCHEMA_VERSION and its chain from $COPIES/$TO/packages/bus/src/store.mjs"
|
||||
[ "$TO" != "$P_COPY" ] || [ -n "$PROOF_HEADER" ] ||
|
||||
{ [ "$NO_PROOF" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --proof-header "* ]]; } ||
|
||||
{ [ "$NO_SEND" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --send "* ]]; } ||
|
||||
refuse already-active "$TO is the pointer's copy"
|
||||
}
|
||||
|
||||
@@ -690,6 +693,33 @@ console_for_target() {
|
||||
DROP_TESTER="--tester-port $inherited"
|
||||
fi
|
||||
proof_for_target
|
||||
send_for_target
|
||||
}
|
||||
|
||||
# Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose
|
||||
# it; without the flag the pointer's --send goes on unchanged. Rollback and pm-on never take it, so
|
||||
# they carry the pointer as it is. Sets SEND_PLAN (the plan's send: line) and SEND_STEP.
|
||||
send_for_target() {
|
||||
local had=0 i
|
||||
local -a without_send=()
|
||||
for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do
|
||||
if [ "${RUN_CONSOLE[i]}" = --send ]; then had=1; else without_send+=("${RUN_CONSOLE[i]}"); fi
|
||||
done
|
||||
SEND_STEP=""
|
||||
if [ "$NO_SEND" = 1 ]; then
|
||||
RUN_CONSOLE=("${without_send[@]}")
|
||||
if [ "$had" = 1 ]; then
|
||||
SEND_PLAN="send: dropped; --no-send was given"
|
||||
SEND_STEP="drop --send; --no-send was given"
|
||||
else
|
||||
SEND_PLAN="send: off; --no-send was given and the pointer holds no --send"
|
||||
SEND_STEP="--no-send was given and the pointer holds no --send, so there is nothing to drop"
|
||||
fi
|
||||
elif [ "$had" = 1 ]; then
|
||||
SEND_PLAN="send: carried"
|
||||
else
|
||||
SEND_PLAN="send: off"
|
||||
fi
|
||||
}
|
||||
|
||||
# Row 187: the row 178 proof pair in RUN_CONSOLE. A new pair replaces the pointer's and goes last;
|
||||
@@ -943,6 +973,7 @@ plan() {
|
||||
echo "bus: scripts/bus-service.sh install --copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
|
||||
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
|
||||
echo "$PROOF_PLAN"
|
||||
echo "$SEND_PLAN"
|
||||
echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
|
||||
echo "CLI: scripts/stack-copy.sh wrapper $TO --copies $COPIES --bin $BIN"
|
||||
local r
|
||||
@@ -956,6 +987,7 @@ run_steps() {
|
||||
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
|
||||
[ -z "$DROP_TESTER" ] || say "drop $DROP_TESTER; the target Console does not support it"
|
||||
[ -z "$PROOF_STEP" ] || say "$PROOF_STEP"
|
||||
[ -z "$SEND_STEP" ] || say "$SEND_STEP"
|
||||
trap 'after_fail signal' INT TERM HUP
|
||||
stop_both || rollback_in_run stop-timeout
|
||||
if [ -e "$STORE" ]; then
|
||||
@@ -1478,7 +1510,7 @@ STORE="$DATA/bus/bus.sqlite"
|
||||
BUS_UNIT="mosaic-bus@$BUSINESS.service"
|
||||
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
|
||||
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
|
||||
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" PLAN_REFUSE=()
|
||||
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
|
||||
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
|
||||
case "$OP" in
|
||||
adopt) do_adopt ;;
|
||||
|
||||
Reference in New Issue
Block a user