Compare commits

..
21 changed files with 263 additions and 1644 deletions
-100
View File
@@ -1,100 +0,0 @@
# 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.
-1
View File
@@ -1 +0,0 @@
ae3c36f24883e870541158d3ade1f5d3ef2ab856
@@ -1,3 +0,0 @@
74ac7e9ed38c230e60e7f7ece373104446c362b6fce0a4f92ca2210a981dc4f4 packages/business/src/access.mjs
7ee59b6e792fb47048fd06d227b65467ef71dabfcbdabdc0527b780656323d20 packages/business/src/index.mjs
3bec097f55909b31faf2bbc62c81e1d4d0290f50e696d3d3d367e77d82c5b238 packages/business/tests/access.test.mjs
-127
View File
@@ -1,127 +0,0 @@
#!/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,158 @@
# PRD 1.7 draft: the chief of staff's hire and fire authority, the coder's push to its fork, and agent
# host access through the credential store
Draft by Honker for row 194 (#1692), under Mos's ruling mos-1017 (through Sage, sage-1365). Not approved.
It changes nothing until Jason approves it as a `prd.approve` decision in the Console. Sage opens that
decision and sends Mos its id. Base: `docs/prd/mosaic-stack.md` version 1.6 at 53bb59462.
## What it changes
Row 194 adds `role.hire` and `role.fire` to the vocabulary, and row 199 adds `git.push.remote`. Under
REQ-WAIT-3 all three are hard waits, so today Jason resolves every hire, every fire and every push to a
remote. This version routes hire and fire for the chief of staff within fixed bounds, so the chief hires
and fires as needed and the user's per-agent forbid still holds. It routes `git.push.remote` for the coder
only (Mos, mos-1028 item 3). It adds agent host access with sudo through the credential store for
agents with a grant (Q183, Q184, Vikunja #424, due 2026-10-27). One `prd.approve` decision covers it all
(Vikunja #420).
When this version lands, a reviewed commit:
1. adds `role.hire`, `role.fire` and `git.push.remote` to `ROUTED` in
`packages/business/src/vocabulary.mjs` and in `packages/bus/src/broker.mjs`.
2. adds `role.hire` and `role.fire` to `roles/chief-of-staff.json` withinRole, and `git.push.remote` to
`roles/coder.json` withinRole. No other role file gets `git.push.remote`.
3. switches the broker's check on `instance.hire` and `instance.fire` from a cited decision to the
`reason` form, as `role.end` works for REQ-ROLE-2, and makes `publish`'s `decision` optional for a
within-role class (row 199 D9 step 1).
4. states, for a `git.push.remote` with no decision, where the source commit comes from: the latest work
commit the bus recorded for the coder's task. The check still matches it against the work ref tip and
the push sends it by oid (row 199, duckworth-0022 N9).
5. adds the host credential grant check and the host access it allows (REQ-CRED-3, Vikunja #424).
Every other check stays as rows 194 and 199 build it. Filling `hireable` stays a separate reviewed role
file commit in `roles/`.
## Text for the PRD
Status line, appended to the current paragraph:
> Version 1.7 routes the chief of staff's hire and fire actions and the coder's push to its own fork,
> and adds agent host access through the credential store, approved by Jason in decision
> <prd.approve decision id>.
Source, a new bullet above "Version 1.6 source":
> - Version 1.7 source, TARGET.md line 66: "Chief of staff agent has hire/fire authority for agents as
> needed; unless user forbids per agent". And Jason through Mos (mos-1067), DECISIONS Q183, in part: "I will
> still want to allow infra agent(s) on my .53 instance to access other servers, etc. However, not all
> agents will need to have that access. I envision a credential store within Mosaic Stack that works
> as some sort of variable for the agent to call. The system will enforce access to the credentials to
> keep the agents sandboxed with proper security. I need to be able to trust my agents with assisting
> me. This will require sudo access to several systems. If we can utilize the credential store in
> Mosaic Stack and allow agents to perform their jobs with NOPASSWD sudo on various machines, I am a
> happy man." DECISIONS Q184, on whether that is in place by 10-27: "Q5: by 10.27"
Under "Added in version 1.2", after REQ-COS-2:
> Added in version 1.7:
>
> - **REQ-COS-3.** The chief of staff hires and fires role instances without a decision each time. Hiring
> adds a role instance to the organization file and lists it for launch. Firing ends the instance's
> runs and removes it from the launch list. It stays in the organization file. Only the user deletes
> an instance. These actions are `role.hire` and `role.fire`. They leave the hard waits and route
> under this requirement, within these bounds:
> - The chief acts only in its own organization.
> - The chief hires only role definitions that need no credentials, until a later version adds
> credentialed roles.
> - The chief hires only the role kinds its role file lists. Changing that list is a reviewed commit.
> - The chief hires only a role whose every action is on a fixed list of plain work actions: sending
> messages, writing records, asking for review, updating its assigned tasks, and the scope and
> working-push actions of an assigned task. A role holding any other action, such as launch, end,
> organization, naming, hire, review verdict or merge authority, or any action added later, is never
> hired. So the chief never hires a chief, never picks its own reviewers or merge gate, and the author
> is never the reviewer. This holds whatever the role file's list says. Adding to that list is a PRD
> change.
> - The chief may set a hired instance's model and narrow its limits, and nothing else about it.
> - The user can forbid per agent. The user marks an instance so the chief can't fire it, end its runs
> or change its model, and marks role kinds the chief can't hire. Only the user changes these marks, through the
> WebUI or the CLI.
> - The kill switch stops every hire and fire while it is on.
> - Every hire and fire is logged with the chief's instance, its run and the reason.
Under "Landing", after REQ-LAND-1:
> Added in version 1.7:
>
> - **REQ-LAND-2.** A coder pushes its own work to its own fork. The action is `git.push.remote`.
> - What it pushes: one commit, by its oid, never a ref name. That commit is the latest work commit the
> bus recorded for the coder's task, and the coder made that push. A decision that authorizes a push
> names that commit, and it doesn't authorize any other.
> - Where it lands: only the branch `refs/heads/<its Gitea user>/<branch>`. The host builds that
> name, the agent never does. A push never forces.
> - Which remote: the agent's own fork of the project's upstream. The business file names the upstream,
> and the host never contacts it. For each push the host builds the fork's address over https from
> the upstream's host, port and repository name and the agent's own Gitea user. The history check,
> the push and the credential match all use that address. So the agent's own user owns the receiving
> repository on every push, and the agent never names it.
> - The host refuses an upstream on a host or port other than the agent's Gitea, and an upstream the
> agent's own user owns. It refuses a fork that is missing, empty or doesn't share the project's
> history. Gitea's push-to-create stays off, so a push never creates a repository.
> - Who may: the action routes under REQ-DEC-2 and is granted within-role to the coder role only. No
> other role file grants it.
> - Before version 1.7 the action was a hard wait under REQ-WAIT-3, so each push needed a decision
> the human resolved, and that decision named the commit.
>
> Every push is logged with the remote, the ref, the commit and, when one was given, the decision.
Under "Credentials", after REQ-CRED-2:
> Added in version 1.7:
>
> - **REQ-CRED-3.** An agent with a grant can run commands with sudo on another host. The agent asks
> for the host credential by name. The stack checks that a grant exists for that agent and that host
> before the credential is used. The agent never sees the key or any other secret value (REQ-CRED-1,
> REQ-VAR-2). An agent without a grant for that host is refused. Not every agent gets a grant. Who
> grants a host credential, and how, is set by the #424 design, which Jason approves.
REQ-WAIT-3, appended:
> `role.hire` and `role.fire` were added as hard waits and route under REQ-COS-3 since version 1.7.
> `git.push.remote` was added as a hard wait and routes under REQ-LAND-2 since version 1.7.
## Notes for the reviewer and for Mos
- REQ-COS-1 (1.2) already says the chief "hires and terminates role instances". REQ-COS-3 gives that
sentence its actions and bounds. It doesn't widen REQ-COS-1.
- The forbid now names what r3 called "replace": ending a kept instance's run with a reason and changing
its model (webby-0010 N2). A user-resolved decision still ends a kept run (row 194 D2.4).
- The fixed list is `HIRE_ALLOWED` in row 194 D2.3, an allowlist since r4 (webby-0010 B1). r3's text
named the refused authorities instead, which let merge-gate and any later action through.
- The kill switch is row 191's. If row 191 hasn't landed when this version is approved, the bound still
holds through row 194's chain on its candidate.
- REQ-LAND-2 follows row 199's design r6 (4d3007ec9 on megavolt/row199, approved by Duckworth in
duckworth-0027: D9 to D11, D9a, D9c, Q-M1 and Q-M2, and the oid binding from mos-1034 item 5) and
Megavolt's wording in megavolt-0005, megavolt-0008 and megavolt-0014, through Sage (sage-1377,
sage-1380, sage-1387). The fork bullet answers duckworth B5 and B6 and webby-0011 N1. Commit-email
checks, the refusal codes and the URL parse rules (D9c) are host checks with tests in row 199, not
bounds, so they stay out of the text (megavolt-0008).
- Once the routing lands and the decision is optional for the within-role class, the commit is the task's
latest recorded push at check time. It is still checked against the work ref tip and pushed by oid, so
the binding holds without a decision (megavolt-0008). Step 4 of "When this version lands" puts that in
the routing commit (duckworth-0022 N9, sage-1387).
- REQ-LAND-2 and REQ-COS-3 meet at the coder. Coder declares Gitea credentials, so REQ-COS-3's
credentials bound keeps the chief from hiring one until a later version lifts it. `git.push.remote` isn't
on REQ-COS-3's fixed list either, so a coder holding it stays unhireable until a PRD version adds it
(row 194 D2.3).
- Adding to the fixed action list is a PRD change (mos-1035 item 3, sage-1395). The role file's
`hireable` list stays a reviewed commit in `roles/`.
- The credentials bound names no row number, so the text doesn't go stale when row 199 lands. A later
version lifts it.
- REQ-CRED-3 states only what Jason said in Q183 and Q184 (sage-1422). It names the outcome and the
check, not how the stack reaches the host. Proxying the ssh and a signing socket are both designs, and
the #424 design picks one (duckworth-0038 B1).
- Open point: Q183 doesn't say who grants host access or how, so the text leaves it open and defines no
mechanism. The #424 design settles it, Jason approves that design, and Duckworth reviews it because it
gives agents sudo on hosts (Q184, Mos's reading, duckworth-0038 B2). The build is row 210 (#1707,
owner Gizmoduck), a new row on top of row 199 (megavolt-0019, sage-1430).
- This draft follows the PRD's own style: plain words, no semicolons in new sentences, and the status
paragraph never edited in place except to append.
-126
View File
@@ -1,126 +0,0 @@
# 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.
@@ -1,6 +0,0 @@
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
@@ -1,64 +0,0 @@
# 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).
-106
View File
@@ -1,106 +0,0 @@
# 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.
-1
View File
@@ -1363,4 +1363,3 @@ 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
+30 -10
View File
@@ -47,6 +47,11 @@ R1's exported interface as the R1 manifest names it, and R5 lands after R1 and r
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.
Context (sage-1419). `ssoSubjects` is row 192's `console.sso.providers[].subjects` flattened to `{provider,
sub}` pairs; the design's "`console.sso.subjects`" in 3.5 is shorthand for it. `providers` is the list of
`providers[].id`, read through `loadSystem` and row 192's `checkConsole`. `peers` is `[]` until R2 lands, so a grant
naming a peer home refuses. R5 rebases onto row 192's landing as well as R1's.
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.
@@ -78,23 +83,38 @@ 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.
A live window, not a code row (DESIGN.md section 7, row R8). The brief follows r4.11, b93f098f0 on
`gizmoduck/federation-design` (sha256 1bcee8df0a3a8628a6646535b756c936ca6d9e7bcfa7e1dd2bf1d3f01715c8e6),
which Duckworth acked with r4.10 (duckworth-0034). Mos closed design rounds there (mos-1072). 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.
What changed since r4.9 (DECISIONS Q182, Q183, mos-1055, mos-1063). R8 is a recorded acceptance. It starts
with Jason's acceptance of the factory paths to .54 on record, quoted in section 8. The privilege and host
checks still run, and each result goes into the R8 record with the name of whoever ran it. They report and
don't refuse. The four functional checks named at the end of section 7's R8 row still stop R8 when they fail
(mos-1066 item 1).
Order (mos-1042 item 2). R8 runs after the Case 2 install on .54 (#416, due by 2026-10-27, Jason's latest-by
date in Q176, mos-1069), after Bean and Chet are live in Case 2, and after both Hermes gateways on 10.1.1.54
have stopped. Case 2 go-live doesn't wait on R8. The risk, stated for 10-27: federation between the two
instances 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.
- R7, #412 phase 2 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).
`/etc/sudoers.d` names `mosaic`. An agent or Jason runs them as localadmin (mos-1055), and the output goes
into the R8 record with who ran them.
- The factory privilege checks, the key fingerprint comparison and the section 8 privilege path table, each
as section 7's R8 row states them.
Build notes:
- N1 (Duckworth, duckworth-0034, mos-1072). A check that fails on a path Jason didn't name in Q182 or Q183 is
marked "not named in Q182 or Q183" in the record, not filed under the acceptance. Examples: a factory mount
of hub data, another agent on .54, a provider admin token. Mos takes each one to Jason.
- N2 (Duckworth, doc-only). The Q183 and Q4 quotes in section 8 are excerpts. When the R8 record quotes them,
it marks each omission with "…" and checks the full text at DECISIONS.md:1535-1541 in jason-master-restart
(read only).
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.
+70
View File
@@ -0,0 +1,70 @@
# Brief: agent host access with sudo through the credential store (#424)
Sage, 2026-10-20, for Jason's rulings Q183 and Q184 relayed by Mos (mos-1056 item 3, mos-1067). Gitea #1707, Vikunja P19
#424, due 2026-10-27 (Q184: "Q5: by 10.27"). Owner Gizmoduck (design and build), reviewer Duckworth, gate owner
Sage. Chained on row 199's candidate (Q177): row 199 design r6 (4d3007ec9 on `megavolt/row199`, approved in
duckworth-0027) is not reopened. This row lands after 199 and rebases onto its landing.
## What Jason asked for
Q183, through Mos: "I envision a credential store within Mosaic Stack that works as some sort of variable for
the agent to call. The system will enforce access to the credentials to keep the agents sandboxed with proper
security... If we can utilize the credential store in Mosaic Stack and allow agents to perform their jobs with
NOPASSWD sudo on various machines, I am a happy man." Q183 also says not every agent needs that access.
Mos's requirements (mos-1067 item 1):
- An agent asks for a host credential by name.
- The stack checks a grant for that agent and that host, and does the ssh and sudo for it.
- The agent never sees the key or any secret value.
- Only agents with a grant can do it.
The PRD text is REQ-CRED-3, drafted by Honker for PRD 1.7 (sage-1422). If it misses 1.7 it goes into 1.8, and
this row lands only after the version that holds it is approved.
## What row 199 gives and what it doesn't (megavolt-0019)
It builds on 199 as 199 stands: the store layout under `<configDir>/secrets/agents/<business>/<agent>/`, the
lifecycle verbs and their `changes.jsonl` receipts, `useFor(session, service, fn)` with the D5 allowlist test,
`assertClean` on every result, and the grant shape of `publish` (a list on the role entry, checked by the
broker, plus a hard-wait action that isn't routed).
This row's own changes:
- A host kind in the closed kind set (vocabulary.mjs `SERVICES`, credentials.mjs, the broker's service check,
the CLI date flag, the remove regex), with a naming rule for one credential per host or per agent.
- A per-kind value check. An OpenSSH private key fails today's `TOKEN` pattern.
- Handing the key to ssh without the agent seeing it: an ssh-agent on a private socket, or a 0600 temp file
removed on every path, the way publish's one-shot helper works.
- Pinned host keys per host, and no trust on first use.
- The command boundary: what an agent may run under sudo, and how output returns without echoing the secret.
This is the main design work.
- The executor: a ticket and a host op running in the bus child, the same split row 199 uses for publish
(Megavolt's deviation in 199's BUILD.md).
## Steps
1. DESIGN.md first, in `agents/gizmoduck/work/queue-ROW/`. It answers each item above, names the grant shape
(`roles.<agent>.hosts` and a `host.sudo` action, or a reasoned alternative), says who grants (Jason, through
a reviewed role change, unless REQ-CRED-3 says otherwise), and lists tests and mutants. Duckworth approves it
before code. Sage reads it too, because it gives agents sudo on hosts.
2. Build on 199's candidate. Single test files run freely. Suites and mutant batches need Sage's slot.
3. The live part is not this row. Putting a key in the store and granting a host is a separate window with
Jason or Mos, after landing.
## Hosts
The first targets are `localadmin` with NOPASSWD sudo on 10.1.1.53 and 10.1.1.54 (#423 step 1, applied by
ops-01 at 20:05:47Z), and the servers agents already reach as localadmin (Q172). ops-01 owns the host side.
Tests use scratch sshd fixtures on 127.0.0.1 or fakes, never a real host.
## Boundaries
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files, the live units or `~/.mosaic`. No key is
read, copied or printed. No ssh to any real host from a test or a seat.
- No widening of row 199's kinds or regex inside row 199. Both rows touch them, so this row rebases after 199
lands.
## Gate
Duckworth approves DESIGN.md, then the manifest. The bus, business and cli suites and every
`scripts/test-*.sh` are green on Sage's gate, and the mutants are killed (one per refusal, plus one that leaks
the key into the agent's output and one that skips the host key pin).
@@ -25,6 +25,10 @@ names it, with Sage's go (sage-1373). R1's level check accepts `viewer` and stil
level. Tests in `packages/business/tests/` (section 8, Business), with the four mutants in Morgana's
BUILD.md. FED R4 chains on this row.
Duckworth's N1 on R1 (duckworth-0033, sage-1417): with viewer added, `covers` called with no level would let a
viewer grant cover a collaborator ask. This row makes `level` required in `covers`, with a test and a mutant that
makes it optional again. R3 and R4 call `covers` with a level.
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
gate, and the mutants are killed.
-185
View File
@@ -1,185 +0,0 @@
// 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;
}
-1
View File
@@ -10,6 +10,5 @@ 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";
-280
View File
@@ -1,280 +0,0 @@
// 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);
});
-29
View File
@@ -1009,35 +1009,6 @@ 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
-288
View File
@@ -1,288 +0,0 @@
// `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`;
}
-10
View File
@@ -18,7 +18,6 @@ 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";
@@ -574,15 +573,6 @@ 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));
-306
View File
@@ -1,306 +0,0 @@
// `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);
});
+1 -1
View File
@@ -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|access) 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) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
esac
exec node "$REPO/packages/seat/src/cli.mjs" "$@"