Compare commits

..
Author SHA1 Message Date
jason.woltje b008eed728 row 202 round 2: candidate manifest CANDIDATE.sha256 (48a3fb96e, six digests) 2026-10-20 15:24:52 -05:00
jason.woltje 7fd265033b row 202 round 2 manifest: candidate 48a3fb96e (FED R5) 2026-10-20 15:23:26 -05:00
jason.woltje 48a3fb96e0 row 202 round 2 (#1700): lock refuses and never reclaims; fs seam with temp cleanup; BusinessError exits 3; refusals before context (Duckworth B1, Sage sage-1429, sage-1437)
- Lock: a dead-pid, empty or unreadable lock refuses and names the path (and pid); a human removes it. No reclaim, so the reclaim race is gone.
- writeAtomic takes the fs calls through deps.fs; a failed write removes its temp file and leaves the old bytes.
- A BusinessError from loadAccess or loadBusiness exits 3 with the file and key path; no stack trace.
- contextFor is built after the agent and tester refusals and the human proof.
- Tests: 21 in access.test.mjs, single file green. New: dead-pid refuses, failed rename, 0664 file, refusal before context.
2026-10-20 15:23:05 -05:00
jason.woltje 959bfb2752 row 202: manifest for Duckworth review (FED R5) 2026-10-20 15:10:16 -05:00
jason.woltje 030809e15a row 202 (#1700): mosaic access list, grant and revoke (FED R5), human proof, atomic write, lock, receipt; 18 tests 2026-10-20 15:10:09 -05:00
jason.woltje 35aaceac97 Merge commit '44e36fc4c' into quackerjack/fed-r5 2026-10-20 15:03:55 -05:00
jason.woltje 44e36fc4c6 row 201: drop a stray __pycache__ file from the queue-201 packet 2026-10-20 14:57:07 -05:00
jason.woltje 80fe310713 row 201 (#1699): FED R1 access.json loader and validator, covers, tests and 44 mutants (Gizmoduck, round 1) 2026-10-20 14:57:00 -05:00
jason.woltje 2d0aa29374 agents: quackerjack row 202 plan (FED R5, mosaic access list, grant, revoke) 2026-10-20 14:52:57 -05:00
jason.woltje ae3c36f248 agents: Gizmoduck seat dir and roster row (FED R1 owner, #414) 2026-10-20 14:42:15 -05:00
jason.woltje a2af7ab495 plans: FED R1, R5, R2 and R8 briefs (#414, r4.9 a1daa3c07) and row 193a-d build briefs (#1691); Quackerjack seat dir and roster row 2026-10-20 14:41:03 -05:00
58 changed files with 1896 additions and 3580 deletions
+2
View File
@@ -23,6 +23,8 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
| Herb | Builder, a second gate lane (#1695) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
| Bushroot | Builder, retire the control board writer on 7331 (#1696) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1008 | none, T3 only |
| Megavolt | Builder, MVP T1 item 6 (agent credentials and git identity, #1697) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Quackerjack | Builder, federation R5 and R2 (#414) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1023 | none, T3 only |
| Gizmoduck | Federation design (#414) and builder of FED R1 | T3 session (Claude, Opus 5.5, thread 9312a896) | none, T3 only |
| Webby | Reviewer: Case 2 designs, rows 192 and 194 | T3 session (Claude, Opus 5.5, thread 03086006), started by Mos (mos-0981) | none, T3 only |
| Duckworth | Reviewer: federation designs, row 193 | T3 session (Claude, Opus 5.5, thread ffceafec), started by Mos (mos-0977) | none, T3 only |
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
+9
View File
@@ -0,0 +1,9 @@
# Gizmoduck
Design and builder seat, Claude Opus 5.5, a T3 thread (9312a896). Author of the federation design
(`agents/gizmoduck/work/federation/DESIGN.md` on `gizmoduck/federation-design`, #414). Builds FED R1, the
access.json loader and validator, under Mos's order mos-1023 item 1.
No launcher in this directory. The seat works in its own worktree and has no credentials of its own; until
the credentials row settles per-agent identity, it pushes only its own `gizmoduck/*` branches through the
repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+100
View File
@@ -0,0 +1,100 @@
# Row 201 round 1 (#1699): FED R1, the access.json loader and validator
Gizmoduck, 2026-10-20. Reviewer Duckworth, gate owner Sage. Federation design #414.
- Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, section "Brief: FED R1", at a2af7ab49.
- Design: `agents/gizmoduck/work/federation/DESIGN.md` section 3.5, r4.9 a1daa3c07 on
`gizmoduck/federation-design`.
- Base: ae3c36f24 (`origin/refactor` when I branched, contains the brief). Branch `gizmoduck/fed-r1`.
- Candidate: the commit that adds this file. `candidate-manifest.sha256` lists the three code files.
## Interface
This is what R5 and row 193a build against. Any change to it in review goes to Sage before it lands.
Morgana confirmed it (morgana-0014) and Sage accepted it with `ssoSubjects` required (sage-1399). Sage
relays it to Quackerjack.
| Export | Shape |
|--------|-------|
| `LEVELS` | frozen `["collaborator"]`, the one allowed level set. 193a widens it with the single line `["collaborator", "viewer"]` |
| `accessFilePath(dir = configDir())` | `<dir>/access.json` |
| `checkAccess(doc, { providers, peers, businesses, ssoSubjects })` | pure, returns a frozen deep copy, throws `BusinessError` exit 2 naming the key path |
| `loadAccess(file, context, { uid = process.getuid() } = {})` | one no-follow read through `readJsonFile`, then the stat checks, then `checkAccess`. `uid` is a test seam |
| `covers(grant, { business, project, instance }, now, { level } = {})` | boolean. `now` must be a valid `Date`, otherwise TypeError |
The context: `providers` is an array of provider ids, `peers` an array of peer ids (never `local`),
`businesses` maps a business id to `{ human, instances }`, and `ssoSubjects` is an array of
`{ provider, sub }`, the owner's `console.sso.subjects`. A missing or malformed context refuses.
All four are exported from `src/index.mjs` too.
## Rules, code and tests
DESIGN 3.5 rule, where `access.mjs` enforces it, and the test that shows the refusal names the key path.
| 3.5 rule | access.mjs | access.test.mjs test | Mutant |
|----------|-----------|----------------------|--------|
| regular file, not a symlink | 167 (`readJsonFile`, util.mjs:66-89) | "loadAccess refuses a missing file, a symlink and a directory" | M03 |
| owned by the instance's uid | 168 | "loadAccess refuses another owner and group or other write" | M01 |
| no group or other write | 169 | same | M02 |
| unknown keys refuse | 147, 71, 83, 121 | "top level", "users: unknown keys", "users: subjects", "grants: unknown keys" | M05 M10 M16 M22 |
| `accessVersion` is 1 | 148 | "top level" | M06 |
| `revision` positive integer | 149 | "top level" | M07 |
| user id matches the id rule | 72 | "users: unknown keys and the id rule" | M11 |
| never `human` or `tester` | 73 | "users: human, tester and any business's human" | M12 |
| never any business's `human` | 74 | same, both businesses | M13 |
| provider is a given provider | 84-86 | "users: subjects, provider, sub" | M17 |
| a pair appears once in the file | 90 | same, within one user and across two | M20 |
| a pair is never in `console.sso.subjects` | 89 | same | M19 |
| `home` is a given peer or `local` | 75-77 | "users: home is a given peer or local" | M14 |
| grant names a declared business | 126-128 | "grants: unknown keys, id, user and business" | M26 |
| `projects` is `["*"]` or UUIDs | 98-107, 130-132 | "grants: projects" | M27 M28 M29 M30 |
| `instances` is `["*"]` or declared instances | 98-107, 133-135 | "grants: instances" | M31 |
| `level` is `collaborator` | 136 | "grants: any level other than collaborator" (missing, null, wrong case) | M32 |
| `expires` optional ISO time | 111-117, 137 | "grants: expires is optional ISO time" | M33 M34 |
| past `expires` is absent | covers 179 | "covers: an expired grant doesn't match, at and after the instant" | M38 M39 |
`covers`: `*` and listed ids match (180-182), an unlisted project, instance or business doesn't, an
expired grant doesn't (179), and a level filter rejects another level (183). Mutants M38 to M44.
## Deviations from the brief
1. Expiry. The brief's test list says expiry refuses. DESIGN 3.5 line 257 says a grant past `expires` is
treated as absent. Sage ruled the design wins (sage-1399), so a past `expires` passes `checkAccess`
and `covers` returns false at and after the instant. A malformed `expires` still refuses.
2. The brief lists "a grant disjoint from the given subjects". 3.5 has no rule that ties a grant to a
subject, and a grant names a user, not a subject. I read it as the 3.5 lines 248-249 subject rules:
a `(provider, sub)` pair that is also in `ssoSubjects` refuses, and so does a pair that appears twice.
Duckworth, say if you meant something else.
## Rules I added beyond 3.5
Each one is fail closed and has a test and a mutant.
- User ids and grant ids are unique (154, 123; M21, M24). A grant's `user` names a user in the file
(125; M25). Without these, a grant could point at nobody or two users could share an id.
- `projects` and `instances` are non-empty, distinct, and `*` never mixes with ids (98-106).
- `expires` must be UTC with a `Z` and must round-trip (`2026-02-30T00:00:00Z` refuses). `Date.parse`
accepts that date and rolls it to March 2, so the regex alone wasn't enough.
- The context is checked too (43-67; M35 to M37). `peers` can't name `local`, because that would make a
peer user look local.
- `covers` throws a TypeError for a `now` that isn't a valid Date (M44), since a bad clock would
otherwise make every grant look unexpired.
## Checks
- `node --test packages/business/tests/access.test.mjs`: 24 pass, 0 fail (single file, no slot).
- Morgana's `access-levels.test.mjs` at 08bb4d827, as a cross-check, not part of the candidate. On this
candidate it passes 3 of 4, and the one failure is the viewer case 193a adds. In a scratch copy with
`LEVELS` widened to `["collaborator", "viewer"]` it passes 4 of 4.
- `mutants.py`: 44 mutants, one per refusal and covers branch. All 44 anchors match once and every
mutant passes `node --check`. **Not run yet**: mutants need Sage's slot. Run as
`python3 -I agents/gizmoduck/work/queue-201/mutants.py <checkout> <empty scratch dir>`. It runs a control
first, puts each run in its own process group with a 60 s timeout, and exits 1 if any mutant survives.
- `node tools/unslop-hook/unslop-check.js` is clean on all three code files and this file.
- No test reads or pins `src/index.mjs` by hash (grep of packages/*/tests and scripts/test-*.sh).
- Not run: the business suite and `scripts/test-*.sh`. Both need Sage's slot.
## Boundaries kept
Nothing touched `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any access.json. Tests write
only under `mkdtemp` scratch dirs.
+1
View File
@@ -0,0 +1 @@
ae3c36f24883e870541158d3ade1f5d3ef2ab856
@@ -0,0 +1,3 @@
74ac7e9ed38c230e60e7f7ece373104446c362b6fce0a4f92ca2210a981dc4f4 packages/business/src/access.mjs
7ee59b6e792fb47048fd06d227b65467ef71dabfcbdabdc0527b780656323d20 packages/business/src/index.mjs
3bec097f55909b31faf2bbc62c81e1d4d0290f50e696d3d3d367e77d82c5b238 packages/business/tests/access.test.mjs
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Row 201 (FED R1) mutants: one per refusal in packages/business/src/access.mjs.
Usage: mutants.py <checkout> <scratch-dir>
Copies packages/business and packages/plugins from <checkout> into
<scratch-dir>, applies one mutant at a time (each anchor must match exactly
once), runs `node --test tests/access.test.mjs` in its own process group with
a timeout, and expects it to fail. Prints one line per mutant and exits 1 if
any survives, an anchor misses, or the unmutated control fails. Needs Sage's
slot. Writes nothing outside <scratch-dir>.
"""
import os, shutil, signal, subprocess, sys
MUTANTS = [
# loadAccess file checks
("M01 owner check", 'if (stat.uid !== uid) refuse(', 'if (false) refuse('),
("M02 group/other write check", 'if ((stat.mode & 0o022) !== 0) refuse(`access file', 'if (false) refuse(`access file'),
("M03 one-fd nofollow read", 'const document = readJsonFile(file, "access file", (stat) => {',
'const document = JSON.parse(readFileSync(file, "utf8")); ((stat) => {'),
# top level
("M04 document object", 'if (!isPlainObject(document)) refuse("access file must be a JSON object");', ''),
("M05 top unknown keys", 'rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");', ''),
("M06 accessVersion", 'if (document.accessVersion !== 1) refuse(', 'if (false) refuse('),
("M07 revision", 'requirePositiveInt(document.revision, "revision");', ''),
("M08 users array", 'requireArray(document.users, "users").forEach(', '(document.users ?? []).forEach('),
("M09 grants array", 'requireArray(document.grants, "grants").forEach(', '(document.grants ?? []).forEach('),
# users
("M10 user unknown keys", 'rejectUnknownKeys(user, ["id", "home", "subjects"], at);', ''),
("M11 user id rule", 'requireId(user.id, `${at}.id`);', ''),
("M12 human/tester", 'if (RESERVED_USERS.includes(user.id)) refuse(', 'if (false) refuse('),
("M13 business human", 'if (humans.has(user.id)) refuse(', 'if (false) refuse('),
("M14 home", 'if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {', 'if (false) {'),
("M15 subjects non-empty", 'if (subjects.length === 0) refuse(', 'if (false) refuse('),
("M16 subject unknown keys", 'rejectUnknownKeys(s, ["provider", "sub"], sat);', ''),
("M17 provider", 'if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {', 'if (false) {'),
("M18 sub", 'requireText(s.sub, `${sat}.sub`);', ''),
("M19 owner subject", 'if (ctx.owners.has(key)) refuse(', 'if (false) refuse('),
("M20 duplicate subject", 'if (seenSubjects.has(key)) refuse(', 'if (false) refuse('),
("M21 duplicate user id", 'if (users.has(user.id)) refuse(', 'if (false) refuse('),
# grants
("M22 grant unknown keys", 'rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);', ''),
("M23 grant id rule", 'requireId(grant.id, `${at}.id`);', ''),
("M24 duplicate grant id", 'if (seenGrants.has(grant.id)) refuse(', 'if (false) refuse('),
("M25 grant user", 'if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(', 'if (false) refuse('),
("M26 grant business", 'if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {', 'if (false) {'),
("M27 scope non-empty", 'if (list.length === 0) refuse(`${where} must not be empty`);', ''),
("M28 scope * alone", 'if (list.length !== 1) refuse(', 'if (false) refuse('),
("M29 scope duplicate", 'if (seen.has(entry)) refuse(', 'if (false) refuse('),
("M30 project UUID", 'if (typeof p !== "string" || !UUID.test(p)) refuse(', 'if (false) refuse('),
("M31 declared instance", 'if (typeof x !== "string" || !business.instances.has(x)) refuse(', 'if (false) refuse('),
("M32 level", 'if (!LEVELS.includes(grant.level)) refuse(', 'if (false) refuse('),
("M33 expires format", 'if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {', 'if (typeof value !== "string") {'),
("M34 expires real time", 'if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(', 'if (false) refuse('),
# context
("M35 context unknown keys", 'rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);', ''),
("M36 context peers local", 'if (peers.has(LOCAL_HOME)) refuse(', 'if (false) refuse('),
("M37 context ssoSubjects array", 'requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach(', '(context.ssoSubjects ?? []).forEach('),
# covers
("M38 covers expiry", 'if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;', ''),
("M39 covers expiry boundary", 'now.getTime() >= Date.parse(grant.expires)', 'now.getTime() > Date.parse(grant.expires)'),
("M40 covers business", 'if (grant.business !== business) return false;', ''),
("M41 covers project", 'if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;', ''),
("M42 covers instance", 'if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;', ''),
("M43 covers level", 'if (level !== undefined && grant.level !== level) return false;', ''),
("M44 covers now check", 'if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw', 'if (false) throw'),
]
TIMEOUT = 60
def run(cwd):
proc = subprocess.Popen(["node", "--test", "tests/access.test.mjs"], cwd=cwd,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
try:
out, _ = proc.communicate(timeout=TIMEOUT)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.communicate()
return None, "timeout"
return proc.returncode, out.decode(errors="replace")
def main():
if len(sys.argv) != 3:
sys.exit("usage: mutants.py <checkout> <scratch-dir>")
checkout, scratch = (os.path.abspath(a) for a in sys.argv[1:])
if not os.path.isfile(os.path.join(checkout, "packages/business/src/access.mjs")):
sys.exit(f"no packages/business/src/access.mjs under {checkout}")
if os.path.exists(scratch) and os.listdir(scratch):
sys.exit(f"scratch dir not empty: {scratch}")
for pkg in ("business", "plugins"):
shutil.copytree(os.path.join(checkout, "packages", pkg), os.path.join(scratch, "packages", pkg))
pkg = os.path.join(scratch, "packages/business")
target = os.path.join(pkg, "src/access.mjs")
original = open(target).read()
code, out = run(pkg)
if code != 0:
print(out)
sys.exit(f"control failed (exit {code}): the unmutated tests must pass")
print("control: pass")
bad = 0
for name, old, new in MUTANTS:
n = original.count(old)
if n != 1:
print(f"{name}: ANCHOR {n}")
bad += 1
continue
mutated = original.replace(old, new)
if name.startswith("M03"):
mutated = 'import { readFileSync } from "node:fs";\n' + mutated
open(target, "w").write(mutated)
code, _ = run(pkg)
open(target, "w").write(original)
if code == 0:
print(f"{name}: SURVIVED")
bad += 1
else:
print(f"{name}: killed ({'timeout' if code is None else f'exit {code}'})")
print(f"mutants {len(MUTANTS)}, not killed {bad}")
sys.exit(1 if bad else 0)
if __name__ == "__main__":
main()
-10
View File
@@ -1,10 +0,0 @@
# #436b build carry items
The design is approved at r5: 1be535aca, DESIGN.md sha256 68df02cdf890564c6ed0a5426204114389ba7eb0eea1cf04f1bf5938b6de45f4. Dewey gave the approval in dewey-0763 (verdict sha256 de52be1b…6b68). Section 7 is approved at fa5658941 by duckworth-0067 and is unchanged since. DESIGN.md stays as approved. The row's build takes the items below, and its BUILD.md cites this file.
- N9 (dewey-0763). Revoke writes `grant.revoked`, not a `grant.step`. A restart after a revoke on an open rotation therefore still sees a `commit` with no settle after it, and settles again: no file, `grant-lost`, a wrong message to Jason, and repeated deletes that come back 404 and go to `serviceLeft`. Two fixes:
- The section 5 restart row "`commit`, rotation, settle-pending" takes the held row's clause "with no `grant.revoked` after it".
- A 404 on a delete by id counts as done, not as a `serviceLeft` entry. That applies to revoke, sweep and the settle deletes.
- Add a test: revoke on an open rotation, then a restart. No settle runs, no Gitea call is made, and the restart sends no message about it.
- N10 (dewey-0763). `grant.revoked {serviceLeft}` can now hold two ids, so it is a list of ids everywhere. `sweep` reads it as one.
- duckworth-0067 N1 is in r4 (section 5, X-G15). Nothing more is carried for it.
-640
View File
@@ -1,640 +0,0 @@
# Gitea grant path, design draft r5 (Vikunja #436b, Gitea #1697 follow-up)
Author: Megavolt. Reviewer: Dewey. GitHub half: Bushroot. Base: Gizmoduck's #436a r6 (f9039682e, final,
`agents/gizmoduck/work/machine-grant/DESIGN.md` on origin/gizmoduck/machine-grant-design) and row 199's
DESIGN.md section 7a (e10e03f6b). Mos rulings used here: mos-1130 and mos-1136.
This draft builds nothing. It says how an Approve in Jason's inbox makes an agent's Gitea user, its token and
its forks with no manual step, and it answers mos-1130 item 3 (a to e) on the executor's admin credential.
## r5 changes (dewey-0761 on r4 3e66f5acf)
Section 7 is unchanged from fa5658941.
- Item 1 (blocks). A revoke on an open rotation deletes both tokens. When the last `grant.step` is a
rotation's `commit` with no settle after it (settle-pending or `grant-token-unknown`), section 6 step 3
deletes `previousTokenId` and the minted `tokenId`, each failure to `serviceLeft`. Section 5's
`grant-token-unknown` text now says both (X-G5).
- N8. Step 0 refuses `grant-token-unknown` while the agent is held, so a revoke is the only way out.
## r4 changes (dewey-0759 on r3 fa5658941, sage-1578, duckworth-0067)
Section 7 is unchanged from fa5658941, the text Duckworth approved and Gizmoduck cites.
- Item 1 (blocks), option (b), Sage's ruling in sage-1578. A step 0 refusal leaves the decision unspent only
until the next start. #436a r6 X3's start reconcile spends every resolved-yes grant decision with no
`action.allowed` as `interrupted`, and this draft no longer claims otherwise. `grants run` works while the
bus that refused is still up; after a restart the chief raises again and Jason approves again. `status`
says so on each open decision (D5, section 4 step 0, section 5, X-G11). (a) would change #436a r6, which is
final. The cost is in D5.
- Item 2 (blocks). A settle that gets 200 with an `id` that is neither token no longer leaves the file in use.
Memory drops the credential (199 `revoke`'s memory step), the file and both recorded tokens stay, nothing
is deleted at Gitea, and the step ends `grant.failed {code: 'grant-token-unknown'}` for Jason. A restart
keeps the credential out of memory (`Credentials` `held`, built in #436b's row like `reload`). Revoke clears
it (section 5, X-G6).
- N7. 3d says the private session-less writer runs `#secretCheck` on every guarded body, as `#event` does
(`broker.mjs:559`). The sentence is in 3d, so section 7 doesn't change.
- duckworth-0067 N1. Every reader of grant evidence filters on `kind = 'action.allowed'` and the grant actor
pair, so a `grant.*` name under another kind is not evidence (section 5, X-G15).
## r3 changes (dewey-0756 on r2 6db7cc45d, gizmoduck-0081 and -0084, duckworth-0065, mos-1144)
- B1 (blocks). Only `#consumeGrant`'s own record carries `decision`. Every other `grant.*` body names the
decision `grantDecision`, so `#spent` sees exactly one spend. A step 0 or step 1 refusal leaves the
decision unspent (section 7, X-G11, a mutant).
- B2 (blocks). Settle with the seat token is proven on scratch (F13): `GET /api/v1/token` answers 200 with the
minted id and 401 after the token is deleted. Only a 401 ends `grant-lost`. 403, 404, 5xx, a timeout, an
unreachable Gitea or a failed C1 to C4 leave the step open as `settle-pending`, delete nothing, and retry
on the next start and in `grants sweep` (section 5, X-G6). `Credentials.reload` is built by #436b's own
row in `packages/bus/src/credentials.mjs`; row 199's candidate doesn't change.
- B3 (blocks). Step 5 compares against the user's forks of every upstream in the agent's `publish` list, not
only `target.projects`. The rendered block names those other upstreams as reach the token may have. A fork
whose upstream has left `publish` refuses `gitea-reach-wider` with its name (section 2, step 5, X-G12).
- Section 7, the guard (gizmoduck-0081, -0084, duckworth-0065): one pattern, `grant.*`, on `body.action` and
on `body.operation` (Dewey N2: the consume record's `operation: 'grant.<method>'`). #436a renames its
`host.enroll.step`, `host.enrolled`, `host.checked` and `host.removed` into `grant.host.*`, so row 210's
`host.sudo` and `host.run` stay outside. The grant actor is `actor_role NULL, actor_run 'grant'`, which no
session or human event can produce (answers Dewey N1, same safety as `_bus`).
- D12 / Q-B4 closed by mos-1146 (section 8).
- N3. Step 5 reads every page of `/user/repos` and checks the total. N4. One nonce format, and the adopt
check compares the lowercased login. N5. `grants run` takes the `#inflight` claim. N6. One open-decision
query, `openGrantDecisions`, for `status`, rotate's void count and the start reconcile (section 7).
## r2 changes (from r1 714c07304, Dewey's dewey-0754, verdict sha256 20d63dfb…)
- D1 (blocks). `gitea_users` records the Gitea user `id`, and every later grant, rotation and fire compares it
(step 2). A nonce goes in `grant.step user` before the create and in the user's `full_name`, so a restart
adopts a user it made and refuses anything else (section 5, X-G6). Both are proven on scratch (F8, F9).
- D2 (blocks). The audit records are `action.allowed` bodies with reserved `grant.*` actions, plus a trigger
in the store migration that only `system/grant` writes them (section 7). A call and its result are two
records paired by `callId`, because `events` is append-only (3d). To settle once with Gizmoduck for #436a.
- D3 (blocks). Seat-token rotation gets its own rows. After `issue` has renamed the file, the executor settles
by asking Gitea which token the file holds (`GET /api/v1/token`, F7), and goes forward or back on that
answer, never by guessing (section 5).
- D4 (blocks). 3c and section 8 now state the real gap. Reading the admin file gives instance admin, which is
more than a forged Approve gives. `write:admin` resets any user's password, email and admin flag, proven on
scratch (F10). The separate uid is deferred on cost, and the draft says so.
- D5. The admin checks run before `#consumeGrant`, so a predictable refusal leaves Jason's Approve unspent. A
human `mosaic grants run <decision>` starts it again (step 0). The raise refuses when the admin slot is past
its rotate-by date. r4 (sage-1578): unspent only until the next start, when #436a r6 X3's reconcile spends
it as `interrupted`. The cost: if a step 0 refusal is followed by a restart before Jason rotates the admin
token or upgrades Gitea, the chief raises again and Jason approves again.
- D6. One list of start checks (3a, 3e). The "no organization" claim is now part of the bootstrap promise,
and the check is that `GET /user/repos` is empty, which catches org and collaborator repositories (F11, and
F12 for a site-admin token).
- D7. Admin rotation: a timestamped name (F9: names are unique per user), `--rotate-by` required, and `setAt`
changes, so open approvals are voided and `status` says so.
- D8. The restart's remote undo runs the same admin checks and writes the same call records.
- D9 (Q-B1). On fire: `prohibit_login` (F10) and delete every token the user has, not only the recorded one.
- D10 (Q-B2). Step 5 checks the seat token's real reach with `GET /user/repos` (F11). Section 8 says reach can
grow later.
- D11. One Gitea per business, stated, and checked at `set` and at raise.
- D12. Section 8 asks for the jason.woltje comparison. I can't check it myself.
## 0. What this adds to #436a
#436a section 11 left `method: "gitea"` for a later draft, because the executor needs an admin credential and
roles may never hold the admin scope (`packages/business/src/vocabulary.mjs:108-112`). That rule stays. The
admin credential here is not a role service: no `useFor` reaches it, no definition can list it, and only the
executor reads it.
Everything in #436a's sections 4 and 5 holds unless this draft says otherwise: the `credential.mint` raise
with a `grant` body, chief only (R3), the rendered block (R4), Jason's Approve via cli (R5, X1),
`#consumeGrant` with `operation: 'grant.gitea'`, the `#inflight` claim and abort (X5), `grant.step` records and
restart reconcile (X3), and revoke local first (section 6).
What row 199 already gives: the agent's `identity = {name, email, gitea: {url, user}}`, its
`publish: [project]` grant and each project's `remote` (199 D8, D9), the store and `issue(instance, service,
token, date, commit)` (`packages/bus/src/credentials.mjs:156`), and section 7a's rule that `issue` accepts a
resolved `credential.mint` decision as the human path. `issue` writes the file (tmp, fsync, rename), then calls
`commit`, then fills memory (`credentials.mjs:156-193`). Section 5 depends on that order.
## 1. Facts, proven on a scratch Gitea
Run on gitea/gitea:1.27.1 (sha256:fe4e4b1c8b93) on 127.0.0.1, throwaway data, container removed after each
run. Scripts and logs: `/mnt/storage/scratch/megavolt-r199/g436b/`. No log holds a token or password value.
F1 to F6 were reported in megavolt-0034, and Mos accepted them in mos-1136.
- F1 (`run4.log`). `POST /api/v1/users/{u}/tokens` with `Authorization: Basic <admin>:<admin token>` creates a
token for another user: 201. The same token in a `token` or `Bearer` header gets 401 on that route, while the
`token` header works on `GET /api/v1/user` (200). So the executor sends Basic on the token routes and
`token` everywhere else.
- F2 (`run4.log`). The child-scope cap: a child scope broader than the parent's gets 403 ("cannot create an
access token with a broader scope than the authenticating token"), for `write:organization` and for `all`.
A narrower one gets 201.
- F3 (`run4.log`). TOTP 2FA on the admin account blocks password Basic (401) and doesn't block token Basic
(201).
- F4. The route's check is `reqSelfOrAdmin`: whether the account is a site admin, not the token's scope. Any
`write:user` token on a site-admin account can mint a token for any user.
- F5. `write:X` implies `read:X` under Gitea's `HasScope`.
- F6. The cap is Gitea #38406, backported in #38426 (de4b8277e9 on release/v1.27, 2026-07-12). It first ships
in v1.27.0. v1.27.0-rc0 lacks it, and so does every 1.24.x, 1.25.x and 1.26.x through 1.26.4.
- F7 (`introspect.log`). `GET /api/v1/token` with the `token` header gives 200 with `id`, `name`, `scopes`,
`user`, `created_at` and `last_used_at`, and no token value. With Basic it gives 404. Scopes come back
normalized (3b).
- F8 (`r2probe.log` 1a-1d). `POST /admin/users` with a `full_name` gives 201 with the user's `id` and that
`full_name`. `GET /users/SEAT-ONE` finds `seat-one` with the same `id`: names match case-insensitively. A
create of `Seat-One` next to `seat-one` gets 422.
- F9 (`r2probe.log` 2a-2c). A second token with a name already used by that user gets 400. `GET
/users/{u}/tokens` with Basic lists the user's tokens.
- F10 (`r2probe.log` 4a-4f, 5a). With `write:admin`, `PATCH /admin/users/{u}` on another site admin sets a new
password (200; the new password then signs in, the old one gets 401) and a new email. On a plain user it sets
`admin: true`, then `admin: false` with `prohibit_login: true`. Without `write:admin` the same PATCH gets 403.
- F11 (`reach.log`). With the seat token (`write:repository`, `read:user`), `GET /user/repos` lists the user's
own fork, then also a repository it was made collaborator on, then also an org repository after it joined an
org team. `GET /user/orgs` and `GET /users/{u}/orgs` need `read:organization`, which neither token holds.
- F12 (`c4.log`). With a site-admin account's three-scope token, `GET /user/repos` is empty on an instance
where other users and an org it isn't in hold public and private repositories: being site admin doesn't add
them. It lists a repository once the account is a collaborator, and an org repository once it joins that
org's team. Joining an org with no repositories leaves it empty, and `GET /user/orgs` gets 403 (no
`read:organization`).
- F13 (`b2.log` b1-b7). Two seat tokens minted (201, ids 2 and 3). `GET /api/v1/token` with each token in the
`token` header gives 200, and the `id` is the minted one for both. After `DELETE` of seat-a's token (204), the
same call with it gives 401. After `prohibit_login` on seat-b (200), the call with seat-b's token gives 403
("This account is prohibited from signing in"), not 401. A value never issued gives 401. So 401 is the only
answer that means the token is gone; 403 means the account is shut, and section 5 leaves it `settle-pending`.
## 2. The binding
The shared part (section 10) is proposed to Bushroot in megavolt-0036. This section is the Gitea half.
```
grant: { "method": "gitea", "agent": "<instance>", "rotateBy": "YYYY-MM-DD",
"target": { "projects": ["<project>", ...] } }
```
Code fixes the scopes of the agent's token: `write:repository` and `read:user`, row 199 A2's set. The raise
can't name scopes, so the chief can't widen them.
Checks at raise, inside the raise transaction. Each refusal stores nothing:
- `agent` is a role instance of this business, its definition declares `{"service": "gitea"}`, and it has
`identity` and a `publish` grant (199 D8). Otherwise `gitea-not-declared`.
- Each project in `target.projects` is in the agent's `publish` list (`remote-not-granted`), and its
`remote`'s origin equals `identity.gitea.url`'s (`remote-host-mismatch`), the same checks as 199 D9 step 2.
- The business has an admin slot for `gitea` whose recorded `url` has the same origin
(`grant-admin-missing`), and the slot isn't past its rotate-by date (`grant-admin-rotation-due`).
- `rotateBy` is inside 199's rotation window.
The rendered block, appended by the broker as #436a R4 does: agent, Gitea user, email, Gitea URL, each fork
URL it will make (`<url>/<user>/<repo>`), the two fixed scopes, the rotate-by date, one of "new Gitea user",
"existing stack-made user, new token" or "token rotation" (step 2), and the line "The token can write every
repository this user can write. Step 5 checks that this is only its forks." Then, when the agent's `publish`
list has projects outside `target.projects`, the line "It may also reach this user's existing forks of:" and
each such upstream. The raise makes no network call, so the block names every such upstream, not only those
with a fork (B3). The 4096-character cap applies as in R4.
The raise event body gets `grant = {method, agent, target, rotateBy, definitionSha256, identitySha256,
adminSha256}`:
- `definitionSha256` as in #436a.
- `identitySha256` over the agent's `identity`, its `publish` list and the `remote` of every project in
`publish` (step 5 checks against all of them).
- `adminSha256` over the admin slot's metadata (`url`, `account`, `setAt`), never over the token.
At step 1 any of the three that differs refuses `decision-mismatch`. So a changed identity, publish list,
upstream or admin credential between raise and Approve voids the Approve (REQ-GRANT-2).
## 3. The executor's admin credential (mos-1130 item 3)
### 3a. A dedicated account
One Gitea account per Gitea instance, named `mosaic-grant` by default. It is a site admin and has TOTP 2FA on
(F3: the token path still works). Its password and TOTP secret stay with Jason and never reach the stack. It
is never jason.woltje or any other human's account. It owns no repositories, belongs to no organization and is
no repository's collaborator.
The admin checks, one list, run at `set` (3e), before every executor start (step 0) and before every remote
undo (section 5):
- C1. `GET /api/v1/version` is at least 1.27.0 (F6), compared as numbers, or `gitea-too-old`.
- C2. `GET /api/v1/user` gives `login == account` as recorded at `set`, and `is_admin` true, or
`grant-admin-account`.
- C3. `GET /api/v1/token` gives exactly the three scopes (3b), or `grant-admin-scope`.
- C4. `GET /api/v1/user/repos` is empty, or `grant-admin-not-dedicated`. By F11 and F12 this list covers
owned, collaborator and org-team repositories, and site admin alone doesn't fill it.
What code can't check: that nobody else uses the account, and org membership with no repositories (F11: the
orgs routes need `read:organization`, which the admin token doesn't hold and shouldn't). Both are the bootstrap
step's promise and go in the approval notes (section 8).
### 3b. The scopes, each shown needed
The admin token holds exactly `write:admin`, `write:user` and `write:repository` (mos-1136 item 1). From
`matrix.log`, with each scope dropped in turn:
| Parent token | A1 create user | A2 mint seat token | A3 fork (seat token) | A5 delete seat token |
|---|---|---|---|---|
| all four | 201 | 201 | 202 | 204 |
| without `write:admin` | **403** | 201 | 202 | 204 |
| without `write:user` | 201 | **403** | 202 | **403** |
| without `write:repository` | 201 | **403** (cap, F2) | 202 | 204 |
| without `read:user` | 201 | 201 | 202 | 204 |
- `write:admin` is needed for A1, and for `prohibit_login` on fire (F10).
- `write:user` is needed for A2 and A5.
- `write:repository` is needed only because of the cap. The seat's token holds `write:repository`, and a
parent must hold at least what it mints. The executor itself never writes a repository with it.
- `read:user` is covered by `write:user` (F5), so it is dropped.
C3 reads the scopes with the `token` header (F7). Gitea reports normalized scopes, so a token made with the
three plus `read:user` reads as the three. That is harmless, because `read:user` adds nothing to `write:user`.
A token made with `all`, or with another category, refuses.
### 3c. Where it lives, who can read it
- The token: `<configDir>/secrets/grants/<business>/gitea/admin`, 0600, in 0700 dirs, owned by the stack's
OS user. Written and checked by row 199's store code (D1: tmp, fsync, rename, the mode and owner checks).
- The metadata: `admin.json` next to it, 0600, holding `url`, `account`, `rotateBy` and `setAt`. No secret.
- A business grants on one Gitea. `set` refuses a second `url` while a slot exists (`grant-admin-exists`;
`remove` first), and the raise checks the agent's Gitea against the slot's `url` (section 2). Two businesses
on one Gitea each hold their own token for the same `mosaic-grant` account.
Who can read the file: the stack's OS user, and root.
Whether an agent process runs as that uid:
- On dragon-lin, yes. The stack runs as jwoltje, and so does every agent process on the machine: 13 claude, 4
codex and 2 pi processes, the bwrap sandboxes and node, counted 2026-10-20. That includes the T3 dev seats,
which are not sandboxed and can read any jwoltje file.
- Stack workers run in the seat sandbox, which has a private HOME and no `~/.config`, `~/.mosaic` or `~/.ssh`
(`packages/seat/src/sandbox.mjs`). A sandboxed worker can't reach the file by path. The sandbox doesn't
confine the network or the host uid (`sandbox.mjs:29-31`). So the file is safe from a sandboxed worker and
not from an unsandboxed process running as the same uid.
- On the .53 and .54 installs (#432) the stack runs as its own OS user. Workers run sandboxed as that user.
The T3 seats don't run there.
What a separate uid would buy. A same-uid process has two paths. It can forge a human proof (`verifyHuman` is
a cooperative same-UID check, `packages/bus/src/human.mjs:50-52`), and an approved grant then gives it a
two-scope token for a stack-made, non-admin user, because step 2 refuses foreign and admin users. Or it can
read the admin file, which gives instance admin (section 8). A separate uid for the file and the executor would
close the second path and leave the first. That is a real step up, from "a seat token for a stack user" to "no
admin".
I defer it on cost, not because it wouldn't help. It means the executor runs as its own process under its own
uid, with an IPC path from the bus that carries the decision id and nothing the bus could forge. That is a row
of its own, and it is cleaner after the human proof stops being same-uid (#435 behind Q175 sign-in). Jason
approves the gap as section 8 states it.
### 3d. Every use logged, and rotation
Every Gitea call made with the admin token, and every call the executor makes with a seat token, writes two
records (section 7 has where they live):
```
grant.call { callId, reason, method: "gitea", op } written before the call
grant.result { callId, status } written after it
```
- `op` is one of `version`, `self`, `token-scopes`, `repos-check`, `user-get`, `user-create`, `user-patch`,
`token-list`, `token-create`, `token-delete`, `admin-rotate`, `fork-get`, `fork`, `seat-self`,
`seat-repos`, `token-whoami`.
- `reason` is the decision id for a grant or its undo, the `grant.revoked` record id for a revoke, the
`grant.sweep` record id for a sweep, or `admin:<verb>` with the human actor for `set`, `rotate` and `remove`.
- No body, no header, no token, no URL query. `Credentials.assertClean` (`packages/bus/src/credentials.mjs:233`,
the runtime's `secretCheck`) runs on both. The private session-less writer of section 7 runs `#secretCheck`
on every guarded body it takes, `grant.step`, `grant.applied`, `grant.failed`, `grant.revoked`,
`grant.sweep` and the consume record included, as `#event` does (`packages/bus/src/broker.mjs:559`) (N7).
- `events` is append-only (`events_no_update`, `packages/bus/src/migrations/3b.sql:131`), so the result is its
own record, written in the transaction of the step's next `grant.step` where there is one. A `grant.call`
with no `grant.result` means the process died during the call. Reconcile lists each one in its message.
Rotation of the admin token: a human-only verb, `mosaic grants admin rotate gitea --rotate-by <date>`. It never
runs from an agent, the chief or the tester, and it doesn't need a decision because only `verifyHuman` can
start it:
1. C1 to C4 with the current token.
2. Mint a successor for the admin account itself: `POST /users/<account>/tokens` with Basic and the same three
scopes, named `mosaic-grant-<UTC yyyymmddThhmmssZ>`, so a second rotation on one day doesn't collide (F9).
F2's cap holds it to the current token's scopes.
3. C2 and C3 with the successor.
4. Write it through 199's tmp, fsync and rename. Update `admin.json` with the new `rotateBy` and a new `setAt`.
5. Delete the old token at Gitea by name, with the new token. If that fails, `status` shows
`adminTokenLeft: <name>` until `rotate --finish` succeeds.
A new `setAt` changes `adminSha256`, so every grant decision raised before the rotation and not yet run
refuses `decision-mismatch` (fail closed). `rotate` prints how many open grant decisions that voids, and
`status` lists them.
`status` shows the rotate-by date. Past it, the raise and the executor refuse `grant-admin-rotation-due` until
Jason rotates. A forgotten rotation stops grants rather than letting an old token run on.
If the admin token leaks, Jason deletes it in Gitea (Settings, Applications, as `mosaic-grant`) or disables
the account, then runs `mosaic grants admin remove gitea`, which unlinks the slot. Live agent tokens keep
working. Each one is revoked on its own (section 6).
### 3e. The one bootstrap step
Once per Gitea instance per stack, at #436b deploy. This is the only manual credential step left, and
Jason's approval names it:
1. In Gitea, Jason creates `mosaic-grant` as a site admin with no repositories and no organizations, turns on
TOTP, and as that user creates a token with `write:admin`, `write:user` and `write:repository`.
2. On the stack host, as the stack's OS user:
`mosaic grants admin set gitea --url <url> --account mosaic-grant --rotate-by <date>`, with the token on
stdin (never argv).
`set` is human-only (`verifyHuman`). It runs C1 to C4 (3a) before it writes anything, and any failure writes
nothing.
## 4. The executor's steps
0. **Start and pre-checks.** X1 starts the executor on Jason's yes. It claims `(agent, "gitea")` in
`#inflight` (#436a X5), then runs C1 to C4 and the admin rotate-by check. A refusal here writes
`grant.failed {code, grantDecision}` and leaves the decision unspent (section 7's spend rule). After the
fix (a rotation, an upgrade), Jason runs `mosaic grants run <decision>` (human-only, `verifyHuman`), which
starts step 0 again, claim included. That holds only while the bus that refused is up: at the next start,
#436a r6 X3's reconcile spends every resolved-yes grant decision with no `action.allowed` as
`interrupted`, a step 0 refusal included, and the chief raises again (D5, sage-1578). `status` lists each
such open decision with `spentAtNextStart: true`. While the agent is held (`grant-token-unknown`, section
5), step 0 refuses `grant-token-unknown`, so a revoke is the only way out (N8). While a start or another `grants run` holds the claim it refuses
`grant-busy` before any call, as #436a X2's `host-busy` does. It refuses `decision-consumed` for a spent
decision. No other path starts it. The tag is #436a X2 step 2's, over business, agent and `"gitea"`.
1. **Recheck and consume**, in one transaction: `#consumeGrant` with `operation: 'grant.gitea'`, the
definition still declares `gitea`, the three hashes match (section 2), no revoke of `(agent, gitea)` newer
than the raise. A failure rolls the transaction back, so the decision is not spent, and then writes
`grant.failed {code, grantDecision}`. Nothing remote has changed. `grants run` may retry it, but these
refusals (`decision-mismatch`, a newer revoke, the definition) recur until the chief raises again.
2. **User.** `GET /users/{user}` (F8: case-insensitive).
- 404: write `grant.step {step: 'user', nonce}`, where `nonce` is 16 random lowercase hex digits. Then
`POST /admin/users` with the agent's `gitea.user` and `email`, `full_name` = `<identity.name> [mosaic
<nonce>]`, a random password that is never stored, and `must_change_password: false`. Then, in one
transaction, record `gitea_users {user, id, nonce}` with the `id` from the response (F8) and
`grant.result`.
- 200, and `gitea_users` has this business and agent with the same `id`: go on. That is a second grant or a
rotation. A user deleted at Gitea and remade by a human under the same name has a new `id`, and refuses.
- 200 with any other `id`, or no `gitea_users` record: refuse `gitea-user-foreign`. The executor never mints
a token for a user this stack didn't create. Without this check an `identity` naming a human's account,
jason.woltje included, would get a token minted for that account on Approve.
- The user is a site admin, or has `prohibit_login`: refuse `gitea-user-admin` or `gitea-user-disabled`.
3. **Token.** Write `grant.step {step: 'mint', tokenName}`, with the name `mosaic-<tag>-<decision>`. Then
`POST /users/{user}/tokens` with Basic and scopes `write:repository` and `read:user`. The value is held in
memory only. Write `grant.step {step: 'minted', tokenId}`.
4. **Forks.** With the new token, for each project: `GET /repos/{user}/{repo}`. If it exists, its `parent`
must be the project's upstream, or `fork-foreign`. If it's 404, `POST /repos/{owner}/{repo}/forks`.
Push-to-create stays off on the Gitea side (199 N11).
5. **Verify.** With the new token: `GET /api/v1/user` gives `login == user` and the recorded `id`. Then
`GET /api/v1/user/repos?limit=50&page=N` from page 1 until a page holds fewer than 50, with the count
equal to `X-Total-Count`. More than 20 pages, or a count that disagrees, refuses `gitea-reach-unknown`.
Every listed repository must be owned by this user and have as `parent` the upstream of a project in the
agent's `publish` list (F11). Anything else refuses `gitea-reach-wider` and names it: a repository from a
collaborator or org-team add, or the user's old fork of an upstream that has left `publish`. So the token
never reaches past the rendered block (B3).
6. **Go live.** Write `grant.step {step: 'commit', previousTokenId}` (`null` for a first grant). Check the
abort signal, and with no `await` between, call 199 `issue(agent, "gitea", token, rotateBy, commit)`.
`commit` writes the `gitea_grants` record (tokenId, tokenName, projects, rotateBy, decision). The reference
is registered first (#436a X4).
7. **Old token.** For a rotation, `DELETE /users/{user}/tokens/{previousTokenId}` after the new one is live.
A failure adds `serviceLeft: <id>`, which `sweep` clears.
8. **Applied.** `grant.applied {grantDecision, agent, user, userId, tokenId, forks}` and a message to the human and
the chief. The token appears in no record, message or log.
The claim is released in a `finally`.
## 5. Failure and restart
Ordered so a failure leaves nothing an agent can use. "Delete the token" is `DELETE /users/{user}/tokens/{id
or name}` with the admin token, after C1 to C4, with `grant.call` and `grant.result` whose reason is the
decision (D8). Best effort; a failure adds `serviceLeft`.
**Settle.** Once `issue` has renamed the file, the file may hold the new token or the previous one, and the
record may name either. The executor and the restart never guess. They read the store file and ask Gitea which
token it is: `GET /api/v1/token` with that value in the `token` header (F7 for admin tokens, F13 for the
two-scope seat token; op `token-whoami`). The answer is an `id`, never a value.
- 200, `id` is the new token: forward. The `gitea_grants` record gets the new token (if `commit` hadn't
run), memory loads the file (`Credentials.reload(instance, service)`, a new method with load's checks,
built in #436b's row; row 199 doesn't change), and the previous token is deleted.
- 200, `id` is the previous token: back. The new token is deleted. The record keeps or gets the previous
token.
- 401: the file's token no longer exists at Gitea (F13 b4). The previous token can't be in the file, so the
agent holds nothing usable. Delete both by id, unregister, delete the record, unlink the file, and end
`grant.failed {code: 'grant-lost'}`. A new decision is needed. No file at all is the same case.
- 200, `id` is neither token: the file holds a live token this grant didn't mint and step 5 never checked.
Memory drops it (199 `revoke`'s memory step) and the agent gets `credential-unavailable`. The file, the
record and both recorded tokens stay, and nothing is deleted at Gitea. The step ends `grant.failed {code:
'grant-token-unknown', grantDecision, tokenId}`, where `tokenId` is the id Gitea gave, and Jason gets a
message. `status` shows `tokenUnknown`. At each start, before `Credentials` loads, the reconcile passes such
`(agent, "gitea")` pairs as `held` (a `Credentials` option built in #436b's row, like `reload`), so memory
never loads that file. A revoke (section 6) clears it: it unlinks the file and deletes both recorded tokens.
The unknown token is Jason's to look at.
- Anything else is `settle-pending`, and nothing is deleted: 403, 404, 5xx, a timeout, Gitea unreachable, or
C1 to C4 failing first. The last `grant.step` stays `commit`, the
`grant.call` without a `grant.result` stays listed, and `status` shows `settlePending`. The file stays as
it is, and the agent can use it: it holds one of the two tokens, and both passed step 5. The next start
and `mosaic grants sweep gitea` settle again. Only a 401 deletes.
| Fails at | State left | Cleanup |
|---|---|---|
| 0 | nothing, the decision unspent until the next start | none. `grants run` retries while this bus is up; after a restart the reconcile spends it (`interrupted`) |
| 1 | nothing | none |
| 2, before the create | nothing | none |
| 2, after the create, before the record | a user with the nonce | none here. Restart adopts it by the nonce (below) |
| 3, before `minted` | maybe a token nobody holds | delete the token by its recorded name |
| 4 or 5 | a token, maybe some forks | delete the token. Forks stay; they hold only upstream content |
| 6 before the rename, first grant | a token | unregister, delete the token |
| 6 after the rename, first grant | a store file with the new token, maybe the record | unlink the file, unregister, delete the record if written, delete the token |
| 6 before the rename, rotation | the previous grant intact | delete the new token |
| 6 after the rename, rotation | the file holds the new token, memory the previous | settle (forward: the new token passed step 5) |
| 7 | both tokens live, the new one in use | `serviceLeft: <previousTokenId>` |
**Restart**, before `Credentials` loads, from the last `grant.step`, as #436a X3. The local part runs first;
the remote part runs after the bus is up, with C1 to C4.
| Last step | Local | Remote |
|---|---|---|
| `user` | none | `GET /users/{user}`. 200 with `full_name` ending `[mosaic <nonce>]`, not admin: adopt, record `gitea_users` with its `id`. 404: none. Anything else: `gitea-user-orphan` with the name, for Jason to look at; nothing is deleted |
| `mint` or `minted` | none | delete the token by name |
| `commit`, first grant | unlink the file if present, delete the record if present | delete the new token |
| `commit`, rotation | none | settle |
| `commit`, rotation, settle-pending | none; memory loads the file as usual | none. The step stays open, and the next start or `grants sweep` settles again |
| `grant.failed {code: 'grant-token-unknown'}` with no `grant.revoked` after it | pass `(agent, "gitea")` as `held`; memory doesn't load the file | none. Jason's message repeats until a revoke |
Every reader of grant evidence (this reconcile, `status`, `openGrantDecisions`'s callers, `grants sweep`)
reads a `grant.*` record only where `kind = 'action.allowed'` and `actor_role IS NULL AND actor_run IS
'grant'`, so a `grant.*` name under another kind or actor is never evidence (duckworth-0067 N1).
Then `grant.failed {code: 'interrupted'}` (or `grant.applied` if a rotation settled forward) and a message to
Jason. A `grant.call` with no `grant.result` is listed in that message. After `interrupted`, the chief raises
again and Jason approves again.
## 6. Revoke
As #436a section 6, with the same revokers (the human, the assistant in its own organization, the tester on
dev only):
1. 199 `revoke`: memory, abort a use or the executor in flight, unlink the file.
2. One store transaction deletes the `gitea_grants` record and records `grant.revoked`.
3. After C1 to C4, `DELETE /users/{user}/tokens/{tokenId}` with the admin token. If Gitea can't be reached,
`grant.revoked {serviceLeft: <tokenId>}`, and `mosaic grants sweep gitea` clears it later. When the last
`grant.step` is a rotation's `commit` with no settle after it (settle-pending, or `grant-token-unknown`),
two tokens are live and the record may name either (section 5), so the revoke deletes both
`previousTokenId` and the minted `tokenId`, each failure listed in `serviceLeft` (dewey-0761). The id
`grant-token-unknown` named is not one this stack minted; it stays for Jason.
That closes 199 D12's gap: the token is gone at Gitea without Jason's A5 step.
The Gitea user and its forks stay after a revoke, so a new grant can reuse them.
On `credentials delete` (a fire), after the steps above and with the recorded `id` checked first:
- `GET /users/{user}/tokens` with Basic (F9), and delete every token it lists, not only the recorded one. A
token the record doesn't know about doesn't outlive a fire.
- `PATCH /admin/users/{user}` with `prohibit_login: true` (F10).
- Each step that fails is listed in `serviceLeft`, and `sweep` retries it.
## 7. Store records
Two tables, in #436a S1's migration if #436a row b and this row land together, otherwise a `3j-3k.sql`:
- `gitea_users`: business, agent, user, id, nonce, the decision that created it.
- `gitea_grants`: business, agent, user, tokenId, tokenName, projects, rotate-by date, decision.
The executor, revoke and `sweep` are the only writers, each in the transaction of its record. The admin slot
is a file, not a table, so a store backup holds no admin token.
The audit records (`grant.step`, `grant.call`, `grant.result`, `grant.applied`, `grant.failed`,
`grant.revoked`, `grant.sweep`) are `action.allowed` events with that name in `body.action`. They are not new
event kinds, so `events` and its CHECK (`3b.sql:9-30`) aren't rebuilt. This section is the rule's only home
(mos-1144). #436a r6 (f9039682e) uses it too: #436a row a's BUILD.md cites this section at the commit Duckworth
approves and maps r6's names to `body.action` values, with these renames (gizmoduck-0084):
`host.enroll.step` to `grant.host.enroll.step`, `host.enrolled` to `grant.host.enrolled`, `host.checked` to
`grant.host.checked`, `host.removed` to `grant.host.removed`. Cites are origin/refactor 00d4cc1f6.
**The guarded namespace** is one pattern: an `action.allowed` whose `body.action` or `body.operation` GLOBs
`'grant.*'`. `operation` is included because `#consumeGrant`'s consume record is
`{operation: 'grant.<method>', decision}` (#436a X1), and a forged one would spend a decision (Dewey N2). Row
210's `host.sudo` and `host.run`, written from agent sessions, stay outside it, and no grant-path name is left
under `host.*`.
**The grant actor** is `actor_role IS NULL AND actor_run IS 'grant'`. The only production insert into `events`
is `#event` (`packages/bus/src/broker.mjs:568`), which writes the session's role and run. Every session with a
NULL role also has a NULL run: the human (`:537`), the reader (`:549`) and a session-less record (`:1124`). The
tester's role is `tester`. So neither a session nor the human path can produce that pair, as no instance can
be named `_bus` (`:166-168`). A `system` role would not be safe, because `system` matches `ID_PATTERN`
(Dewey N1). The broker writes guarded events, the consume record included, through a private session-less
writer added in #436a row a; `#event` is unchanged. Revoke, sweep and check run for the human, the assistant,
the tester or the chief, and their events still go out under the grant actor, with `body.by` naming the caller
the broker checked.
**Two guards** stop a forged record:
- `recordEvent`, the trusted adapters' path (`broker.mjs:1111`), refuses `reserved-event` for any
`action.allowed` in the guarded namespace, as it does today for `git.push.working` and the merge action.
- The migration adds a trigger: an `action.allowed` in the guarded namespace aborts unless
`actor_role IS NULL AND actor_run IS 'grant'`. That covers any path that misses the first guard.
**Spend rule (B1).** `#spent` (`broker.mjs:811-817`) counts any `action.allowed` that names the decision in
`body.decision` and whose `operation` isn't `decision.raise`; a record with no `operation` counts. So the
consume record is the only guarded body with a `decision` key. Every other `grant.*` body names the decision
`grantDecision`. A step 0 or step 1 refusal therefore leaves the decision unspent, and the start reconcile
for "resolved yes, unspent" keeps reading `#spent` unchanged.
A wider gap sits under this one and isn't this row's: today any `recordEvent` `action.allowed` that carries
`body.decision` spends that decision. Follow-up for Sage to file.
**Open decisions (N6).** One query, `openGrantDecisions(business, method)`: `credential.mint` decisions
resolved yes by the human whose raise body has `grant.method = method`, and for which `#spent` is false.
`status` lists them, `grants admin rotate` counts the ones it voids (all of them, since `adminSha256`
changes), and the start reconcile reads the same query.
**No task refs.** Guarded bodies never carry `target` or `task_ref` holding a task ref, so they stay clear of
`task-subject-required` (`broker.mjs:561-565`) on any path. No other `action.allowed` reader sees these
events: the rest key on `decision.raise` (`:670`, `:803`), `git.push.working` (`:863`, `:2019`) or
routine (`:2100-2102`).
## 8. Approval notes for Jason
Stated once, here, for the #436b approval:
- **Site admin plus `write:user`, and `write:admin`.** The admin token can mint a token for any Gitea user,
with any scope up to its own three, including users the stack didn't create (F4). With `write:admin` it can
also set any user's password, email and admin flag, and disable any user (F10, proven on scratch against a
second site admin). Whoever holds the token can take over any account on that Gitea, jason.woltje's
included, make any user a site admin, and read or write any repository. The executor uses none of that
beyond its steps, but the token itself can.
- **Same uid on dragon-lin.** The token is a file readable by jwoltje, and every agent process on dragon-lin,
T3 dev seats included, runs as jwoltje (3c). The seat sandbox keeps sandboxed workers away from it. It does
not keep an unsandboxed same-uid process away. Such a process gets instance admin by reading the file, which
is more than it could get by forging an Approve. A separate uid would close that path. It is deferred on cost
(3c), not because it wouldn't help.
- **For comparison, today (D12).** Every dev seat on dragon-lin already pushes as jason.woltje through
`scripts/git-credential-mosaic.mjs` (mos-1106). jason.woltje is a site admin on git.mosaicstack.dev, with
restricted false (mos-1146: Mos read `/api/v1/users/jason.woltje` with the mos token at 23:05:29Z; Jason's
token wasn't used). So a same-uid process on dragon-lin can already act as a site admin there through that
credential, and the admin file adds little exposure on that Gitea beyond what the seats already have. It
still matters on any other Gitea a business grants on, and once seats stop pushing as jason.woltje (#431).
- **A seat token's reach can grow.** Step 5 checks, at the grant, that the token reaches only its forks. If
someone later adds the user to an org or as a collaborator, the token reaches that too, until the next grant
or rotation checks again.
- **The admin account is dedicated by promise.** Code checks that it is a site admin with these three scopes
and no reachable repositories (C1 to C4). It can't check that nobody else uses it or that it is in no org.
- **The bootstrap step** (3e) is the one manual credential step: Jason creates `mosaic-grant`, its TOTP and
its token, and enters the token once on stdin.
## 9. Tests and mutants (for the row, not built here)
Unit tests use a fake Gitea over TLS, as row 199's publish tests do. Section 1's facts get a scratch
gitea/gitea:1.27.1 test on 127.0.0.1 with throwaway data, removed after. No test contacts git.mosaicstack.dev
or any other live service. No token value is printed.
| Test | Claim |
|---|---|
| X-G1 | a gitea raise for an agent without `gitea`, `identity` or `publish`, or a project outside `publish`, a remote on another host, no admin slot, or a slot past rotate-by refuses and stores nothing |
| X-G2 | `set` refuses a version below 1.27.0 (fake reports 1.26.4, 1.27.0-rc0, and 1.9.0 against 1.27.0 as a string trap), a non-admin, a login mismatch, an account that reaches a repository, a token with another scope or `all`, and a second `url`. It writes nothing on any refusal |
| X-G3 | on scratch 1.27.1, end to end: user made with the nonce and its `id` recorded, token minted with the two scopes, forks made, step 5 passes, `issue` writes the 0600 file, `grant.applied`, then revoke deletes the token at Gitea (gone from its list) |
| X-G4 | an existing user with no record, a recorded name whose `id` differs (deleted and remade, and a case variant of the name), a site-admin user and a disabled user each refuse before any token call |
| X-G5 | each failure row of section 5 leaves the stated state, rotation rows included. A failed delete leaves `serviceLeft`. A revoke while a rotation's `commit` is settle-pending, and one after `grant-token-unknown`, deletes both `previousTokenId` and the minted `tokenId` at the fake, and a failure of either lands in `serviceLeft`. A new grant for a held agent refuses `grant-token-unknown` at step 0, before any Gitea call |
| X-G6 | a restart after `user` adopts a user whose `full_name` ends with ` [mosaic <nonce>]` (16 lowercase hex), whose lowercased login equals `gitea.user` lowercased and who isn't an admin; does nothing on 404; and reports `gitea-user-orphan` for a different `full_name`, a different login or an admin. A restart after `mint`, `minted` and `commit` of a first grant leaves no token at Gitea, no file and no record. A restart after `commit` of a rotation settles forward when the file holds the new token and back when it holds the previous one, ends `grant-lost` only on a 401, and stays `settle-pending` with nothing deleted on 403, 404, 500, a timeout and an unreachable fake. On 200 with an unknown `id` it drops the credential from memory, deletes nothing at Gitea, keeps the file, ends `grant-token-unknown`, and a second restart still doesn't load the file; a revoke then clears it |
| X-G7 | an identity, publish, upstream or admin slot change between raise and Approve refuses `decision-mismatch` before any Gitea call. An admin rotate voids open grant decisions and `status` lists them |
| X-G8 | every admin-token and seat-token call writes `grant.call` before it and `grant.result` after it, paired by `callId`. A crash between them is listed by reconcile. No record body holds a token |
| X-G9 | admin rotate: C1 to C4, the successor named by time, checked, written, the old token deleted. Two rotations in one second's name refuse cleanly, not half done. Past rotate-by, the raise and the executor refuse |
| X-G10 | seat-token rotation: the new token is live before the old one is deleted |
| X-G11 | a step 0 refusal (rotation due, too old) and a step 1 refusal leave `#spent` false, and `grants run` by the human gets past `#consumeGrant`. `grants run` from an agent, the chief or the tester refuses. A `grants run` while a start holds the claim refuses `grant-busy` before any Gitea call. No `grant.*` body other than the consume record has a `decision` key. A step 0 refusal, then a restart, then `grants run`: the reconcile has spent the decision as `interrupted` and `grants run` refuses `decision-consumed`; before the restart `status` shows `spentAtNextStart` |
| X-G12 | step 5 refuses `gitea-reach-wider` when the user was made collaborator on another repository, when it joined an org team, and when it owns a fork of an upstream that has left `publish`, and deletes the token. It passes with an old fork of a `publish` project outside `target.projects`, which the rendered block named. A fake returning 51 forks over two pages is read in full; a total that disagrees refuses `gitea-reach-unknown` |
| X-G13 | `recordEvent` refuses an `action.allowed` with `body.action` `grant.call` (and each `grant.*`) and one with `body.operation` `grant.gitea`. A direct store insert aborts on the trigger for `grant.step` and `grant.host.enrolled` from a session, and for `grant.step` under the human's NULL pair. `host.sudo` from an agent session still writes |
| X-G14 | fire: every token the user has is deleted, the user gets `prohibit_login`, and a failure of either lands in `serviceLeft` |
| X-G15 | a `grant.step {step: 'commit'}` stored under `action.refused`, and one under the human's NULL pair, are ignored by the restart reconcile: no settle runs and no Gitea call is made |
Mutants, a first list: drop the version floor; compare the version as a string; accept another admin scope;
skip `gitea-user-foreign`; compare the user by name instead of `id`; skip the admin-user refusal; adopt a user
at restart without the nonce; create the user before writing `grant.step user`; mint before writing
`grant.step mint`; delete the old token before `issue`; settle a rotation by the record instead of
`token-whoami`; run C1 to C4 after `#consumeGrant` instead of before; drop the `identitySha256` compare; drop
`adminSha256`; skip the `grant.call` write; write `grant.result` before the call; drop the `grant.` reservation
in `recordEvent`; drop the trigger; widen the trigger to `host.*` (breaks `host.sudo`); guard `body.action` only, not
`operation`; key the trigger on `actor_run` alone; put `decision` in step 0's `grant.failed`; delete both
tokens on a settle 403 or 5xx; compare step 5 against `target.projects` instead of `publish`; read one page
of `/user/repos`; let `grants run` skip the claim; send the admin token in a `token` header on the mint route; revoke the
remote token before the local steps; skip the fork `parent` check; skip step 5's reach check; delete only the
recorded token on fire; let `grants run` accept an agent; keep the file in use on a settle 200 with an
unknown `id`; load a `held` pair at start; revoke only `tokenId` on an open rotation; let step 0 pass a held agent; drop the `kind` filter from the restart reconcile.
## 10. Shared with Bushroot's GitHub half (proposed in megavolt-0036, answer pending)
- S1. The `grant` body: `{method, agent, rotateBy, target}`, scopes fixed by code per method.
- S2. The admin slot: `<configDir>/secrets/grants/<business>/<method>/admin`, set once by
`mosaic grants admin set <method>`, never an agent service. One service instance per method per business.
- S3. `grant.call` and `grant.result` (r2: two records, paired by `callId`) for every service call, as reserved
`action.allowed` bodies (section 7).
- S4. The executor shape: pre-checks before consume (step 0), claim, consume, mint, verify, `issue`, applied,
with `grant.step` before and after the mint, and settle by asking the service which credential the file
holds. GitHub may not fit "mint once and store" (App installation tokens expire within an hour). If so, S4
becomes per method and the rest stays shared.
- S5. Revoke local first, then the record, then the service, with `serviceLeft` and `grants sweep`.
- S6. A capability floor per method, checked at `set`, before every start and before every remote undo.
Dewey notes items 2, 3 and 5 of dewey-0754 apply to S3, S4 and S6. The r2 text above carries them.
## 11. Questions
- Q-B1. Answered (dewey-0754 item 9): on fire, `prohibit_login` and delete every token (section 6). The PATCH
route is proven (F10).
- Q-B2. Answered (dewey-0754 item 10): no organizations. The token's real reach is checked at step 5 and
stated in section 8.
- Q-B3. Bushroot's answer on S1 to S6.
- Q-B4. Answered (mos-1146): jason.woltje is a site admin on git.mosaicstack.dev, restricted false. Section 8
says so.
- Q-B5. Answered (gizmoduck-0081, -0084, mos-1144): one rule for #436a and #436b, section 7. #436a row a's
BUILD.md cites section 7 at the commit Duckworth approves, or carries its text in full if row a's candidate
comes first. Duckworth checks it with that candidate.
-167
View File
@@ -1,167 +0,0 @@
# Row 199 (#1697): per-agent credential store, each agent's own git identity, publish to its fork
Builder: Megavolt. Brief: `docs/plans/2026-10-20_agent-credentials.md`. Design: `DESIGN.md` r6 at
4d3007ec9, approved by Duckworth in duckworth-0027 (REVIEW-r6.md at 640011518). Authority model ruled
by Mos in mos-1027. Branch: megavolt/row199. Reviewer: Duckworth.
Status: in progress. The full gate has not run; it is Sage's. Only single test files have run on
this branch, apart from one slip noted under "Runs".
## Commits so far
- 286bf598c business: agent identity, the publish grant, the project upstream with the D9c parse, store
credential references; git.push.remote as a hard wait.
- a2c387dba bus: broker publish authority (checkPublish, recordPublish, publishRefused, pushCommit on
the raise); the D9c parse moves to `business/src/vocabulary.mjs`; pm.md and chief-of-staff.md list
git.push.remote among the hard waits.
- 609dc9364 bus: the per-agent credential store in `credentials.mjs`.
- 504336797 bus: runtime wiring, the store root and the four publish entries in the op table.
- a18fc6cf8 tasks: request calls use `useFor` keyed from the session; the start probe keeps `use`. The
D5 test in `credential-store.test.mjs` pins the `use(` call sites to `credentials.mjs` (useFor's own
call) and `tasks/src/adapter.mjs` (the host's @sync and start probe).
- aa740cc71 bus: the lifecycle verbs `credentials.issue`, `credentials.revoke` and `credentials.delete`
in `credential-verbs.mjs`, the broker branch, the runtime wiring; `organizationWriter` gets a
`detail` on `write` and a receipt-only `record`.
- 0cdace415 cli: `mosaic credentials status|issue|rotate|revoke|delete`, the scripts/mosaic route, docs.
## Deviations from DESIGN.md r6
1. **Identity is optional on an agent.** D8 reads as identity on every agent. Today's business files
carry none, so identity stays optional and `publish` requires it (N10's load refusal). An agent
without identity commits as E7 (T6).
2. **Revoke of a `{file}` or `{env}` reference.** Section 6 describes revoke for store references.
For a reference outside the store, revoke forgets the token in memory and never unlinks the file;
the lifecycle verb then turns the business file's reference into `store`, so a restart finds
nothing. The test: `credential-store.test.mjs`, "revoke of a {file} reference".
3. **Issue order.** Issue writes the token file (tmp, fsync, rename, dir fsync), then runs the
caller's commit (the business file's reference becomes `store`), then sets memory. A failed commit
leaves the file on disk and the token out of memory, so the agent stays `not-issued` until the
issue is run again. The test: "a failed commit leaves the token on disk but not in memory".
4. **The D9c parse lives in `business/src/vocabulary.mjs`, not `business.mjs`.** The broker reads
`forkProblem` and `forkUrl`, and `project-runtime.test.mjs` pins the broker's import closure to
files that import nothing. `business.mjs` wraps `parseGiteaUrl` with the loader's refusal
messages. The pin test names the two new imports.
5. **`withGitea` takes the publish ticket, not a cap (D9b).** The key is the session checkPublish
checked with `#agent` (a run holding its claim; never a tester, reader or human), read through
`broker.publishSession(ticket)`. The token is usable only between checkPublish and recordPublish
or publishRefused. Its refusals are filed once, by publishRefused on the task's trail, and not
also by `refusing()`. The test: `publish.test.mjs`, "runtime: withGitea keys from the open ticket
only".
6. **Role contracts.** `workflow-roles.test.mjs` requires the hard waits listed in `roles/pm.md` and
`roles/chief-of-staff.md` to equal GATED_ONLY. Adding git.push.remote to the vocabulary made it
fail, so both lists now name git.push.remote. Prose only; no hash pin reads either file. Duckworth
should review this hunk because `roles/` is reviewed authority.
7. **Delete takes a reason.** Section 6 lists `delete AGENT` with no reason. The broker requires
`reason` on delete as on revoke, so every receipt that ends a credential says why.
8. **Rotate is `credentials.issue`.** There are three bus verbs, not four. An issue over an entry the
bus holds is recorded as `rotate` in the receipt and the answer. The CLI's `rotate` reads the
status first and refuses before reading stdin when nothing is issued.
9. **Issue covers entries the bus loaded.** The business file must already name the kind for the
agent, and the bus must have loaded that reference at start; otherwise `credential-unknown`. An
agent added after the bus started needs the restart row 165 already asks for. Row 194's human
`create` is where a new agent's `store` references come from.
10. **No Console page yet.** Section 6 puts the verbs in the Console's agent page too. This branch has
the bus verbs and the CLI. The Console page is a separate piece (packages/webui, its `BUS_VERBS`
and `STATUS` pins); I raise the split with Sage before the manifest. Ruled in sage-1421: the
Console page is out of row 199 and becomes a follow-up row with Webby as reviewer. The manifest
holds bus, business, tasks, cli and seat.
11. **The push runs in the bus child, and the git socket polls for it (D9b).** The git socket runs in
the host process. The broker, the runtime and the token are in the bus child that
`packages/cli/src/host.mjs` forks, and host ops cross that IPC one at a time with a 10 s answer
limit. ls-remote and the push can run longer than that, so `withGitea(ticket, fn)` can't wrap a
host-side git. The split: the socket runs D9 steps 3 and 4 (tip and identity under the lock,
`working.publish`), then op `publishPush`, which starts `publishRemote` in the bus child under
`withGitea` and answers `{state: 'running'}`. The socket polls op `publishResult` every 200 ms
until it reads `pushed` or `refused`. Fork, user, source and destination come from the ticket
(`broker.publishTarget`), never from the host's message. `recordPublish` also refuses `ref-moved`
when the pushed ref isn't the ticket's destination. A second `publishPush` on a running ticket
refuses `publish-busy`. The token reaches git on fd 3 only (`runGit` option `pass`), read once
by a one-shot credential helper that answers `get` for https on the fork's exact host and port.
On close, a running publish's git isn't awaited; git's own time limit ends it, and the ticket
goes with the process. The 10 s IPC limit stays as it is (sage-1440). The poll has a bound
(sage-1440): `publishDeadlineMs`, by default four times git's limit and grace (publishRemote runs
at most four git commands) plus 30 s. Past it the socket refuses `publish-timeout`, filed once by
publishRefused, and logs that the bus process's push may still reach the fork. A failure that is not a refusal is filed as `git-failed` and logged on
the bus's stderr by error name and code only. Tests: `seat/tests/publish.test.mjs` (11),
`bus/tests/publish.test.mjs` (the process ops), `cli/tests/publish-socket.test.mjs` (7, end to
end through the socket, the in-process runtime, the store and a TLS fake Gitea; one is the
publish-timeout outcome).
12. **No harness tool for publish.** `packages/harness/src/git-tools.mjs` has push, fetch and land
clients. A runner tool that calls the socket's `publish` op is in packages/harness, outside the
manifest scope sage-1421 set. Today a publish needs a socket client; the tool is a follow-up.
13. **The bind rule's `git.push.remote` clause has no test of its own.** D11 adds it and the
launcher has it. A role can publish only a push its own role made (`work-not-yours`), and
`recordPush` needs `git.push.working`, which already binds the socket. No role reaches the
clause alone, so dropping it changes no outcome today. It matters once a role can publish work
it didn't push. Ruled in sage-1440: keep it; its mutant goes in the section 11 list as
equivalent, with this reasoning, and Duckworth judges it.
14. **A push between checkPublish and the git push refuses; it never lands a different commit.**
T3 says such a push "doesn't change the commit that lands". Under the hard wait it refuses:
`working.publish` checks the work ref's tip against the ticket's source under the lock
(`ref-moved`, `seat/tests/publish.test.mjs`), and `recordPublish` checks `pushCommit` again
(`decision-mismatch`, `bus/tests/publish.test.mjs` "r4 B4"). The fork gets only the ticket's
oid either way. N9's routing-commit test still applies. Ruled in sage-1440: T3's wording in
DESIGN.md now reads "refuses with `ref-moved` or `decision-mismatch`", and an end-to-end test
asserts each code mid-flight in `cli/tests/publish-socket.test.mjs`: a push after checkPublish
refuses `ref-moved` with no request to the fork; a push recorded while the bus process pushes
refuses `decision-mismatch`, filed once, and the fork holds only the ticket's oid.
15. **Fix: rotate on a due gitea key (gizmoduck-0050, sage-1494).** The CLI's `ISSUED` set
(`packages/cli/src/credentials.mjs`) listed valid, expiring and expired, but the bus's `status()`
gives a gitea key past its rotate-by date `rotation-due`. So `credentials rotate` refused the one
case it exists for. `ISSUED` now names every state status() gives a held token. The test:
`cli/tests/credentials.test.mjs`, "rotate goes ahead on each held state". Its mutant (the old set)
fails that test and goes in the section 11 list.
## N9, for the routing commit (sage-1388)
Today git.push.remote is a hard wait, so every publish carries a decision, and the decision's
`pushCommit` fixes the commit. If a later commit routes git.push.remote within role, a publish has
no decision and no `pushCommit`. `#publishFacts` then takes the commit from `#latestPush`, and the
git socket's tip check under the lock is the only thing that holds it. That commit needs a test
that a push between checkPublish and the git push doesn't change what lands.
## R1, the backup set
Checked at 504336797. Row 185's `backup_store` (`scripts/stack-release.sh:372-392`) copies only
`<dataRoot>/bus/bus.sqlite`, plus its sums, modes, listing and stamps, into
`<dataRoot>/backups/stack/<activation>/`. Nothing under `<configDir>` is copied, so
`<configDir>/secrets/agents` isn't in the backup set. `rehearse` copies the live business file
through `R_BUSINESS_JS` (`:1268-1288`). Its `swap` replaces every credential reference, a `store`
one included, with a placeholder file in the rehearsal's own dir. The guard then refuses
`credential-ref-live` unless every reference is such a placeholder (`:1329-1337`), so a rehearsal
never reads a store token. A backup tool outside this repository that copies `<configDir>` would
copy tokens. That is R1's open half, and it is Jason's to check on each VM.
## T1 limit
A wrong owner on a store dir or file can't be set up without root, so T1 covers modes, symlinks and
dirs. The owner check (`st.uid !== process.getuid()`) is in `privateDir` and `#read`, and is untested.
## Runs
Single files, each green on its commit: bus broker, decision-close, decision-route, decisions,
hard-waits, land, project-runtime, launch-notice, review, publish, credential-store, credentials,
credential-status, limits, tester, plugins-verb, routes-verb, limits-runtime, instances-verb, web,
usage-runtime, limit-checks, daily-quota-runtime, chief-of-staff, tasks, tester-writes; every
business test file. Since: credential-verbs (2/2), credential-store (11/11, with D5), each tasks file,
tester, credential-status, plugins-verb, routes-verb, instances-verb; business launch-settings,
plugin-settings, instance-settings, token-limit-settings; cli credentials (3/3), cli, app,
tester-reads, tester, other-tree, transport, instances, limits, verbs. Hash pins: none on a changed
file (`thread-migration.test.mjs:46` pins DDL only).
Publish, single files, each green before its commit: seat
checkout (77), working (26), publish (11), landing (8); bus process (8), land (17), publish (12),
credential-verbs (2); cli publish-socket (4), git-socket (24), launcher (29); harness tools (22).
Hash pins: none on a changed file.
sage-1440 changes (the poll bound, the mid-flight tests), single files, green before the commit: cli
publish-socket (7), git-socket (24), launcher (29). Hash pins: none on a changed file.
The rotation-due fix, single files: cli credentials (4), green; the old-set mutant fails it 3/1.
Hash pins: none on a changed file.
Slip, 2026-10-20 about 19:51Z: I ran `node --test packages/tasks/tests/*.test.mjs` in one command,
which is a package suite, without asking Sage for the slot. It passed, 97 pass and 0 fail, in under a
minute. Reported to Sage in megavolt-0017.
-430
View File
@@ -1,430 +0,0 @@
# Row 199 design: a credential store per agent, own git identity, pushes to a remote
Megavolt, 2026-10-20. Gitea #1697, MVP T1 item 6. Brief: `docs/plans/2026-10-20_agent-credentials.md`.
Reviewer Duckworth. Mos rules on the authority model (section 9) before any code. Base: refactor at 992650232.
No credential value was read to write this. Evidence is code and docs only.
r2 answers duckworth-0013 (review at 6ab9cd927 on duckworth/reviews): B1 in D3a, T1 and T5, B2 in D9 step 5
and T3, N1 in section 6 and Q-M3, N2 in section 6, N3 in D9 step 3, N4 in D9a and T3, N5 in E5 and D9b.
r3 records Mos's rulings (mos-1027) in section 9. The Q-M2 hard wait changes D9 step 1, D9b and T3. Q-M1
drops O2 (D10, A3, R2).
r4 answers duckworth-0017 (REVIEW-r3.md at 4065ed59f): B3 in T3 and D9 step 5, B4 in D9 steps 1, 3 and 5, N6
and N7 in D9a, N8 in D9 step 1.
r5 takes webby-0011 N1 (row 194 review, through honker-0009): the host checks at each push that the
remote's owner is the agent's own Gitea user. r4 named one `remote` per project, which can't be a fork per
agent (O1) once two agents publish the same project. So `projects.<p>.remote` now names the upstream
repository, and the host builds each agent's fork URL from it (D9 step 2, D10, T3). It also fixes a conflict
found while building: D8 made `identity` required for every agent whose definition declares `gitea`, while D9
step 2 says today's files load unchanged. Coder declares `gitea`, so r5 makes `identity` optional and
`publish` require it (D8).
r6 answers duckworth-0026 (REVIEW-r5.md at 3cb6888e1), with Mos's acceptance (mos-1043). B5: the derived URL
has one name, `fork`, and D9a's `ls-remote`, step 5's push, the helper's host match, `checkPublish`'s result
and step 6's event all use it (D9, D9a, D9b, T3). B6: the URL parse is written down (D9c) and tested (T3).
N10 in D8 and T3. N11 in D9a and A3. N9 is carried to BUILD.md for the routing commit.
## 0. Terms
- **Stack instance**: one Mosaic Stack deployment (row 190). It has one OS user, one config dir
`~/.config/mosaic-NAME` (here `<configDir>`), one data root and one bus. Case 1 and Case 2 are two stack
instances on two VMs (DECISIONS Q180).
- **Agent**: a role instance in a business file (`roles.<agent>`). Today's code calls it "instance". This
design says agent.
- **Kind**: a credential type: `gitea`, `vikunja`, `model`.
- **Host**: the bus process and its git socket, running as the stack instance's OS user, outside any sandbox.
## 1. What exists (measured at 992650232)
| Code | Fact | Where |
|---|---|---|
| E1 | The business file references credentials per agent: `roles.<agent>.credentials.<kind> = {file or env, rotateBy or expires}`. The kinds an agent may hold are the ones its role definition lists | `business/src/business.mjs:89-111`, `credentials.mjs:18-38`, `role.mjs:105-118` |
| E2 | The bus reads each token once at start (O_NOFOLLOW, own uid, 0600, at most 16 KiB, outside the repos and data root) into a private map keyed `<business>/<agent>:<kind>` | `bus/src/credentials.mjs:18-89`, `bus/src/runtime.mjs:78-89` |
| E3 | Only Vikunja uses a token: `credentials.use("<business>/<agent>", "vikunja", ...)`. Gitea tokens are loaded and never used. `assertClean` refuses any event that contains a token | `tasks/src/adapter.mjs:70`, `runtime.mjs:134`, `broker.mjs:560` |
| E4 | Agents run in bwrap as the bus's own uid. `~/.config`, `~/.mosaic` and `~/.ssh` are not bound. The git socket is bound only for `git.push.working`, `review.verdict` or a landing | `seat/src/sandbox.mjs:21-30, 104-124`, `cli/src/launcher.mjs:401` |
| E5 | A session proves who it is with a 64-hex cap the broker mints and maps to a frozen session record. The git socket resolves the cap to (business, role) before anything else | `broker.mjs:280, 379-383`, `packages/cli/src/git-socket.mjs:398-407` |
| E6 | The model credential is one env file per host (`EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env`). `ENV_ALLOW` passes it into every sandbox, so every agent holds the same model token | `cli/systemd/mosaic-bus.service.in:24`, `seat/src/session.mjs:83-94` |
| E7 | Commits in a session's clone use `<agent>@<business>.mosaic.invalid`. Landed squashes use "Mosaic bus host" | `sandbox.mjs:151-157`, `working.mjs:280-281` |
| E8 | No code pushes to a remote. The git socket pushes bundles into a local bare working repo with no remote and no credential | `working.mjs:1-14, 198-238` |
| E9 | Adding or changing an agent refuses a definition that declares credentials (`organization-credentials-out-of-scope`). Binding them is a hand edit. Row 194's design keeps that refusal for create and hire "until the credentials row" | `business/src/instance-settings.mjs:20-29`, row 194 DESIGN.md D1 |
| E10 | The development repository's helper pushes as one identity from one JSON file | `scripts/git-credential-mosaic.mjs` |
So per-agent references and a host-only reader already exist for two kinds. What's missing: a fixed place for
the files, an agent identity, any remote push, lifecycle verbs, and a model credential that agents don't
share.
## 2. The store (brief item 1)
D1. **Layout.** Each stack instance keeps its agents' secrets under its own config dir:
```
<configDir>/secrets/agents/ 0700, owner = the stack instance's OS user
<configDir>/secrets/agents/<business>/<agent>/ 0700
<configDir>/secrets/agents/<business>/<agent>/<kind> 0600, the token only, one line
```
This is row 190's secrets root (`<configDir>/secrets/...`, as Q175's slot uses). The token file keeps E2's
format, so the bus reader doesn't change. Nothing in a token file is a name, an email or a URL. Those are not
secret and go in the business file (D6), where review sees them.
D2. **The business file reference.** A new reference form, `{"store": true, "rotateBy" | "expires": DATE}`,
means "the file at the layout path for this business, agent and kind". The path is derived, never written,
so an agent's entry can't name another agent's file. The old `{file}` and `{env}` forms stay valid for
migration (section 7). A `{file}` reference that points inside `<configDir>/secrets/agents/` but not at its
own agent's path refuses `credential-not-yours` when the business file loads.
D3. **Read path.** Unchanged in shape: the bus reads every entry once at start (E2) and on a store change
(section 6), and holds the values in memory. No sandbox binds `<configDir>` (E4 already holds). No token goes
into a sandbox for `gitea` or `vikunja`. The agent asks the bus to act, and the bus uses the token.
D3a. **A `store` reference with no file.** ENOENT on a `store` path loads as an absent entry. `status` shows
`not-issued`, and `useFor` refuses `credential-unavailable`. The bus still starts. This is the state after a
create without `issue` (D13) and after `revoke` (section 6). Every other defect still refuses start: wrong
owner or mode on the file or a dir, a symlink, a non-regular file, bad format, a missing or wrong-mode
`secrets/agents/` dir. `{file}` and `{env}` keep E2's behavior, where a missing file refuses start.
## 3. The "RLS equivalent" (brief item 1)
Three layers. Each one is enforced by code or the OS, not by prose.
D4. **OS, between stack instances.** Files are 0600 and dirs 0700, owned by the stack instance's own OS user.
Case 1's user can't read Case 2's store (Q180: each instance under its own user without sudo). The bus refuses
to start when any file or dir under `secrets/agents/` has the wrong owner or mode (`credential-file`, as E2
does for files today, extended to the dirs).
D5. **Bus, between agents of one stack instance.** The principal for every credential use comes only from the
verified cap (E5). The agent-facing API is `credentials.useFor(session, kind, op)`: `session` is the broker's
frozen record, and the key is built from `session.business` and `session.role`. It has no agent argument, so
there is nothing in a request to point at another agent. The existing `use(name, kind, op)` stays for the
host's own `@sync` entry and is not reachable from a request. The tasks adapter's per-role calls (`ctx.as`,
`verbs.mjs:126`) move to `useFor`. Any request field that names an agent other than the caller (`as`,
`agent`, `instance`, `role` on a credential path) is refused `credential-not-yours` and recorded through
`broker.refuse()` as `action.refused`, with the caller's cap and the task ref. A build test greps every
`credentials.use(` call site and fails on a new one outside the allowlist.
D6. **What stays open.** A process of the same OS user outside the sandbox can read every agent's file. That
is the same limit as the human proof (P19 333). Q180's per-user VMs move the boundary to "one stack instance
per OS user". Agents of one instance are kept apart by the sandbox and D5, not by the OS.
## 4. Kinds (brief item 2)
| Kind | 10-27 | Use |
|---|---|---|
| `gitea` | yes | Git push to a granted remote (section 5). Gitea API verbs (issues, pull requests) wait. The factory's review flow runs in the working repo today, and no 10-27 case needs an agent to open a pull request |
| `vikunja` | yes, exists | Moves to `store` references and `useFor`. No behavior change |
| `model` | per stack instance only | See D7 |
D7. **Model provider.** A model token has to sit inside the sandbox today, because the harness calls the
provider itself (E6). Per-agent model tokens need a proxy that holds the real token and gives each run its own
short-lived one, which is the CLIProxyAPI line in TARGET.md. That waits for its own row. For 10-27 the model
credential is per stack instance: row 190's `<configDir>/secrets/claude-session.env`, owned by that
instance's OS user. It is never a copy of jwoltje's file. Jason signs in on each VM user himself (A6). Whether
Case 1 and Case 2 run on Jason's existing subscription or need a new account is Jason's call (Q-J1). This row
doesn't pick. A pi model credential also waits for P19 #383.
## 5. Git identity and push (brief item 3)
D8. **Identity.** A new business-file key, `roles.<agent>.identity = {name, email, gitea: {url, user}}`. It is
optional, because today's business files with a `gitea` agent (coder) must load unchanged. `publish` (D9
step 2) requires it, and requires that the definition declare `gitea`. A business file with a `publish`
grant on an agent that lacks either refuses at load, and the error names `roles.<agent>.publish` and says
"needs identity and a gitea definition" (N10). Load is the only place this is checked: a business file that
fails it never reaches the bus. The sandbox sets `GIT_AUTHOR_*` and `GIT_COMMITTER_*` from
it in place of E7's invalid domain. The email is the one Jason gives the agent's Gitea user, so Gitea links the
commits to that user. Agents without `gitea` keep E7.
D9. **Push to a remote: a new git socket op, `publish`.** No new socket, no bundle, and no token in the sandbox.
```
publish {"cap", "op": "publish", "task_ref", "project", "branch", "decision"}
-> {"ok": true, "result": {"fork", "ref", "commit", "previous"}} or a refusal
```
The host checks, in order, and each refusal goes through `broker.refuse()`:
1. Identity from the cap (E5), then the broker's `#checkAuthority(s, "git.push.remote", {decision, target:
task_ref})`, as `checkLand` does for a merge (`broker.mjs:907-913`). `git.push.remote` is a new action in
ACTIONS and is not in ROUTED (REQ-WAIT-3, `packages/business/src/vocabulary.mjs:52-58`,
`broker.mjs:42-69`), so it is a hard wait and classifies `gated` for every role. Every publish therefore
needs a decision the human resolved for this task ref. A decision is spent once (`decision-consumed`).
**The decision binds one commit.** `#authorityFor` binds action, role, thread and target only
(`broker.mjs:769-798`), so the commit needs its own carrier. The raise records it the same way it records
`holderRun` for `role.revoke` (`broker.mjs:1733`). When the action is `git.push.remote`, the
`decision.raise` event body gets `pushCommit`. That is the commit of `#latestPush(business, target)` at
raise time, read by the broker and never taken from the agent. A raise with no recorded push for the task
refuses `no-push`. The decision's question shown to the human ends with that commit. `#authorityFor` then
refuses `decision-mismatch` when `context.pushCommit` differs from the commit of `#latestPush` now, which
is the same check `holderRun` gets at `broker.mjs:796`. So the sequence approve at A, push B, publish B
refuses.
No role definition grants the action yet. When Jason's PRD version routes it (Sage, Vikunja #420), a
reviewed commit adds it to both ROUTED lists and to coder's `withinRole`. From then on a coder publishes
without a decision, and `decision` is optional for a within-role class only.
2. The project is in the agent's grant, `roles.<agent>.publish: [project]`, and the business file names the
project's upstream repository, `projects.<project>.remote: "https://HOST/OWNER/REPO"`
(`remote-not-granted`). Both URLs pass D9c's parse at load. The upstream's host and port equal
`identity.gitea.url`'s (`remote-host-mismatch`). The host never contacts that URL. It builds the agent's
fork, `fork = https://HOST[:PORT]/<gitea.user>/REPO`, from the upstream's origin and repository name and
the agent's own `gitea.user`. `fork` is the only URL the rest of `publish` uses: D9a's `ls-remote`, step
5's push, the helper's host match, `checkPublish`'s result and step 6's event. So the owner of the
repository that receives a push is the agent's own Gitea user on every push, by construction, and the
agent can't name it (webby-0011 N1). D9c is what makes "by construction" hold. Gitea's refusal of a
push to a repository the user doesn't own or collaborate on is the second layer. An upstream whose owner
equals the agent's `gitea.user`, compared without case, refuses `remote-not-fork`, at load and at push. `remote` is a new key
beside `root` and `repo` (`business.mjs:186`), and `identity` and `publish` are new keys on a role entry
(`business.mjs:89`). All three are optional, so today's business files load unchanged.
3. The source is one commit, `C = context.pushCommit`. It is checked three ways, so all three are equal:
the decision's commit, the commit of `#latestPush`, and the work ref's tip in the working repo,
`work/vikunja-<project>-<task>` (`working.mjs:110-114`). The tip is read under the repository's lock, and
if they differ the push refuses `ref-moved`. The work ref is per task, not per agent. Two checks make it the
caller's: `#latestPush` was recorded by the caller's role (`work-not-yours`), and step 4. The destination is always `refs/heads/<gitea.user>/<branch>`, built by the host.
`validBranch(branch)` holds (`invalid-request`). The agent never names the destination ref.
4. Every commit in `main..<work ref>` has author and committer email equal to `identity.email`
(`identity-mismatch`).
5. The push runs through `credentials.useFor(session, "gitea", ...)`: `git push <fork> <C>:<dst>`, by oid,
with no `+` and no `--force`. It never pushes by ref name, so a push to the working repo after
`checkPublish` can't change what goes out (`non-fast-forward` from git stays a refusal). Git runs with E8's SAFE flags
(`working.mjs:92-98`), which set `protocol.allow=never`. `publish` adds exactly two flags,
`protocol.https.allow=always` and `http.followRedirects=false`, plus `GIT_TERMINAL_PROMPT=0` and no global
or system config. The token reaches git through a one-shot credential helper that reads it from an
inherited pipe, never through argv, env, the URL or a file. Seat `gitEnv` (`checkout.mjs:79-89`) takes only
PATH from the host and sets the rest itself, so no proxy or `GIT_SSL_*` variable reaches git. `publish` takes a `tls` parameter that
defaults to `[]`, the same way `push` takes `fsck = FSCK` (`working.mjs:100`). Tests pass
`["-c", "http.sslCAInfo=<scratch CA>"]`. The host never sets it, and nothing in config or env reaches it. The helper answers `get` only when `protocol`
is `https` and `host`, port included, equals `fork`'s. It writes nothing for any other `get` and for
`store` and `erase`. `assertClean` checks git's output, so a fork that echoes the token gets
`credential-leak`.
6. The bus records `action.allowed` for `git.push.remote` with fork, ref and commit.
D9a. **The agent's fork must share root's history.** A push sends every object the fork lacks. The working
repo's `main` is the project root's history (`working.mjs`, seed), so a fork that doesn't share it would
receive all of root, landing squashes included. The check runs on `fork`, never on the upstream: a related
upstream says nothing about what the agent's `<user>/REPO` holds.
`publish` runs `seed` first (`working.mjs:164-176`), under the repository's lock as `push` does, so `main`
catches up with root before the check below. That means a fork also receives any landed root commits that
upstream doesn't have yet, because they sit under the agent's work. That is expected: the agent's branch is
built on them.
Before the push, the host runs `git ls-remote --symref <fork> HEAD` (same flags and helper). It refuses
`remote-unrelated` when the fork is empty or when that tip isn't a commit the working repo already has and an
ancestor of `main`. When `ls-remote` exits non-zero, for a fork that doesn't exist, a refused token or a
network failure, `publish` refuses `fork-unavailable` and the push doesn't run (N11). Git's stderr isn't
parsed to tell those apart and isn't returned, since it can carry the URL. Only `ls-remote` crosses the
network for this, so nothing is fetched from the fork. If the fork has moved past root, the refusal stands
until root catches up. That is a human pull into root, not an agent's.
D9c. **The URL parse (B6).** `projects.<p>.remote` and `identity.gitea.url` are parsed with `new URL()` at load,
and the business file refuses to load unless all of these hold:
- The scheme is `https:`. There is no username, password, query or fragment.
- The string equals its own normalized form: for the upstream, `url.origin + url.pathname`; for
`gitea.url`, `url.origin + "/"` or `url.origin`. So a dot segment (`/<user>/../OWNER/REPO`), a
percent-encoded dot, an upper-case host, an explicit `:443`, a backslash, userinfo, a query and a fragment
each refuse, because `new URL()` would change them (checked with node: each differs from its normalized
form). A trailing slash survives normalization and is refused by the two-segment rule below. The checks below run on that path, so the owner that is checked is
the owner that is used.
- The upstream's path is exactly `/OWNER/REPO`: two non-empty segments, no more and no fewer. One trailing `.git` on
REPO is allowed and stripped. `fork` is built from the stripped REPO.
- `OWNER`, `REPO` and `gitea.user` each match Gitea's name rule, `^[A-Za-z0-9_.-]{1,100}$` and not `.` or
`..`. REPO after stripping must still match.
- `gitea.url`'s path is `/`. A Gitea served under a sub-path isn't supported by this row and refuses at load.
- Host and port are compared after `new URL()` normalized them, and an omitted port counts as 443 on both
sides.
- Gitea compares user and repository names without case (`lower_name`), so `remote-not-fork` compares the
upstream's OWNER with `gitea.user` without case. An upstream owned by `Coder` and a `gitea.user` of `coder`
would otherwise build a "fork" that is the upstream itself.
D9b. **How `publish` reaches the broker and the store.** The git socket runs in the bus process
(`cli.mjs:504-506`) and reaches the broker only through the host's op table (`bus/src/runtime.mjs:222-246`).
`publish` adds three entries there, each wrapped in `refusing()`:
- `checkPublish(cap, args)`: steps 1 to 3, including the decision check. It returns `{ticket, fork, user,
email, source}` and no token. The ticket holds the session and the decision until `recordPublish`, the
same way `checkLand` holds a landing.
- `withGitea(cap, fn)`: `credentials.useFor(broker.session(cap), "gitea", fn)`. `fn` gets the token and runs
step 5. The token never leaves that call.
- `recordPublish(ticket, result)`: step 6, and it spends the decision.
The op table gets no entry that returns a token.
D10. **Which refs.** Our side: only `refs/heads/<gitea.user>/*`, set by the host (D9 step 3). The default
branch and other agents' prefixes can't be named. Gitea side, two options (Q-M1):
- **O1. A fork per agent. Ruled by Mos (mos-1027).** The agent's Gitea user has read on the upstream repo and owns a fork
named like it. The host derives the fork's URL from the upstream and the agent's user (D9 step 2).
Its token can write only repos the user owns or collaborates on, so Gitea itself stops any push to upstream
or to another agent's fork. No branch rules are needed per agent, so none can drift. The cost: work reaches
upstream as a pull request from the fork, opened by a human or a later API verb.
- **O2. One shared repo. Not chosen.** The agent is a Write collaborator. Upstream's default and release branches get
branch protection with no agent on the push list, plus a rule `<user>/*` per agent. Gitea doesn't stop agent
A from creating an unprotected name outside `A/*` unless a catch-all rule outranks the per-agent ones. That
depends on Gitea's rule priority, which I haven't verified on our version (probe P1). Every hire adds a rule.
Token scopes for either: `write:repository` and `read:user`, nothing else, per vocabulary.mjs rules (no
`all`, no admin).
D11. **The host git socket.** `publish` is added to the socket. `push`, `fetch` and `land` don't change. The
socket's bind rule (E4) adds `git.push.remote`. The working repo stays credential-free and remote-free. The
`fork` URL is passed per call and never written into its config. A landing still squashes into the project
root as "Mosaic bus host". Landings don't push anywhere.
## 6. Lifecycle (brief item 4)
All verbs are on the bus, in the CLI (`mosaic credentials ...`) and in the Console's agent page. Every verb is
human-only. Under Q173 the tester may run `status` and `revoke` on the dev stack instance. `issue` and
`rotate` stay with Jason, because the tester is an agent and the token would pass through its session
(duckworth N1). Each one appends to the organization
writer's `changes.jsonl` (actor, agent, kind, verb, date; never a value). No schema migration, no new event
kind. A refusal uses `action.refused`.
| Verb | What it does |
|---|---|
| `issue AGENT KIND --rotate-by DATE` (gitea) or `--expires DATE` (vikunja) | Reads the token on stdin (never argv), checks E2's format, writes `<kind>.tmp` 0600, fsyncs, renames over `<kind>`, and sets the business file reference to `{"store": true, date}`. The bus reloads that one entry, with no restart |
| `rotate` | Same as `issue` over an existing entry. The old value is gone from disk and memory when the call returns. Jason then deletes the old token in Gitea (A5) |
| `revoke AGENT [KIND] --reason` | Drops the entry from the bus's memory first, then unlinks the file. A use in flight finishes. Every later use refuses `credential-unavailable` |
| `delete AGENT` | `revoke` for every kind, then removes the agent's dir. Refused while the agent has a running claim |
| `status [AGENT]` | Kind, date and state per entry (E3's `status()`), no values |
D12. **"Stops working at once."** On our side this holds without a timing window. A fired agent never holds a
`gitea` or `vikunja` token (D3), so revoking means the bus no longer has it. On the Gitea and Vikunja side the
token still exists until Jason deletes it (A5). The status line for a revoked entry says so until Jason
confirms. The model token is the exception: it sits in the running sandbox (D7), so a fired agent's runs must
end. Row 194's fire ends them.
D13. **What row 194 calls.**
- `credentials.issue` is never called by the chief. Row 194's hire stays uncredentialed (its D1).
- A human `create` of a credentialed definition: row 194 lifts E9's refusal for the human path only. The agent
is created with `store` references and no files. `launch` refuses `credential-unavailable` until Jason runs
`issue`.
- `fire` and `delete`: row 194 calls `credentials.revoke(business, agent, {reason, actor})` before it changes
the business file. A failed revoke stops the fire (`credential-revoke-failed`), so an agent can't be fired
with its credentials still live on our side.
- `regenerate`: no credential change.
## 7. Who does what (brief item 5)
Agents never mint accounts or tokens. Jason's steps, per agent that needs `gitea`:
- A1. Create the Gitea user: Site Administration, User Accounts, Create User Account. The name is the agent's
`gitea.user`. The email is the agent's `identity.email`.
- A2. Create its token: sign in as that user, Settings, Applications, Generate New Token, scopes
`write:repository` and `read:user`, nothing else.
- A3. Create the fork of each granted repo as that user (O1). Gitea's push-to-create
(`ENABLE_PUSH_CREATE_USER`, `ENABLE_PUSH_CREATE_ORG`) stays off, so a push to a missing `<user>/REPO`
fails rather than creating a plain repository (N11). `publish` refuses `fork-unavailable` before that.
- A4. On the stack instance's VM, as its OS user: `mosaic credentials issue AGENT gitea --rotate-by DATE`, with
the token pasted into stdin.
- A5. On fire or rotate: delete the token in Gitea (Settings, Applications), or disable the user.
- A6. Model: sign in to the provider on each VM user to write `<configDir>/secrets/claude-session.env`.
Vikunja bot accounts and tokens are already Jason's (tracker bots). Only A4 changes for them.
## 7a. The grant path (mos-1124, Q188, Q190)
Q188: no agent creates a grant. The chief raises the request, Jason approves it in the inbox, and protected
stack code creates the grant. Jason called manual grant steps a no-fly, so A1 to A4 above are the fallback only.
- a. `credentials.issue`, and so `rotate`, accepts a resolved `credential.mint` decision, Jason's Approve, as
the human path. #436b's executor calls it with that decision and nothing pasted. #436b adds that path and
the decision kind. Row 199 ships the verbs with today's human check only.
- b. Under Q190 ("Q12: O1"), each agent seat gets its own Gitea account. On the Approve, #436b's executor does
A1 to A3: the seat's Gitea user, its token and its forks. Until #436b lands, pushes stay as jason.woltje.
- c. Q185 settles Q-J1 and Q190 settles Q-J2.
No code in 07141966e changes for this section. The executor and the decision route are #436b's.
## 8. Migration (brief item 6)
- M1. Existing `{file}` and `{env}` references keep working. A business moves one agent at a time with
`issue`, which rewrites that agent's reference to `store`.
- M2. Nothing here touches the dev repository's helper, its `.git/config`, `~/.mosaic` or `~/.mosaic-dev`.
Dev seats (jarvis, seat tokens) keep pushing as today. The mos-0981 rule ends only when Mos says so.
- M3. The live dev stack instance needs no change at re-pin: no reference uses `store` until someone issues
one.
## 9. Questions
Mos ruled on the authority model in mos-1027 (2026-10-20T19:27Z):
- Q-M1. O1, one fork per agent.
- Q-M2. `git.push.remote` and its bounds in D9 to D11 are approved. It lands as a hard wait: out of ROUTED,
and no role grants it within-role (D9 step 1). Sage puts its routing in the PRD version draft going to
Jason with `role.hire` and `role.fire` (Vikunja #420). After his approval, a reviewed commit routes it and
grants it to coder.
- Q-M3. Issue, rotate and delete are human-only, and the chief gets none of them. Under Q173 the dev
instance's tester gets `status` and `revoke` only.
- Q-M4. The model credential stays per stack instance for 10-27. Per-agent model tokens wait for a proxy
row.
Open for Jason. Mos takes these to him one at a time:
- Q-J1. The model account for Case 1 and Case 2: existing subscription or a new account. Settled by Q185 (7a).
- Q-J2. Gitea users and tokens for the agents that need them (A1 to A3). Settled by Q190 (7a).
## 10. What this closes (brief item 7)
- G11: closes the store (D1 to D6) and lifecycle (section 6) for `gitea` and `vikunja`. Still open: per-agent
model credentials (D7), and the same-uid limit inside one stack instance (D6).
- G13: closes "own credentials for git" (D8 to D10). Still open: Gitea API verbs (section 4).
- C2.5 (git remote): Case 2's books repo can use `publish` once its project names a remote.
## 11. Tests and mutants
All tests use scratch roots, fake tokens and a fake Gitea: `git http-backend` behind a node http server on
127.0.0.1 that checks basic auth against a fake token and records each ref update. No request leaves the host.
- T1. Layout: wrong mode or owner on a dir or file refuses start. A `{file}` reference into another agent's
path refuses `credential-not-yours`. A bus restart after a create without `issue` starts, and the agent's
status is `not-issued`. A missing `{file}` still refuses start.
- T2. `useFor` keys from the cap only. A request naming another agent is refused and leaves one
`action.refused`.
- T3. The fake Gitea serves https on 127.0.0.1 with a cert from a scratch CA made by the test. It reaches git
only through the `tls` parameter (D9 step 5). Each refusal test that should reach the network asserts that
the first port received the request, so no test passes because git refused http first. `publish`: the
happy path lands `refs/heads/<user>/<branch>` on the agent's fork in the fake, which shares root's history, at
exactly the decision's commit. Refusals for each step in D9, one test each, including `work-not-yours`. Until routing, a publish with
no decision refuses, one with a decision for another task or commit refuses, and a second publish on a
spent decision refuses `decision-consumed`. Approve while the latest push is A, push B, publish: refuses
`decision-mismatch`, and the fork receives nothing. A push to the working repo between `checkPublish` and
the git push refuses with `ref-moved` or `decision-mismatch` (sage-1440, BUILD.md deviation 14). A raise with no recorded push refuses `no-push`. A test pins that `git.push.remote` is in neither ROUTED list
and that no `roles/*.json` grants it. A force-push attempt is
refused by git's non-fast-forward. A fake fork that answers 302 to a second 127.0.0.1 port: the push
fails, and the second port receives no request with an Authorization header. A remote on the right host
and a different port is refused at step 2. Two agents granted one project each land on their own fork:
agent A's push reaches `/<A's user>/REPO` only and B's reaches `/<B's user>/REPO` only, and the fake's
upstream repository receives nothing. An upstream owned by the agent's own user refuses `remote-not-fork`,
and so does one whose owner differs from `gitea.user` only in case. With an upstream that shares root's
history, a first publish to an empty fork and to an unrelated fork each refuse `remote-unrelated`, and
neither fork nor upstream receives objects. A fork that doesn't exist refuses `fork-unavailable`, and the
fake records no push. `ls-remote` and the push each reach the fork's path only (the fake records every
path). Load refusals (D9c), one test each: a dot segment, an extra path segment, a missing segment,
a trailing slash, userinfo, a query, a fragment, an explicit `:443`, a name outside Gitea's rule, and a `gitea.url` with a
sub-path. A `.git` suffix loads and builds the fork without it. A `publish` grant on an agent without
`identity`, and on one whose definition lacks `gitea`, each refuse at load with the N10 message.
- T4. Token handoff: the token never appears in argv, env, the fork URL, the working repo config,
`changes.jsonl`, events or logs (scan every file under the scratch roots).
- T5. Lifecycle: issue, rotate, revoke and delete, each live with no restart. After revoke, `publish` and the
Vikunja path refuse `credential-unavailable`, and so they do after a bus restart that follows the revoke.
The tester can run `status` and `revoke`, and `issue` and `rotate` refuse it.
- T6. Default: an agent without `identity` commits as E7. Existing `{file}` references load unchanged.
Mutants to kill: drop the cap-derived key in `useFor`, let the request name the destination ref, add `+` to
the refspec, skip the identity check, put the token in the URL, revoke file before memory, skip the dir mode
check, allow a `{file}` reference into another agent's dir, make ENOENT on a `store` path fatal again, make
ENOENT on a `{file}` path silent, a helper that answers any host, drop `http.followRedirects=false`, drop the
`work-not-yours` check, drop the `remote-unrelated` check, add `git.push.remote` to ROUTED, skip the spent
check on the decision, drop the `pushCommit` comparison in `#authorityFor`, push by ref name in place of the
oid, push to the upstream URL in place of the derived fork, run `ls-remote` against the upstream in place of
the fork, take the fork's owner from anywhere but the agent's `gitea.user`, compare the owner with case, run the
D9c checks on the raw path in place of the normalized one, read a failed `ls-remote` as empty, skip the tip
check under the lock, and pass a CA flag in production.
## 12. Risks
- R1. Backups: if a backup tool copies `<configDir>`, it copies tokens. The build checks row 185's backup set
and says what it includes.
- R2. Withdrawn. O1 was ruled, so probe P1 isn't needed.
- R3. Outbound HTTPS from the bus host to Gitea is new network reach for the host process. On the VMs this is
Jason's network to confirm.
+10
View File
@@ -0,0 +1,10 @@
# Quackerjack
Builder seat, Claude Haiku 5.5 at high effort, started by Sage under mos-1023 item 1 and Q177 for federation
rows R5 (`mosaic access list, grant, revoke`) and R2 (the `federation` config key), per Gizmoduck's design
section 7 (#414).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own
`quackerjack/*` branches through the repository credential helper (mos-0981), never `refactor`, `main` or
another seat's branch.
+126
View File
@@ -0,0 +1,126 @@
# Row 202 BUILD: FED R5, `mosaic access list|grant|revoke`
Quackerjack, 2026-10-20. Plan: `PLAN.md` (commit 2d0aa2937). Manifest: `MANIFEST.md` in this directory.
Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, "Brief: FED R5". Reviewer Duckworth. Gate owner Sage.
## What was built
- `packages/cli/src/access.mjs`: the three verbs, the human proof, the context (`accessContextFor`), the
atomic write, the lock and the receipt.
- `packages/cli/src/cli.mjs`: routes `access` to `accessCommand`.
- `scripts/mosaic`: sends `access` to the CLI.
- `packages/cli/README.md`: the verbs, the human proof, the lock, the `instances` limit, the removal order,
and the limits on peers and subjects.
- `packages/cli/tests/access.test.mjs`: 21 tests (round 2; 18 in round 1). Single file, green on this branch.
R1 (`packages/business/src/access.mjs`) is merged in unchanged at 80fe31071, the Duckworth-approved
candidate. 44e36fc4c, the next commit on that branch, only removes a stray pyc file.
## The lock file (not in PLAN.md; added at Sage's request)
- Path: `<configDir>/access.json.lock`, the access file's path plus `.lock`. Mode 0600.
- Created with `O_EXCL` (`wx`). The holder writes its pid and a newline into it, then holds it for the
read, the check, the temp write and the rename. It is removed in a `finally`.
- A second writer that finds the lock reads the pid. If the pid is alive (or the pid is reused by a live
process), it refuses with `refused: ... being written by another command; try again` (exit 3). Nothing
is written.
- A lock whose pid is gone (`kill(pid, 0)` returns ESRCH) also refuses, and nothing reclaims it. The
refusal names the path and the pid. A human checks that no write is running and removes the file. This
is the crash case: the holder died mid-write, and a human clears it.
- An empty lock, or one that is not a pid, refuses. Its holder may still be between create and write. A
holder that died in that gap leaves an empty lock; a human removes it.
Reclaim was removed at Sage's ruling (sage-1429, 10-20). It was the one design with a race, so the
race below is gone, not accepted.
Former limit, now removed: reclaim race. The old design let a writer take a stale lock by unlinking it
and retaking it. The case that lost: writers A and B both read the same dead pid N. A unlinks the lock
and takes it. B then unlinks A's live lock and takes its own. Both read the same revision, both write
it, and the second rename silently replaces the first. The revision check did not catch it, because both
had read the old revision before either wrote.
Known limits, stated plainly:
1. A dead holder's lock blocks every write until a human removes it. That is the fail-closed price of
no reclaim. The refusal says which pid and which path.
2. A reused pid reads as alive, so a stale lock refuses. The fix is to remove the file by hand.
3. The lock is per access file. It does not cover the bus's own reads.
4. Crash-safety of the write path: the temp file is removed on any failure before or at the rename. The
directory is not fsynced after the rename, so a power loss right after a rename can lose the rename.
The bytes are never mixed.
Tests for the lock (all in `access.test.mjs`):
- "a live holder's lock refuses and leaves the file and the lock alone": pid = this process.
- "an empty or unreadable lock refuses": empty file, and `not-a-pid`.
- "a lock left by a dead pid refuses, names the pid, and is not reclaimed": a child that has exited. The
file, the lock and the receipt are unchanged.
- "the lock is mode 0600 while held and removed after a refused write".
Tests for the write path and the refusals (added in this round):
- "a failed rename leaves the old bytes, removes the temp file, and writes no receipt": the fs seam
(`deps.fs`) makes `renameSync` throw. Checks the old bytes, no `.access-*.tmp`, no lock, no receipt.
- "a group-writable access file refuses with exit 3, names the file, and changes nothing": a 0664 file.
Exit 3, the file path in the message, nothing written.
- "an agent or tester caller is refused before the context or the access file is read": `contextFor`
throws if called, and the file is 0664. The refusal must come first.
## The human proof, and the bus behavior behind test 10
The proof is one `project.list` call through `humanTransport`. The bus gates it with `#human`
(`packages/bus/src/broker.mjs`, the `project.list` case at :2319, and `#human` at :377). The call is in
one function, `proveHuman` in `access.mjs`, for R4 to swap.
Test 10 runs the real `human-cli.mjs` on a scratch bus from agent ancestry (`CLAUDECODE=1` in the child's
env). The bus refuses. The client reports `outcome-unknown`, not `human-required`, because the bus closes
the socket on a failed proof and `packages/bus/src/client.mjs:32-33` maps a closed socket to
`outcome-unknown`. Exit 1 (`busExit`). The test pins that code and asserts the access file and the
receipt are unchanged.
This is a bus behavior outside R5. It is not changed here. Gizmoduck and Gander own the verifier (row 196).
Until it changes, a human who hits the agent refusal sees `outcome-unknown`.
## Context
`accessContextFor(system, env)`:
- `providers`: `system.console.sso.providers[].id`. Row 192's shape (packages/webui/src/sso-config.mjs on
row 192's candidate).
- `ssoSubjects`: each provider's `subjects[].sub`, paired with the provider id. Gizmoduck's design says
`console.sso.subjects`; Sage ruled that is shorthand for this (erratum).
- `peers`: `[]` until row 203 adds the federation key. A grant that names a peer home refuses.
- `businesses`: `loadBusiness` for each `<configDir>/businesses/*.json`; `human` and the role names as
`instances`. A business file that fails to load fails the command.
No `console` key gives empty providers and subjects, so every grant naming a subject refuses. The
`checkConsole` check from row 192 is not on this branch yet. R5 rebases onto row 192's landing, and then
reads `console` through `checkConsole`.
## Mutant list (one per refusal; run on Sage's go, before review)
| # | Mutant | Expected kill |
|---|---|---|
| M1 | remove the `refuseInsideAgent` call | "an agent caller is refused before the bus is asked" |
| M2 | remove the `--tester` check | "--tester is refused on every verb" |
| M3 | the proof's error is swallowed | "a bus that refuses the human proof blocks the write" |
| M4 | the revision compare is removed | "a stale revision refuses with revision-conflict" |
| M5 | `takeLock` unlinks a dead-pid lock and retakes it (the reclaim this round removed) | "a lock left by a dead pid refuses, names the pid, and is not reclaimed" |
| M6 | `pidAlive` always returns true (a dead pid reads as live) | "a lock left by a dead pid refuses, names the pid, and is not reclaimed" |
| M11 | `writeAtomic` writes straight to access.json, no temp file or rename | "a failed rename leaves the old bytes, removes the temp file, and writes no receipt" |
| M12 | the BusinessError catch in `accessCommand` is removed | "a group-writable access file refuses with exit 3, names the file, and changes nothing" |
| M13 | `contextFor()` is called before the agent and tester refusals | "an agent or tester caller is refused before the context or the access file is read" |
| M7 | `checkAccess` is skipped before the write | "a grant naming an unknown instance is refused before any write" |
| M8 | the receipt append is removed | "grant writes ... a receipt line" |
| M9 | a project name is written as given (no lookup) | "a project name resolves to its id" |
| M10 | the lock is not taken | "a live holder's lock refuses" |
Round 2 closed the direct-write gap: `writeAtomic` takes the fs calls through `deps.fs` (default: the real
`node:fs` functions), and a rename that throws is tested (M11 is the direct-write mutant). Not yet run:
the mutant batch, the cli suite and `scripts/test-*.sh`. All wait on Sage's slot.
Round 2 also changed the file's refusal path: a `BusinessError` from `loadAccess` or `loadBusiness` now
exits 3 with the file path and key path on stderr, no stack trace (Duckworth B1, Sage sage-1437).
## Not done in this row
- `--peers` and the federation key: row 203.
- `access.reload`: row R4.
- Gizmoduck's `console.sso.subjects` name: erratum, the flattened shape is used.
- The suite (`packages/cli` and every `scripts/test-*.sh`) and the mutant batch: on Sage's gate.
@@ -0,0 +1,6 @@
e2d3e613a8d6f04544904acb2af8aec44dedf9ba6820f1936e1f7d6547be2028 packages/cli/src/access.mjs
e956ac5b54844af30ac7717e85b5b60282b2ccf00dfc134d1d5c1d9f3cb44f32 packages/cli/src/cli.mjs
de7d15dc6989379a47b1efd4f449e9707004978198f01d704b2d61c8411ed6ca packages/cli/tests/access.test.mjs
f50760a67337147e50af032969ea65ca313ac7b76f6239e23e5c0d95431bd4b7 packages/cli/README.md
aeddef32c28c883eedf166bcac96d1e6828b71c862e683977e097706339baf86 scripts/mosaic
79f2c5344c0d73754804ab58c68edfb82bad7bfef50718550c698f61ae2a8ab5 agents/quackerjack/work/queue-202/BUILD.md
@@ -0,0 +1,64 @@
# Row 202 manifest: FED R5, mosaic access list, grant, revoke (round 2)
Candidate: 48a3fb96e on quackerjack/fed-r5 (round 2). Supersedes the round-1 manifest at 959bfb275, which
pointed at build 030809e15 and was reviewed by Duckworth as round 1 (changes, B1; REVIEW-r1.md, a76767e87).
Base: R1 at 80fe31071, merged; 44e36fc4c and 35aaceac9 on the branch before the build.
Owner Quackerjack, reviewer Duckworth, gate owner Sage. Brief: docs/plans/2026-10-20_federation-r1-r2-r5.md, Brief: FED R5.
## Files (sha256 of the blob at 48a3fb96e)
```
e2d3e613a8d6f04544904acb2af8aec44dedf9ba6820f1936e1f7d6547be2028 packages/cli/src/access.mjs
e956ac5b54844af30ac7717e85b5b60282b2ccf00dfc134d1d5c1d9f3cb44f32 packages/cli/src/cli.mjs
de7d15dc6989379a47b1efd4f449e9707004978198f01d704b2d61c8411ed6ca packages/cli/tests/access.test.mjs
f50760a67337147e50af032969ea65ca313ac7b76f6239e23e5c0d95431bd4b7 packages/cli/README.md
aeddef32c28c883eedf166bcac96d1e6828b71c862e683977e097706339baf86 scripts/mosaic
79f2c5344c0d73754804ab58c68edfb82bad7bfef50718550c698f61ae2a8ab5 agents/quackerjack/work/queue-202/BUILD.md
```
scripts/mosaic is unchanged since round 1.
## What changed in round 2
- Lock: no reclaim. A dead-pid, empty or unreadable lock refuses and names the path (and pid); a human
removes it. The reclaim race is gone (Sage sage-1429).
- Atomic write: fs calls go through `deps.fs` (default: the real `node:fs` functions). A failed write
removes its temp file and leaves the old bytes (Sage sage-1429, the direct-write gap).
- BusinessError from loadAccess or loadBusiness: exit 3, file and key path on stderr, no stack trace
(Duckworth B1, Sage sage-1437). Sage's ruling is exit 3, so the checkAccess refusals move from exit 2 to
exit 3 too.
- `contextFor` is a function, built after the agent and tester refusals and the human proof. A refused
caller never reads access.json or the business files (Sage sage-1437).
- The help text and README say the command runs as the instance's uid (Duckworth N6, Sage sage-1437).
## Interface used from R1 (80fe31071)
checkAccess(doc, { providers, peers, businesses, ssoSubjects }), loadBusiness, loadAccess (via readCurrent),
accessFilePath is not used (the CLI takes configDir), covers is not used by R5.
## Named in review
- Human proof: one project.list call (proveHuman, access.mjs). Gate: packages/bus/src/broker.mjs:2319
(project.list case) and :377 (#human). Agent and --tester refusals come before the proof.
- Lock: <configDir>/access.json.lock, 0600, pid stamp. Live pid refuses (BUSY); dead, empty or unreadable
refuses and names the path. No reclaim. See BUILD.md.
- Write: checkAccess on the new document, then temp file, fsync, rename; revision compared inside the lock.
- Pinned bus behavior: a failed proof reads as outcome-unknown (client.mjs:32-33). Outside R5.
- Context: accessContextFor, built lazily; peers [] until row 203; subjects flattened from
console.sso.providers[].subjects.
## Tests and mutants
- packages/cli/tests/access.test.mjs: 21 tests, single file green on 48a3fb96e (21/21, run on the candidate).
- Mutants M1-M13 in BUILD.md. M11 (direct write) and M12 (no BusinessError catch) and M13 (context built
early) are new. Run on Sage's go and slot only.
- Not run in this round: the mutant batch, the cli suite and scripts/test-*.sh.
## Not in this row
- R2 peers and the federation key (row 203).
- R4 access.reload (its brief takes N1: project.list exempts testers, so access.reload needs #human).
- row 192 checkConsole (the context reads console.sso directly until 192 lands).
- Viewer level (Sage's carry, item 5): whichever of 202 and 204 lands second adds it. Row 204 is not on
refactor yet as of 10-20 20:23Z.
- Cli suite and scripts/test-*.sh (Sage's gate).
+106
View File
@@ -0,0 +1,106 @@
# Row 202, FED R5: `mosaic access list`, `grant`, `revoke`
Quackerjack, 2026-10-20. Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, "Brief: FED R5". Design:
`agents/gizmoduck/work/federation/DESIGN.md` at a1daa3c07 (sections 3.5, 3.7, 7 row R5). Reviewer Duckworth.
Chained on row 201 (R1, Gizmoduck). Interface as Sage relayed it (sage-1400, gizmoduck-0030).
## Status before build
- Claim: `scripts/mosaic queue move 202 in-progress --op quackerjack-202-start --by quackerjack`, run from
the canonical checkout, exits 2: "row 202 waits on 201 (done; now briefed)". Row 201 is `briefed`, so the
claim is not made. I will not build past the plan until Sage says how to proceed. This plan is a file in
my own worktree; it changes no queue row.
- `gizmoduck/fed-r1` is not on origin yet. R5 imports `packages/business/src/access.mjs` only after R1 lands.
Until then, code and tests run against the names in sage-1400, with the R1 import stubbed in one place.
## Interface I build against (R1, as relayed)
- `checkAccess(doc, { providers, peers, businesses, ssoSubjects })`: pure. Returns the checked document,
frozen. Throws `BusinessError` (exit 2) naming the key path. `ssoSubjects` is required; a missing one fails
closed.
- `covers(grant, { business, project, instance }, now, { level } = {})`.
- `accessFilePath(dir = configDir())`: `<configDir>/access.json`.
- `loadAccess(file, context)`: one read, refuses a symlink, a non-regular file, another owner, or group or
other write, then runs `checkAccess`. An expired grant is not refused here.
## Files I will change
New:
- `packages/cli/src/access.mjs`: argument parsing for `list`, `grant`, `revoke`; the human check; the write;
the receipt; the usage text. Exports a `main`-style function the way `thread.mjs` and `project.mjs` do.
- `packages/cli/tests/access.test.mjs`: all R5 tests below.
Edited:
- `scripts/mosaic`: add `access` to the dispatch case that sends a verb to `packages/cli/src/cli.mjs`.
- `packages/cli/src/cli.mjs`: route `access` to `access.mjs`.
- `packages/cli/README.md`: the three verbs, the `instances` limit, and the provider-removal order.
Not touched: `packages/business/src/access.mjs` (R1 owns it), `scripts/mosaic-config.mjs` (R2), any bus
broker code (R3/R4), `~/.mosaic-dev`, `~/.config/mosaic-dev`, the units, any live `access.json`.
## Behaviour
- `list`: prints the users, grants and revision of `accessFilePath()`. Reads through `loadAccess`, so a bad
file refuses here too. Shows each grant's project as the id (and the name, when `project.list` resolves
it). Shows `["*"]` as "all projects, now and later".
- `grant`: `--user`, `--business`, `--projects`, `--instances`, `--level collaborator`, optional
`--expires`, `--revision N`. Builds the new document in memory, runs `checkAccess` on it, then writes. A
bad grant refuses before any write. `--project <name>` resolves the name through `project.list` at grant
time and writes the id (design 3.5).
- `revoke`: removes the user's grants, or one grant with `--grant ID`, same flow.
- Write: a temp file in the same directory, mode 0600, fsync, rename over `access.json`. Revision goes up by
one. The caller passes the revision it read, and a mismatch refuses with `revision-conflict` before the
rename.
- Receipt: one line appended to `<dataRoot>/state/access-log.jsonl`. Append-only, same shape as
`activation-log.jsonl` (`packages/runs/src/state.mjs`). It records the time, the verb, the actor, the
revision before and after, and the grant ids touched. No secret or token goes in it.
- Help text states two things. First, a grant's `instances` is the real limit: a collaborator drives the
granted instance with the owner's role (design 3.5). Second, the removal order: take a user out of
`access.json` first, which acts at once. Removing a provider from `console.sso` takes effect only after a
Console restart (design 3.7).
## Human proof
The check runs as the human transport does now: `humanTransport` (`packages/cli/src/transport.mjs`) runs
`packages/bus/src/human-cli.mjs` against the bus socket, and `refuseInsideAgent` refuses first in any agent
ancestry. Open point, for Sage or Gizmoduck to settle before build: the design names no bus verb for the
grant check. My reading is that R5 makes one call through `humanTransport` to a read the bus already gates
as human-only, and uses its success as the proof, with no new bus verb (R5 must not widen the bus).
If that is wrong, I stop and report it.
Tests use two stand-ins, the pattern `packages/cli/tests/helpers.mjs` already has:
- Stand-in human CLI: `fakeCli(root, body)` replaces `human-cli.mjs` as the `cli` argument of
`humanTransport`, so the answer comes from a table. Used for the caller-refusal tests and most of the rest.
- Scratch bus: `startBroker` from `packages/bus/src/runtime.mjs` on a scratch socket and scratch data root.
One integration test runs the real `HUMAN_CLI` against it, so the proof path is checked end to end. The
bus accepts only its own `human-cli.mjs` path (memory #225), so that test uses the real file, not a copy.
- Tester caller: a `tester` proof, through `testerTransport` with `TESTER_CLI`. Refused with `tester-refused`
or `tester-required` as the bus returns it.
- Agent caller: env with `MOSAIC_RUN_ID` (and the other markers in `AGENT_MARKERS`) set. Refused by
`refuseInsideAgent` (exit 3) before any transport call.
## Test list (`packages/cli/tests/access.test.mjs`) and one mutant per refusal
Each test uses a scratch `MOSAIC_CONFIG` dir and a scratch data root. None reads `~/.config/mosaic-dev`.
| # | Refusal or behaviour | Test | Mutant (must be killed) |
|---|---|---|---|
| 1 | Agent caller refused | env with `MOSAIC_RUN_ID` runs `grant`; exit 3, no file change | `refuseInsideAgent` call removed from the access path |
| 2 | Tester caller refused | `tester` proof runs `grant`; `tester-refused` (exit 3), no write | tester branch lets a tester write |
| 3 | Human proof required | stand-in answers `human-required`; exit 3, no write | proof result ignored |
| 4 | Concurrent write refused | two writers read revision 3; second with `--revision 3` gets `revision-conflict`; file keeps revision 4 | revision compare removed |
| 5 | Receipt appended | after `grant`, one new line in `access-log.jsonl` with both revisions; a second write adds a second line, the first is unchanged | receipt write removed, or the write truncates the log |
| 6 | Bad grant refused before write | grant with a non-UUID project, unknown instance, level `viewer`; exit 2, file bytes unchanged | `checkAccess` runs after the rename |
| 7 | Atomic write | the write is a temp file and a rename; a failed rename leaves the old file whole | direct write to `access.json` |
| 8 | Revoke removes the grant | `revoke` removes the user; `list` no longer shows it; revision +1 | revoke leaves the grant |
| 9 | `--project <name>` resolves to an id | name resolves through `project.list`; the file holds the id | name written as-is |
| 10 | Real proof path | scratch `startBroker` plus real `HUMAN_CLI` with a valid proof; grant succeeds | (control: the success path must pass with no mutant) |
The mutant batch needs Sage's slot. Single test files run freely.
## Gate
- Duckworth approves this manifest and the code.
- The cli suite and every `scripts/test-*.sh` green on Sage's gate.
- Each mutant above is killed.
- Rebase onto R1's landing; re-run every touched test file singly before in-review.
+1
View File
@@ -1363,3 +1363,4 @@ are never rewritten or removed; corrections are new entries.
2026-10-20T18:41:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design D1/D2 check (DESIGN.md blob eba861f5d2bb at d55d86bb0), static git diff 817e8f51b..d55d86bb0 | ack: D1 and D2 match; design approved; Mos C1 sequence confirm still open; no Gitea/queue record
2026-10-20T18:57:10Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r2 (#1688, DESIGN.md sha256 5493c9df3ec0) | CHANGES to Darkwing, cc Sage: B1 claim check on MOSAIC_CONFIG-only selection, B2 shared prefix drop-in dirs; N1-N7 notes; static read, no slot, no Gitea or queue record
2026-10-20T19:00:04Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r3 (#1688, DESIGN.md sha256 98236a1aff98) | APPROVE to Darkwing, cc Sage: B1 and B2 closed with M13 and M14, N7 settled by Sage's 48ebee0ff; static read, no slot; manifest round gets a queue review record (sage-1337)
2026-10-20T19:57:00Z | Gizmoduck (Opus 5.5, 9312a896) | row 201 FED R1 (#1699, #414) round 1: packages/business/src/access.mjs, index export, tests/access.test.mjs, packet agents/gizmoduck/work/queue-201 | 24/24 single file; 44 mutants written, not run (needs Sage's slot); expiry per sage-1399; to Duckworth for manifest review
-6
View File
@@ -238,12 +238,6 @@ Exit codes: `0` ok · `2` invalid file or credential reference · `3` system
config problem · `4` usage or a required file missing. Details:
`packages/business/README.md`.
`mosaic credentials status|issue|rotate|revoke|delete` (row 199) goes to
`packages/cli` and the bus's human-only credential verbs: an agent's own
token in `<configDir>/secrets/agents/<business>/<agent>/<kind>`, read on
stdin, never printed. The tester may run `status` and `revoke`. Details:
`packages/cli/README.md`.
`instances` and `instance add|set` (row 165) go to `packages/cli` and the
bus's human-only `instances` and `instances.set` verbs, so they need a live
bus host and refuse inside an agent session. `instances` prints the revision,
@@ -0,0 +1,100 @@
# Federation build rows R1, R2 and R5 (#414)
Sage, 2026-10-20, for Mos's orders mos-1023 item 1, mos-1028 item 2 and mos-1034 item 1. The design is
Gizmoduck's `agents/gizmoduck/work/federation/DESIGN.md` at a1daa3c07 on `gizmoduck/federation-design` (r4.9,
sha256 a31cbebd070f9e3a5ca4ccb158b1747c6f5a83c3469855705b0026a581fb5c6a). Duckworth approved r4.5
(duckworth-0012), r4.6, r4.7 (duckworth-0020) and r4.8 (duckworth-0021, 8a22d0897 on `duckworth/reviews`),
and acked r4.9 (duckworth-0024). r4.7 applies Jason's Q181 hosts, r4.8 adds N22's R8 checks and r4.9 names who
runs them. None of them changes R1, R2 or R5. Section 7's table
sets each row's scope and test gate. Vikunja P19 #414. Seats push only their own `<seat>/*` branches
(mos-0981).
The other R rows are filed when their parents have candidates: R3 after R1 and row 196, R4 after R3, R5 and
row 193's viewer level (193a, mos-1019), R6 after #412 phase 1, R1, R2 and R3, R7 after R1-R6 and row 190.
R8 is a live window, not a code row. Its brief is the last section here.
## Brief: FED R1, the access.json loader and validator
Owner Gizmoduck, reviewer Duckworth, gate owner Sage. Base: refactor at this brief's commit. No parent.
`packages/business/src/access.mjs`: the access.json loader and validator from DESIGN.md section 3.5, and the
pure `covers`. Providers, peers and businesses come in as arguments. One read plus the file checks. The
exported interface is what R5 and row 193a build against, so the manifest names it, and any change to it in
review goes to Sage before it lands. Section 7 names `covers` but not the loader and validator exports or
the context shape. Morgana's 193a (508ee4d61 on `morgana/row193a`) assumes `checkAccess(doc, {providers,
peers, businesses})`, with `businesses` mapping an id to `{human, instances}`, throwing a `BusinessError` that
names the key path. Use those names, or tell Morgana and Quackerjack yours before the manifest goes to
Duckworth. Row 193a later widens the level check to `viewer` on R1's candidate (mos-1019).
Tests, in `packages/business/tests/`: every rule in 3.5 refuses with the key path named. That covers a
symlink, a non-regular file, another owner, group or other write, expiry, `*`, a grant disjoint from the given
subjects, `human` and `tester` ids, any level other than `collaborator`, and a project entry that isn't a
UUID. For `covers`: `*` and listed ids match, while an unlisted project, instance or business doesn't, an
expired grant doesn't, and a level filter rejects another level. One mutant per refusal, each killed.
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. Tests
use scratch roots. Single test files run freely, and suites need Sage's slot.
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
gate, and the mutants are killed.
## Brief: FED R5, mosaic access list, grant and revoke
Owner Quackerjack, reviewer Duckworth, gate owner Sage. Chained on R1 (Q177). The build starts now against
R1's exported interface as the R1 manifest names it, and R5 lands after R1 and rebases onto R1's landing.
`mosaic access list`, `grant` and `revoke`: human proof, atomic write, revision bump, a receipt line. The help
text states the `instances` limit and the provider-removal order (DESIGN.md section 7, row R5). R4 later makes
these verbs call `access.reload`, so R5 leaves no other reload path.
Tests, in `packages/cli/tests/`: tester and agent callers are refused, a concurrent write refuses on a
revision conflict, the receipt line is appended, and a bad grant is refused before anything is written. One
mutant per refusal, each killed.
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. The
human proof in tests uses the scratch stand-in pattern (a scratch bus verifies its own human-cli path).
Single test files run freely, and suites need Sage's slot.
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
and the mutants are killed.
## Brief: FED R2, the federation key in the system config
Owner Quackerjack, reviewer Webby, gate owner Sage. Chained on row 192 (#412 phase 1). The build starts when
row 192 has a candidate, on that candidate.
A `federation` key in `scripts/mosaic-config.mjs` with the shape in DESIGN.md section 3. A config with no
`federation` key still loads as it does today. This changes the config schema, so the manifest lists every
reader of the key.
Tests, in `scripts/test-config.sh`: a valid key loads, an unknown sub-key and a bad origin refuse, and a
config without the key loads. One mutant per refusal, each killed.
Boundaries: `~/.config/mosaic-dev/config.json` is user-authored and is not touched. Tests use scratch config
dirs only.
Gate: Webby approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and
the mutants are killed.
## Brief: FED R8, the hub window on 10.1.1.54
A live window, not a code row (DESIGN.md section 7, row R8, r4.9). Run by Jason and Mos. Nothing here
authorizes a step on any host; each step waits for the window's go.
Order (mos-1042 item 2). R8 runs after Bean and Chet are live in Case 2 and both Hermes gateways on 10.1.1.54
are stopped, because R8 requires that the hub host runs no other instance or agent and Q181 keeps Melanie's
Hermes on .54 until then. Case 2 go-live doesn't wait on R8. The risk, stated for 10-27: federation between
the two instances now depends on Melanie's switch-over date.
Entry checks, all before anything starts:
- R7, #412 phase 2, the hub host live (Vikunja #418) and Jason's #419 answer, as section 7 lists.
- The four hub user checks on 10.1.1.54 (N22, mos-1035 item 1): `sudo -l -U mosaic` grants nothing, `id
mosaic` shows only its own group, `~mosaic/.ssh/authorized_keys` is absent or empty, and no file under
`/etc/sudoers.d` names `mosaic`. Jason runs them as localadmin with his new key (#421 step 1), and the output
goes into the R8 record. No factory uid runs them, because R8 requires every factory root path to .54 to
fail (r4.9, mos-1037).
- The factory privilege checks, the key fingerprint comparison and the section 8 privilege path table, each
as section 7's R8 row states them.
Done when every entry check has its output recorded, Jason signs in to the hub and sends one message, Melanie
revokes, and his next request is refused.
@@ -0,0 +1,66 @@
# Row 193 build rows: 193a, 193b1, 193b2, 193c and 193d (#1691)
Sage, 2026-10-20, for Mos's go mos-1034 item 4. Row 193's design is Morgana's
`agents/morgana/work/queue-193/DESIGN.md` at e2d161e2e on `morgana/row193` (r5, sha256
dd7fcb419ed8510e9d8d74b569183386d0942af8b68eecc0bbb5e182e677b523), approved by Duckworth (duckworth-0018).
Section 8 lists the tests and mutants, and section 9 the split. The brief behind them is
`docs/plans/2026-10-20_human-users.md`. Vikunja P19 #414/#415, Gitea #1691.
Owner Morgana for every row, reviewer Duckworth, gate owner Sage. Each row chains on its parent's candidate
(Q177) and rebases onto the parent's landing. Seats push only their own `<seat>/*` branches (mos-0981).
Single test files run freely, and suites and mutant batches need Sage's slot.
Boundaries for every row: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, any
live access.json or `~/.mosaic`. No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
No user thread goes live before 193b1's message filter has landed (mos-1021 B2, FED R8).
The owner-visible records are fixed by Mos (mos-1023 item 2) and named in one user sentence, which FED R6
reuses word for word: "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks
and project files, but not your messages."
## Brief: 193a, the access level check widened to viewer
Chained on FED R1. Started early on `morgana/row193a` (508ee4d61) against R1's interface as the FED brief
names it, with Sage's go (sage-1373). R1's level check accepts `viewer` and still refuses any other new
level. Tests in `packages/business/tests/` (section 8, Business), with the four mutants in Morgana's
BUILD.md. FED R4 chains on this row.
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
gate, and the mutants are killed.
## Brief: 193b1, viewer verbs, board refusals and the message filter
Chained on FED R3, FED R4 and row 196's verifier. The bus gets the viewer verb list, per-grant level
checks, board reads refused to users, and the section 5.7 `trail` and `replies` filter with `#readable`
owner-only. No schema change. The governance pin test names each item in the user sentence above. Bus tests
and mutants 1, 3, 6, 10, 11 and 12 from section 8.
Gate: Duckworth approves the manifest. The bus suite and every `scripts/test-*.sh` are green on Sage's gate,
and the mutants are killed.
## Brief: 193b2, thread shares (schema 3j)
Chained on 193b1. `thread_shares`, the share verbs, the share rules of `#readable` and `#threadRead`, and the
section 5.5 share filters. Schema 3i to 3j. The table is append-only, and row 181's restore guard and
`COUNTED` include it. Bus tests for shares and the migration, and mutants 2, 4, 7, 8 and 9 from section 8.
Going live needs a `--migrate` window with Jason. Sage takes it to Mos when this row is near.
Gate: Duckworth approves the manifest. The bus suite, the cli suite and every `scripts/test-*.sh` are green on
Sage's gate, and the mutants are killed.
## Brief: 193c, mosaic access user verbs and --level viewer
Chained on FED R5. `mosaic access user add/remove` and `grant --level viewer`, only where R5 lacks them. They
refuse tester and agent callers, write a receipt line and call `access.reload` (section 8, CLI). If R5
already has all of them, this row records that and closes with no code.
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
and the mutants are killed.
## Brief: 193d, the Console's shared list, share controls and viewer read-only
Chained on FED R6, row 192 and 193b2 (for share controls). A viewer has no composer or share control, and
with each UI check removed the bus still refuses (section 8, Webui).
Gate: Duckworth approves the manifest. The webui suite and every `scripts/test-*.sh` are green on Sage's
gate, and the mutants are killed.
-7
View File
@@ -215,13 +215,6 @@ an operator inspect content and delivery evidence after an uncertain result.
| `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. |
| `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. |
| `credentials` | No args; human and reader sessions only. `[{service,account,role,dateKind,date,state}]` for the capability's business, never a token, file path or variable name. |
| `credentials.issue` | `{agent,service,token,date}`; human only (row 199, #1697). Writes the token to `<configDir>/secrets/agents/<business>/<agent>/<service>` (0600, dirs 0700: tmp, fsync, rename), rewrites the agent's reference in the business file to `{"store":true,<rotateBy or expires>:date}`, then holds the token in memory, with no restart. Over an issued entry it is a rotate: the old value is gone from disk and memory when it returns. `service` is `gitea` or `vikunja` and the business file must already name it for the agent (`credential-unknown`). A token the bus already holds is `credential-leak`; a bad token `credential-format`; a bad date `credential-date`. Returns `{agent,verb,service,date,state}`, `verb` `issue` or `rotate`. The tester gets `tester-refused`. |
| `credentials.revoke` | `{agent,service?,reason}`; human, or the tester (Q173). Drops the entry from memory, then unlinks a store file; a `file` or `env` reference keeps its file and becomes `store`, so a restart loads nothing. Without `service`, every kind the agent has. Returns `{agent,verb,services:[{service,state}]}`. |
| `credentials.delete` | `{agent,reason}`; human only. `credentials.revoke` for every kind, then removes the agent's store dir; a file there other than a token is `credential-file`. While the agent or one of its threads holds a claim, `agent-running`. |
Each of the three leaves one receipt in the organization's `changes.jsonl`: the actor and a
`detail` of `{verb,agent,kind,date or reason}`, never a value. A change to the store alone writes
a receipt with empty `changes`. A refusal is `action.refused` with its code alone.
| `requirements` | No args; role sessions only, as `task.create` is (row 90, #1584). The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. |
| `state` | No args; role, human and reader sessions (row 109, #1603). `{launch:{state,at,event},tasks:{open:[{task_ref,title}],total,cap}}` for the capability's business. `launch` is the latest `launch.revoked` or `launch.restored` event: `allowed` with `at` and `event` null when none exists. `open` is the tracker snapshot's open tasks in `task_ref` order, at most `cap` (the `tasks` page size, 50); `total` counts every open task. The harness runner reads it before each turn. Writes nothing. |
| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. |
+6 -153
View File
@@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path';
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl } from '../../business/src/vocabulary.mjs';
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -18,8 +18,6 @@ export const ACTIONS = Object.freeze([
'git.push.working',
'git.push.protected',
'git.merge.protected',
// Row 199 (#1697, mos-1027 Q-M2): publish to the agent's fork. Not in ROUTED, so a hard wait.
'git.push.remote',
'review.request',
'review.verdict',
'record.write',
@@ -103,11 +101,6 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
// Row 199 (#1697, design section 6): the credential lifecycle, human-only. Under Q173 the tester may
// revoke; issue (rotate is an issue over an issued entry) and delete refuse it, because a token it
// issued would pass through its session (duckworth N1). The status read stays the `credentials` verb.
export const CREDENTIAL_VERBS = Object.freeze(['credentials.issue', 'credentials.revoke', 'credentials.delete']);
const TESTER_CREDENTIAL_VERBS = Object.freeze(['credentials.revoke']);
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
const POLL_KINDS = new Set([
'task.changed.external',
@@ -198,13 +191,10 @@ export class Broker {
#received = new Set();
// Row 137 (#1631): landings checkLand let start, by ticket, until recordLand or landRefused ends them.
#landings = new Map();
// Row 199: a publish between checkPublish and recordPublish or publishRefused.
#publishes = new Map();
#secretCheck;
#credentialStatus;
#requirements;
#organization;
#credentialVerbs;
#project;
#usage;
#limits;
@@ -213,15 +203,13 @@ export class Broker {
#stops = new Map();
#clock = 0;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
this.#businesses = structuredClone(businesses);
this.#store = store;
this.#secretCheck = secretCheck;
this.#credentialStatus = credentialStatus;
this.#organization = organization;
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
this.#credentialVerbs = credentialVerbs;
this.#project = project;
this.#usage = usage;
this.#limits = limits;
@@ -807,12 +795,7 @@ export class Broker {
// records `?? null` and the current holder reads undefined.
if (action === 'role.revoke' && (context.holderRun ?? null) !== (this.#holder(s.business, target)?.holder_run ?? null))
fail('decision-mismatch');
// Row 199 (r4 B4): approve at A, push B, publish refuses.
if (action === 'git.push.remote' && (context.pushCommit ?? null) !== (this.#latestPush(s.business, target)?.commit ?? null))
fail('decision-mismatch');
return action === 'git.push.remote'
? { class: cls, decision: d.id, pushCommit: context.pushCommit ?? null }
: { class: cls, decision: d.id };
return { class: cls, decision: d.id };
}
// The raise's context: its target and approval option, from the decision.raise action.allowed.
#raised(business, decision) {
@@ -875,14 +858,13 @@ export class Broker {
if (body.commit !== push.commit) fail('verdict-stale');
}
// A push is the action.allowed recordPush wrote, the only one for git.push.working with a commit.
// role is the role that pushed (row 199, work-not-yours).
#latestPush(business, task) {
const r = this.#store.get(
"SELECT body, actor_role FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.action')='git.push.working' AND json_type(body,'$.commit')='text' AND subject=? ORDER BY seq DESC LIMIT 1",
"SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.action')='git.push.working' AND json_type(body,'$.commit')='text' AND subject=? ORDER BY seq DESC LIMIT 1",
business,
task,
);
return r ? { ...JSON.parse(r.body), role: r.actor_role } : null;
return r ? JSON.parse(r.body) : null;
}
// Row 93: the bus host's git socket asks before it takes a push's bytes. Nothing is recorded
// unless it refuses: the push is recorded, and its authority consumed, once the ref moved.
@@ -1016,98 +998,6 @@ export class Broker {
this.#landings.delete(ticket);
return code;
}
// Row 199 (#1697, design D9 steps 1 to 3, D9b): the bus host's git socket publishes a task's
// recorded work commit to the agent's own fork. checkPublish runs before anything touches the
// network. The run holds its claim; git.push.remote is a hard wait, so it cites a decision the
// human resolved for this task, bound to the task's latest push (#authorityFor). The project must
// be in the agent's publish grant with an upstream; the fork is built from that upstream and the
// agent's own Gitea user, never from the request. The ticket carries the session and decision to
// recordPublish or publishRefused. No token is in the result.
checkPublish(cap, args) {
const s = this.#session(cap);
return this.#store.transaction(() => {
keys(args, ['task_ref', 'project', 'branch', 'decision'], ['task_ref', 'project', 'branch']);
if (!taskRef(args.task_ref) || (args.decision !== undefined && !id(args.decision))) fail('invalid-request');
identifier(args.project);
string(args.branch, 200);
this.#agent(s);
const { task_ref, project, branch } = args;
const decision = args.decision ?? null;
const result = this.#checkAuthority(s, 'git.push.remote', { decision, target: task_ref });
const facts = this.#publishFacts(s, task_ref, project, result);
const ticket = randomUUID();
const dst = `refs/heads/${facts.user}/${branch}`;
this.#publishes.set(ticket, { s, task_ref, project, decision, dst, ...facts });
return { ticket, task_ref, project, fork: facts.fork, user: facts.user, email: facts.email, source: facts.source, dst };
});
}
// Steps 2 and 3, checked again at recordPublish. After routing a within-role publish has no
// decision, so the commit is the latest push's (N9); the git socket still checks the work ref tip.
#publishFacts(s, task_ref, project, result) {
if (result.decision && this.#spent(s.business, result.decision)) fail('decision-consumed');
const b = this.#business(s.business);
const role = b.roles?.[s.role];
const upstream = b.projects?.[project]?.remote;
if (!role?.identity || !(role.publish ?? []).includes(project) || !upstream) fail('remote-not-granted');
const problem = forkProblem(upstream, role.identity);
if (problem) fail(problem);
const push = this.#latestPush(s.business, task_ref);
if (!push) fail('no-push');
if (push.role !== s.role) fail('work-not-yours');
if (push.project !== project) fail('work-ref');
const source = result.pushCommit ?? push.commit;
if (source !== push.commit) fail('decision-mismatch');
const user = role.identity.gitea.user;
return { fork: forkUrl(upstream, user), user, email: role.identity.email, source };
}
#publish(ticket) {
return (typeof ticket === 'string' && this.#publishes.get(ticket)) || fail('publish-unknown');
}
// Design D5: the only key the git socket's gitea token use gets is the agent checkPublish checked
// (#agent: a run holding its claim, never a tester, reader or human). Trusted host only.
publishSession(ticket) {
const { s } = this.#publish(ticket);
return Object.freeze({ business: s.business, role: s.role });
}
// Where the bus process's git sends the push (D9 step 5): all from the ticket, none from the
// host's message. Trusted host only.
publishTarget(ticket) {
const { s, project, fork, user, source, dst } = this.#publish(ticket);
return Object.freeze({ business: s.business, project, fork, user, source, dst });
}
// Step 6: records the push the git socket made as the action.allowed for git.push.remote, which
// spends the decision. The checks run again; the run may have ended while git ran, so the holder
// check is not repeated (as recordLand). Trusted host only.
recordPublish(ticket, pushed) {
const t = this.#publish(ticket);
const done = this.#store.transaction(() => {
keys(pushed, ['ref', 'commit', 'previous'], ['ref', 'commit', 'previous']);
if (!oid(pushed.commit) || (pushed.previous !== null && !oid(pushed.previous))) fail('invalid-request');
string(pushed.ref, 260);
const result = this.#authorityFor(t.s, 'git.push.remote', { decision: t.decision, target: t.task_ref });
const facts = this.#publishFacts(t.s, t.task_ref, t.project, result);
if (facts.source !== t.source || pushed.commit !== t.source || facts.fork !== t.fork || pushed.ref !== t.dst) fail('ref-moved');
const { ref, commit, previous } = pushed;
this.#event(
t.s,
'action.allowed',
{ project: t.project, fork: t.fork, ref, commit, previous, action: 'git.push.remote', ...result, target: t.task_ref },
t.task_ref,
);
return { task_ref: t.task_ref, fork: t.fork, ref, commit, previous };
});
this.#publishes.delete(ticket);
return done;
}
// Ends a publish that didn't push: action.refused with the code on the task's trail. The git
// socket answers the run with the same code. Trusted host only.
publishRefused(ticket, { code } = {}) {
const t = this.#publish(ticket);
if (!/^[a-z-]{1,64}$/.test(code ?? '')) fail('invalid-request');
this.#store.transaction(() => this.#event(t.s, 'action.refused', { code }, t.task_ref));
this.#publishes.delete(ticket);
return code;
}
// The outcome as a RESULT from the bus, as #notice sends: the runner takes a turn on it and sends
// no automatic reply. It goes to the role in the thread the decision was raised in.
#landMessage(s, decision, body) {
@@ -1355,7 +1245,6 @@ export class Broker {
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
if (PROJECT_FILE_VERBS.includes(request.verb))
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set"].includes(request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
@@ -1427,35 +1316,6 @@ export class Broker {
throw this.#refused(s, e, request);
}
}
// Row 199 (#1697, design section 6). The token of an issue comes in args from the human's stdin; the
// secret check above has refused one the bus already holds, a refusal records its code alone, and
// nothing returned carries it. Outside the store transaction, as the organization branch.
#requestCredential(s, verb, a) {
if (s.tester ? !TESTER_CREDENTIAL_VERBS.includes(verb) : !s.human) fail(s.tester ? 'tester-refused' : 'human-required');
if (!this.#credentialVerbs) fail('not-configured');
if (verb === 'credentials.issue') keys(a, ['agent', 'service', 'token', 'date'], ['agent', 'service', 'token', 'date']);
else keys(a, verb === 'credentials.revoke' ? ['agent', 'service', 'reason'] : ['agent', 'reason'], ['agent', 'reason']);
string(a.agent, 64);
if (a.service !== undefined && !['gitea', 'vikunja'].includes(a.service)) fail('invalid-request');
if (a.token !== undefined) string(a.token, 16384);
if (a.date !== undefined) string(a.date, 10);
if (a.reason !== undefined) string(a.reason, 500);
if (!Object.hasOwn(this.#business(s.business).roles, a.agent)) fail('credential-unknown');
// A running agent keeps its tokens until its runs end: delete waits for every claim to end.
if (verb === 'credentials.delete' && (this.#holder(s.business, a.agent)?.op === 'claim' || this.#threadHolders(s.business, a.agent).length))
fail('agent-running');
let result;
try {
result = this.#credentialVerbs(s.business, verb, a, s.tester ? 'tester' : s.human);
} catch (e) {
if (e instanceof BusError && e.code.startsWith('credential-')) throw e;
if (e.code === 'revision-conflict' || e.code?.startsWith('organization-')) fail(e.code);
if (e.name === 'BusinessError') fail('invalid-request');
throw new BusError('outcome-unknown');
}
this.#secretCheck(result);
return result;
}
// Only the runner's bound session and delivered message identify a turn; no host path crosses the wire.
// Row 156 (#1651, sage-0808): a role run's landing turn is a merge decision id instead. Its begin
// needs the decision held for the run's role; its end needs that begin, since the landing may
@@ -1838,12 +1698,6 @@ export class Broker {
by,
);
}
// Row 199 (#1697, design D9 step 1, r4 B4): a publish decision binds one commit, the task's
// latest recorded push now, read here and never taken from the agent. The human sees it.
const pushCommit =
a.action === 'git.push.remote' ? (this.#latestPush(s.business, a.target)?.commit ?? fail('no-push')) : null;
const question = pushCommit ? `${a.question} (commit ${pushCommit})` : a.question;
string(question);
const did = randomUUID();
this.#store.run(
'INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,task_ref,requirement_ref,blocking,supersedes,spec_path,spec_commit) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
@@ -1856,7 +1710,7 @@ export class Broker {
cls,
a.action,
route,
question,
a.question,
JSON.stringify(a.options),
a.recommendation,
a.task_ref ?? null,
@@ -1878,7 +1732,6 @@ export class Broker {
approvalChoice,
holderRun:
a.action === 'role.revoke' ? (this.#holder(s.business, a.target)?.holder_run ?? null) : null,
...(pushCommit ? { pushCommit } : {}),
},
a.task_ref ?? (taskRef(a.target) ? a.target : did),
);
-53
View File
@@ -1,53 +0,0 @@
import { BusError } from './broker.mjs';
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete of one agent's credentials, for
// the broker's human-only verbs. The broker checks the session, the keys and a running claim first.
// Each verb leaves one receipt in the organization's changes.jsonl: the actor and a detail naming the
// verb, agent, kinds, date or reason, never a value. Nothing here logs, and a result carries no token.
const DATE_KEY = Object.freeze({ gitea: 'rotateBy', vikunja: 'expires' });
const deny = (code) => {
throw new BusError(code);
};
export function credentialVerbs({ credentials, writer }) {
const state = (instance, service) =>
credentials.status().find((e) => e.instance === instance && e.service === service)?.state;
return (id, verb, args, actor) => {
const { agent } = args;
const instance = id + '/' + agent;
const { document, revision } = writer.read(id);
const current = document.roles?.[agent]?.credentials;
if (!current) deny('credential-unknown');
if (verb === 'credentials.issue') {
const { service, token, date } = args;
if (!current[service]) deny('credential-unknown');
// rotate is an issue over an entry the bus holds now; the old value is gone when this returns.
const kind = ['not-issued', 'revoked', undefined].includes(state(instance, service)) ? 'issue' : 'rotate';
const next = structuredClone(document);
next.roles[agent].credentials[service] = { store: true, [DATE_KEY[service]]: date };
const detail = { verb: kind, agent, kind: [service], date };
const status = credentials.issue(instance, service, token, date, () =>
writer.write(id, next, { revision, actor, detail }),
);
return { agent, verb: kind, service, date: status.date, state: status.state };
}
const services = verb === 'credentials.revoke' && args.service ? [args.service] : Object.keys(current);
if (services.some((service) => !current[service])) deny('credential-unknown');
// Memory first, then the store file (Credentials.revoke), then the business file. A reference
// outside the store becomes `store` with its date, so a restart loads nothing for it. When that
// write fails, the entry stays revoked in memory and the refusal says to run the verb again.
if (verb === 'credentials.delete') credentials.remove(instance);
else for (const service of services) credentials.revoke(instance, service);
const detail = { verb: verb.slice('credentials.'.length), agent, kind: services, reason: args.reason };
const moved = services.filter((service) => current[service].store !== true);
if (moved.length) {
const next = structuredClone(document);
for (const service of moved)
next.roles[agent].credentials[service] = { store: true, [DATE_KEY[service]]: current[service][DATE_KEY[service]] };
writer.write(id, next, { revision, actor, detail });
} else writer.record(id, { actor, detail });
return {
agent,
verb: detail.verb,
services: services.map((service) => ({ service, state: state(instance, service) ?? 'not-issued' })),
};
};
}
+18 -162
View File
@@ -1,8 +1,5 @@
import {
openSync, closeSync, fstatSync, readFileSync, lstatSync, realpathSync, constants, mkdirSync, writeSync, fsyncSync,
renameSync, unlinkSync, rmdirSync, readdirSync,
} from 'node:fs';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { openSync, closeSync, fstatSync, readFileSync, lstatSync, realpathSync, constants } from 'node:fs';
import { isAbsolute, relative, resolve } from 'node:path';
import { BusError } from './broker.mjs';
const deny = (code) => {
throw new BusError(code);
@@ -16,33 +13,9 @@ const day = (value) =>
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
!Number.isNaN(Date.parse(value)) &&
new Date(value).toISOString().slice(0, 10) === value;
const TOKEN = /^[-A-Za-z0-9._~]+\n?$/;
// Row 199 (#1697, design D1, D4): a store dir is the stack instance's user's own and nobody else's.
const privateDir = (path, { missing = false } = {}) => {
let st;
try {
st = lstatSync(path);
} catch (e) {
if (missing && e.code === 'ENOENT') return false;
deny('credential-file');
}
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077) !== 0) deny('credential-file');
return true;
};
export class Credentials {
#tokens = new Map();
// Row 199: every reference the bus loaded, so issue, rotate, revoke and status work per entry.
#refs = new Map();
#revoked = new Set();
#storeRoot;
#repoRoots;
#dataRoot;
// storeRoot is <configDir>/secrets/agents (design D1); a reference with store: true must name the
// path under it for its own business, agent and service.
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null }) {
this.#storeRoot = storeRoot;
this.#repoRoots = repoRoots;
this.#dataRoot = dataRoot;
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env }) {
try {
for (const [instance, services] of Object.entries(references))
for (const [service, ref] of Object.entries(services)) {
@@ -53,41 +26,12 @@ export class Credentials {
if (
(ref.service !== undefined && ref.service !== service) ||
Object.keys(ref).some(
(k) => !['service', 'file', 'env', 'store', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
(k) => !['service', 'file', 'env', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
) ||
Boolean(ref.file) === Boolean(ref.env) ||
(ref.store !== undefined && (ref.store !== true || ref.file !== this.#storePath(instance, service)))
Boolean(ref.file) === Boolean(ref.env)
)
deny('credential-reference');
this.#refs.set(instance + ':' + service, { ref, date });
const token = this.#read(ref, env);
if (token !== null) this.#tokens.set(instance + ':' + service, { token, date });
}
} catch (e) {
this.close();
throw e;
}
}
#storePath(instance, service) {
if (!this.#storeRoot) deny('credential-reference');
const [business, agent, ...rest] = instance.split('/');
if (!business || !agent || rest.length || agent.startsWith('@')) deny('credential-reference');
return join(this.#storeRoot, business, agent, service);
}
// One token, read and checked, or null for a store reference with no file yet (design D3a).
#read(ref, env) {
let token;
if (ref.store) {
const agentDir = dirname(ref.file);
privateDir(this.#storeRoot);
if (!privateDir(dirname(agentDir), { missing: true }) || !privateDir(agentDir, { missing: true })) return null;
try {
lstatSync(ref.file);
} catch (e) {
if (e.code === 'ENOENT') return null;
deny('credential-file');
}
}
if (ref.file) {
if (!isAbsolute(ref.file)) deny('credential-location');
let resolved, original;
@@ -99,7 +43,7 @@ export class Credentials {
}
if (original.isSymbolicLink() || !original.isFile()) deny('credential-file');
if (
[...this.#repoRoots, this.#dataRoot].filter(Boolean).some((root) =>
[...repoRoots, dataRoot].filter(Boolean).some((root) =>
inside(
(() => {
try {
@@ -137,98 +81,16 @@ export class Credentials {
if (!/^[A-Z_][A-Z0-9_]*$/.test(ref.env)) deny('credential-reference');
token = env[ref.env];
}
return Credentials.#format(token);
}
static #format(token) {
if (typeof token !== 'string' || token.length > 16384 || !TOKEN.test(token)) deny('credential-format');
token = token.replace(/\n$/, '');
if (token.length < 16) deny('credential-format');
return token;
}
#known(instance, service) {
const known = this.#refs.get(instance + ':' + service);
if (!known) deny('credential-unknown');
return known;
}
// Row 199 (design section 6): write the token to the agent's store path (tmp, fsync, rename), then
// `commit` (the business file's reference becomes store), then memory. The token comes from the
// caller's stdin and is never logged. Returns the entry's new status.
issue(instance, service, token, date, commit = () => {}) {
this.#known(instance, service);
if (!day(date)) deny('credential-date');
token = Credentials.#format(token);
const file = this.#storePath(instance, service);
const agentDir = dirname(file);
mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 });
privateDir(this.#storeRoot);
for (const dir of [dirname(agentDir), agentDir]) {
if (!privateDir(dir, { missing: true })) mkdirSync(dir, { mode: 0o700 });
privateDir(dir);
}
const tmp = file + '.tmp';
try {
unlinkSync(tmp);
if (typeof token !== 'string' || token.length > 16384 || !/^[-A-Za-z0-9._~]+\n?$/.test(token))
deny('credential-format');
token = token.replace(/\n$/, '');
if (token.length < 16) deny('credential-format');
this.#tokens.set(instance + ':' + service, { token, date });
}
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
this.close();
throw e;
}
let fd;
try {
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
writeSync(fd, token + '\n');
fsyncSync(fd);
} finally {
if (fd !== undefined) closeSync(fd);
}
renameSync(tmp, file);
const dirFd = openSync(agentDir, constants.O_RDONLY);
try {
fsyncSync(dirFd);
} finally {
closeSync(dirFd);
}
commit();
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
this.#tokens.set(instance + ':' + service, { token, date });
this.#revoked.delete(instance + ':' + service);
return this.status().find((e) => e.instance === instance && e.service === service);
}
// Memory first, then the store file (design section 6, D12). A file or env reference outside the
// store is never unlinked; the caller turns its reference into store, so a restart finds nothing.
revoke(instance, service) {
const { ref } = this.#known(instance, service);
this.#tokens.delete(instance + ':' + service);
this.#revoked.add(instance + ':' + service);
if (ref.store) {
try {
unlinkSync(ref.file);
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
}
}
return { file: Boolean(ref.store) };
}
// revoke for every service, then the agent's dir. A file other than a token or its tmp refuses.
remove(instance) {
const services = [...this.#refs.keys()].filter((k) => k.startsWith(instance + ':')).map((k) => k.slice(instance.length + 1));
for (const service of services) this.revoke(instance, service);
const agentDir = dirname(this.#storePath(instance, 'gitea'));
let names;
try {
names = readdirSync(agentDir);
} catch (e) {
if (e.code === 'ENOENT') return { services };
deny('credential-file');
}
if (names.some((n) => !/^(gitea|vikunja)(\.tmp)?$/.test(n))) deny('credential-file');
for (const n of names) unlinkSync(join(agentDir, n));
rmdirSync(agentDir);
return { services };
}
// The agent-facing use (design D5): the key comes only from the broker's verified session.
async useFor(session, service, operation) {
if (!session || typeof session.business !== 'string' || typeof session.role !== 'string' || session.role.startsWith('@'))
deny('credential-not-yours');
return this.use(session.business + '/' + session.role, service, operation);
}
assertClean(value) {
let encoded;
@@ -247,16 +109,14 @@ export class Credentials {
let result;
try {
result = await operation(entry.token);
} catch (e) {
// Row 199: a host step's own refusal (publish) keeps its code; anything else is the service's.
if (e instanceof BusError) throw e;
} catch {
deny('service-failed');
}
this.assertClean(result);
return result;
}
status() {
return [...this.#refs.entries()].map(([key, { date }]) => {
return [...this.#tokens.entries()].map(([key, { date }]) => {
const i = key.lastIndexOf(':'),
service = key.slice(i + 1),
left = Date.parse(date + 'T00:00:00Z') - Date.now();
@@ -264,11 +124,8 @@ export class Credentials {
instance: key.slice(0, i),
service,
date,
state: !this.#tokens.has(key)
? this.#revoked.has(key)
? 'revoked'
: 'not-issued'
: service === 'gitea'
state:
service === 'gitea'
? left <= 0
? 'rotation-due'
: 'valid'
@@ -282,6 +139,5 @@ export class Credentials {
}
close() {
this.#tokens.clear();
this.#refs.clear();
}
}
+1 -13
View File
@@ -14,11 +14,6 @@ const LAUNCH_OPS = new Set([
'checkLand',
'recordLand',
'landRefused',
'checkPublish',
'publishPush',
'publishResult',
'recordPublish',
'publishRefused',
'landHeld',
'threadsState',
'threadIdle',
@@ -39,12 +34,6 @@ function launchOp(m) {
if (m.op === 'checkLand') return runtime.checkLand(m.cap, m.args);
if (m.op === 'recordLand') return runtime.recordLand(m.ticket, m.land);
if (m.op === 'landRefused') return runtime.landRefused(m.ticket, m.args);
// Row 199 (#1697, design D9b): the git socket's publish op. No entry returns a token.
if (m.op === 'checkPublish') return runtime.checkPublish(m.cap, m.args);
if (m.op === 'publishPush') return runtime.publishPush(m.ticket);
if (m.op === 'publishResult') return runtime.publishResult(m.ticket);
if (m.op === 'recordPublish') return runtime.recordPublish(m.ticket, m.pushed);
if (m.op === 'publishRefused') return runtime.publishRefused(m.ticket, m.args);
if (m.op === 'landHeld') return runtime.landHeld(m.record);
// Row 121 (#1615): the launcher's view of the business's threads, and its idle release.
if (m.op === 'threadsState') return runtime.threadsState(m.business);
@@ -110,8 +99,7 @@ if (!process.send) {
booted = true;
clearTimeout(timer);
// `trackers` is S3's plain-data boot config; the adapter is loaded only when a business has one.
// Row 199: publishTls is the tests' scratch CA; no boot config sets it.
const { trackers, publishTls: _tls, ...config } = message.config ?? {};
const { trackers, ...config } = message.config ?? {};
if (trackers) {
const { tasksAdapter } = await import('../../tasks/src/adapter.mjs');
config.tasks = tasksAdapter({ trackers });
+1 -56
View File
@@ -8,12 +8,9 @@ import { Credentials } from './credentials.mjs';
import { serve } from './server.mjs';
import { launchSettings } from '../../business/src/launch-settings.mjs';
import { readSessions } from '../../seat/src/session.mjs';
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { organizationWriter } from '../../business/src/organization.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { webService } from './web.mjs';
import { verifyHuman } from './human.mjs';
import { createUsage } from './usage.mjs';
@@ -75,9 +72,6 @@ export async function startBroker({
repoRoots = [],
tasks = null,
organization = null,
// Row 199: the tests' scratch CA for publish's git (`-c http.sslCAInfo=...`). process.mjs drops
// it from the boot config, so only an in-process caller sets it.
publishTls = [],
}) {
let store, credentials, server, adapter;
try {
@@ -92,9 +86,7 @@ export async function startBroker({
...repoRoots,
...Object.values(businesses).flatMap((b) => Object.values(b.projects ?? {}).map((p) => p.root)),
];
// Row 199 (#1697, design D1): the per-agent store is <configDir>/secrets/agents.
const storeRoot = organization ? join(organization.dir, 'secrets', 'agents') : null;
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot });
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot });
store = new Store(dataRoot);
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
@@ -141,11 +133,6 @@ export async function startBroker({
}:null,
secretCheck: (value) => credentials.assertClean(value),
credentialStatus: () => credentials.status(),
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete, live with no restart; each
// rewrites or records through the organization writer, so changes.jsonl holds the receipt.
credentialVerbs: organization
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) })
: null,
// Row 65: a new launch binds only on a harness its business switches on, read from the file
// as it stands now; an unreadable file switches none on.
harnessOn: (id, name) => {
@@ -225,42 +212,6 @@ export async function startBroker({
// Row 137 (#1631): the git socket's land op. checkLand's refusal is the run's, like checkPush's.
// A landing then ends in recordLand or landRefused under its ticket, which outlive the run's cap.
const checkLand = (cap, args) => refusing(cap, () => broker.checkLand(cap, args), args?.task_ref);
// Row 199 (#1697, design D9b): the git socket's publish op. checkPublish's refusal is the run's.
// The gitea token is used only under the open ticket, keyed from the agent checkPublish checked,
// and the op table returns no token. A publish then ends in recordPublish or publishRefused,
// which files any refusal once, withGitea's included.
const checkPublish = (cap, args) => refusing(cap, () => broker.checkPublish(cap, args), args?.task_ref);
const withGitea = async (ticket, fn) => credentials.useFor(broker.publishSession(ticket), 'gitea', fn);
// The push itself runs here, where the token is (D9 step 5), and the git socket's host polls
// for its end: the host's op channel answers one op at a time within 10 s, and ls-remote and
// push can take longer. Fork, user, source and dst all come from the ticket. A WorkingError's
// code is the refusal; git's stderr stays here.
const remotes = new Map();
function publishPush(ticket) {
if (remotes.has(ticket)) throw new BusError('publish-busy');
const { business, project, fork, user, source, dst } = broker.publishTarget(ticket);
const entry = { state: 'running' };
remotes.set(ticket, entry);
withGitea(ticket, (token) =>
publishRemote({ repo: workingRepo(dataRoot, business, project), fork, user, source, dst, token, tls: publishTls }).catch((e) => {
throw e instanceof WorkingError ? new BusError(e.code) : e;
}),
).then(
(r) => Object.assign(entry, { state: 'pushed', ...r }),
(e) => {
// Not a refusal: the error's name and code only, never a message that could carry git's output.
if (!(e instanceof BusError)) process.stderr.write(`bus: publish git-failed: ${JSON.stringify({ name: e?.name, code: e?.code })}\n`);
Object.assign(entry, { state: 'refused', code: e instanceof BusError ? e.code : 'git-failed' });
},
);
return { state: 'running' };
}
function publishResult(ticket) {
const entry = remotes.get(ticket);
if (!entry) throw new BusError('publish-unknown');
if (entry.state !== 'running') remotes.delete(ticket);
return { ...entry };
}
// Metadata only (service, date, state); the token stays in Credentials.
function credentialStatus(business, role) {
return credentials
@@ -286,12 +237,6 @@ export async function startBroker({
checkLand,
recordLand: (ticket, land) => broker.recordLand(ticket, land),
landRefused: (ticket, args) => broker.landRefused(ticket, args),
checkPublish,
withGitea,
publishPush,
publishResult,
recordPublish: (ticket, pushed) => broker.recordPublish(ticket, pushed),
publishRefused: (ticket, args) => broker.publishRefused(ticket, args),
landHeld: (record) => broker.landHeld(record),
// Row 156 (#1651): the launcher's landing pass.
landHolders: (business) => broker.landHolders(business),
@@ -1,182 +0,0 @@
// Row 199 (#1697, design D1, D3a, D4, section 6, T1 and T5): the per-agent credential store under
// <configDir>/secrets/agents/<business>/<agent>/<service>. Fake tokens in a scratch root only.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Credentials } from '../src/credentials.mjs';
const TOKEN = 'fake_store_token_0123456789';
const ROTATED = 'fake_rotated_token_9876543210';
const AGENT = { business: 'acme', role: 'coder' };
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-store-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const storeRoot = join(root, 'secrets', 'agents');
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets'), 0o700);
const file = join(storeRoot, 'acme', 'coder', 'gitea');
const references = { 'acme/coder': { gitea: { service: 'gitea', store: true, file, rotateBy: '2099-01-01' } } };
const open = (extra = {}) => new Credentials({ references, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data', ...extra });
const state = (c) => c.status().find((e) => e.instance === 'acme/coder' && e.service === 'gitea').state;
const read = (c, session = AGENT) => c.useFor(session, 'gitea', async (token) => ({ same: token === TOKEN, rotated: token === ROTATED }));
return { root, storeRoot, file, references, open, state, read };
}
test('T1: a store reference with no file yet starts, and the agent is not-issued until issue', async (t) => {
const s = setup(t);
const c = s.open();
assert.equal(s.state(c), 'not-issued');
await assert.rejects(s.read(c), /credential-unavailable/);
const status = c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
assert.equal(status.state, 'valid');
assert.deepEqual(await s.read(c), { same: true, rotated: false });
assert.equal(statSync(s.file).mode & 0o777, 0o600);
for (const dir of [join(s.storeRoot, 'acme'), join(s.storeRoot, 'acme', 'coder')]) assert.equal(statSync(dir).mode & 0o777, 0o700);
assert.deepEqual(readdirSync(join(s.storeRoot, 'acme', 'coder')), ['gitea']);
// A restart reads the issued file.
assert.deepEqual(await s.read(s.open()), { same: true, rotated: false });
});
test('T1: a store path the reference does not own, or no store root, refuses the reference', (t) => {
const s = setup(t);
const other = join(s.storeRoot, 'acme', 'pm', 'gitea');
assert.throws(() => s.open({ references: { 'acme/coder': { gitea: { store: true, file: other, rotateBy: '2099-01-01' } } } }), /credential-reference/);
assert.throws(() => new Credentials({ references: s.references, repoRoots: [], dataRoot: '/nonexistent-data' }), /credential-reference/);
assert.throws(() => s.open({ references: { 'acme/@tester': { gitea: { store: true, file: join(s.storeRoot, 'acme', '@tester', 'gitea'), rotateBy: '2099-01-01' } } } }),
/credential-reference/);
});
test('T1: a wrong mode on the store root, a business dir, an agent dir or the file refuses start', (t) => {
const s = setup(t);
s.open().issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
for (const [path, bad, good] of [
[s.storeRoot, 0o750, 0o700],
[join(s.storeRoot, 'acme'), 0o705, 0o700],
[join(s.storeRoot, 'acme', 'coder'), 0o770, 0o700],
[s.file, 0o640, 0o600],
]) {
chmodSync(path, bad);
assert.throws(() => s.open(), /credential-file/, path);
chmodSync(path, good);
}
s.open();
});
test('T1: a missing store root refuses start; a missing {file} reference still refuses start', (t) => {
const s = setup(t);
rmSync(s.storeRoot, { recursive: true });
assert.throws(() => s.open(), /credential-file/);
assert.throws(() => s.open({ references: { coder: { gitea: { file: join(s.root, 'absent'), rotateBy: '2099-01-01' } } } }), /credential-file/);
});
test('T1: a store file that is a directory or a symlink refuses start', (t) => {
const s = setup(t);
mkdirSync(join(s.storeRoot, 'acme', 'coder'), { recursive: true, mode: 0o700 });
chmodSync(join(s.storeRoot, 'acme'), 0o700);
mkdirSync(s.file, { mode: 0o700 });
assert.throws(() => s.open(), /credential-file/);
rmSync(s.file, { recursive: true });
const target = join(s.root, 'target');
writeFileSync(target, TOKEN + '\n', { mode: 0o600 });
symlinkSync(target, s.file);
assert.throws(() => s.open(), /credential-file/);
});
test('T2: useFor keys from the session only', async (t) => {
const s = setup(t);
const c = s.open();
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
await assert.rejects(s.read(c, { business: 'acme', role: 'pm' }), /credential-unavailable/);
await assert.rejects(s.read(c, { business: 'acme', role: '@tester' }), /credential-not-yours/);
await assert.rejects(s.read(c, null), /credential-not-yours/);
await assert.rejects(s.read(c, { business: 'acme' }), /credential-not-yours/);
});
test('T5: issue, rotate, revoke and delete are live with no restart, and revoke holds across one', async (t) => {
const s = setup(t);
const c = s.open();
let committed = 0;
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01', () => committed++);
assert.equal(committed, 1);
// Rotate is a second issue: the new token is used at once, with no tmp file left.
c.issue('acme/coder', 'gitea', ROTATED + '\n', '2099-06-01');
assert.deepEqual(await s.read(c), { same: false, rotated: true });
assert.equal(c.status()[0].date, '2099-06-01');
assert.deepEqual(readdirSync(join(s.storeRoot, 'acme', 'coder')), ['gitea']);
// Revoke: memory first, then the file.
assert.deepEqual(c.revoke('acme/coder', 'gitea'), { file: true });
assert.equal(s.state(c), 'revoked');
assert.equal(existsSync(s.file), false);
await assert.rejects(s.read(c), /credential-unavailable/);
const restarted = s.open();
await assert.rejects(s.read(restarted), /credential-unavailable/);
assert.equal(s.state(restarted), 'not-issued');
// Delete removes the agent's dir.
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
assert.deepEqual(c.remove('acme/coder'), { services: ['gitea'] });
assert.equal(existsSync(join(s.storeRoot, 'acme', 'coder')), false);
await assert.rejects(s.read(c), /credential-unavailable/);
});
test('T5: a failed commit leaves the token on disk but not in memory, and a bad token or date writes nothing', async (t) => {
const s = setup(t);
const c = s.open();
assert.throws(() => c.issue('acme/coder', 'gitea', 'short', '2099-01-01'), /credential-format/);
assert.throws(() => c.issue('acme/coder', 'gitea', TOKEN, '2099-13-01'), /credential-date/);
assert.throws(() => c.issue('acme/pm', 'gitea', TOKEN, '2099-01-01'), /credential-unknown/);
assert.equal(existsSync(s.file), false);
assert.throws(() => c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01', () => { throw new Error('commit failed'); }), /commit failed/);
assert.equal(existsSync(s.file), true);
assert.equal(s.state(c), 'not-issued');
await assert.rejects(s.read(c), /credential-unavailable/);
});
test('T5: revoke of a {file} reference forgets the token and never unlinks the file', async (t) => {
const s = setup(t);
const path = join(s.root, 'outside-token');
writeFileSync(path, TOKEN + '\n', { mode: 0o600 });
const c = s.open({ references: { 'acme/coder': { gitea: { file: path, rotateBy: '2099-01-01' } } } });
assert.deepEqual(await s.read(c), { same: true, rotated: false });
assert.deepEqual(c.revoke('acme/coder', 'gitea'), { file: false });
assert.equal(existsSync(path), true);
await assert.rejects(s.read(c), /credential-unavailable/);
});
test('T5: delete refuses an agent dir holding anything but tokens', (t) => {
const s = setup(t);
const c = s.open();
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
writeFileSync(join(s.storeRoot, 'acme', 'coder', 'notes'), 'x', { mode: 0o600 });
assert.throws(() => c.remove('acme/coder'), /credential-file/);
assert.equal(existsSync(join(s.storeRoot, 'acme', 'coder', 'notes')), true);
});
// Design D5: the name-keyed use() is the host's own, for @sync and the tracker's start check of each
// role's token. Every other credential use goes through useFor, keyed from the broker's session. A
// new use( call site outside this list fails here.
test('D5: every credentials use( call site is on the allowlist', () => {
const packages = new URL('../../', import.meta.url);
const allowed = new Map([
['bus/src/credentials.mjs', 1], // useFor's own call
['tasks/src/adapter.mjs', 1], // the host's use(instance) for @sync and probe
]);
const found = new Map();
const walk = (dir, rel) => {
for (const name of readdirSync(dir)) {
if (name === 'node_modules' || name === 'tests') continue;
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path, `${rel}${name}/`);
else if (name.endsWith('.mjs') || name.endsWith('.js')) {
const code = readFileSync(path, 'utf8').split('\n').filter((line) => !/^\s*\/\//.test(line)).join('\n');
const n = (code.match(/(?:credentials|this)\.use\(/g) ?? []).length;
if (n) found.set(`${rel}${name}`, n);
}
}
};
for (const pkg of readdirSync(packages)) if (statSync(new URL(`${pkg}/src`, packages), { throwIfNoEntry: false })?.isDirectory())
walk(new URL(`${pkg}/src`, packages).pathname, `${pkg}/src/`);
assert.deepEqual(Object.fromEntries([...found].sort()), Object.fromEntries([...allowed].sort()));
});
@@ -1,163 +0,0 @@
// Row 199 (#1697, design section 6, T4, T5): credentials.issue, credentials.revoke and
// credentials.delete through the runtime. Human-only; the tester revokes and never issues. Each verb
// is live with no restart and leaves one receipt in changes.jsonl with no value in it. Fake tokens in a
// scratch root only.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { Broker, CREDENTIAL_VERBS } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
const TOKEN = 'fake_issued_token_0123456789';
const ROTATED = 'fake_rotated_token_9876543210';
const OTHER = 'fake_other_token_5555555555';
const DATE = '2099-01-01';
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
async function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE };
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
// The bus's resolved business carries the same references; a store one names its derived file.
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
roles: {
coder: { ...role, credentials: { gitea: { store: true, file: store('coder', 'gitea'), rotateBy: DATE }, vikunja: doc.roles.coder.credentials.vikunja } },
reviewer: { ...role, credentials: doc.roles.reviewer.credentials },
},
};
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(() => bus.close());
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const tester = bus.broker.bindTester({ business: 'demo' });
const ask = (cap, verb, args) => bus.broker.request(cap, { verb, args });
const file = () => JSON.parse(readFileSync(join(root, 'businesses/demo.json'), 'utf8'));
const receipts = () => {
const log = join(root, 'businesses/.history/demo/changes.jsonl');
return existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse) : [];
};
const committed = () => receipts().filter((r) => r.state === 'committed');
const status = (agent) => Object.fromEntries(bus.credentialStatus('demo', agent).map((e) => [e.service, e.state]));
return { root, bus, human, tester, ask, file, receipts, committed, status, store };
}
test('row 199: the lifecycle verbs refuse every session but the human, and the tester revokes only', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-gate-'));
const s = new Store(root);
t.after(() => { s.close(); rmSync(root, { recursive: true, force: true }); });
const calls = [];
const b = new Broker({
store: s,
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, roles: { coder: role } } },
credentialVerbs: (id, verb, args, actor) => (calls.push([verb, actor]), { ok: true }),
});
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const tester = b.bindTester({ business: 'demo' });
const reader = b.bindReader({ business: 'demo' });
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' });
const args = {
'credentials.issue': { agent: 'coder', service: 'gitea', token: TOKEN, date: DATE },
'credentials.revoke': { agent: 'coder', reason: 'fired' },
'credentials.delete': { agent: 'coder', reason: 'fired' },
};
assert.deepEqual(CREDENTIAL_VERBS, Object.keys(args));
for (const verb of CREDENTIAL_VERBS) {
assert.throws(() => b.request(reader, { verb, args: args[verb] }), /human-required/, verb);
assert.throws(() => b.request(coder, { verb, args: args[verb] }), /human-required/, verb);
}
assert.throws(() => b.request(tester, { verb: 'credentials.issue', args: args['credentials.issue'] }), /tester-refused/);
assert.throws(() => b.request(tester, { verb: 'credentials.delete', args: args['credentials.delete'] }), /tester-refused/);
// Keys: a token on revoke, a missing reason, an unknown service, an unknown agent, an @ agent.
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { ...args['credentials.revoke'], token: TOKEN } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { agent: 'coder' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], service: 'model' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], file: '/x' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], agent: 'pm' } }), /credential-unknown/);
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { agent: '@sync', reason: 'x' } }), /credential-unknown/);
assert.equal(calls.length, 0);
// A refusal records its code alone, never the token.
const refused = s.all("SELECT body FROM events WHERE kind='action.refused'");
assert.ok(refused.length >= 10);
for (const { body } of refused) assert.equal(body.includes(TOKEN), false);
// delete waits for the agent's claim to end.
b.request(coder, { verb: 'role.claim' });
assert.throws(() => b.request(human, { verb: 'credentials.delete', args: args['credentials.delete'] }), /agent-running/);
b.request(coder, { verb: 'role.release' });
for (const verb of CREDENTIAL_VERBS) b.request(human, { verb, args: args[verb] });
b.request(tester, { verb: 'credentials.revoke', args: args['credentials.revoke'] });
assert.deepEqual(calls, [['credentials.issue', 'jason'], ['credentials.revoke', 'jason'], ['credentials.delete', 'jason'], ['credentials.revoke', 'tester']]);
// No lifecycle without the runtime's store.
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } } } });
const h2 = bare.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
assert.throws(() => bare.request(h2, { verb: 'credentials.revoke', args: args['credentials.revoke'] }), /not-configured/);
});
test('T5: issue, rotate, revoke and delete are live, rewrite the business file and leave one receipt each, with no value anywhere', async (t) => {
const c = await setup(t);
const coder = { business: 'demo', role: 'coder' };
const read = (service) => c.bus.credentials.useFor(coder, service, async (token) => token === TOKEN ? 'issued' : token === ROTATED ? 'rotated' : 'other');
assert.deepEqual(c.status('coder'), { gitea: 'not-issued', vikunja: 'valid' });
// issue: the file, then the business file's reference, then memory.
const issued = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN + '\n', date: DATE });
assert.deepEqual(issued, { agent: 'coder', verb: 'issue', service: 'gitea', date: DATE, state: 'valid' });
assert.equal(await read('gitea'), 'issued');
assert.equal(statSync(c.store('coder', 'gitea')).mode & 0o777, 0o600);
assert.deepEqual(c.file().roles.coder.credentials.gitea, { store: true, rotateBy: DATE });
let last = c.committed().at(-1);
assert.equal(last.actor, 'jason');
assert.deepEqual(last.detail, { verb: 'issue', agent: 'coder', kind: ['gitea'], date: DATE });
// The same token again is one the bus holds: the secret check refuses it before any write.
const count = c.receipts().length;
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN, date: DATE }), /credential-leak/);
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: 'short', date: DATE }), /credential-format/);
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: OTHER, date: '2099-02-30' }), /credential-date/);
assert.equal(c.receipts().length, count);
// rotate: an issue over an issued entry, used at once.
const rotated = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: ROTATED, date: '2099-06-01' });
assert.equal(rotated.verb, 'rotate');
assert.equal(await read('gitea'), 'rotated');
assert.deepEqual(c.file().roles.coder.credentials.gitea, { store: true, rotateBy: '2099-06-01' });
assert.deepEqual(c.committed().at(-1).detail, { verb: 'rotate', agent: 'coder', kind: ['gitea'], date: '2099-06-01' });
// revoke of a store entry by the tester: memory and file, no document change, a receipt.
const bytes = readFileSync(join(c.root, 'businesses/demo.json'), 'utf8');
const revoked = c.ask(c.tester, 'credentials.revoke', { agent: 'coder', service: 'gitea', reason: 'test' });
assert.deepEqual(revoked, { agent: 'coder', verb: 'revoke', services: [{ service: 'gitea', state: 'revoked' }] });
await assert.rejects(read('gitea'), /credential-unavailable/);
assert.equal(existsSync(c.store('coder', 'gitea')), false);
assert.equal(readFileSync(join(c.root, 'businesses/demo.json'), 'utf8'), bytes);
last = c.committed().at(-1);
assert.equal(last.actor, 'tester');
assert.deepEqual(last.changes, []);
assert.deepEqual(last.detail, { verb: 'revoke', agent: 'coder', kind: ['gitea'], reason: 'test' });
// revoke of a {file} reference: memory, the outside file kept, the reference becomes store.
const outside = c.file().roles.coder.credentials.vikunja.file;
c.ask(c.human, 'credentials.revoke', { agent: 'coder', service: 'vikunja', reason: 'moved' });
await assert.rejects(read('vikunja'), /credential-unavailable/);
assert.equal(existsSync(outside), true);
assert.deepEqual(c.file().roles.coder.credentials.vikunja, { store: true, expires: DATE });
assert.ok(c.committed().at(-1).changes.some((e) => e.path === 'roles.coder.credentials.vikunja.store' && e.new === true));
// delete: every kind revoked, the agent's dir gone, both references store.
c.ask(c.human, 'credentials.issue', { agent: 'reviewer', service: 'gitea', token: OTHER, date: DATE });
assert.equal(existsSync(c.store('reviewer', 'gitea')), true);
const deleted = c.ask(c.human, 'credentials.delete', { agent: 'reviewer', reason: 'fired' });
assert.deepEqual(deleted.services.map((e) => e.state), ['revoked', 'revoked']);
assert.equal(existsSync(join(c.root, 'secrets', 'agents', 'demo', 'reviewer')), false);
assert.deepEqual(c.file().roles.reviewer.credentials, { gitea: { store: true, rotateBy: DATE }, vikunja: { store: true, expires: DATE } });
assert.deepEqual(c.committed().at(-1).detail, { verb: 'delete', agent: 'reviewer', kind: ['gitea', 'vikunja'], reason: 'fired' });
// The status read never carries a value.
assert.deepEqual(c.ask(c.human, 'credentials', {}).map((e) => e.state).sort(), ['revoked', 'revoked', 'revoked', 'revoked']);
// T4: no token in any file under the root (receipts, business file, events, logs).
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? walk(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
for (const path of walk(c.root))
for (const token of [TOKEN, ROTATED, OTHER]) assert.equal(readFileSync(path).includes(token), false, path);
});
+3 -7
View File
@@ -10,7 +10,7 @@ import { fixture } from './spec-fixtures.mjs';
// PRD 1.5 REQ-WAIT-1, REQ-WAIT-2 and REQ-DEC-2 (row 139, #1633).
const WAITS = ['spend', 'data.delete', 'network.expose', 'account.create', 'credential.mint', 'message.external',
'git.push.protected', 'git.push.remote', 'deploy'];
'git.push.protected', 'deploy'];
const LEFT = ['git.merge.protected', 'policy.change', 'prd.approve', 'role.revoke'];
const cite = f => ({ project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
const routed = (f, d) => ({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) });
@@ -36,14 +36,10 @@ test('REQ-WAIT-2: a business granting a hard wait at either class is refused; th
});
test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbiter cannot resolve it', async t => {
const f = await fixture(t, { roles: { coder: { definition: 'coder',
authority: { withinRole: ['message.send', 'record.write', 'git.push.working'], crossRole: [] } } } });
// Row 199: a git.push.remote raise binds the task's latest push, so the task has one.
f.broker.recordPush(f.coder, { task_ref: 'vikunja:1/2', project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2',
commit: 'a'.repeat(40), previous: null });
const f = await fixture(t);
for (const action of WAITS) {
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }]) {
const d = await f.raise({ action, ...(action === 'git.push.remote' ? { target: 'vikunja:1/2' } : {}), ...args });
const d = await f.raise({ action, ...args });
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, `${action} ${JSON.stringify(args)}`);
}
}
+2 -2
View File
@@ -22,8 +22,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
"import { MERGE, LandingRefusal, readLanding } from './landing.mjs';",
// Row 141 (#1635): the business ID_PATTERN for instance names. The vocabulary is already in
// RUNNER_FILES and imports nothing. Row 155 (#1649) reads THREAD_DEFINITION and END_PROTECTED
// from it too. Row 199 (#1697) reads the D9c fork parse, forkProblem and forkUrl, from it.
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl } from '../../business/src/vocabulary.mjs';",
// from it too.
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
-274
View File
@@ -1,274 +0,0 @@
// Row 199 (#1697, design D9 steps 1 to 3 and 6, D9b): the broker side of publish. git.push.remote
// is a hard wait; its decision binds the task's latest push (pushCommit). checkPublish builds the
// agent's fork from the project's upstream and the agent's own Gitea user; recordPublish spends the
// decision; publishRefused files the refusal.
import test from 'node:test';
import assert from 'node:assert/strict';
import { fork } from 'node:child_process';
import { once } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
const TASK = 'vikunja:1/2';
const A = 'a'.repeat(40);
const B = 'b'.repeat(40);
const C = 'c'.repeat(40);
const identity = (user) => ({ name: user, email: `${user}@example.com`, gitea: { url: 'https://git.example', user } });
const pusher = { withinRole: ['git.push.working', 'message.send'], crossRole: [] };
function businesses(upstream = 'https://git.example/mosaic/stack') {
return {
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'reviewer', delivery: 'reviewer' },
projects: { stack: { root: '/nonexistent', remote: upstream }, books: { root: '/nonexistent-books' } },
roles: {
coder: { authority: pusher, identity: identity('acme-coder'), publish: ['stack'] },
second: { authority: pusher, identity: identity('acme-second'), publish: ['stack'] },
plain: { authority: pusher },
reviewer: { authority: { withinRole: ['message.send'], crossRole: [] } },
},
},
};
}
function setup(t, upstream) {
const root = mkdtempSync(join(tmpdir(), 'bus-publish-'));
const store = new Store(root);
const b = new Broker({ store, businesses: businesses(upstream) });
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const caps = {};
for (const role of ['coder', 'second', 'plain', 'reviewer']) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
b.request(caps[role], { verb: 'role.claim', args: {} });
}
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const call = (cap, verb, args) => b.request(cap, { verb, args });
const push = (commit, previous = null, role = 'coder', project = 'stack') => {
b.checkPush(caps[role], TASK);
return b.recordPush(caps[role], { task_ref: TASK, project, branch: 'feat', ref: 'work/vikunja-stack-2', commit, previous });
};
const raise = (role = 'coder') =>
call(caps[role], 'decision.raise', {
action: 'git.push.remote', target: TASK, task_ref: TASK, question: 'Publish?', blocking: false, recommendation: 'yes',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }],
});
const approve = (role = 'coder') => {
const d = raise(role);
call(human, 'decision.resolve', { id: d.id, choice: 'yes' });
return d;
};
const publish = (decision, role = 'coder', extra = {}) =>
b.checkPublish(caps[role], { task_ref: TASK, project: 'stack', branch: 'feat', ...(decision ? { decision: decision.id } : {}), ...extra });
const events = (kind) => store.all('SELECT * FROM events WHERE kind=? ORDER BY seq', kind).map((e) => ({ ...e, body: JSON.parse(e.body) }));
return { b, store, caps, human, call, push, raise, approve, publish, events };
}
test('a raise with no recorded push refuses no-push; a raise binds the latest push and shows it', (t) => {
const f = setup(t);
assert.throws(() => f.raise(), /no-push/);
f.push(A);
const d = f.raise();
assert.match(d.question, new RegExp(`commit ${A}\\)$`));
const raised = f.events('action.allowed').find((e) => e.body.operation === 'decision.raise');
assert.equal(raised.body.pushCommit, A);
assert.deepEqual({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) }, { class: 'gated', route_to: 'human' });
});
test('happy path: the fork is the upstream repo under the agent\'s own user, the destination is host-built', (t) => {
const f = setup(t);
f.push(A);
const d = f.approve();
const r = f.publish(d);
assert.deepEqual({ ...r, ticket: typeof r.ticket }, {
ticket: 'string', task_ref: TASK, project: 'stack', fork: 'https://git.example/acme-coder/stack',
user: 'acme-coder', email: '[email protected]', source: A, dst: 'refs/heads/acme-coder/feat',
});
const done = f.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null });
assert.deepEqual(done, { task_ref: TASK, fork: r.fork, ref: r.dst, commit: A, previous: null });
const allowed = f.events('action.allowed').filter((e) => e.body.action === 'git.push.remote' && e.body.operation !== 'decision.raise');
assert.equal(allowed.length, 1);
assert.equal(allowed[0].subject, TASK);
assert.equal(allowed[0].body.decision, d.id);
assert.equal(allowed[0].body.fork, r.fork);
// Spent once.
assert.throws(() => f.publish(d), /decision-consumed/);
assert.throws(() => f.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null }), /publish-unknown/);
});
test('a hard wait: no decision refuses, and a decision for another task refuses', (t) => {
const f = setup(t);
f.push(A);
assert.throws(() => f.publish(null), /decision-required/);
const other = f.call(f.caps.coder, 'decision.raise', {
action: 'deploy', target: 'vikunja:1/3', question: 'Ship?', blocking: false, recommendation: 'yes',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }],
});
f.call(f.human, 'decision.resolve', { id: other.id, choice: 'yes' });
assert.throws(() => f.publish(other), /decision-mismatch/);
const d = f.raise();
assert.throws(() => f.publish(d), /decision-not-approved/);
});
test('r4 B4: approve while the latest push is A, push B, publish refuses decision-mismatch', (t) => {
const f = setup(t);
f.push(A);
const d = f.approve();
f.push(B, A);
assert.throws(() => f.publish(d), /decision-mismatch/);
// And a push between checkPublish and recordPublish refuses the record.
const f2 = setup(t);
f2.push(A);
const d2 = f2.approve();
const r = f2.publish(d2);
f2.push(C, A);
assert.throws(() => f2.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null }), /decision-mismatch/);
assert.throws(() => f2.b.recordPublish(r.ticket, { ref: r.dst, commit: C, previous: null }), /decision-mismatch/);
});
test('grant and fork refusals', (t) => {
const f = setup(t);
f.push(A, null, 'plain');
// plain has no identity and no grant; its raise still binds the push, the publish refuses.
const d = f.approve('plain');
assert.throws(() => f.publish(d, 'plain'), /remote-not-granted/);
assert.throws(() => f.publish(d, 'plain', { project: 'books' }), /remote-not-granted|decision-mismatch/);
// work-not-yours: second publishes a push coder made.
const g = setup(t);
g.push(A);
const d2 = g.approve('second');
assert.throws(() => g.publish(d2, 'second'), /work-not-yours/);
});
test('remote-not-fork, compared without case, and remote-host-mismatch, at push', (t) => {
for (const [upstream, code] of [
['https://git.example/acme-coder/stack', /remote-not-fork/],
['https://git.example/ACME-Coder/stack', /remote-not-fork/],
['https://other.example/mosaic/stack', /remote-host-mismatch/],
['https://git.example:3000/mosaic/stack', /remote-host-mismatch/],
]) {
const f = setup(t, upstream);
f.push(A);
const d = f.approve();
assert.throws(() => f.publish(d), code, upstream);
}
});
test('two agents granted one project each get their own fork', (t) => {
const f = setup(t);
f.push(A);
const r1 = f.publish(f.approve());
assert.equal(r1.fork, 'https://git.example/acme-coder/stack');
const g = setup(t);
g.push(A, null, 'second');
const r2 = g.publish(g.approve('second'), 'second');
assert.equal(r2.fork, 'https://git.example/acme-second/stack');
assert.equal(r2.dst, 'refs/heads/acme-second/feat');
});
test('the request cannot name the fork, the user or the destination', (t) => {
const f = setup(t);
f.push(A);
const d = f.approve();
for (const extra of [{ fork: 'https://git.example/x/stack' }, { dst: 'refs/heads/main' }, { user: 'x' }, { remote: 'https://x' }])
assert.throws(() => f.publish(d, 'coder', extra), /invalid-request/);
});
test('publishRefused files the code on the task\'s trail and drops the ticket', (t) => {
const f = setup(t);
f.push(A);
const r = f.publish(f.approve());
assert.equal(f.b.publishRefused(r.ticket, { code: 'remote-unrelated' }), 'remote-unrelated');
const refused = f.events('action.refused').filter((e) => e.subject === TASK);
assert.deepEqual(refused.at(-1).body, { code: 'remote-unrelated' });
assert.throws(() => f.b.publishRefused(r.ticket, { code: 'x' }), /publish-unknown/);
});
test('git.push.remote is in neither ROUTED list and no shipped role grants it', async () => {
const { ACTIONS, HARD_WAITS } = await import('../src/broker.mjs');
const vocabulary = await import('../../business/src/vocabulary.mjs');
assert.ok(ACTIONS.includes('git.push.remote') && HARD_WAITS.includes('git.push.remote'));
assert.ok(!vocabulary.ROUTED.includes('git.push.remote'));
const { readdirSync, readFileSync } = await import('node:fs');
const dir = new URL('../../../roles/', import.meta.url);
for (const name of readdirSync(dir).filter((n) => n.endsWith('.json')))
assert.ok(!readFileSync(new URL(name, dir), 'utf8').includes('git.push.remote'), name);
});
// D1 and D9b through the real startBroker: the store is <configDir>/secrets/agents, and the gitea
// token is used only under an open publish ticket, keyed from the agent checkPublish checked.
test('runtime: withGitea keys from the open ticket only, and the store root is the organization dir', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { chmodSync, mkdirSync } = await import('node:fs');
const root = mkdtempSync(join(tmpdir(), 'bus-publish-rt-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
// The runtime reads the harness switch from the organization file as it stands.
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets'), 0o700);
const demo = businesses().demo;
demo.roles.coder.credentials = { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: '2099-01-01' } };
const organization = { dir: root, rolesDir, system: systemFor(root) };
// No organization, no store root: a store reference refuses the start.
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo } }), /credential-reference/);
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization });
t.after(() => bus.close());
assert.deepEqual(bus.credentialStatus('demo', 'coder'), [{ service: 'gitea', date: '2099-01-01', state: 'not-issued' }]);
const coder = bus.broker.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' });
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
bus.broker.request(coder, { verb: 'role.claim', args: {} });
bus.checkPush(coder, TASK);
bus.recordPush(coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2', commit: A, previous: null });
const d = bus.broker.request(coder, { verb: 'decision.raise', args: {
action: 'git.push.remote', target: TASK, task_ref: TASK, question: 'Publish?', blocking: false, recommendation: 'yes',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }] } });
bus.broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
assert.throws(() => bus.checkPublish(coder, { task_ref: TASK, project: 'stack', branch: 'feat' }), /decision-required/);
const ticket = bus.checkPublish(coder, { task_ref: TASK, project: 'stack', branch: 'feat', decision: d.id }).ticket;
await assert.rejects(bus.withGitea(ticket, async () => 1), /credential-unavailable/);
bus.credentials.issue('demo/coder', 'gitea', 'fake_publish_token_0123456789', '2099-01-01');
assert.equal(await bus.withGitea(ticket, async (token) => token === 'fake_publish_token_0123456789'), true);
await assert.rejects(bus.withGitea(ticket, async (token) => ({ echo: token })), /credential-leak/);
await assert.rejects(bus.withGitea('not-a-ticket', async () => 1), /publish-unknown/);
await assert.rejects(bus.withGitea(coder, async () => 1), /publish-unknown/);
bus.recordPublish(ticket, { ref: 'refs/heads/acme-coder/feat', commit: A, previous: null });
await assert.rejects(bus.withGitea(ticket, async () => 1), /publish-unknown/);
});
// Row 199 (D9b, deviation 11): the bus process answers the socket's five publish ops from their own
// launchOp lines. A LAUNCH_OPS name without one would get launch-op-refused. A boot config that
// carries publishTls still boots; process.mjs drops it before startBroker.
test('the bus process answers the publish ops from their own lines, not launch-op-refused', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-publish-ops-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const child = fork(new URL('../src/process.mjs', import.meta.url), [], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
t.after(() => {
if (child.exitCode === null) child.kill('SIGKILL');
});
const ask = async (m) => {
const reply = once(child, 'message');
child.send(m);
return (await reply)[0];
};
const config = { dataRoot: root, businesses: businesses(), launches: [], readers: [], publishTls: ['-c', 'http.sslCAInfo=/nonexistent'] };
assert.equal((await ask({ op: 'boot', config })).ok, true);
const unknown = 'not-a-ticket';
assert.deepEqual(await ask({ op: 'publishPush', ticket: unknown, id: 1 }), { ok: false, error: 'publish-unknown', id: 1 });
assert.deepEqual(await ask({ op: 'publishResult', ticket: unknown, id: 2 }), { ok: false, error: 'publish-unknown', id: 2 });
assert.deepEqual(await ask({ op: 'recordPublish', ticket: unknown, pushed: { ref: 'refs/heads/x/y', commit: A, previous: null }, id: 3 }), { ok: false, error: 'publish-unknown', id: 3 });
assert.deepEqual(await ask({ op: 'publishRefused', ticket: unknown, args: { code: 'x' }, id: 4 }), { ok: false, error: 'publish-unknown', id: 4 });
assert.deepEqual(await ask({ op: 'checkPublish', cap: 'not-a-cap', args: { task_ref: TASK, project: 'stack', branch: 'feat' }, id: 5 }), { ok: false, error: 'unauthenticated', id: 5 });
const exit = once(child, 'exit');
child.send({ op: 'close' });
assert.equal((await exit)[0], 0);
});
+185
View File
@@ -0,0 +1,185 @@
// The access file, <configDir>/access.json (federation design section 3.5,
// FED R1, #414). It names the extra users who may sign in to this instance
// and the grants that let them reach a business's threads. The validator is
// pure: providers, peers, businesses and the owner's sign-in subjects come in
// as arguments. Any failed rule refuses the whole file, which leaves every
// user with no grant (fail closed).
import { join } from "node:path";
import { refuse } from "./errors.mjs";
import { configDir } from "./business.mjs";
import { isPlainObject, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, readJsonFile, deepFreeze } from "./util.mjs";
// The grant levels. Row 193a widens this to collaborator and viewer.
export const LEVELS = Object.freeze(["collaborator"]);
// Ids a user may never take: the bus's own principals.
const RESERVED_USERS = Object.freeze(["human", "tester"]);
const LOCAL_HOME = "local";
const ALL = "*";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
const SUB_MAX = 255;
export function accessFilePath(dir = configDir()) {
return join(dir, "access.json");
}
function requireArray(value, where) {
if (!Array.isArray(value)) refuse(`${where} must be an array`);
return value;
}
function requireText(value, where) {
if (typeof value !== "string" || value.length === 0 || value.length > SUB_MAX || value.includes("\0")) {
refuse(`${where} must be a non-empty string of at most ${SUB_MAX} characters`);
}
return value;
}
function subjectKey(provider, sub) {
return JSON.stringify([provider, sub]);
}
function checkContext(context) {
const where = "access context";
requireObject(context, where);
rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);
const providers = new Set(requireArray(context.providers, `${where}.providers`).map((p, i) => requireText(p, `${where}.providers[${i}]`)));
const peers = new Set(requireArray(context.peers, `${where}.peers`).map((p, i) => requireId(p, `${where}.peers[${i}]`)));
if (peers.has(LOCAL_HOME)) refuse(`${where}.peers must not name ${JSON.stringify(LOCAL_HOME)}`);
requireObject(context.businesses, `${where}.businesses`);
const businesses = new Map();
for (const [id, business] of Object.entries(context.businesses)) {
const at = `${where}.businesses.${id}`;
requireId(id, `${where}.businesses key`);
requireObject(business, at);
requireId(business.human, `${at}.human`);
const instances = requireArray(business.instances, `${at}.instances`).map((x, i) => requireId(x, `${at}.instances[${i}]`));
businesses.set(id, { human: business.human, instances: new Set(instances) });
}
const owners = new Set();
requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach((s, i) => {
const at = `${where}.ssoSubjects[${i}]`;
requireObject(s, at);
owners.add(subjectKey(requireText(s.provider, `${at}.provider`), requireText(s.sub, `${at}.sub`)));
});
return { providers, peers, businesses, owners };
}
function checkUser(user, at, ctx, humans, seenSubjects) {
requireObject(user, at);
rejectUnknownKeys(user, ["id", "home", "subjects"], at);
requireId(user.id, `${at}.id`);
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)} (got ${JSON.stringify(user.home)})`);
}
const subjects = requireArray(user.subjects, `${at}.subjects`);
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
subjects.forEach((s, i) => {
const sat = `${at}.subjects[${i}]`;
requireObject(s, sat);
rejectUnknownKeys(s, ["provider", "sub"], sat);
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
refuse(`${sat}.provider must be a configured provider (got ${JSON.stringify(s.provider)})`);
}
requireText(s.sub, `${sat}.sub`);
const key = subjectKey(s.provider, s.sub);
if (ctx.owners.has(key)) refuse(`${sat} is an owner sign-in subject (console.sso.subjects)`);
if (seenSubjects.has(key)) refuse(`${sat} appears more than once in the file`);
seenSubjects.add(key);
});
}
// Either ["*"] alone or a non-empty list of distinct entries, each checked.
function checkScope(value, where, check) {
const list = requireArray(value, where);
if (list.length === 0) refuse(`${where} must not be empty`);
if (list.includes(ALL)) {
if (list.length !== 1) refuse(`${where} must be ["*"] alone or list entries without "*"`);
return;
}
const seen = new Set();
list.forEach((entry, i) => {
check(entry, `${where}[${i}]`);
if (seen.has(entry)) refuse(`duplicate entry in ${where}: ${JSON.stringify(entry)}`);
seen.add(entry);
});
}
function checkExpires(value, where) {
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
}
const iso = new Date(value).toISOString();
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
}
function checkGrant(grant, at, ctx, users, seenGrants) {
requireObject(grant, at);
rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);
requireId(grant.id, `${at}.id`);
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
seenGrants.add(grant.id);
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file (got ${JSON.stringify(grant.user)})`);
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
refuse(`${at}.business must name a declared business (got ${JSON.stringify(grant.business)})`);
}
const business = ctx.businesses.get(grant.business);
checkScope(grant.projects, `${at}.projects`, (p, where) => {
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
});
checkScope(grant.instances, `${at}.instances`, (x, where) => {
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
});
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
}
// Check a parsed access document against the instance's context. Returns the
// document frozen. Every refusal is a BusinessError naming the key path. An
// expired grant is not refused: covers() treats it as absent.
export function checkAccess(document, context) {
const ctx = checkContext(context);
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
if (!isPlainObject(document)) refuse("access file must be a JSON object");
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
if (document.accessVersion !== 1) refuse(`accessVersion must be 1 (got ${JSON.stringify(document.accessVersion)})`);
requirePositiveInt(document.revision, "revision");
const users = new Set();
const seenSubjects = new Set();
requireArray(document.users, "users").forEach((user, i) => {
checkUser(user, `users[${i}]`, ctx, humans, seenSubjects);
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id: ${user.id}`);
users.add(user.id);
});
const seenGrants = new Set();
requireArray(document.grants, "grants").forEach((grant, i) => checkGrant(grant, `grants[${i}]`, ctx, users, seenGrants));
return deepFreeze(structuredClone(document));
}
// Read and check the access file. One descriptor, opened without following a
// link, serves the stat checks and the read (readJsonFile). The file holds
// authority, so it must belong to this uid and not be writable by group or
// other, as the business file is.
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
const document = readJsonFile(file, "access file", (stat) => {
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`);
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
});
return checkAccess(document, context);
}
// Does a checked grant cover a thread? `target` is the thread's business, bus
// project id and instance. With `level`, the grant's level must be that one;
// without it, any level covers. An expired grant covers nothing.
export function covers(grant, { business, project, instance }, now, { level } = {}) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
if (grant.business !== business) return false;
if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;
if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;
if (level !== undefined && grant.level !== level) return false;
return true;
}
+8 -57
View File
@@ -10,7 +10,6 @@ import { checkVars } from "./vars.mjs";
import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
import { checkPlugins } from "../../plugins/src/kinds.mjs";
import { forkProblem, giteaName, parseGiteaUrl } from "./vocabulary.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
} from "./util.mjs";
@@ -41,7 +40,7 @@ function checkBot(tracker, where) {
return { bot: tracker.bot, botId: tracker.botId };
}
function checkCredentialMap(map, services, where, store = null) {
function checkCredentialMap(map, services, where) {
requireObject(map, where);
const names = Object.keys(map).sort();
const wanted = [...services].sort();
@@ -49,37 +48,10 @@ function checkCredentialMap(map, services, where, store = null) {
refuse(`${where} must reference exactly the services its role definition needs (${wanted.join(", ") || "none"}; got ${names.join(", ") || "none"})`);
}
const out = {};
for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`, { store });
for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`);
return out;
}
// Row 199 (#1697, design D8): the agent's own git identity. The email is the one its Gitea user
// has, so Gitea links the commits to that user; gitea.url is the instance its pushes go to.
const EMAIL = /^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,190}\.[A-Za-z]{2,24}$/;
const NAME = /^[^\0\n\r<>]{1,100}$/;
// Row 199 (design D9c): the parse is vocabulary.mjs's, where the broker reads it; this wraps it with
// the loader's refusals. Returns {url, owner, repo}.
export function httpsUrl(value, where, { repo = false } = {}) {
const parsed = parseGiteaUrl(value, { repo });
if (parsed.problem === "form") refuse(`${where} must be a normalized https URL with no user, query or fragment`);
if (parsed.problem === "instance") refuse(`${where} must name the Gitea instance only, https://HOST[:PORT]`);
if (parsed.problem === "repository") refuse(`${where} must name a repository, https://HOST/OWNER/REPO`);
return parsed;
}
function checkIdentity(identity, where) {
requireObject(identity, where);
rejectUnknownKeys(identity, ["name", "email", "gitea"], where);
if (typeof identity.name !== "string" || !NAME.test(identity.name)) refuse(`${where}.name must be 1 to 100 characters with no <, > or line break`);
if (typeof identity.email !== "string" || !EMAIL.test(identity.email)) refuse(`${where}.email must be an email address`);
requireObject(identity.gitea, `${where}.gitea`);
rejectUnknownKeys(identity.gitea, ["url", "user"], `${where}.gitea`);
httpsUrl(identity.gitea.url, `${where}.gitea.url`);
if (!giteaName(identity.gitea.user)) refuse(`${where}.gitea.user must be a Gitea user name`);
return { name: identity.name, email: identity.email, gitea: { url: identity.gitea.url, user: identity.gitea.user } };
}
function checkTracker(tracker, file) {
const where = `${file} tracker`;
requireObject(tracker, where);
@@ -102,7 +74,7 @@ function checkTracker(tracker, file) {
return { sync, labels };
}
function checkRoles(roles, file, rolesDir, business) {
function checkRoles(roles, file, rolesDir) {
const where = `${file} roles`;
requireObject(roles, where);
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
@@ -114,7 +86,7 @@ function checkRoles(roles, file, rolesDir, business) {
requireId(instance, `${where} instance name`);
const at = `${where}.${instance}`;
requireObject(entry, at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish"], at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials"], at);
requireId(entry.definition, `${at}.definition`);
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
definitions[entry.definition] = definition;
@@ -136,18 +108,8 @@ function checkRoles(roles, file, rolesDir, business) {
holder: entry.holder === undefined ? null : requireId(entry.holder, `${at}.holder`),
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
tracker,
credentials: checkCredentialMap(entry.credentials ?? {}, services, `${at}.credentials`,
{ configDir: dirname(dirname(file)), business, agent: instance }),
credentials: checkCredentialMap(entry.credentials ?? {}, services, `${at}.credentials`),
};
// Row 199 (#1697, design D8, D9 step 2): identity and publish are optional, so today's files
// load unchanged. publish needs both an identity and a gitea credential; the project ids it
// names are checked against projects once those are read.
if (entry.identity !== undefined) out[instance].identity = checkIdentity(entry.identity, `${at}.identity`);
if (entry.publish !== undefined) {
// N10: one message for both, naming the grant.
if (!out[instance].identity || !services.includes("gitea")) refuse(`${at}.publish needs identity and a gitea definition`);
out[instance].publish = requireDistinctList(entry.publish, `${at}.publish`, (p) => requireId(p, `${at}.publish entry`), { nonEmpty: true });
}
}
return { roles: out, definitions };
}
@@ -197,7 +159,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
}
const human = requireId(document.human, "business human");
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id);
const { roles, definitions } = checkRoles(document.roles, file, rolesDir);
requireObject(document.arbiters, `${file} arbiters`);
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
@@ -221,7 +183,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
for (const [id, entry] of Object.entries(document.projects)) {
requireId(id, `${file} project id`);
requireObject(entry, `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root", "repo", "remote"], `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root", "repo"], `${file} projects.${id}`);
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
}
@@ -229,18 +191,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
normalize(entry.repo) !== entry.repo || entry.repo.includes("\0"))) {
refuse(`${file} projects.${id}.repo must be a normalized absolute path`);
}
// Row 199 (#1697, design D9 step 2): the upstream; a granted agent's work goes to its fork.
if (Object.hasOwn(entry, "remote")) httpsUrl(entry.remote, `${file} projects.${id}.remote`, { repo: true });
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
...(Object.hasOwn(entry, "remote") ? { remote: entry.remote } : {}) };
}
for (const [instance, role] of Object.entries(roles)) {
for (const project of role.publish ?? []) {
if (!Object.hasOwn(projects, project)) refuse(`${file} roles.${instance}.publish names ${project}, which the business file doesn't declare`);
if (!projects[project].remote) refuse(`${file} roles.${instance}.publish names ${project}, which has no "remote"`);
const problem = forkProblem(projects[project].remote, role.identity);
if (problem) refuse(`${file} roles.${instance}.publish names ${project}: ${problem}`);
}
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}) };
}
const tracker = checkTracker(document.tracker, file);
+8 -29
View File
@@ -3,7 +3,7 @@
// lstat and realpath only; nothing in this package opens a token file.
import { lstatSync, realpathSync } from "node:fs";
import { isAbsolute, join, normalize, relative, sep } from "node:path";
import { isAbsolute, normalize, relative, sep } from "node:path";
import { refuse } from "./errors.mjs";
import { SERVICES } from "./vocabulary.mjs";
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
@@ -14,35 +14,21 @@ const WARN_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
// Shape check for one reference. Returns a frozen { service, file | env,
// rotateBy | expires }. Row 199 (#1697, design D1 and D2): `store` is the
// file at <configDir>/secrets/agents/<business>/<agent>/<service>, derived
// here and never written, so an entry can't name another agent's file. A
// reference with no agent (the tracker's sync bot) passes store = null and
// can't use the form. A `file` reference into the agents' store must be the
// agent's own path (`credential-not-yours`).
export function parseCredentialRef(ref, service, where, { store = null } = {}) {
// rotateBy | expires }.
export function parseCredentialRef(ref, service, where) {
if (!SERVICES.includes(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
requireObject(ref, where);
const dateKey = DATE_KEY[service];
rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where);
rejectUnknownKeys(ref, ["file", "env", dateKey], where);
const hasFile = ref.file !== undefined;
const hasEnv = ref.env !== undefined;
const hasStore = ref.store !== undefined;
if (Number(hasFile) + Number(hasEnv) + Number(hasStore) !== 1) refuse(`${where} must name exactly one of "file", "env" or "store"`);
if (hasFile === hasEnv) refuse(`${where} must name exactly one of "file" or "env"`);
const out = { service };
if (hasStore) {
if (ref.store !== true) refuse(`${where}.store must be true`);
if (!store) refuse(`${where}: a "store" reference needs an agent`);
out.store = true;
out.file = storePath(store, service);
} else if (hasFile) {
if (hasFile) {
if (typeof ref.file !== "string" || !isAbsolute(ref.file) || normalize(ref.file) !== ref.file || ref.file.includes("\0")) {
refuse(`${where}.file must be a normalized absolute path`);
}
out.file = ref.file;
if (store && inside(join(store.configDir, "secrets", "agents"), ref.file) && ref.file !== storePath(store, service)) {
refuse(`${where}.file credential-not-yours: ${ref.file} is in the agents' store but not this agent's path`);
}
} else {
if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`);
out.env = ref.env;
@@ -52,11 +38,6 @@ export function parseCredentialRef(ref, service, where, { store = null } = {}) {
return Object.freeze(out);
}
// The store path for one agent and service (design D1).
export function storePath({ configDir, business, agent }, service) {
return join(configDir, "secrets", "agents", business, agent, service);
}
function inside(root, path) {
const rel = relative(root, path);
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
@@ -74,10 +55,8 @@ export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(),
let stat = null;
try {
stat = lstatSync(ref.file);
} catch (e) {
// Row 199 (design D3a): a store reference with no file is an agent not issued yet.
if (ref.store && e.code === "ENOENT") warnings.push(`${label}: not issued`);
else problems.push(`${label}: not found`);
} catch {
problems.push(`${label}: not found`);
}
if (stat) {
if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`);
+1
View File
@@ -10,5 +10,6 @@ export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
export { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "./access.mjs";
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
export { systemVars, resolveInstance, classify } from "./resolve.mjs";
+3 -22
View File
@@ -174,9 +174,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
}
}
// Row 199 (#1697, design section 6): `detail` says what a credential verb did (verb, agent, kind,
// date, reason), never a value; the other setters pass none.
function write(id, document, { revision, actor, detail }) {
function write(id, document, { revision, actor }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
return withLock(id, ({ home, parent }) => {
@@ -203,7 +201,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
syncDir(tx);
const event = {
transaction, actor, business: id, before, after,
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
changes: changes(old, written), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
syncDir(home);
@@ -221,22 +219,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
});
}
// Row 199: a receipt with no document change, for a credential verb that changes only the agents'
// store (a revoke or delete of store references). Committed at once; there is nothing to recover.
function record(id, { actor, detail }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
return withLock(id, () => {
recover(id);
const hash = sha256(regular(businessFilePath(id, dir)));
const transaction = randomUUID();
log(id, {
transaction, actor, business: id, before: hash, after: hash, changes: [], detail,
at: new Date().toISOString(), state: "committed",
});
return { revision: hash, transaction };
});
}
return { logFile, read, write, record, recover: id => withLock(id, () => recover(id)) };
return { logFile, read, write, recover: id => withLock(id, () => recover(id)) };
}
+2 -52
View File
@@ -20,9 +20,6 @@ export const ACTIONS = Object.freeze([
"git.push.working",
"git.push.protected",
"git.merge.protected",
// Row 199 (#1697, mos-1027 Q-M2): the host pushes an agent's recorded work commit to the fork its
// business grants it. A hard wait under REQ-WAIT-3 until a PRD version routes it.
"git.push.remote",
"review.request",
"review.verdict",
"record.write",
@@ -86,8 +83,8 @@ export const ROUTED = Object.freeze([
// The hard waits (PRD 1.5 REQ-WAIT-1): always gated, always the human's,
// cited spec or not. A role file may not list these under withinRole or
// crossRole (REQ-WAIT-2). Today this is spend, data.delete, network.expose,
// account.create, credential.mint, message.external, git.push.protected,
// git.push.remote and deploy; a test pins the list.
// account.create, credential.mint, message.external, git.push.protected and
// deploy; a test pins the list.
export const GATED_ONLY = Object.freeze(ACTIONS.filter((action) => !ROUTED.includes(action)));
// pi's documented built-in tools; the same list scripts/mosaic-task.mjs
@@ -136,50 +133,3 @@ export const THREAD_DEFINITION = "assistant";
// the human resolved through the CLI, may end or revoke. Row 155 moves the list here, and no
// organization write may give an instance one of them.
export const END_PROTECTED = Object.freeze([THREAD_DEFINITION]);
// Row 199 (#1697, design D9c): the Gitea URL parse. It lives here because the broker reads it and
// this file imports nothing. Gitea's name rule for users and repositories: letters, digits, "_", "."
// and "-", not "." or "..".
export const giteaName = (name) =>
typeof name === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(name) && name !== "." && name !== "..";
// An https URL that is already its own normalized form, so a dot segment, an encoded dot, userinfo, a
// query, a fragment, an upper-case host or an explicit :443 each fail, and the path checked is the
// path used. With repo, the path is exactly /OWNER/REPO (one ".git" stripped); without, it is "/".
// Returns {url, owner, repo}, or {problem} naming the rule that failed: form, instance or repository.
export function parseGiteaUrl(value, { repo = false } = {}) {
let url = null;
try {
url = typeof value === "string" ? new URL(value) : null;
} catch {
url = null;
}
const normal = url && (repo ? [url.origin + url.pathname] : [url.origin, url.origin + "/"]);
if (!url || url.protocol !== "https:" || !normal.includes(value)) return { problem: "form" };
if (!repo) return url.pathname === "/" ? { url } : { problem: "instance" };
const segments = url.pathname.split("/").slice(1);
const name = segments[1]?.endsWith(".git") ? segments[1].slice(0, -4) : segments[1];
if (segments.length !== 2 || !giteaName(segments[0]) || !giteaName(name)) return { problem: "repository" };
return { url, owner: segments[0], repo: name };
}
// Design D9 step 2: the agent's fork of an upstream, https://HOST[:PORT]/<user>/REPO, or null for an
// upstream that doesn't parse. The owner is always the agent's own Gitea user; nothing else names it.
// URL.host drops a default port, so 443 counts the same on both sides.
export function forkUrl(upstream, user) {
const { url, repo } = parseGiteaUrl(upstream, { repo: true });
return url ? `https://${url.host}/${user}/${repo}` : null;
}
// The refusal code for publishing `upstream` as an agent with `identity`, or null. Gitea compares
// names without case (lower_name), so the owner check does too. An upstream or gitea.url that doesn't
// parse is remote-not-granted: the business loader refuses both, so only a hand-built business gets
// here.
export function forkProblem(upstream, identity) {
const { url, owner } = parseGiteaUrl(upstream, { repo: true });
const instance = parseGiteaUrl(identity?.gitea?.url).url;
if (!url || !instance || typeof identity.gitea.user !== "string") return "remote-not-granted";
if (url.host !== instance.host) return "remote-host-mismatch";
if (owner.toLowerCase() === identity.gitea.user.toLowerCase()) return "remote-not-fork";
return null;
}
+280
View File
@@ -0,0 +1,280 @@
// FED R1 (#414, row 201): the access file loader and validator, DESIGN.md
// section 3.5, and covers(). Every test works in a scratch directory.
import { test } from "node:test";
import assert from "node:assert/strict";
import { chmodSync, mkdirSync, symlinkSync } from "node:fs";
import { join } from "node:path";
import { BusinessError } from "../src/errors.mjs";
import { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "../src/access.mjs";
import { tmp, writeJson } from "./helpers.mjs";
const P1 = "0f8b6c1e-2a3d-4e5f-8a9b-0c1d2e3f4a5b";
const P2 = "11111111-2222-4333-8444-555555555555";
function context(over = {}) {
return {
providers: ["authentik"],
peers: ["factory"],
businesses: {
"creative-hub": { human: "melanie", instances: ["researcher", "writer"] },
acme: { human: "jason", instances: ["coder"] },
},
ssoSubjects: [{ provider: "authentik", sub: "owner-sub" }],
...over,
};
}
function doc() {
return {
accessVersion: 1,
revision: 3,
users: [
{ id: "bean", home: "factory", subjects: [{ provider: "authentik", sub: "s-1" }] },
{ id: "chet", home: "local", subjects: [{ provider: "authentik", sub: "s-2" }] },
],
grants: [
{ id: "g-1", user: "bean", business: "creative-hub", projects: ["*"], instances: ["researcher"], level: "collaborator", expires: "2026-12-31T00:00:00Z" },
{ id: "g-2", user: "chet", business: "creative-hub", projects: [P1], instances: ["*"], level: "collaborator" },
],
};
}
// Refuses with a BusinessError, exit 2 unless given, and a message matching `pattern`.
function refuses(fn, pattern, exitCode = 2) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `not a BusinessError: ${error}`);
assert.equal(error.exitCode, exitCode);
assert.match(error.message, pattern);
return true;
});
}
function mutated(change) {
const d = doc();
change(d);
return d;
}
test("a valid document passes and comes back frozen", () => {
const checked = checkAccess(doc(), context());
assert.deepEqual(checked, doc());
assert.ok(Object.isFrozen(checked));
assert.ok(Object.isFrozen(checked.grants[0].projects));
});
test("checkAccess does not freeze or alias the caller's document", () => {
const d = doc();
const checked = checkAccess(d, context());
assert.ok(!Object.isFrozen(d));
assert.notEqual(checked.grants, d.grants);
});
test("LEVELS is the single allowed level set, collaborator only", () => {
assert.deepEqual([...LEVELS], ["collaborator"]);
assert.ok(Object.isFrozen(LEVELS));
});
test("accessFilePath is access.json in the config directory", () => {
assert.equal(accessFilePath("/x/y"), join("/x/y", "access.json"));
const dir = tmp();
const saved = process.env.MOSAIC_CONFIG;
process.env.MOSAIC_CONFIG = join(dir, "config.json");
try {
assert.equal(accessFilePath(), join(dir, "access.json"));
} finally {
if (saved === undefined) delete process.env.MOSAIC_CONFIG;
else process.env.MOSAIC_CONFIG = saved;
}
});
test("top level: object, unknown keys, accessVersion, revision, arrays", () => {
refuses(() => checkAccess([], context()), /access file must be a JSON object/);
refuses(() => checkAccess(mutated((d) => { d.extra = 1; }), context()), /unsupported access file key: "extra"/);
refuses(() => checkAccess(mutated((d) => { d.accessVersion = 2; }), context()), /accessVersion must be 1/);
refuses(() => checkAccess(mutated((d) => { delete d.accessVersion; }), context()), /accessVersion must be 1/);
for (const bad of [0, -1, 1.5, "3", undefined]) {
refuses(() => checkAccess(mutated((d) => { d.revision = bad; }), context()), /revision must be a positive integer/);
}
refuses(() => checkAccess(mutated((d) => { d.users = {}; }), context()), /^users must be an array/);
refuses(() => checkAccess(mutated((d) => { delete d.grants; }), context()), /^grants must be an array/);
});
test("users: unknown keys and the id rule", () => {
refuses(() => checkAccess(mutated((d) => { d.users[1].role = "x"; }), context()), /unsupported users\[1\] key: "role"/);
refuses(() => checkAccess(mutated((d) => { d.users[0] = "bean"; }), context()), /users\[0\] must be a JSON object/);
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "Bean"; }), context()), /users\[0\]\.id must match/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id: bean/);
});
test("users: human, tester and any business's human are refused", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human"/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester"/);
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human \("melanie"\)/);
// The human of a business the grant doesn't name is refused too.
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human \("jason"\)/);
});
test("users: home is a given peer or local", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local" \(got "elsewhere"\)/);
refuses(() => checkAccess(mutated((d) => { delete d.users[1].home; }), context()), /users\[1\]\.home must be a configured peer/);
refuses(() => checkAccess(doc(), context({ peers: [] })), /users\[0\]\.home must be a configured peer/);
});
test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects = []; }), context()), /users\[0\]\.subjects must not be empty/);
refuses(() => checkAccess(mutated((d) => { delete d.users[0].subjects; }), context()), /users\[0\]\.subjects must be an array/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].extra = 1; }), context()), /unsupported users\[0\]\.subjects\[0\] key: "extra"/);
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider \(got "google"\)/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = ""; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = 7; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
// A pair twice in one user, and across two users.
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects.push({ provider: "authentik", sub: "s-1" }); }), context()), /users\[0\]\.subjects\[1\] appears more than once/);
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "s-1"; }), context()), /users\[1\]\.subjects\[0\] appears more than once/);
// The owner's own sign-in subject can't be an extra user.
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "owner-sub"; }), context()), /users\[1\]\.subjects\[0\] is an owner sign-in subject/);
// The same sub under another provider is a different pair.
const twoProviders = context({ providers: ["authentik", "google"] });
assert.ok(checkAccess(mutated((d) => { d.users[1].subjects[0] = { provider: "google", sub: "owner-sub" }; }), twoProviders));
});
test("grants: unknown keys, id, user and business", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[0].role = "x"; }), context()), /unsupported grants\[0\] key: "role"/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id: g-1/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].id = "G 1"; }), context()), /grants\[0\]\.id must match/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file \(got "nobody"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business \(got "other"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "constructor"; }), context()), /grants\[1\]\.business must name a declared business/);
});
test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID \(got "stack"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1.toUpperCase()]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, 7]; }), context()), /grants\[1\]\.projects\[1\] must be "\*" or a bus project id/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", P1]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, P1]; }), context()), /duplicate entry in grants\[1\]\.projects/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = []; }), context()), /grants\[1\]\.projects must not be empty/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = "*"; }), context()), /grants\[1\]\.projects must be an array/);
assert.ok(checkAccess(mutated((d) => { d.grants[1].projects = [P1, P2]; }), context()));
});
test("grants: instances is [\"*\"] or distinct declared instances of that business", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of creative-hub \(got "coder"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["*", "writer"]; }), context()), /grants\[0\]\.instances must be \["\*"\] alone/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["writer", "writer"]; }), context()), /duplicate entry in grants\[0\]\.instances/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = []; }), context()), /grants\[0\]\.instances must not be empty/);
assert.ok(checkAccess(mutated((d) => { d.grants[0].instances = ["researcher", "writer"]; }), context()));
});
test("grants: any level other than collaborator refuses, naming grants[i].level", () => {
for (const bad of ["viewer", "owner", "Collaborator", "COLLABORATOR", "", null, 1]) {
refuses(() => checkAccess(mutated((d) => { d.grants[1].level = bad; }), context()), /grants\[1\]\.level must be one of collaborator/);
}
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator \(got undefined\)/);
});
test("grants: expires is optional ISO time, and a past expiry is not refused", () => {
for (const bad of ["2026-12-31", "2026-12-31 00:00:00Z", "2026-12-31T00:00:00+01:00", "tomorrow", 1798675200000, null]) {
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = bad; }), context()), /grants\[0\]\.expires must be an ISO time/);
}
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-02-30T00:00:00Z"; }), context()), /grants\[0\]\.expires is not a real time/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-13-01T00:00:00Z"; }), context()), /grants\[0\]\.expires must be an ISO time/);
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2026-12-31T00:00:00.500Z"; }), context()));
// DESIGN 3.5: a grant past expires is treated as absent, not refused (sage-1399).
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2001-01-01T00:00:00Z"; }), context()));
});
test("the context is required and checked", () => {
refuses(() => checkAccess(doc()), /access context must be a JSON object/);
for (const key of ["providers", "peers", "ssoSubjects"]) {
const c = context();
delete c[key];
refuses(() => checkAccess(doc(), c), new RegExp(`access context\\.${key} must be an array`));
}
refuses(() => checkAccess(doc(), context({ businesses: [] })), /access context\.businesses must be a JSON object/);
refuses(() => checkAccess(doc(), context({ extra: 1 })), /unsupported access context key: "extra"/);
refuses(() => checkAccess(doc(), context({ peers: ["local"] })), /access context\.peers must not name "local"/);
refuses(() => checkAccess(doc(), context({ ssoSubjects: [{ provider: "authentik" }] })), /access context\.ssoSubjects\[0\]\.sub must be a non-empty string/);
refuses(() => checkAccess(doc(), context({ businesses: { acme: { human: "jason" } } })), /access context\.businesses\.acme\.instances must be an array/);
});
// loadAccess: the file checks, then checkAccess.
test("loadAccess reads a valid file and checks it", () => {
const file = writeJson(join(tmp(), "access.json"), doc());
assert.deepEqual(loadAccess(file, context()), doc());
});
test("loadAccess refuses a missing file, a symlink and a directory with exit 4", () => {
const dir = tmp();
refuses(() => loadAccess(join(dir, "access.json"), context()), /access file not found/, 4);
const real = writeJson(join(dir, "real.json"), doc());
const link = join(dir, "access.json");
symlinkSync(real, link);
refuses(() => loadAccess(link, context()), /access file must be a regular, non-symbolic-link file/, 4);
const sub = join(dir, "d");
mkdirSync(sub);
refuses(() => loadAccess(sub, context()), /access file must be a regular, non-symbolic-link file/, 4);
});
test("loadAccess refuses another owner and group or other write", () => {
const file = writeJson(join(tmp(), "access.json"), doc());
refuses(() => loadAccess(file, context(), { uid: process.getuid() + 1 }), /access file must belong to uid/);
for (const mode of [0o620, 0o602, 0o666]) {
chmodSync(file, mode);
refuses(() => loadAccess(file, context()), /access file must not be writable by group or other/);
}
chmodSync(file, 0o644);
assert.ok(loadAccess(file, context()));
});
test("loadAccess refuses bad JSON and a bad document with exit 2", () => {
const dir = tmp();
refuses(() => loadAccess(writeJson(join(dir, "a.json"), "{"), context()), /access file is not valid JSON/);
refuses(() => loadAccess(writeJson(join(dir, "b.json"), mutated((d) => { d.grants[0].level = "owner"; })), context()), /grants\[0\]\.level/);
});
// covers.
const NOW = new Date("2026-10-20T12:00:00Z");
const target = (over = {}) => ({ business: "creative-hub", project: P1, instance: "researcher", ...over });
test("covers: * and listed ids match", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g1, target({ project: P2 }), NOW), true);
assert.equal(covers(g2, target({ instance: "writer" }), NOW), true);
assert.equal(covers(g2, target(), NOW), true);
});
test("covers: an unlisted project, instance or business doesn't match", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g2, target({ project: P2 }), NOW), false);
assert.equal(covers(g1, target({ instance: "writer" }), NOW), false);
assert.equal(covers(g2, target({ business: "acme" }), NOW), false);
assert.equal(covers(g1, target({ business: "acme" }), NOW), false);
});
test("covers: an expired grant doesn't match, at and after the instant", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g1, target(), new Date("2026-12-30T23:59:59.999Z")), true);
assert.equal(covers(g1, target(), new Date("2026-12-31T00:00:00Z")), false);
assert.equal(covers(g1, target(), new Date("2027-01-01T00:00:00Z")), false);
// No expires never expires.
assert.equal(covers(g2, target(), new Date("2099-01-01T00:00:00Z")), true);
});
test("covers: a level filter rejects another level, and no filter accepts any", () => {
const g = checkAccess(doc(), context()).grants[0];
assert.equal(covers(g, target(), NOW, { level: "collaborator" }), true);
assert.equal(covers(g, target(), NOW, {}), true);
assert.equal(covers(g, target(), NOW, { level: "viewer" }), false);
assert.equal(covers({ ...g, level: "viewer" }, target(), NOW, { level: "collaborator" }), false);
});
test("covers: now must be a valid Date", () => {
const g = checkAccess(doc(), context()).grants[0];
assert.throws(() => covers(g, target(), Date.now()), TypeError);
assert.throws(() => covers(g, target(), new Date("nope")), TypeError);
});
@@ -1,126 +0,0 @@
// Row 199 (#1697): an agent's own git identity, its publish grant, the project's upstream and the
// credential store reference. Design D1, D8, D9 step 2 and D9c.
import { test } from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { BusinessError, parseCredentialRef, validateBusinessDocument } from "../src/index.mjs";
import { forkProblem, forkUrl } from "../src/vocabulary.mjs";
import { businessDoc, FAR_DATE, REPO_ROLES, tmp } from "./helpers.mjs";
function refuses(fn, pattern) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
assert.equal(error.exitCode, 2);
assert.match(error.message, pattern);
return true;
});
}
const IDENTITY = { name: "Coder", email: "[email protected]", gitea: { url: "https://git.example", user: "acme-coder" } };
// The fixture with coder granted publish on stack, whose upstream is mosaic/stack.
function check(mutate) {
const root = tmp();
const doc = businessDoc(root);
doc.projects.stack.remote = "https://git.example/mosaic/stack";
doc.roles.coder.identity = structuredClone(IDENTITY);
doc.roles.coder.publish = ["stack"];
mutate?.(doc, root);
return validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
}
test("today's files load unchanged: identity, publish and remote are optional", () => {
const root = tmp();
const business = validateBusinessDocument(businessDoc(root), join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
assert.equal(business.roles.coder.identity, undefined);
assert.equal(business.roles.coder.publish, undefined);
assert.equal(business.projects.stack.remote, undefined);
});
test("a granted agent loads with its identity, grant and the project's upstream", () => {
const business = check();
assert.deepEqual({ ...business.roles.coder.identity, gitea: { ...business.roles.coder.identity.gitea } }, IDENTITY);
assert.deepEqual([...business.roles.coder.publish], ["stack"]);
assert.equal(business.projects.stack.remote, "https://git.example/mosaic/stack");
});
test("N10: publish needs identity and a gitea definition, and the error names the grant", () => {
refuses(() => check((d) => { delete d.roles.coder.identity; }), /roles\.coder\.publish needs identity and a gitea definition/);
refuses(() => check((d) => { d.roles.chief.identity = structuredClone(IDENTITY); d.roles.chief.publish = ["stack"]; }),
/roles\.chief\.publish needs identity and a gitea definition/);
});
test("publish names declared projects that have a remote, each once", () => {
refuses(() => check((d) => { d.roles.coder.publish = ["books"]; }), /publish names books, which the business file doesn't declare/);
refuses(() => check((d) => { delete d.projects.stack.remote; }), /publish names stack, which has no "remote"/);
refuses(() => check((d) => { d.roles.coder.publish = []; }), /publish/);
refuses(() => check((d) => { d.roles.coder.publish = ["stack", "stack"]; }), /publish/);
});
test("identity fields are checked", () => {
refuses(() => check((d) => { d.roles.coder.identity.email = "coder"; }), /identity\.email/);
refuses(() => check((d) => { d.roles.coder.identity.name = "a\nb"; }), /identity\.name/);
refuses(() => check((d) => { d.roles.coder.identity.extra = 1; }), /identity/);
refuses(() => check((d) => { d.roles.coder.identity.gitea.user = ".."; }), /gitea\.user/);
refuses(() => check((d) => { d.roles.coder.identity.gitea.user = "a/b"; }), /gitea\.user/);
});
test("D9c: the upstream must be its own normalized form, exactly /OWNER/REPO", () => {
const bad = {
"a dot segment": "https://git.example/acme-coder/../mosaic/stack",
"an encoded dot segment": "https://git.example/acme-coder/%2e%2e/mosaic/stack",
"an extra segment": "https://git.example/mosaic/stack/more",
"a missing segment": "https://git.example/stack",
"a trailing slash": "https://git.example/mosaic/stack/",
userinfo: "https://u:[email protected]/mosaic/stack",
"a query": "https://git.example/mosaic/stack?x=1",
"a fragment": "https://git.example/mosaic/stack#f",
"an explicit :443": "https://git.example:443/mosaic/stack",
"an upper-case host": "https://GIT.example/mosaic/stack",
"a backslash": "https://git.example\\mosaic\\stack",
"a name outside Gitea's rule": "https://git.example/mosaic/st%41ck",
"a dot-dot repo": "https://git.example/mosaic/...git",
http: "http://git.example/mosaic/stack",
};
for (const [what, remote] of Object.entries(bad)) {
assert.throws(() => check((d) => { d.projects.stack.remote = remote; }), BusinessError, what);
}
});
test("D9c: one .git is stripped, and the fork is built from the stripped name", () => {
assert.equal(check((d) => { d.projects.stack.remote = "https://git.example/mosaic/stack.git"; }).projects.stack.remote,
"https://git.example/mosaic/stack.git");
assert.equal(forkUrl("https://git.example/mosaic/stack.git", "acme-coder"), "https://git.example/acme-coder/stack");
assert.equal(forkUrl("https://git.example:3000/mosaic/stack", "acme-coder"), "https://git.example:3000/acme-coder/stack");
});
test("D9c: gitea.url names the instance only", () => {
for (const url of ["https://git.example/gitea", "https://git.example/gitea/", "https://git.example/?x", "https://git.example:443", "http://git.example"]) {
assert.throws(() => check((d) => { d.roles.coder.identity.gitea.url = url; }), BusinessError, url);
}
check((d) => { d.roles.coder.identity.gitea.url = "https://git.example/"; });
});
test("the upstream's host and port must be the agent's Gitea's", () => {
refuses(() => check((d) => { d.projects.stack.remote = "https://other.example/mosaic/stack"; }), /remote-host-mismatch/);
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example:3000/mosaic/stack"; }), /remote-host-mismatch/);
assert.equal(forkProblem("https://git.example/mosaic/stack", { gitea: { url: "https://git.example/", user: "u" } }), null);
});
test("remote-not-fork: an upstream the agent's own user owns, compared without case", () => {
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example/acme-coder/stack"; }), /remote-not-fork/);
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example/ACME-Coder/stack"; }), /remote-not-fork/);
});
test("store references: own path only (design D1, D3a)", () => {
const store = { configDir: "/cfg", business: "acme", agent: "coder" };
const ref = parseCredentialRef({ store: true, rotateBy: FAR_DATE }, "gitea", "r", { store });
assert.equal(ref.file, "/cfg/secrets/agents/acme/coder/gitea");
assert.equal(ref.store, true);
refuses(() => parseCredentialRef({ store: true, rotateBy: FAR_DATE }, "gitea", "r"), /store/);
refuses(() => parseCredentialRef({ store: true, file: "/a", rotateBy: FAR_DATE }, "gitea", "r", { store }), /exactly one/);
refuses(() => parseCredentialRef({ file: "/cfg/secrets/agents/acme/pm/gitea", rotateBy: FAR_DATE }, "gitea", "r", { store }),
/credential-not-yours/);
assert.equal(parseCredentialRef({ file: "/cfg/secrets/agents/acme/coder/gitea", rotateBy: FAR_DATE }, "gitea", "r", { store }).file,
"/cfg/secrets/agents/acme/coder/gitea");
});
+1 -1
View File
@@ -9,7 +9,7 @@ import { validateRoleDocument } from "../src/index.mjs";
import { tmp } from "./helpers.mjs";
const WAITS = ["spend", "data.delete", "network.expose", "account.create", "credential.mint", "message.external",
"git.push.protected", "deploy", "git.push.remote"];
"git.push.protected", "deploy"];
const LEFT = ["git.merge.protected", "policy.change", "prd.approve", "role.revoke"];
const role = (authority) => {
const dir = tmp();
+27 -11
View File
@@ -1009,18 +1009,34 @@ 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`).
## credentials (row 199)
## Access (FED R5, #414)
`mosaic credentials [status [AGENT]] [--json] [--tester]` lists each agent's
credential kinds with date and state, never a value. `issue AGENT gitea
--rotate-by DATE` and `issue AGENT vikunja --expires DATE` read the token on
stdin (pipe it, or paste it and end input) and send it once to the bus's
`credentials.issue`; it is never an argument. `rotate` is the same over an
entry the bus holds now, and refuses before reading stdin when there is none.
`revoke AGENT [KIND] --reason TEXT` and `delete AGENT --reason TEXT` drop the
entries; `delete` refuses `agent-running` while the agent holds a claim. With
`--tester` only `status` and `revoke` run. After a rotate or revoke, delete
the old token in Gitea or Vikunja yourself; the bus only forgets it.
```
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
+288
View File
@@ -0,0 +1,288 @@
// `mosaic access list|grant|revoke` (federation design section 3.5, FED R5, #414).
// The human edits <configDir>/access.json here. Each write is checked against the
// instance's context before it lands, bumps the revision by one, and appends one
// receipt line to <dataRoot>/state/access-log.jsonl. The human proof is one call
// through humanTransport: a bus that does not see a human shell refuses it, so
// no agent and no tester can write. The context (providers, peers, businesses,
// ssoSubjects) comes in as a function; the caller decides where it comes from.
import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, openSync, writeSync, fsyncSync, closeSync, existsSync, unlinkSync } from "node:fs";
import { randomBytes } from "node:crypto";
import { join, dirname } from "node:path";
import { BusinessError, accessFilePath, checkAccess, configDir, loadAccess, loadBusiness, LEVELS } from "../../business/src/index.mjs";
import { rolesDir } from "./config.mjs";
import { CliError } from "./errors.mjs";
import { refuseInsideAgent } from "./transport.mjs";
export const ACCESS_USAGE = [
"mosaic access list --business ID [--json]",
"mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator [--expires ISO] --revision N",
"mosaic access revoke --business ID --user ID [--grant GID] --revision N",
].join("\n");
// The help text. A grant's `instances` is the real limit: the granted instance
// runs with the owner's role, so a collaborator drives it with that authority.
// Removing a user acts at once. Removing a provider from console.sso waits for a
// Console restart, so take the users out of access.json first. Peer homes are
// empty until the federation key lands (row 203), so a grant naming a peer
// home refuses until then. A grant naming a subject needs console.sso.
export const ACCESS_HELP = `${ACCESS_USAGE}
A grant gives a user exactly what the granted instances can do. The instances
list is the real limit: a collaborator's message drives a granted instance with
the owner's role.
To cut a user off, revoke them here first. It acts at once. Removing a provider
from console.sso takes effect only after a Console restart, so do that second.
Peer homes are not read yet (row 203 adds the federation key), so a grant that
names a peer home refuses. A grant that names a sign-in subject needs console.sso
in the system config; without it every such grant refuses.
The command runs as the instance's uid: access.json must belong to it and must
not be writable by group or other, or the command refuses (exit 3) and writes nothing.`;
// The instance's context for checkAccess, read from the system config and the business
// files. providers and ssoSubjects come from console.sso (row 192's shape: each provider
// has an id and a subjects list). peers is empty until row 203. A business file that
// fails to load fails the whole command.
export function accessContextFor(system, env) {
const providers = system.console?.sso?.providers ?? [];
const ssoSubjects = providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, sub: s.sub })));
const dir = join(configDir(env), "businesses");
const businesses = {};
if (existsSync(dir)) {
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) {
const id = file.slice(0, -".json".length);
const doc = loadBusiness(id, { dir: configDir(env), rolesDir: rolesDir(env) });
businesses[id] = { human: doc.human, instances: Object.keys(doc.roles) };
}
}
return { providers: providers.map((p) => p.id), peers: [], businesses, ssoSubjects };
}
// Every verb needs --business: the human proof runs through that business's bus.
// --tester is refused outright on every verb, before any bus call.
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === "--json" || a === "--tester") out.flags.add(a);
else if (a.startsWith("--")) {
if (i + 1 >= args.length || args[i + 1].startsWith("--")) throw new CliError(`${a} needs one value\n${ACCESS_USAGE}`, 4);
out.values[a] = args[++i];
} else out.positional.push(a);
}
return out;
}
const list = (value) => (value === "*" ? ["*"] : value.split(",").filter(Boolean));
function readCurrent(file, context) {
if (!existsSync(file)) return { document: { accessVersion: 1, revision: null, users: [], grants: [] }, revision: 0 };
const document = loadAccess(file, context);
return { document, revision: document.revision };
}
// The file calls the write path makes. A test passes a stand-in through deps.fs.
const realFs = { openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync };
// Atomic write: a temp file in the same directory, 0600, fsynced, then renamed over
// the old file. A reader sees the old bytes or the new bytes, never a mix. A failure
// before or at the rename removes the temp file and leaves the old bytes in place.
function writeAtomic(file, text, fs = realFs) {
const tmp = join(dirname(file), `.access-${randomBytes(8).toString("hex")}.tmp`);
try {
const fd = fs.openSync(tmp, "wx", 0o600);
try {
fs.writeSync(fd, text);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmp, file);
} catch (error) {
try {
fs.unlinkSync(tmp);
} catch {
// Already gone: the temp file was never created.
}
throw error;
}
}
function receipt(dataRoot, line) {
const dir = join(dataRoot, "state");
mkdirSync(dir, { recursive: true });
appendFileSync(join(dir, "access-log.jsonl"), `${JSON.stringify(line)}\n`, { mode: 0o600 });
}
// Two writers never interleave: the read, the check and the rename happen under an
// exclusive lock file, `<access.json>.lock`, mode 0600. The file holds the holder's
// pid and a newline. A lock whose holder is alive refuses. A lock that is empty,
// unreadable, or whose pid is gone also refuses, and nothing reclaims it: a human
// removes it once no write is running. Fail closed, with no reclaim race.
const BUSY = "refused: the access file is being written by another command; try again";
function lockPid(lock) {
let text;
try {
text = readFileSync(lock, "utf8");
} catch {
return null;
}
const pid = Number(text.trim());
return Number.isSafeInteger(pid) && pid >= 1 ? pid : null;
}
function pidAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error.code !== "ESRCH";
}
}
function lockRefusal(lock) {
const pid = lockPid(lock);
if (pid !== null && pidAlive(pid)) return BUSY;
if (pid === null) return `refused: lock ${lock} is unreadable or not a pid; if no write is running, remove it by hand and try again`;
return `refused: lock ${lock} is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
}
function takeLock(lock, fs) {
try {
return fs.openSync(lock, "wx", 0o600);
} catch (error) {
if (error.code !== "EEXIST") throw error;
}
throw new CliError(lockRefusal(lock), 3);
}
export function withLock(file, fn, fs = realFs) {
const lock = `${file}.lock`;
const fd = takeLock(lock, fs);
try {
fs.writeSync(fd, `${process.pid}\n`);
} finally {
fs.closeSync(fd);
}
try {
return fn();
} finally {
fs.unlinkSync(lock);
}
}
// The one human-proof call: project.list, which the bus gates with #human
// (broker.mjs:2319, and #human at :377). It also returns the rows that resolve
// project names. Row R4 swaps this call for access.reload.
async function proveHuman(transport, business) {
return transport(business)("project.list", {});
}
// Resolve a --projects item: "*" and UUIDs pass through, anything else is a project
// name looked up in project.list at grant time (the id is what gets written).
function resolveProjects(items, rows) {
return items.map((item) => {
if (item === "*" || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(item)) return item;
const hits = rows.filter((r) => r.name === item);
if (hits.length !== 1) throw new CliError(`refused: project-not-found: no project named ${item} in this business (or the name is ambiguous)`, 2);
return hits[0].id;
});
}
// deps: { system: { dataRoot }, transport(business) -> call(verb, args), contextFor() -> context, configDir?, now?, fs? }
export async function accessCommand(args, io, deps) {
refuseInsideAgent(io.env);
try {
return await accessVerb(args, io, deps);
} catch (error) {
// A file or business doc the checks refuse to load (group-writable, a wrong uid, a
// hand edit) is a refusal with exit 3 and the file and key path, not a crash.
if (error instanceof BusinessError) throw new CliError(`refused: ${error.message}`, 3);
throw error;
}
}
async function accessVerb(args, io, deps) {
const parsed = parse(args);
const sub = parsed.positional[0];
if (!["list", "grant", "revoke"].includes(sub) || parsed.positional.length !== 1) throw new CliError(ACCESS_HELP, 4);
if (parsed.flags.has("--tester")) throw new CliError("refused: access writes take the human's shell, not --tester", 3);
const business = parsed.values["--business"];
if (!business) throw new CliError(`--business is required\n${ACCESS_USAGE}`, 4);
const rows = await proveHuman(deps.transport, business);
const file = deps.file ?? accessFilePath(deps.configDir ?? configDir(io.env));
// Built after the human proof, so a refused caller never reads the business files.
const context = deps.contextFor();
if (sub === "list") {
const { document } = readCurrent(file, context);
io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(document, null, 2)}\n` : formatList(document));
return;
}
const expected = parsed.values["--revision"];
if (!/^\d+$/.test(expected ?? "")) throw new CliError(`--revision takes the revision you read\n${ACCESS_USAGE}`, 4);
const user = parsed.values["--user"];
if (!user) throw new CliError(`${sub} needs --user\n${ACCESS_USAGE}`, 4);
const result = withLock(file, () => {
const { document, revision } = readCurrent(file, context);
if (Number(expected) !== revision) {
throw new CliError(`refused: revision-conflict: the access file is at revision ${revision}, not ${expected}; run list again`, 3);
}
let next;
let touched;
if (sub === "grant") {
const level = parsed.values["--level"];
if (!parsed.values["--projects"] || !parsed.values["--instances"] || level === undefined) {
throw new CliError(`grant needs --projects, --instances and --level\n${ACCESS_USAGE}`, 4);
}
if (!LEVELS.includes(level)) throw new CliError(`--level takes ${LEVELS.join(", ")}`, 2);
const id = `g-${randomBytes(4).toString("hex")}`;
const grant = {
id,
user,
business,
projects: resolveProjects(list(parsed.values["--projects"]), rows),
instances: list(parsed.values["--instances"]),
level,
};
if (parsed.values["--expires"] !== undefined) grant.expires = parsed.values["--expires"];
touched = [id];
next = { ...document, revision: revision + 1, grants: [...document.grants, grant] };
} else {
const only = parsed.values["--grant"];
const drop = (g) => g.user === user && g.business === business && (only === undefined || g.id === only);
touched = document.grants.filter(drop).map((g) => g.id);
if (touched.length === 0) throw new CliError(`refused: no grant for ${user} at ${business} to revoke`, 2);
next = { ...document, revision: revision + 1, grants: document.grants.filter((g) => !drop(g)) };
}
// Every check runs on the new document before a byte is written.
const checked = checkAccess(next, context);
writeAtomic(file, `${JSON.stringify(checked, null, 2)}\n`, deps.fs);
return { checked, revision, touched };
}, deps.fs);
receipt(deps.system.dataRoot, {
at: (deps.now ?? (() => new Date()))().toISOString(),
verb: sub,
business,
user,
actor: "human",
revisionBefore: result.revision,
revisionAfter: result.checked.revision,
grants: result.touched,
});
io.stdout.write(`access ${sub}: ${result.touched.join(", ")} at ${business}, revision ${result.checked.revision}\n`);
}
function formatList(doc) {
const lines = [`access file revision ${doc.revision ?? "none"}`];
for (const u of doc.users) lines.push(`user ${u.id} home ${u.home}`);
for (const g of doc.grants) {
lines.push(`grant ${g.id} user ${g.user} business ${g.business} projects ${g.projects.join(",")} instances ${g.instances.join(",")} level ${g.level}${g.expires ? ` expires ${g.expires}` : ""}`);
}
return `${lines.join("\n")}\n`;
}
+10 -4
View File
@@ -17,8 +17,8 @@ import { projectBusCommand, projectCommand, PROJECT_BUS_USAGE, PROJECT_USAGE } f
import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TALK_USAGE, THREAD_USAGE, THREADS_USAGE } from "./thread.mjs";
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.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";
@@ -39,7 +39,6 @@ export const USAGE = `usage:
${USAGE_USAGE}
${LIMITS_USAGE}
${PLUGINS_USAGE}
${CREDENTIALS_USAGE}
${INSTANCES_USAGE}
${PROJECT_USAGE}
${PROJECT_BUS_USAGE}
@@ -575,11 +574,18 @@ 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));
// Row 199 (#1697): issue and rotate read the token on stdin.
if (verb === "credentials") return credentialsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 165 (#1662): scripts/mosaic sends only `business instances` and `business instance` here.
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
if (verb === "talk") return talk(rest, io, deps);
-83
View File
@@ -1,83 +0,0 @@
// `mosaic credentials` (row 199, #1697, design section 6): an agent's per-agent credentials through
// the broker's credentials, credentials.issue, credentials.revoke and credentials.delete. Every verb
// is the human's; with --tester (Q173) only status and revoke. issue and rotate read the token on
// stdin, never argv, and nothing here prints it: the broker's answer carries the kind, date and state.
import { CliError } from "./errors.mjs";
export const CREDENTIALS_USAGE = "mosaic credentials [status [AGENT]] [--json] [--tester] | issue|rotate AGENT gitea --rotate-by DATE | " +
"issue|rotate AGENT vikunja --expires DATE | revoke AGENT [KIND] --reason TEXT [--tester] | delete AGENT --reason TEXT [--business ID]";
const DATE_FLAG = Object.freeze({ gitea: "--rotate-by", vikunja: "--expires" });
const MAX_TOKEN = 16384;
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--business", "--rotate-by", "--expires", "--reason"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(CREDENTIALS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(CREDENTIALS_USAGE, 4);
else out.positional.push(arg);
}
return out;
}
// The whole of stdin, one token; a terminal shows what is typed, so a pipe or a paste then EOF.
async function readToken(stdin) {
let text = "";
for await (const chunk of stdin) {
text += chunk;
if (text.length > MAX_TOKEN + 2) throw new CliError("refused: the token on stdin is too long", 2);
}
if (!text.trim()) throw new CliError("refused: no token on stdin; pipe or paste it, then end input", 4);
return text;
}
// The states the bus's status() gives a held token: gitea is valid or rotation-due, vikunja valid,
// expiring or expired. not-issued and revoked hold none.
const ISSUED = new Set(["valid", "rotation-due", "expiring", "expired"]);
export async function credentialsCommand(args, io, callFor) {
const parsed = parse(args);
const [verb = "status", agent, kind, ...extra] = parsed.positional;
const v = parsed.values;
const shape = {
status: () => kind === undefined && !v["--rotate-by"] && !v["--expires"] && !v["--reason"],
issue: () => agent && DATE_FLAG[kind] && v[DATE_FLAG[kind]] && !v[kind === "gitea" ? "--expires" : "--rotate-by"] && !v["--reason"],
revoke: () => agent && (kind === undefined || DATE_FLAG[kind]) && v["--reason"] && !v["--rotate-by"] && !v["--expires"],
delete: () => agent && kind === undefined && v["--reason"] && !v["--rotate-by"] && !v["--expires"],
};
shape.rotate = shape.issue;
if (extra.length || !shape[verb]?.()) throw new CliError(CREDENTIALS_USAGE, 4);
// Q173 (duckworth N1): a token the tester issued would pass through its session.
if (parsed.flags.has("--tester") && !["status", "revoke"].includes(verb)) {
throw new CliError(`--tester takes only status and revoke\n${CREDENTIALS_USAGE}`, 4);
}
try {
const call = callFor(parsed);
let result;
if (verb === "status") {
const rows = await call("credentials", {});
result = agent ? rows.filter((r) => r.role === agent) : rows;
if (!parsed.flags.has("--json")) {
io.stdout.write(result.map((r) => `${r.role}\t${r.service}\t${r.state}\t${r.dateKind} ${r.date}`).join("\n") + (result.length ? "\n" : ""));
return;
}
} else if (verb === "issue" || verb === "rotate") {
// rotate names an entry the bus holds now; checked before stdin is read.
if (verb === "rotate") {
const held = (await call("credentials", {})).find((r) => r.role === agent && r.service === kind);
if (!ISSUED.has(held?.state)) throw new CliError(`refused: ${agent} has no issued ${kind} credential to rotate; use issue`, 2);
}
const token = await readToken(io.stdin);
result = await call("credentials.issue", { agent, service: kind, token, date: v[DATE_FLAG[kind]] });
} else if (verb === "revoke") {
result = await call("credentials.revoke", { agent, ...(kind ? { service: kind } : {}), reason: v["--reason"] });
} else result = await call("credentials.delete", { agent, reason: v["--reason"] });
io.stdout.write(JSON.stringify(result, null, 2) + "\n");
} catch (error) {
if (error instanceof CliError) throw error;
throw new CliError(error.message, 2);
}
}
+3 -72
View File
@@ -26,19 +26,6 @@
// squash that will land. The gate runs after the answer, and its
// outcome is the broker's: recordLand or landRefused, each on the
// task's trail and told to the role as a RESULT message.
// publish {"cap", "op": "publish", "task_ref", "project", "branch", "decision"}
// (row 199, #1697, design D9 to D11). The host answers {"ok": true,
// "result": {fork, ref, commit, previous}} or a refusal. The broker's
// checkPublish names the fork, the agent's email, the source commit
// and the destination; the request names none of them. publish
// (working.mjs) checks the work ref and the commits' identity under
// the repository's lock, then the bus process runs ls-remote and the
// push with the agent's gitea token (publishPush), and the host polls
// publishResult every publishPollMs: the host's op channel answers
// one op at a time within 10 s. The poll ends at publishDeadlineMs
// (publish-timeout), past git's own limit on each of the four git
// runs. recordPublish files the push; any refusal after checkPublish
// is filed once, by publishRefused.
//
// A refusal is {"ok": false, "error": "<code>"}, and the connection ends.
// The host checks, in order: identity (a role of this business), the request
@@ -77,10 +64,9 @@ import { randomBytes } from "node:crypto";
import { closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, rmSync, statSync, symlinkSync, writeSync, chmodSync } from "node:fs";
import { createServer } from "node:net";
import { join } from "node:path";
import { GIT_GRACE_MS, GIT_TIMEOUT_MS } from "../../seat/src/checkout.mjs";
import { gitSocketPath } from "../../seat/src/session.mjs";
import { gateEnv, isMosaicStack, rootHits, runGate } from "../../seat/src/landing.mjs";
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, publish, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
import { CliError } from "./errors.mjs";
const HEADER_MAX = 4096;
@@ -98,11 +84,6 @@ export const bodyDeadline = (size) => BODY_IDLE_MS + Math.ceil(size / BODY_MIN_R
const MAX_ACTIVE = 8;
// The longest close() waits for its host-closing answers to leave.
export const CLOSE_WAIT_MS = 2000;
// How often the host asks the bus process whether a publish's push ended.
const PUBLISH_POLL_MS = 200;
// The longest the host polls: publishRemote runs at most four git commands
// (ls-remote, cat-file, merge-base, push), each ended by git's own limit.
const PUBLISH_DEADLINE_MS = 4 * (GIT_TIMEOUT_MS + GIT_GRACE_MS) + 30_000;
const CAP = /^[0-9a-f]{64}$/;
const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
@@ -291,7 +272,7 @@ Applied-under: git socket land (bus host)
// host: startHost()'s handle; business: loadBusiness() output. bundleMax,
// bodyDeadline, bodyIdle, maxActive, fsck and gate (LAND_GATE's shape) are
// for the tests.
export function createGitSocket({ host, business: b, dataRoot, env = process.env, log = () => {}, bundleMax = BUNDLE_MAX, bodyDeadline: deadlineOf = bodyDeadline, bodyIdle = BODY_IDLE_MS, maxActive = MAX_ACTIVE, fsck = FSCK, gate = LAND_GATE, publishPollMs = PUBLISH_POLL_MS, publishDeadlineMs = PUBLISH_DEADLINE_MS }) {
export function createGitSocket({ host, business: b, dataRoot, env = process.env, log = () => {}, bundleMax = BUNDLE_MAX, bodyDeadline: deadlineOf = bodyDeadline, bodyIdle = BODY_IDLE_MS, maxActive = MAX_ACTIVE, fsck = FSCK, gate = LAND_GATE }) {
const path = gitSocketPath(dataRoot);
const dir = join(dataRoot, "bus-host");
let server = null;
@@ -405,55 +386,6 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
})();
}
async function doPublish(socket, cap, req) {
const { task_ref, branch, decision } = req;
const p = project(req.project);
if (!p || !validBranch(branch) || (decision !== undefined && typeof decision !== "string")) throw new Refusal("invalid-request");
workRef(task_ref);
const facts = await op({ op: "checkPublish", cap, args: { task_ref, project: req.project, branch, ...(decision === undefined ? {} : { decision }) } });
const { ticket } = facts;
// From the ticket on, a refusal is the publish's own, filed once by publishRefused.
const refused = async (e) => {
const code = e instanceof Refusal || e instanceof Recorded || e instanceof WorkingError ? e.code : "git-failed";
if (code === "git-failed") log(`git socket: publish ${task_ref}: ${code}: ${e.message}`);
await host.op({ op: "publishRefused", ticket, args: { code } }).catch((x) => log(`git socket: publish ${task_ref}: refusal ${code} not recorded: ${x.code ?? x.message}`));
return new Recorded(code);
};
let pushed;
try {
pushed = await publish({
dataRoot, business: b.id, project: req.project, root: p.root, taskRef: task_ref, source: facts.source, email: facts.email, env,
remote: async () => {
await op({ op: "publishPush", ticket });
const until = Date.now() + publishDeadlineMs;
for (;;) {
// The bus's git may still finish after this; the fork could then hold the commit with
// no record, as when recordPublish refuses after a push.
if (Date.now() >= until) {
log(`git socket: publish ${task_ref}: publish-timeout; the bus process's push may still reach the fork`);
throw new Refusal("publish-timeout");
}
await new Promise((r) => setTimeout(r, publishPollMs));
const r = await op({ op: "publishResult", ticket });
if (r.state === "pushed") return { ref: r.ref, commit: r.commit, previous: r.previous };
if (r.state === "refused") throw new Recorded(r.code);
}
},
});
} catch (e) {
throw await refused(e);
}
// The commit is on the fork now; a record the broker refuses is filed as that refusal.
let result;
try {
result = await op({ op: "recordPublish", ticket, pushed });
} catch (e) {
log(`git socket: publish ${task_ref}: ${pushed.commit} is on ${facts.fork} but wasn't recorded: ${e.code}`);
throw await refused(e);
}
socket.end(`${JSON.stringify({ ok: true, result: { fork: result.fork, ref: result.ref, commit: result.commit, previous: result.previous } })}\n`);
}
async function serve(socket) {
let cap = null;
let subject = null;
@@ -478,8 +410,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
if (req.op === "push") await doPush(socket, cap, req, rest, file);
else if (req.op === "fetch") await doFetch(socket, cap, req, file);
else if (req.op === "land") await doLand(socket, cap, req);
else if (req.op === "publish") await doPublish(socket, cap, req);
else throw new Refusal("invalid-request", "op is push, fetch, land or publish");
else throw new Refusal("invalid-request", "op is push, fetch or land");
} catch (e) {
if (e instanceof Recorded) return fail(e.code);
const code = e instanceof Refusal || e instanceof WorkingError ? e.code : "git-failed";
+3 -5
View File
@@ -47,9 +47,8 @@
// launchedBy = the business's human and no caller capability.
//
// listen() also serves the git socket (src/git-socket.mjs, row 93), which
// the sandbox binds for a role that holds git.push.working,
// review.verdict or git.push.remote (row 199), or a merge decision to land
// (landHeld, row 137);
// the sandbox binds for a role that holds git.push.working or
// review.verdict, or a merge decision to land (landHeld, row 137);
// session.json names it as gitSocket.
//
// Thread sessions (row 121, #1615). A thread instance (an instance whose
@@ -398,9 +397,8 @@ export function createLauncher({
const transcript = call(model.harness, "transcript", () => harness.transcript({ runDir, store }));
const harnessMounts = call(model.harness, "mounts", () => harness.mounts({ env }));
// Row 93: the git socket, for a role that pushes work or reviews it. Row 137: or lands it.
// Row 199 (D11): or publishes it, once a reviewed commit routes git.push.remote within-role.
const held = [...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole];
const git = land || ["git.push.working", "review.verdict", "git.push.remote"].some((a) => held.includes(a)) ? gitSocketPath(dataRoot) : null;
const git = land || held.includes("git.push.working") || held.includes("review.verdict") ? gitSocketPath(dataRoot) : null;
writeSessionFile(runDir, {
...sessionDefaults,
business: b.id,
+306
View File
@@ -0,0 +1,306 @@
// `mosaic access` (FED R5, #414). Every write goes to a scratch config dir and a
// scratch data root. The success paths run on a stand-in human CLI (fakeCli).
// The one test that runs the real human-cli.mjs does it on a scratch bus from
// agent ancestry, so the bus must refuse, and nothing is written.
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import * as nodeFs from "node:fs";
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { startBroker } from "../../bus/src/runtime.mjs";
import { accessCommand, accessContextFor, withLock } from "../src/access.mjs";
import { CliError } from "../src/errors.mjs";
import { socketPath } from "../src/config.mjs";
import { HUMAN_CLI, humanTransport } from "../src/transport.mjs";
import { fakeCli, humanEnv, io, tmp, fixture } from "./helpers.mjs";
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
const PROJECT = "3f1c2a64-0d8e-4b7a-9c55-1a2b3c4d5e6f";
const ROWS = [{ id: PROJECT, name: "hub", repo: null }];
const CONTEXT = {
providers: ["authentik"],
peers: [],
businesses: { acme: { human: "jason", instances: ["coder"] } },
ssoSubjects: [{ provider: "authentik", sub: "jason-sub" }],
};
const DOC = {
accessVersion: 1,
revision: 1,
users: [{ id: "alice", home: "local", subjects: [{ provider: "authentik", sub: "alice-sub" }] }],
grants: [],
};
// The stand-in human CLI. It answers project.list with ROWS, or refuses like a bus
// that sees no human shell when FAKE_MODE is "refuse".
const BODY = `
import { readFileSync, writeFileSync } from "node:fs";
const input = JSON.parse(readFileSync(0, "utf8"));
writeFileSync(process.env.FAKE_LOG, JSON.stringify(input));
if (process.env.FAKE_MODE === "refuse") { process.stderr.write("human-required\\n"); process.exit(3); }
process.stdout.write(JSON.stringify(${JSON.stringify(ROWS)}));
`;
function setup(t, { mode = "ok", doc = DOC } = {}) {
const root = tmp(t, "mosaic-access-");
const config = join(root, "config");
mkdirSync(config, { recursive: true });
const dataRoot = join(root, "data");
const log = join(root, "fake.log");
const cli = fakeCli(root, BODY);
const env = humanEnv({ FAKE_LOG: log, FAKE_MODE: mode });
const file = join(config, "access.json");
if (doc) writeFileSync(file, JSON.stringify(doc, null, 2), { mode: 0o600 });
const deps = {
system: { dataRoot },
configDir: config,
contextFor: () => CONTEXT,
now: () => new Date("2026-10-20T20:00:00Z"),
transport: (business) => humanTransport({ socket: join(root, "bus.sock"), business, env, cli, timeoutMs: 5000 }),
};
const receipts = () => (existsSync(join(dataRoot, "state", "access-log.jsonl")) ? readFileSync(join(dataRoot, "state", "access-log.jsonl"), "utf8") : "");
return { root, config, file, deps, env, log, receipts, read: () => JSON.parse(readFileSync(file, "utf8")) };
}
const GRANT_BASE = ["grant", "--business", "acme", "--user", "alice"];
const GRANT = [...GRANT_BASE, "--projects", PROJECT, "--instances", "coder", "--level", "collaborator"];
async function run(args, deps, env = humanEnv()) {
const i = io(env);
await accessCommand(args, i, deps);
return i.out.text;
}
async function refusal(promise, pattern) {
await assert.rejects(promise, (e) => e instanceof CliError && pattern.test(e.message));
return promise.catch((e) => e);
}
test("list prints the revision and the users", async (t) => {
const s = setup(t);
const out = await run(["list", "--business", "acme"], s.deps);
assert.match(out, /access file revision 1/);
assert.match(out, /user alice home local/);
});
test("grant writes one revision up, a receipt line, and the id in the file", async (t) => {
const s = setup(t);
const out = await run([...GRANT, "--revision", "1"], s.deps);
assert.match(out, /access grant: g-[0-9a-f]{8} at acme, revision 2/);
const doc = s.read();
assert.equal(doc.revision, 2);
assert.equal(doc.grants.length, 1);
assert.deepEqual(doc.grants[0].projects, [PROJECT]);
const [line] = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
assert.equal(line.verb, "grant");
assert.equal(line.revisionBefore, 1);
assert.equal(line.revisionAfter, 2);
assert.equal(line.actor, "human");
});
test("a second write appends a second receipt and leaves the first intact", async (t) => {
const s = setup(t);
await run([...GRANT, "--revision", "1"], s.deps);
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
const lines = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
assert.deepEqual(lines.map((l) => l.verb), ["grant", "revoke"]);
assert.equal(lines[0].revisionAfter, 2);
});
test("a stale revision refuses with revision-conflict and writes nothing", async (t) => {
const s = setup(t);
await run([...GRANT, "--revision", "1"], s.deps);
const before = readFileSync(s.file, "utf8");
await refusal(run([...GRANT, "--revision", "1"], s.deps), /revision-conflict/);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts().trim().split("\n").length, 1);
});
test("a live holder's lock refuses and leaves the file and the lock alone", async (t) => {
const s = setup(t);
writeFileSync(`${s.file}.lock`, `${process.pid}\n`, { mode: 0o600 });
const before = readFileSync(s.file, "utf8");
await refusal(run([...GRANT, "--revision", "1"], s.deps), /being written by another command/);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${process.pid}\n`);
assert.equal(readdirSync(s.config).filter((f) => f.startsWith(".access-")).length, 0);
});
test("an empty or unreadable lock refuses, since its holder may still be writing", async (t) => {
const s = setup(t);
writeFileSync(`${s.file}.lock`, "");
await refusal(run([...GRANT, "--revision", "1"], s.deps), /is unreadable or not a pid/);
writeFileSync(`${s.file}.lock`, "not-a-pid\n");
await refusal(run([...GRANT, "--revision", "1"], s.deps), /is unreadable or not a pid/);
assert.equal(existsSync(`${s.file}.lock`), true);
});
test("a lock left by a dead pid refuses, names the pid, and is not reclaimed", async (t) => {
const s = setup(t);
const dead = spawnSync(process.execPath, ["-e", ""]);
writeFileSync(`${s.file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
const before = readFileSync(s.file, "utf8");
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`${s.file}\\.lock is left by pid ${dead.pid}, which is not running`));
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${dead.pid}\n`);
assert.equal(s.receipts(), "");
});
test("a failed rename leaves the old bytes, removes the temp file, and writes no receipt", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const fs = { ...nodeFs, renameSync: () => { throw Object.assign(new Error("injected rename failure"), { code: "EXDEV" }); } };
await assert.rejects(run([...GRANT, "--revision", "1"], { ...s.deps, fs }), /injected rename failure/);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.deepEqual(readdirSync(s.config).filter((f) => f.startsWith(".access-")), []);
assert.equal(existsSync(`${s.file}.lock`), false);
assert.equal(s.receipts(), "");
});
test("a group-writable access file refuses with exit 3, names the file, and changes nothing", async (t) => {
const s = setup(t);
chmodSync(s.file, 0o664);
const before = readFileSync(s.file, "utf8");
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /must not be writable by group or other/);
assert.equal(err.exitCode, 3);
assert.match(err.message, new RegExp(s.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
assert.equal(existsSync(`${s.file}.lock`), false);
});
test("an agent or tester caller is refused before the context or the access file is read", async (t) => {
const s = setup(t);
chmodSync(s.file, 0o664);
const deps = { ...s.deps, contextFor: () => { throw new Error("context was read"); } };
const env = { ...humanEnv(), MOSAIC_RUN_ID: "run-1" };
await refusal(run([...GRANT, "--revision", "1"], deps, env), /human shell/);
await refusal(run(["list", "--business", "acme", "--tester"], deps), /not --tester/);
});
test("the lock is mode 0600 while held and removed after a refused write", async (t) => {
const s = setup(t);
let mode;
withLock(s.file, () => {
mode = statSync(`${s.file}.lock`).mode & 0o777;
});
assert.equal(mode, 0o600);
assert.equal(existsSync(`${s.file}.lock`), false);
await refusal(run([...GRANT, "--revision", "9"], s.deps), /revision-conflict/);
assert.equal(existsSync(`${s.file}.lock`), false);
});
test("a grant naming an unknown instance is refused before any write", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const bad = [...GRANT_BASE, "--projects", PROJECT, "--instances", "nobody", "--level", "collaborator", "--revision", "1"];
await refusal(run(bad, s.deps), /refused: /);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("a project name resolves to its id at grant time", async (t) => {
const s = setup(t);
const args = [...GRANT_BASE, "--projects", "hub", "--instances", "coder", "--level", "collaborator", "--revision", "1"];
await run(args, s.deps);
assert.deepEqual(s.read().grants[0].projects, [PROJECT]);
});
test("an unknown project name refuses before any write", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const args = [...GRANT_BASE, "--projects", "nope", "--instances", "coder", "--level", "collaborator", "--revision", "1"];
await refusal(run(args, s.deps), /project-not-found/);
assert.equal(readFileSync(s.file, "utf8"), before);
});
test("revoke removes the user's grants and leaves a revision behind", async (t) => {
const s = setup(t);
await run([...GRANT, "--revision", "1"], s.deps);
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
const doc = s.read();
assert.equal(doc.revision, 3);
assert.deepEqual(doc.grants, []);
});
test("revoke of a user with no grant refuses and writes nothing", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
await refusal(run(["revoke", "--business", "acme", "--user", "alice", "--revision", "1"], s.deps), /no grant/);
assert.equal(readFileSync(s.file, "utf8"), before);
});
test("an agent caller is refused before the bus is asked", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const env = { ...humanEnv(), MOSAIC_RUN_ID: "run-1" };
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps, env), /human shell/);
assert.equal(err.exitCode, 3);
assert.equal(existsSync(s.log), false);
assert.equal(readFileSync(s.file, "utf8"), before);
});
test("--tester is refused on every verb before the bus is asked", async (t) => {
const s = setup(t);
for (const verb of [["list"], [...GRANT, "--revision", "1"], ["revoke", "--user", "alice", "--revision", "1"]]) {
const args = [...verb, "--business", "acme", "--tester"];
const err = await refusal(run(args, s.deps), /not --tester/);
assert.equal(err.exitCode, 3);
}
assert.equal(existsSync(s.log), false);
});
test("a bus that refuses the human proof blocks the write", async (t) => {
const s = setup(t, { mode: "refuse" });
const before = readFileSync(s.file, "utf8");
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /human-required/);
assert.equal(err.exitCode, 3);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("the context reads console.sso subjects and the business files, and peers stay empty", async (t) => {
const root = tmp(t, "mosaic-access-ctx-");
const f = fixture(root, "acme");
const system = { console: { sso: { providers: [{ id: "authentik", subjects: [{ sub: "jason-sub" }] }] } } };
const ctx = accessContextFor(system, { MOSAIC_CONFIG: join(root, "config", "config.json") });
assert.deepEqual(ctx.providers, ["authentik"]);
assert.deepEqual(ctx.ssoSubjects, [{ provider: "authentik", sub: "jason-sub" }]);
assert.deepEqual(ctx.peers, []);
assert.equal(ctx.businesses.acme.human, "jason");
assert.ok(Array.isArray(ctx.businesses.acme.instances) && ctx.businesses.acme.instances.length > 0);
const bare = accessContextFor({}, { MOSAIC_CONFIG: join(root, "config", "config.json") });
assert.deepEqual(bare.ssoSubjects, []);
assert.deepEqual(bare.providers, []);
});
// Real human-cli.mjs on a scratch bus, run from agent ancestry. The proof must fail.
test("the real human-cli, run from agent ancestry, refuses and writes nothing", async (t) => {
const root = tmp(t, "mosaic-access-real-");
const f = fixture(root, "acme");
const config = join(root, "config");
const bus = await startBroker({
dataRoot: f.dataRoot,
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
organization: { dir: config, rolesDir: f.roles, system: systemFor(root) },
});
t.after(() => bus.close());
writeJson(join(config, "access.json"), DOC);
const file = join(config, "access.json");
const before = readFileSync(file, "utf8");
const agentEnv = { ...humanEnv(), CLAUDECODE: "1" };
const deps = {
system: { dataRoot: f.dataRoot },
configDir: config,
contextFor: () => CONTEXT,
transport: (business) => humanTransport({ socket: socketPath(f.dataRoot), business, env: agentEnv, cli: HUMAN_CLI, timeoutMs: 10000 }),
};
// The bus closes the socket on a failed human proof, so the client reports
// outcome-unknown (client.mjs:32-33), not human-required. That is a bus behavior
// outside R5 (BUILD.md). The test pins it, and nothing is written.
const err = await refusal(run([...GRANT, "--revision", "1"], deps), /^outcome-unknown/);
assert.equal(err.code, "outcome-unknown");
assert.equal(err.exitCode, 1);
assert.equal(readFileSync(file, "utf8"), before);
assert.equal(existsSync(join(f.dataRoot, "state", "access-log.jsonl")), false);
});
-114
View File
@@ -1,114 +0,0 @@
// Row 199 (#1697, design section 6, T4, T5): `mosaic credentials`. issue and rotate read the token on
// stdin and pass it once to credentials.issue; nothing printed carries it. --tester takes status and
// revoke only. A fake transport records the calls; no bus runs here.
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { main } from "../src/cli.mjs";
import { io, tmp } from "./helpers.mjs";
const TOKEN = "fake_cli_token_0123456789";
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
const ROWS = [
{ service: "gitea", account: null, role: "coder", dateKind: "rotateBy", date: "2099-01-01", state: "valid" },
{ service: "vikunja", account: "bot", role: "coder", dateKind: "expires", date: "2099-01-01", state: "not-issued" },
{ service: "gitea", account: null, role: "reviewer", dateKind: "rotateBy", date: "2099-01-01", state: "revoked" },
];
function deps(calls, rows = ROWS) {
const answer = (who) => (business) => async (verb, args) => {
calls.push({ who, business, verb, args });
if (verb === "credentials") return rows;
if (verb === "credentials.issue") return { agent: args.agent, verb: "issue", service: args.service, date: args.date, state: "valid" };
return { agent: args.agent, verb: verb.slice(12), services: [] };
};
return { system: { dataRoot: "/nonexistent" }, transport: answer("human"), testerTransport: answer("tester") };
}
const withStdin = (x, text) => {
x.stdin.end(text);
return x;
};
test("row 199: issue and rotate send the stdin token once to credentials.issue and print no value", async () => {
const calls = [];
const d = deps(calls);
const x = withStdin(io(), TOKEN + "\n");
await main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], x, d);
assert.deepEqual(calls, [{ who: "human", business: "acme", verb: "credentials.issue", args: { agent: "coder", service: "gitea", token: TOKEN + "\n", date: "2099-01-01" } }]);
assert.equal(x.out.text.includes(TOKEN), false);
assert.equal(JSON.parse(x.out.text).state, "valid");
// rotate checks the entry is issued before it reads stdin.
calls.length = 0;
await main(["credentials", "rotate", "coder", "gitea", "--rotate-by", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), d);
assert.deepEqual(calls.map((c) => c.verb), ["credentials", "credentials.issue"]);
calls.length = 0;
await assert.rejects(main(["credentials", "rotate", "coder", "vikunja", "--expires", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), d), /no issued vikunja credential to rotate; use issue/);
assert.deepEqual(calls.map((c) => c.verb), ["credentials"]);
// No token on stdin, the date flag of the other kind, a token in argv: refused before any write.
calls.length = 0;
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io(), "\n"), d), /no token on stdin/);
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--expires", "2099-01-01", "--business", "acme"], withStdin(io(), TOKEN), d), /mosaic credentials/);
await assert.rejects(main(["credentials", "issue", "coder", "gitea", TOKEN, "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io(), TOKEN), d), /mosaic credentials/);
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io(), "x".repeat(17000)), d), /too long/);
assert.equal(calls.filter((c) => c.verb === "credentials.issue").length, 0);
// From inside an agent run, the human's verbs refuse before any call.
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io({ CLAUDECODE: "1" }), TOKEN), d), /outside any agent run/);
});
// gizmoduck-0050: a gitea key past its rotate-by date is rotation-due, and rotate is how a human
// clears it. Every state the bus's status() gives a held token rotates; the other two refuse.
test("row 199: rotate goes ahead on each held state, rotation-due included, and refuses not-issued, revoked and an unknown entry", async () => {
const states = { valid: true, "rotation-due": true, expiring: true, expired: true, "not-issued": false, revoked: false };
for (const [state, held] of Object.entries(states)) {
const calls = [];
const d = deps(calls, [{ service: "gitea", account: null, role: "coder", dateKind: "rotateBy", date: "2020-01-01", state }]);
const run = main(["credentials", "rotate", "coder", "gitea", "--rotate-by", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), d);
if (held) await run;
else await assert.rejects(run, /no issued gitea credential to rotate; use issue/);
assert.deepEqual(calls.map((c) => c.verb), held ? ["credentials", "credentials.issue"] : ["credentials"], state);
}
const calls = [];
await assert.rejects(main(["credentials", "rotate", "tester", "gitea", "--rotate-by", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), deps(calls)), /no issued gitea credential/);
assert.deepEqual(calls.map((c) => c.verb), ["credentials"]);
});
test("row 199: status, revoke and delete; the tester runs status and revoke only", async () => {
const calls = [];
const d = deps(calls);
const x = io();
await main(["credentials", "status", "coder", "--business", "acme"], x, d);
assert.equal(x.out.text, "coder\tgitea\tvalid\trotateBy 2099-01-01\ncoder\tvikunja\tnot-issued\texpires 2099-01-01\n");
const j = io();
await main(["credentials", "--json", "--business", "acme", "--tester"], j, d);
assert.equal(JSON.parse(j.out.text).length, 3);
await main(["credentials", "revoke", "coder", "gitea", "--reason", "fired", "--business", "acme", "--tester"], io(), d);
await main(["credentials", "revoke", "coder", "--reason", "fired", "--business", "acme"], io(), d);
await main(["credentials", "delete", "coder", "--reason", "fired", "--business", "acme"], io(), d);
assert.deepEqual(calls.map((c) => [c.who, c.verb, c.args]), [
["human", "credentials", {}],
["tester", "credentials", {}],
["tester", "credentials.revoke", { agent: "coder", service: "gitea", reason: "fired" }],
["human", "credentials.revoke", { agent: "coder", reason: "fired" }],
["human", "credentials.delete", { agent: "coder", reason: "fired" }],
]);
calls.length = 0;
for (const args of [
["issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--tester"],
["rotate", "coder", "gitea", "--rotate-by", "2099-01-01", "--tester"],
["delete", "coder", "--reason", "x", "--tester"],
]) await assert.rejects(main(["credentials", ...args, "--business", "acme"], withStdin(io(), TOKEN), d), /--tester takes only status and revoke/);
for (const args of [["revoke", "coder"], ["delete", "coder"], ["delete", "coder", "gitea", "--reason", "x"], ["revoke", "coder", "model", "--reason", "x"], ["purge", "coder"]])
await assert.rejects(main(["credentials", ...args, "--business", "acme"], io(), d), /mosaic credentials/, args.join(" "));
assert.equal(calls.length, 0);
});
test("row 199: scripts/mosaic sends credentials to packages/cli, not the seat CLI", (t) => {
const root = tmp(t, "mosaic-cli-cred-route-");
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
const r = spawnSync(MOSAIC, ["credentials", "purge"], { env, encoding: "utf8" });
assert.equal(r.status, 4);
assert.match(r.stderr, /^mosaic: mosaic credentials \[status \[AGENT\]\]/);
assert.doesNotMatch(r.stderr, /mosaic launch </);
});
-281
View File
@@ -1,281 +0,0 @@
// Row 199 (#1697, design D9, D9b, D11, T3): the git socket's publish op end to end. The socket,
// working.mjs, the bus runtime (in this process, so its publishTls can name the scratch CA), the
// per-agent store and a TLS fake Gitea on 127.0.0.1. The host stand-in answers the socket's ops
// the way packages/bus/src/process.mjs does. Fake tokens in a scratch root only.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { connect } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { startBroker } from "../../bus/src/runtime.mjs";
import { businessDoc, rolesCopy, systemFor, writeJson } from "../../business/tests/helpers.mjs";
import { pushWorking } from "../../harness/src/git-tools.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { workRef } from "../../seat/src/working.mjs";
import { fakeGitea, scratchCa } from "../../seat/tests/fake-gitea.mjs";
import { createGitSocket } from "../src/git-socket.mjs";
const TASK = "vikunja:1/2";
const TASK2 = "vikunja:1/3";
const TOKEN = "fake_socket_token_0123456789";
const TOKEN2 = "fake_second_token_0123456789";
function git(cwd, ...args) {
const r = spawnSync("git", args, { cwd, encoding: "utf8", env: gitEnv() });
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
return r.stdout.trim();
}
function commit(dir, text, email) {
writeFileSync(join(dir, "README"), `${text}\n`);
git(dir, "add", "README");
git(dir, "-c", `user.name=${text}`, "-c", `user.email=${email}`, "commit", "-q", "-m", text);
return git(dir, "rev-parse", "HEAD");
}
function raw(path, req) {
return new Promise((resolve, reject) => {
const s = connect(path);
let buf = "";
s.on("connect", () => s.write(`${JSON.stringify(req)}\n`));
s.on("data", (b) => (buf += b));
s.on("end", () => resolve(JSON.parse(buf.split("\n")[0])));
s.on("error", reject);
});
}
// The socket's ops, answered as process.mjs's launchOp answers them.
const OPS = {
identity: (r, m) => r.identity(m.cap),
refuse: (r, m) => r.refuse(m.cap, m.code, m.subject),
checkPush: (r, m) => r.checkPush(m.cap, m.task_ref),
recordPush: (r, m) => r.recordPush(m.cap, m.push),
checkPublish: (r, m) => r.checkPublish(m.cap, m.args),
publishPush: (r, m) => r.publishPush(m.ticket),
publishResult: (r, m) => r.publishResult(m.ticket),
recordPublish: (r, m) => r.recordPublish(m.ticket, m.pushed),
publishRefused: (r, m) => r.publishRefused(m.ticket, m.args),
};
async function setup(t, { publishDeadlineMs } = {}) {
const root = mkdtempSync(join(tmpdir(), "cli-publish-"));
const down = [];
t.after(async () => {
for (const fn of down) await fn();
rmSync(root, { recursive: true, force: true });
});
const ca = scratchCa(join(root, "ca"));
const gitea = await fakeGitea({ dir: join(root, "gitea"), tls: ca, users: { coder: TOKEN, second: TOKEN2 } });
down.push(() => gitea.close());
const project = join(root, "project");
mkdirSync(project);
git(project, "init", "-q", "-b", "main");
commit(project, "one", "[email protected]");
// Each agent's fork and the upstream, mosaic/stack, all clones of the root.
for (const owner of ["coder", "second", "mosaic"]) {
mkdirSync(join(gitea.repo(owner, "stack"), ".."), { recursive: true });
git(root, "clone", "-q", "--bare", project, gitea.repo(owner, "stack"));
}
const forkRepo = gitea.repo("coder", "stack");
const rolesDir = rolesCopy(root);
writeJson(join(root, "businesses/demo.json"), { ...businessDoc(root, "demo"), plugins: { harness: ["pi"] } });
mkdirSync(join(root, "data"), { mode: 0o700 });
mkdirSync(join(root, "secrets", "agents"), { recursive: true, mode: 0o700 });
chmodSync(join(root, "secrets"), 0o700);
const agent = (user) => ({
authority: { withinRole: ["git.push.working", "message.send"], crossRole: [] },
identity: { name: user, email: `${user}@example.com`, gitea: { url: gitea.origin, user } },
publish: ["stack"],
credentials: { gitea: { store: true, file: join(root, "secrets", "agents", "demo", user, "gitea"), rotateBy: "2099-01-01" } },
});
const demo = {
id: "demo", human: "jason", arbiters: { technical: "reviewer", delivery: "reviewer" },
projects: { stack: { root: project, remote: `${gitea.origin}/mosaic/stack` } },
roles: {
coder: agent("coder"),
second: agent("second"),
reviewer: { authority: { withinRole: ["message.send"], crossRole: [] } },
},
};
const dataRoot = join(root, "data");
const bus = await startBroker({ dataRoot, businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) }, publishTls: ["-c", `http.sslCAInfo=${ca.ca}`] });
down.unshift(() => bus.close());
// A test sets hooks[op] to act around one op: hook(message, next), where next() answers it.
const hooks = {};
const host = { op: async (m) => (hooks[m.op] ? hooks[m.op](m, () => OPS[m.op](bus, m)) : OPS[m.op](bus, m)) };
const logs = [];
mkdirSync(join(dataRoot, "bus-host"), { mode: 0o700 });
const sock = createGitSocket({ host, business: demo, dataRoot, env: process.env, log: (l) => logs.push(l), publishPollMs: 20, ...(publishDeadlineMs ? { publishDeadlineMs } : {}) });
down.unshift(() => sock.close());
await sock.listen();
const caps = {};
for (const role of ["coder", "second"]) {
caps[role] = bus.broker.bindLaunch({ business: "demo", role, run: `${role}-run`, harness: "pi" });
bus.broker.request(caps[role], { verb: "role.claim", args: {} });
}
const human = bus.broker.bindHuman({ business: "demo", human: "jason", via: "cli", outsideAgent: true });
// Each agent's workspace: a clone of the root with one commit of its own on feat, pushed to the
// working repo for its task.
const work = async (role, task) => {
const workspace = join(root, `ws-${role}`);
const tmp = join(root, `tmp-${role}`);
mkdirSync(workspace);
mkdirSync(tmp);
git(workspace, "clone", "-q", project, "stack");
const clone = join(workspace, "stack");
git(clone, "checkout", "-q", "-b", "feat");
const commitId = commit(clone, `work by ${role}`, `${role}@example.com`);
await pushWorking({ path: sock.path, cap: caps[role], workspace, tmp, task_ref: task, project: "stack", branch: "feat" });
return commitId;
};
// A second commit on the coder's feat, pushed to the working repo through the socket.
const again = async () => {
const workspace = join(root, "ws-coder");
const commitId = commit(join(workspace, "stack"), "more work", "[email protected]");
await pushWorking({ path: sock.path, cap: caps.coder, workspace, tmp: join(root, "tmp-coder"), task_ref: TASK, project: "stack", branch: "feat" });
return commitId;
};
const approve = (role = "coder", task = TASK) => {
const d = bus.broker.request(caps[role], { verb: "decision.raise", args: {
action: "git.push.remote", target: task, task_ref: task, question: "Publish?", blocking: false, recommendation: "yes",
options: [{ key: "yes", text: "Yes" }, { key: "no", text: "No" }] } });
bus.broker.request(human, { verb: "decision.resolve", args: { id: d.id, choice: "yes" } });
return d.id;
};
const publish = (decision, extra = {}, role = "coder", task = TASK) =>
raw(sock.path, { cap: caps[role], op: "publish", task_ref: task, project: "stack", branch: "feat", decision, ...extra });
// The bus holds the writer lock; the test reads its events through a read-only handle.
const events = (kind, task = TASK) => {
const db = new DatabaseSync(join(dataRoot, "bus", "bus.sqlite"), { readOnly: true });
try {
return db.prepare("SELECT * FROM events WHERE kind=? AND subject=? ORDER BY seq").all(kind, task).map((e) => ({ ...e, body: JSON.parse(e.body) }));
} finally {
db.close();
}
};
return { root, bus, gitea, forkRepo, work: await work("coder", TASK), more: work, again, hooks, caps, approve, publish, logs, events };
}
test("T3 end to end: publish under an approved decision sends the work commit to the agent's own fork and records it once", async (t) => {
const w = await setup(t);
// Nothing issued: the store has no token, and the refusal is filed once.
const first = w.approve();
assert.deepEqual(await w.publish(first), { ok: false, error: "credential-unavailable" });
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["credential-unavailable"]);
assert.equal(w.gitea.requests.length, 0);
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
const reply = await w.publish(first);
assert.deepEqual(reply, { ok: true, result: { fork: `${w.gitea.origin}/coder/stack`, ref: "refs/heads/coder/feat", commit: w.work, previous: null } });
assert.equal(git(w.root, `--git-dir=${w.forkRepo}`, "rev-parse", "refs/heads/coder/feat"), w.work);
// Every request went to the agent's fork; the upstream, mosaic/stack, received none.
assert.ok(w.gitea.requests.some((r) => r.path === "/coder/stack/git-receive-pack" && r.user === "coder"));
for (const r of w.gitea.requests) assert.match(r.path, /^\/coder\/stack\//);
const allowed = w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise");
assert.equal(allowed.length, 1);
assert.equal(allowed[0].body.fork, `${w.gitea.origin}/coder/stack`);
assert.equal(allowed[0].body.commit, w.work);
// The decision is spent.
assert.deepEqual(await w.publish(first), { ok: false, error: "decision-consumed" });
// T4: no token in any answer, event or log line, nor in any file under the scratch root but the
// agent's own store file.
assert.equal(JSON.stringify([reply, w.events("action.allowed"), w.events("action.refused"), w.logs]).includes(TOKEN), false);
const store = join(w.root, "secrets", "agents", "demo", "coder", "gitea");
const holding = readdirSync(w.root, { recursive: true })
.map((name) => join(w.root, name))
.filter((file) => lstatSync(file).isFile() && readFileSync(file).includes(TOKEN));
assert.deepEqual(holding, [store]);
});
test("T3 end to end: a fork the token can't reach is fork-unavailable, filed once, and nothing is recorded as allowed", async (t) => {
const w = await setup(t);
w.bus.credentials.issue("demo/coder", "gitea", "fake_wrong_token_0123456789", "2099-01-01");
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "fork-unavailable" });
assert.ok(w.gitea.requests.length > 0);
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["fork-unavailable"]);
assert.equal(w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise").length, 0);
});
test("a bad branch or project refuses before the broker; a fork, user or ref in the request is ignored", async (t) => {
const w = await setup(t);
const d = w.approve();
assert.deepEqual(await w.publish(d, { branch: "../main" }), { ok: false, error: "invalid-request" });
assert.deepEqual(await w.publish(d, { project: "nope" }), { ok: false, error: "invalid-request" });
// Filed through the socket's refuse op, as a bad push request is; no publish ticket opened.
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["invalid-request", "invalid-request"]);
assert.equal(w.gitea.requests.length, 0);
// The broker builds the fork and the destination; the request's own are never read.
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
const reply = await w.publish(d, { fork: "https://127.0.0.1:1/other/stack", user: "other", dst: "refs/heads/main", ref: "refs/heads/main" });
assert.deepEqual(reply.result, { fork: `${w.gitea.origin}/coder/stack`, ref: "refs/heads/coder/feat", commit: w.work, previous: null });
for (const r of w.gitea.requests) assert.match(r.path, /^\/coder\/stack\//);
assert.equal(spawnSync("git", [`--git-dir=${w.forkRepo}`, "rev-parse", "-q", "--verify", "refs/heads/other/feat"], { env: gitEnv() }).status, 1);
});
test("T3 end to end: two agents, one project, each lands on its own fork only; the upstream receives nothing", async (t) => {
const w = await setup(t);
const second = await w.more("second", TASK2);
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
w.bus.credentials.issue("demo/second", "gitea", TOKEN2, "2099-01-01");
assert.equal((await w.publish(w.approve())).ok, true);
const coderRequests = w.gitea.requests.length;
for (const r of w.gitea.requests) assert.deepEqual([r.path.split("/")[1], r.user ?? "coder"], ["coder", "coder"]);
const reply = await w.publish(w.approve("second", TASK2), {}, "second", TASK2);
assert.deepEqual(reply.result, { fork: `${w.gitea.origin}/second/stack`, ref: "refs/heads/second/feat", commit: second, previous: null });
for (const r of w.gitea.requests.slice(coderRequests)) assert.deepEqual([r.path.split("/")[1], r.user ?? "second"], ["second", "second"]);
const refs = (owner) => git(w.root, `--git-dir=${w.gitea.repo(owner, "stack")}`, "for-each-ref", "--format=%(refname) %(objectname)");
assert.equal(refs("coder").includes(`refs/heads/coder/feat ${w.work}`), true);
assert.equal(refs("coder").includes("refs/heads/second/"), false);
assert.equal(refs("second").includes(`refs/heads/second/feat ${second}`), true);
assert.equal(refs("second").includes("refs/heads/coder/"), false);
assert.equal(refs("mosaic").split("\n").length, 1, "the upstream holds only main");
});
// sage-1440 on BUILD.md 14: a push between checkPublish and the git push refuses, with one code
// for each place it can come in, and the fork never receives a commit the decision didn't bind.
test("T3: a push after checkPublish and before the lock refuses ref-moved; the fork receives nothing", async (t) => {
const w = await setup(t);
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
w.hooks.checkPublish = async (m, next) => {
const facts = await next();
await w.again();
return facts;
};
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "ref-moved" });
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["ref-moved"]);
assert.equal(w.gitea.requests.length, 0);
assert.equal(git(w.root, `--git-dir=${w.forkRepo}`, "for-each-ref", "refs/heads/coder/"), "");
});
test("T3: a push recorded while the bus process pushes refuses decision-mismatch; only the decision's commit reached the fork", async (t) => {
const w = await setup(t);
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
// The working repo's lock is held through the git push, so a socket push would wait for it; the
// broker's record of a newer push is what a push that won the lock leaves.
w.hooks.publishPush = async (m, next) => {
w.bus.checkPush(w.caps.coder, TASK);
w.bus.recordPush(w.caps.coder, { task_ref: TASK, project: "stack", branch: "feat", ref: workRef(TASK), commit: "c".repeat(40), previous: w.work });
return next();
};
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "decision-mismatch" });
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["decision-mismatch"]);
assert.equal(w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise").length, 0);
assert.equal(git(w.root, `--git-dir=${w.forkRepo}`, "rev-parse", "refs/heads/coder/feat"), w.work);
assert.ok(w.logs.some((l) => l.includes("wasn't recorded")));
});
test("sage-1440: the publishResult poll is bounded: publish-timeout, filed once, and nothing recorded", async (t) => {
const w = await setup(t, { publishDeadlineMs: 150 });
let polls = 0;
w.hooks.publishResult = async () => {
polls++;
return { state: "running" };
};
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "publish-timeout" });
assert.ok(polls > 0);
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["publish-timeout"]);
assert.equal(w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise").length, 0);
assert.ok(w.logs.some((l) => l.includes("publish-timeout")));
});
+3 -7
View File
@@ -101,10 +101,8 @@ export function gitEnv(env = process.env) {
// consequence: a Ctrl-C at the terminal of a host run by hand no longer
// reaches its git, though a systemd stop still does (it kills the cgroup).
// `stdin` names a file git reads as its input (row 137: patch-id); without
// it git gets none. `pass` (row 199, publish) is written once to a pipe on
// fd 3, which git and what it starts inherit; publish's credential helper
// reads its token there, so the token is in no argv, env, URL or file.
export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null, pass = null } = {}) {
// it git gets none.
export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null } = {}) {
return new Promise((resolve) => {
let input = "ignore";
try {
@@ -112,10 +110,8 @@ export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null, pass = nu
} catch (e) {
return resolve({ ok: false, out: "", err: "", why: e.code ?? e.message });
}
const child = spawn("git", argv, { env, detached: true, stdio: [input, "pipe", "pipe", ...(pass === null ? [] : ["pipe"])] });
const child = spawn("git", argv, { env, detached: true, stdio: [input, "pipe", "pipe"] });
if (typeof input === "number") closeSync(input);
// A git that never asked for it closes the pipe unread: EPIPE is no fault.
if (pass !== null) child.stdio[3].on("error", () => {}).end(pass);
let out = "";
let err = "";
let signalled = false;
+1 -89
View File
@@ -63,22 +63,6 @@
// compare and swap (base-moved), then record() runs; when it throws, main
// goes back, and a main that can't go back refuses rollback-failed.
// dropLand removes the worktree; the caller runs it on every path.
//
// publish (row 199, #1697, design D9 to D9b) sends a recorded work commit to
// the agent's own fork. It runs in two processes, because the gitea token
// lives in the bus process and the lock in the host's:
// publish, in the host, under the lock: the seed; the work ref is at the
// source commit (ref-moved); every commit in main..source has author and
// committer email equal to the agent's (identity-mismatch); then
// remote(), which the caller wires to publishRemote in the bus process.
// publishRemote, in the bus process, with the token on fd 3 only:
// `ls-remote --symref <fork> HEAD <dst>`; a non-zero exit refuses
// fork-unavailable. The fork's HEAD must be a commit this repository has
// and an ancestor of main (remote-unrelated, an empty fork included).
// Then `push <fork> <source>:<dst>` by oid, with no `+`. Only https
// crosses (protocol.https.allow), redirects are refused, and output
// holding the token refuses credential-leak. Git's stderr is never
// returned: it can carry the URL.
import { randomBytes } from "node:crypto";
import { closeSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
@@ -117,7 +101,7 @@ export const FSCK = ["-c", "transfer.fsckObjects=true"];
// `limits` is { timeoutMs, graceMs }; the tests shorten them. runGit
// (checkout.mjs) runs git in its own process group and signals the group.
const git = (args, env, { pre = [], limits = GIT_LIMITS, stdin = null, pass = null } = {}) => runGit([...SAFE, ...pre, ...args], gitEnv(env), limits, { stdin, pass });
const git = (args, env, { pre = [], limits = GIT_LIMITS, stdin = null } = {}) => runGit([...SAFE, ...pre, ...args], gitEnv(env), limits, { stdin });
// git with this call's env and limits, for the helpers below.
const bound = (env, limits) => (args, opts = {}) => git(args, env, { ...opts, limits });
@@ -418,75 +402,3 @@ async function removeWorktree(repo, worktree, g) {
if (!r.ok) rmSync(worktree, { recursive: true, force: true });
await g([`--git-dir=${repo}`, "worktree", "prune"]);
}
// await publish({ dataRoot, business, project, root, taskRef, source, email, remote, env, limits })
// Resolves what remote() resolves; rejects with WorkingError.
export function publish({ dataRoot, business, project, root, taskRef, source, email, remote, env = process.env, limits = GIT_LIMITS }) {
if (!PROJECT.test(project ?? "") || !OID.test(source ?? "") || typeof email !== "string" || !email) refuse("invalid-request");
const ref = `refs/heads/${workRef(taskRef)}`;
const repo = workingRepo(dataRoot, business, project);
const g = bound(env, limits);
return locked(repo, async () => {
try {
if (!lstatSync(repo).isDirectory()) refuse("working-repo-invalid");
} catch (e) {
if (e instanceof WorkingError) throw e;
refuse("working-repo-invalid", `${repo} can't be read: ${e.code}`);
}
await seed(repo, root, g);
const at = `--git-dir=${repo}`;
if ((await tip(repo, ref, g)) !== source) refuse("ref-moved", `${ref} isn't at ${source}`);
const range = await g([at, "log", "--format=%ae%x00%ce", `refs/heads/main..${source}`]);
if (!range.ok) refuse("working-repo-invalid", range.why);
for (const line of range.out.split("\n").filter(Boolean)) {
if (line !== `${email}\0${email}`) refuse("identity-mismatch", `a commit in main..${source.slice(0, 12)} isn't by ${email}`);
}
return remote();
});
}
const HOST = /^[a-z0-9.-]{1,253}(:[0-9]{1,5})?$/;
const NAME = /^[A-Za-z0-9_.-]{1,100}$/;
// The one-shot helper: for a `get` on https at exactly the fork's host and
// port, it answers the user and the line it reads from fd 3. Anything else,
// and `store` and `erase`, gets no answer. HOST and NAME keep both shell-safe.
const helper = (host, user) =>
`!f() { test "$1" = get || exit 0; p=; h=; while IFS= read -r l; do case "$l" in protocol=*) p=\${l#protocol=};; host=*) h=\${l#host=};; "") break;; esac; done; ` +
`test "$p" = https && test "$h" = '${host}' || exit 0; IFS= read -r t <&3 || exit 0; printf 'username=%s\\npassword=%s\\n' '${user}' "$t"; }; f`;
// await publishRemote({ repo, fork, user, source, dst, token, tls, env, limits })
// Resolves { ref, commit, previous }, previous being dst's tip on the fork
// before the push, or null. `tls` is for the tests' scratch CA only.
export async function publishRemote({ repo, fork, user, source, dst, token, tls = [], env = process.env, limits = GIT_LIMITS }) {
let url;
try {
url = new URL(fork);
} catch {
refuse("invalid-request");
}
if (url.protocol !== "https:" || !HOST.test(url.host) || !NAME.test(user ?? "") || !OID.test(source ?? "") || !dst?.startsWith(`refs/heads/${user}/`)) refuse("invalid-request");
const secret = String(token).trim();
const net = async (args) => {
const pre = [...tls, "-c", "protocol.https.allow=always", "-c", "http.followRedirects=false", "-c", "credential.helper=", "-c", `credential.helper=${helper(url.host, user)}`];
const r = await git([`--git-dir=${repo}`, ...args], env, { pre, limits, pass: `${secret}\n` });
if (r.out.includes(secret) || r.err.includes(secret)) refuse("credential-leak");
return r;
};
const g = bound(env, limits);
const listed = await net(["ls-remote", "--symref", fork, "HEAD", dst]);
if (!listed.ok) refuse("fork-unavailable");
let head = null;
let previous = null;
for (const line of listed.out.split("\n")) {
const [oid, name] = line.split("\t");
if (!OID.test(oid ?? "")) continue;
if (name === "HEAD") head = oid;
else if (name === dst) previous = oid;
}
if (!head) refuse("remote-unrelated", "the fork is empty");
const known = await g([`--git-dir=${repo}`, "cat-file", "-e", `${head}^{commit}`]);
if (!known.ok || !(await g([`--git-dir=${repo}`, "merge-base", "--is-ancestor", head, "refs/heads/main"])).ok) refuse("remote-unrelated", "the fork's HEAD isn't in main's history");
const pushed = await net(["push", "--porcelain", "--no-follow-tags", fork, `${source}:${dst}`]);
if (!pushed.ok) refuse(/\((non-fast-forward|fetch first)\)/.test(pushed.out) ? "non-fast-forward" : "push-failed");
return { ref: dst, commit: source, previous };
}
-95
View File
@@ -1,95 +0,0 @@
// Row 199 (#1697, design T3): a fake Gitea for publish, on 127.0.0.1 over TLS with a scratch CA,
// serving bare repositories under `<dir>/repos/<user>/<repo>.git` through git http-backend. Every
// request needs Basic auth with the user and token it was made with, or with any pair in `users`,
// as a private repository on Gitea does. It records each request's method, path and user, never the password. `redirect`
// makes every request a 302 to `redirect`. A repository's own hooks run on a push, so a test can
// make the fork echo something back as `remote:` lines. Nothing here leaves 127.0.0.1.
import { spawn, spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createServer } from "node:https";
import { join } from "node:path";
// A CA and a server certificate for IP 127.0.0.1, made once per directory with openssl.
export function scratchCa(dir) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
const ssl = (...args) => {
const r = spawnSync("openssl", args, { cwd: dir, encoding: "utf8" });
if (r.status !== 0) throw new Error(`openssl ${args[0]}: ${r.stderr}`);
};
ssl("req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes", "-keyout", "ca.key", "-out", "ca.pem", "-days", "2", "-subj", "/CN=mosaic test CA");
ssl("req", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes", "-keyout", "server.key", "-out", "server.csr", "-subj", "/CN=127.0.0.1");
writeFileSync(join(dir, "san.ext"), "subjectAltName=IP:127.0.0.1\n");
ssl("x509", "-req", "-in", "server.csr", "-CA", "ca.pem", "-CAkey", "ca.key", "-CAcreateserial", "-out", "server.pem", "-days", "2", "-extfile", "san.ext");
return { ca: join(dir, "ca.pem"), key: readFileSync(join(dir, "server.key")), cert: readFileSync(join(dir, "server.pem")) };
}
export async function fakeGitea({ dir, tls, user, token, users = { [user]: token }, redirect = null }) {
const root = join(dir, "repos");
mkdirSync(root, { recursive: true });
const requests = [];
const server = createServer({ key: tls.key, cert: tls.cert }, (req, res) => {
const url = new URL(req.url, "https://127.0.0.1");
const auth = /^Basic (.+)$/.exec(req.headers.authorization ?? "");
const [who, pass] = auth ? Buffer.from(auth[1], "base64").toString().split(":") : [null, null];
requests.push({ method: req.method, path: url.pathname, user: who });
if (redirect) {
res.writeHead(302, { location: redirect + req.url });
return res.end();
}
if (!who || !Object.hasOwn(users, who) || pass !== users[who]) {
res.writeHead(401, { "www-authenticate": 'Basic realm="Gitea"' });
return res.end();
}
// /<owner>/<repo>/info/refs and the like, on <root>/<owner>/<repo>.git.
const m = /^\/([^/]+)\/([^/]+?)(?:\.git)?(\/.*)$/.exec(url.pathname);
if (!m) {
res.writeHead(404);
return res.end();
}
const cgi = spawn("git", ["http-backend"], {
env: {
PATH: process.env.PATH,
GIT_PROJECT_ROOT: root,
GIT_HTTP_EXPORT_ALL: "1",
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: "/dev/null",
HOME: "/nonexistent",
PATH_INFO: `/${m[1]}/${m[2]}.git${m[3]}`,
REQUEST_METHOD: req.method,
QUERY_STRING: url.search.slice(1),
CONTENT_TYPE: req.headers["content-type"] ?? "",
HTTP_CONTENT_ENCODING: req.headers["content-encoding"] ?? "",
GIT_PROTOCOL: req.headers["git-protocol"] ?? "",
REMOTE_USER: who,
REMOTE_ADDR: "127.0.0.1",
},
stdio: ["pipe", "pipe", "ignore"],
});
req.pipe(cgi.stdin);
const chunks = [];
cgi.stdout.on("data", (c) => chunks.push(c));
cgi.on("close", () => {
const out = Buffer.concat(chunks);
const split = out.indexOf("\r\n\r\n");
const head = out.subarray(0, split).toString().split("\r\n");
const headers = {};
let status = 200;
for (const line of head) {
const [k, ...v] = line.split(":");
if (k.toLowerCase() === "status") status = Number.parseInt(v.join(":").trim(), 10);
else headers[k] = v.join(":").trim();
}
res.writeHead(status, headers);
res.end(out.subarray(split + 4));
});
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const port = server.address().port;
return {
port,
origin: `https://127.0.0.1:${port}`,
requests,
repo: (owner, name) => join(root, owner, `${name}.git`),
close: () => new Promise((r) => server.close(r)),
};
}
-185
View File
@@ -1,185 +0,0 @@
// Row 199 (#1697, design D9 step 3 to 5, D9a, T3): publish and publishRemote (src/working.mjs)
// against a TLS fake Gitea on 127.0.0.1 (fake-gitea.mjs) with a scratch CA, passed only through
// the `tls` parameter. Fake tokens in a scratch root. Each refusal that should reach the network
// checks the fake saw the request, so no case passes because git refused https first.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gitEnv } from "../src/checkout.mjs";
import { publish, publishRemote, push, workingRepo } from "../src/working.mjs";
import { fakeGitea, scratchCa } from "./fake-gitea.mjs";
const TASK = "vikunja:1/2";
const TOKEN = "fake_publish_token_0123456789";
const USER = "coder";
const EMAIL = "[email protected]";
const DST = "refs/heads/coder/feat";
function git(cwd, ...args) {
const r = spawnSync("git", args, { cwd, encoding: "utf8", env: gitEnv() });
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
return r.stdout.trim();
}
function commit(dir, text, email = EMAIL) {
writeFileSync(join(dir, "README"), `${text}\n`);
git(dir, "add", "README");
git(dir, "-c", `user.name=${text}`, "-c", `user.email=${email}`, "commit", "-q", "-m", text);
return git(dir, "rev-parse", "HEAD");
}
const rejects = (p, code) => assert.rejects(p, (e) => e.code === code || assert.fail(`${e.code}: ${e.message}, wanted ${code}`));
// A root, a coder's clone with work on feat pushed to the working repo, and a fake Gitea whose
// coder/stack fork is a bare clone of the root.
async function world(t, { fork = true, redirect = null } = {}) {
const dir = mkdtempSync(join(tmpdir(), "mosaic-publish-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const root = join(dir, "root");
mkdirSync(root);
git(root, "init", "-q", "-b", "main");
const first = commit(root, "one", "[email protected]");
const clone = join(dir, "clone");
git(dir, "clone", "-q", root, clone);
git(clone, "checkout", "-q", "-b", "feat");
const work = commit(clone, "work");
const dataRoot = join(dir, "data");
mkdirSync(dataRoot, { mode: 0o700 });
const file = join(dir, "b.bundle");
git(clone, "bundle", "create", "-q", file, "refs/heads/feat", "--not", "--remotes=origin");
await push({ dataRoot, business: "acme", project: "stack", root, taskRef: TASK, branch: "feat", bundle: file });
const ca = scratchCa(join(dir, "ca"));
const gitea = await fakeGitea({ dir: join(dir, "gitea"), tls: ca, user: USER, token: TOKEN, redirect });
t.after(() => gitea.close());
const forkRepo = gitea.repo(USER, "stack");
if (fork) {
mkdirSync(join(forkRepo, ".."), { recursive: true });
git(dir, "clone", "-q", "--bare", root, forkRepo);
}
const tls = ["-c", `http.sslCAInfo=${ca.ca}`];
const repo = workingRepo(dataRoot, "acme", "stack");
const remote = (extra = {}) => publishRemote({ repo, fork: `${gitea.origin}/${USER}/stack`, user: USER, source: work, dst: DST, token: TOKEN + "\n", tls, ...extra });
const go = (extra = {}) => publish({ dataRoot, business: "acme", project: "stack", root, taskRef: TASK, source: work, email: EMAIL, remote: () => remote(), ...extra });
return { dir, root, first, clone, work, dataRoot, repo, gitea, forkRepo, tls, remote, go };
}
test("T3: publish pushes the work commit by oid to <user>/<branch> on the fork, with the token only through the helper", async (t) => {
const w = await world(t);
assert.deepEqual(await w.go(), { ref: DST, commit: w.work, previous: null });
assert.equal(git(w.dir, `--git-dir=${w.forkRepo}`, "rev-parse", DST), w.work);
// Every request went to the fork's path, and each one that carried auth carried the agent's user.
assert.ok(w.gitea.requests.length > 0);
for (const r of w.gitea.requests) assert.match(r.path, /^\/coder\/stack\/(info\/refs|git-receive-pack|git-upload-pack)$/);
assert.ok(w.gitea.requests.some((r) => r.user === USER && r.path.endsWith("git-receive-pack")));
// A second publish of the same commit is a no-op push and names the previous tip.
assert.deepEqual(await w.go(), { ref: DST, commit: w.work, previous: w.work });
// The working repo holds no remote and no credential config.
assert.equal(spawnSync("git", [`--git-dir=${w.repo}`, "config", "--get-regexp", "^(remote|credential)\\."], { env: gitEnv() }).stdout.toString(), "");
});
test("T3: a moved fork branch refuses non-fast-forward; no + and no force", async (t) => {
const w = await world(t);
const other = join(w.dir, "other");
git(w.dir, "clone", "-q", w.root, other);
const elsewhere = commit(other, "elsewhere");
git(other, "push", "-q", w.forkRepo, `${elsewhere}:${DST}`);
await rejects(w.go(), "non-fast-forward");
assert.equal(git(w.dir, `--git-dir=${w.forkRepo}`, "rev-parse", DST), elsewhere);
});
test("T3: D9a: an empty fork and an unrelated fork refuse remote-unrelated; a missing fork refuses fork-unavailable", async (t) => {
const empty = await world(t, { fork: false });
git(empty.dir, "init", "-q", "--bare", empty.forkRepo);
await rejects(empty.go(), "remote-unrelated");
assert.ok(empty.gitea.requests.length > 0);
assert.equal(empty.gitea.requests.some((r) => r.path.endsWith("git-receive-pack")), false);
const unrelated = await world(t, { fork: false });
const alone = join(unrelated.dir, "alone");
mkdirSync(alone);
git(alone, "init", "-q", "-b", "main");
commit(alone, "unrelated");
git(unrelated.dir, "clone", "-q", "--bare", alone, unrelated.forkRepo);
await rejects(unrelated.go(), "remote-unrelated");
assert.equal(unrelated.gitea.requests.some((r) => r.path.endsWith("git-receive-pack")), false);
const missing = await world(t, { fork: false });
await rejects(missing.go(), "fork-unavailable");
assert.ok(missing.gitea.requests.length > 0);
});
test("T3: a fork past main refuses remote-unrelated until root catches up", async (t) => {
const w = await world(t);
const other = join(w.dir, "other");
git(w.dir, "clone", "-q", w.forkRepo, other);
const ahead = commit(other, "ahead");
git(other, "push", "-q", w.forkRepo, `${ahead}:refs/heads/main`);
await rejects(w.go(), "remote-unrelated");
});
test("T3: a wrong token refuses fork-unavailable; git's stderr isn't in the refusal", async (t) => {
const w = await world(t);
await assert.rejects(w.remote({ token: "fake_wrong_token_0123456789" }), (e) => e.code === "fork-unavailable" && e.message === "fork-unavailable");
assert.ok(w.gitea.requests.length > 0);
assert.equal(git(w.dir, `--git-dir=${w.forkRepo}`, "for-each-ref", DST), "");
});
test("T3: a 302 is not followed: fork-unavailable, and the target sees no request", async (t) => {
const w = await world(t);
const target = await fakeGitea({ dir: join(w.dir, "target"), tls: scratchCa(join(w.dir, "ca2")), user: USER, token: TOKEN });
t.after(() => target.close());
const bounce = await fakeGitea({ dir: join(w.dir, "bounce"), tls: scratchCa(join(w.dir, "ca3")), user: USER, token: TOKEN, redirect: target.origin });
t.after(() => bounce.close());
const tls = ["-c", `http.sslCAInfo=${join(w.dir, "ca3", "ca.pem")}`];
await rejects(w.remote({ fork: `${bounce.origin}/${USER}/stack`, tls }), "fork-unavailable");
assert.ok(bounce.requests.length > 0);
assert.equal(target.requests.length, 0);
});
test("T3: the helper answers only the fork's host and port: a fork on another port gets no token", async (t) => {
const w = await world(t);
// The fork names port 1; insteadOf (through the tests' tls parameter) sends git to the fake's
// port. The helper was told port 1, so it answers nothing and no password goes out.
const before = w.gitea.requests.length;
await rejects(w.remote({ fork: `https://127.0.0.1:1/${USER}/stack`, tls: [...w.tls, "-c", `url.https://127.0.0.1:${w.gitea.port}/.insteadOf=https://127.0.0.1:1/`] }), "fork-unavailable");
assert.ok(w.gitea.requests.length > before, "the rewritten URL reached the fake");
assert.equal(w.gitea.requests.slice(before).some((r) => r.user !== null), false, "no credential went to a host the helper wasn't told");
});
test("T3: a fork that echoes the token refuses credential-leak", async (t) => {
const w = await world(t);
const hook = join(w.forkRepo, "hooks", "pre-receive");
writeFileSync(hook, `#!/bin/sh\necho ${TOKEN} >&2\nexit 0\n`);
chmodSync(hook, 0o755);
await rejects(w.go(), "credential-leak");
});
test("T3: no tls parameter, no trust: the scratch CA reaches git only through it", async (t) => {
const w = await world(t);
await rejects(w.remote({ tls: [] }), "fork-unavailable");
});
test("D9 steps 3 and 4: ref-moved and identity-mismatch refuse before any request", async (t) => {
const w = await world(t);
await rejects(w.go({ source: w.first }), "ref-moved");
const other = commit(w.clone, "by someone else", "[email protected]");
const file = join(w.dir, "b2.bundle");
git(w.clone, "bundle", "create", "-q", file, "refs/heads/feat", "--not", "--remotes=origin");
await push({ dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root, taskRef: TASK, branch: "feat", bundle: file });
await rejects(w.go({ source: other }), "identity-mismatch");
assert.equal(w.gitea.requests.length, 0);
});
test("publishRemote refuses a fork, user or destination that isn't shell- and ref-safe before any git", async (t) => {
const w = await world(t);
for (const extra of [
{ fork: `http://127.0.0.1:${w.gitea.port}/${USER}/stack` },
{ fork: `https://127.0.0.1:${w.gitea.port}'/${USER}/stack` },
{ user: "co'der" },
{ dst: "refs/heads/main" },
{ dst: "refs/heads/pm/feat" },
{ source: "HEAD" },
]) await rejects(w.remote(extra), "invalid-request");
assert.equal(w.gitea.requests.length, 0);
});
+2 -6
View File
@@ -80,11 +80,7 @@ export function tasksAdapter({
tree: null,
bots: new Set([businesses[name].tracker.sync.botId, ...Object.values(s.roles).map((r) => r.botId)]),
sync: use('@sync'),
// Row 199 (#1697, design D5): a request's call keys from the broker's session for its cap,
// never from a role name. probe is the host's own start check of each role's token.
as: (session, method, path, opts) =>
credentials.useFor(session, 'vikunja', (token) => call(token, method, path, opts)),
probe: (role, method, path, opts) => use(role)(method, path, opts),
as: (role, method, path, opts) => use(role)(method, path, opts),
state: 'starting',
refused: null,
queue: Promise.resolve(),
@@ -100,7 +96,7 @@ export function tasksAdapter({
timers: [],
};
ctx.syncCall = (_token, method, path, opts) => ctx.sync(method, path, opts);
ctx.asCall = (role) => (_token, method, path, opts) => ctx.probe(role, method, path, opts);
ctx.asCall = (role) => (_token, method, path, opts) => ctx.as(role, method, path, opts);
ctx.handle = verbs(ctx);
contexts.set(name, ctx);
}
+2 -2
View File
@@ -41,7 +41,7 @@ export async function startup(ctx) {
if (probe.status === 404 || probe.status === 403 || outcome(probe) === 'ok') refuse('scope-too-broad');
expect(probe, 401, 'tracker-unexpected');
for (const [role, r] of Object.entries(ctx.roles)) {
const control = await ctx.probe(role, 'GET', `/tasks/${MISSING}`);
const control = await ctx.as(role, 'GET', `/tasks/${MISSING}`);
if (control.status !== 404 || control.json?.code !== 4002) {
expect(control, 404, 'tracker-unexpected');
refuse('tracker-unexpected');
@@ -49,7 +49,7 @@ export async function startup(ctx) {
const probes = [['DELETE', `/tasks/${MISSING}`, undefined]];
if (r.definition !== 'pm') probes.push(['POST', `/tasks/${MISSING}/labels`, { label_id: 1 }]);
for (const [method, path, body] of probes) {
const p = await ctx.probe(role, method, path, { body });
const p = await ctx.as(role, method, path, { body });
if (p.status === 404 || p.status === 403 || outcome(p) === 'ok') refuse('scope-too-broad');
if (p.status !== 401) refuse(p.status === 0 || p.status >= 500 ? 'tracker-unavailable' : 'tracker-unexpected');
}
+8 -9
View File
@@ -122,9 +122,8 @@ export function verbs(ctx) {
const relatedTo = (task, kind, other) =>
Array.isArray(task.related_tasks?.[kind]) && task.related_tasks[kind].some((t) => t?.id === other);
// One write with the acting role's token. An uncertain answer is settled by a re-read.
// `me` is the broker's session for the caller's cap (design D5).
async function step(me, method, path, body, at, done) {
const r = await ctx.as(me, method, path, { body });
async function step(role, method, path, body, at, done) {
const r = await ctx.as(role, method, path, { body });
const o = outcome(r);
if (o === 'ok') return r;
if (o === 'uncertain' && at !== null && done) {
@@ -145,7 +144,7 @@ export function verbs(ctx) {
if (!mine) fail('tracker-role');
if (PM_VERBS.has(verb) && mine.definition !== 'pm') fail('field-writer');
const plan = prepare(verb, args, role, mine);
if (verb === 'task.create') return create(cap, me, plan);
if (verb === 'task.create') return create(cap, role, plan);
const at = target(args.task_ref);
const before = await read(at);
if (args.expect !== undefined && args.expect !== before.digest) {
@@ -166,7 +165,7 @@ export function verbs(ctx) {
let landed = 0;
try {
for (const [method, path, body, done] of work.steps) {
await step(me, method, path, body, at, done);
await step(role, method, path, body, at, done);
landed++;
}
} catch (e) {
@@ -386,12 +385,12 @@ export function verbs(ctx) {
fail('unknown-verb');
}
}
async function create(cap, me, plan) {
async function create(cap, role, plan) {
if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found');
await located(plan.others);
ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null });
// No re-read can find a create whose answer was lost, so an uncertain create is reported as such.
const r = await step(me, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null);
const r = await step(role, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null);
if (r.status !== 201 && r.status !== 200) fail('tracker-unexpected');
checkTask(r.json);
const id = r.json.id;
@@ -400,9 +399,9 @@ export function verbs(ctx) {
let failure = null;
try {
for (const l of plan.labels)
await step(me, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l));
await step(role, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l));
for (const x of plan.relations)
await step(me, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (a) =>
await step(role, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (a) =>
relatedTo(a.task, x.kind, x.other),
);
} catch (e) {
+1 -2
View File
@@ -73,8 +73,7 @@ business's arbiter for its domain. Pass `domain` to `raise_decision`,
gated raise, and every hard wait, goes to the human through the inbox.
The hard waits are `spend`, `data.delete`, `network.expose`,
`account.create`, `credential.mint`, `message.external`,
`git.push.protected`, `git.push.remote` and `deploy`; a citation never
moves them.
`git.push.protected` and `deploy`; a citation never moves them.
A `role.revoke` raise whose target is the assistant is a hard wait too:
it goes to the human, cited or not.
+1 -2
View File
@@ -53,8 +53,7 @@ business's arbiter for its domain. Pass `domain` to `raise_decision`,
gated raise, and every hard wait, goes to the human through the inbox.
The hard waits are `spend`, `data.delete`, `network.expose`,
`account.create`, `credential.mint`, `message.external`,
`git.push.protected`, `git.push.remote` and `deploy`; a citation never
moves them.
`git.push.protected` and `deploy`; a citation never moves them.
A `role.revoke` raise whose target is the assistant is a hard wait too:
it goes to the human, cited or not.
+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|credentials|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|name|names|follow|threads|thread|access) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
esac
exec node "$REPO/packages/seat/src/cli.mjs" "$@"