Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdf18272a1 | ||
|
|
32b54ac3b0 | ||
|
|
6025ddd9cf | ||
|
|
ecbfd6deb6 | ||
|
|
0498c0613f | ||
|
|
49d1f3a231 | ||
|
|
628e2ca5cd | ||
|
|
088087ac96 | ||
|
|
a0fbefd48d | ||
|
|
f24f2cd40d | ||
|
|
9ba31b5b3f | ||
|
|
9db578ff54 | ||
|
|
8eb9ddd12c | ||
|
|
ae3c36f248 | ||
|
|
a2af7ab495 |
@@ -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` |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Gizmoduck
|
||||
|
||||
Design and builder seat, Claude Opus 5.5, a T3 thread (9312a896). Author of the federation design
|
||||
(`agents/gizmoduck/work/federation/DESIGN.md` on `gizmoduck/federation-design`, #414). Builds FED R1, the
|
||||
access.json loader and validator, under Mos's order mos-1023 item 1.
|
||||
|
||||
No launcher in this directory. The seat works in its own worktree and has no credentials of its own; until
|
||||
the credentials row settles per-agent identity, it pushes only its own `gizmoduck/*` branches through the
|
||||
repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Gosalyn
|
||||
|
||||
Worker seat for Mosaic Stack, MVP track T2 (Case 2, the creative writing hub, Vikunja #416).
|
||||
|
||||
- T3 thread: 7604438a-0ded-41c8-bd3f-26f57fdb5cd2
|
||||
- Model: Opus 5.5 (Claude Code, T3)
|
||||
- Launched by Mos (Jason's main agent, T3 38bc4c81-6270-4021-b00c-6a15c43b9b0e), who briefs the seat
|
||||
- Repository lead and queue: Sage (T3 1ef1e4f8-3ead-4208-beca-38f9f1add079)
|
||||
- Branch: gosalyn/case2-design, pushed only by this seat, landed on refactor by Sage
|
||||
|
||||
Work lives in `work/`. The Case 2 design is `work/case2/DESIGN.md`.
|
||||
@@ -0,0 +1,158 @@
|
||||
# PRD 1.7 draft: the chief of staff's hire and fire authority, the coder's push to its fork, and agent
|
||||
# host access through the credential store
|
||||
|
||||
Draft by Honker for row 194 (#1692), under Mos's ruling mos-1017 (through Sage, sage-1365). Not approved.
|
||||
It changes nothing until Jason approves it as a `prd.approve` decision in the Console. Sage opens that
|
||||
decision and sends Mos its id. Base: `docs/prd/mosaic-stack.md` version 1.6 at 53bb59462.
|
||||
|
||||
## What it changes
|
||||
|
||||
Row 194 adds `role.hire` and `role.fire` to the vocabulary, and row 199 adds `git.push.remote`. Under
|
||||
REQ-WAIT-3 all three are hard waits, so today Jason resolves every hire, every fire and every push to a
|
||||
remote. This version routes hire and fire for the chief of staff within fixed bounds, so the chief hires
|
||||
and fires as needed and the user's per-agent forbid still holds. It routes `git.push.remote` for the coder
|
||||
only (Mos, mos-1028 item 3). It adds agent host access with sudo through the credential store for
|
||||
agents with a grant (Q183, Q184, Vikunja #424, due 2026-10-27). One `prd.approve` decision covers it all
|
||||
(Vikunja #420).
|
||||
|
||||
When this version lands, a reviewed commit:
|
||||
|
||||
1. adds `role.hire`, `role.fire` and `git.push.remote` to `ROUTED` in
|
||||
`packages/business/src/vocabulary.mjs` and in `packages/bus/src/broker.mjs`.
|
||||
2. adds `role.hire` and `role.fire` to `roles/chief-of-staff.json` withinRole, and `git.push.remote` to
|
||||
`roles/coder.json` withinRole. No other role file gets `git.push.remote`.
|
||||
3. switches the broker's check on `instance.hire` and `instance.fire` from a cited decision to the
|
||||
`reason` form, as `role.end` works for REQ-ROLE-2, and makes `publish`'s `decision` optional for a
|
||||
within-role class (row 199 D9 step 1).
|
||||
4. states, for a `git.push.remote` with no decision, where the source commit comes from: the latest work
|
||||
commit the bus recorded for the coder's task. The check still matches it against the work ref tip and
|
||||
the push sends it by oid (row 199, duckworth-0022 N9).
|
||||
5. adds the host credential grant check and the host access it allows (REQ-CRED-3, Vikunja #424).
|
||||
|
||||
Every other check stays as rows 194 and 199 build it. Filling `hireable` stays a separate reviewed role
|
||||
file commit in `roles/`.
|
||||
|
||||
## Text for the PRD
|
||||
|
||||
Status line, appended to the current paragraph:
|
||||
|
||||
> Version 1.7 routes the chief of staff's hire and fire actions and the coder's push to its own fork,
|
||||
> and adds agent host access through the credential store, approved by Jason in decision
|
||||
> <prd.approve decision id>.
|
||||
|
||||
Source, a new bullet above "Version 1.6 source":
|
||||
|
||||
> - Version 1.7 source, TARGET.md line 66: "Chief of staff agent has hire/fire authority for agents as
|
||||
> needed; unless user forbids per agent". And Jason through Mos (mos-1067), DECISIONS Q183, in part: "I will
|
||||
> still want to allow infra agent(s) on my .53 instance to access other servers, etc. However, not all
|
||||
> agents will need to have that access. I envision a credential store within Mosaic Stack that works
|
||||
> as some sort of variable for the agent to call. The system will enforce access to the credentials to
|
||||
> keep the agents sandboxed with proper security. I need to be able to trust my agents with assisting
|
||||
> me. This will require sudo access to several systems. If we can utilize the credential store in
|
||||
> Mosaic Stack and allow agents to perform their jobs with NOPASSWD sudo on various machines, I am a
|
||||
> happy man." DECISIONS Q184, on whether that is in place by 10-27: "Q5: by 10.27"
|
||||
|
||||
Under "Added in version 1.2", after REQ-COS-2:
|
||||
|
||||
> Added in version 1.7:
|
||||
>
|
||||
> - **REQ-COS-3.** The chief of staff hires and fires role instances without a decision each time. Hiring
|
||||
> adds a role instance to the organization file and lists it for launch. Firing ends the instance's
|
||||
> runs and removes it from the launch list. It stays in the organization file. Only the user deletes
|
||||
> an instance. These actions are `role.hire` and `role.fire`. They leave the hard waits and route
|
||||
> under this requirement, within these bounds:
|
||||
> - The chief acts only in its own organization.
|
||||
> - The chief hires only role definitions that need no credentials, until a later version adds
|
||||
> credentialed roles.
|
||||
> - The chief hires only the role kinds its role file lists. Changing that list is a reviewed commit.
|
||||
> - The chief hires only a role whose every action is on a fixed list of plain work actions: sending
|
||||
> messages, writing records, asking for review, updating its assigned tasks, and the scope and
|
||||
> working-push actions of an assigned task. A role holding any other action, such as launch, end,
|
||||
> organization, naming, hire, review verdict or merge authority, or any action added later, is never
|
||||
> hired. So the chief never hires a chief, never picks its own reviewers or merge gate, and the author
|
||||
> is never the reviewer. This holds whatever the role file's list says. Adding to that list is a PRD
|
||||
> change.
|
||||
> - The chief may set a hired instance's model and narrow its limits, and nothing else about it.
|
||||
> - The user can forbid per agent. The user marks an instance so the chief can't fire it, end its runs
|
||||
> or change its model, and marks role kinds the chief can't hire. Only the user changes these marks, through the
|
||||
> WebUI or the CLI.
|
||||
> - The kill switch stops every hire and fire while it is on.
|
||||
> - Every hire and fire is logged with the chief's instance, its run and the reason.
|
||||
|
||||
Under "Landing", after REQ-LAND-1:
|
||||
|
||||
> Added in version 1.7:
|
||||
>
|
||||
> - **REQ-LAND-2.** A coder pushes its own work to its own fork. The action is `git.push.remote`.
|
||||
> - What it pushes: one commit, by its oid, never a ref name. That commit is the latest work commit the
|
||||
> bus recorded for the coder's task, and the coder made that push. A decision that authorizes a push
|
||||
> names that commit, and it doesn't authorize any other.
|
||||
> - Where it lands: only the branch `refs/heads/<its Gitea user>/<branch>`. The host builds that
|
||||
> name, the agent never does. A push never forces.
|
||||
> - Which remote: the agent's own fork of the project's upstream. The business file names the upstream,
|
||||
> and the host never contacts it. For each push the host builds the fork's address over https from
|
||||
> the upstream's host, port and repository name and the agent's own Gitea user. The history check,
|
||||
> the push and the credential match all use that address. So the agent's own user owns the receiving
|
||||
> repository on every push, and the agent never names it.
|
||||
> - The host refuses an upstream on a host or port other than the agent's Gitea, and an upstream the
|
||||
> agent's own user owns. It refuses a fork that is missing, empty or doesn't share the project's
|
||||
> history. Gitea's push-to-create stays off, so a push never creates a repository.
|
||||
> - Who may: the action routes under REQ-DEC-2 and is granted within-role to the coder role only. No
|
||||
> other role file grants it.
|
||||
> - Before version 1.7 the action was a hard wait under REQ-WAIT-3, so each push needed a decision
|
||||
> the human resolved, and that decision named the commit.
|
||||
>
|
||||
> Every push is logged with the remote, the ref, the commit and, when one was given, the decision.
|
||||
|
||||
Under "Credentials", after REQ-CRED-2:
|
||||
|
||||
> Added in version 1.7:
|
||||
>
|
||||
> - **REQ-CRED-3.** An agent with a grant can run commands with sudo on another host. The agent asks
|
||||
> for the host credential by name. The stack checks that a grant exists for that agent and that host
|
||||
> before the credential is used. The agent never sees the key or any other secret value (REQ-CRED-1,
|
||||
> REQ-VAR-2). An agent without a grant for that host is refused. Not every agent gets a grant. Who
|
||||
> grants a host credential, and how, is set by the #424 design, which Jason approves.
|
||||
|
||||
REQ-WAIT-3, appended:
|
||||
|
||||
> `role.hire` and `role.fire` were added as hard waits and route under REQ-COS-3 since version 1.7.
|
||||
> `git.push.remote` was added as a hard wait and routes under REQ-LAND-2 since version 1.7.
|
||||
|
||||
## Notes for the reviewer and for Mos
|
||||
|
||||
- REQ-COS-1 (1.2) already says the chief "hires and terminates role instances". REQ-COS-3 gives that
|
||||
sentence its actions and bounds. It doesn't widen REQ-COS-1.
|
||||
- The forbid now names what r3 called "replace": ending a kept instance's run with a reason and changing
|
||||
its model (webby-0010 N2). A user-resolved decision still ends a kept run (row 194 D2.4).
|
||||
- The fixed list is `HIRE_ALLOWED` in row 194 D2.3, an allowlist since r4 (webby-0010 B1). r3's text
|
||||
named the refused authorities instead, which let merge-gate and any later action through.
|
||||
- The kill switch is row 191's. If row 191 hasn't landed when this version is approved, the bound still
|
||||
holds through row 194's chain on its candidate.
|
||||
- REQ-LAND-2 follows row 199's design r6 (4d3007ec9 on megavolt/row199, approved by Duckworth in
|
||||
duckworth-0027: D9 to D11, D9a, D9c, Q-M1 and Q-M2, and the oid binding from mos-1034 item 5) and
|
||||
Megavolt's wording in megavolt-0005, megavolt-0008 and megavolt-0014, through Sage (sage-1377,
|
||||
sage-1380, sage-1387). The fork bullet answers duckworth B5 and B6 and webby-0011 N1. Commit-email
|
||||
checks, the refusal codes and the URL parse rules (D9c) are host checks with tests in row 199, not
|
||||
bounds, so they stay out of the text (megavolt-0008).
|
||||
- Once the routing lands and the decision is optional for the within-role class, the commit is the task's
|
||||
latest recorded push at check time. It is still checked against the work ref tip and pushed by oid, so
|
||||
the binding holds without a decision (megavolt-0008). Step 4 of "When this version lands" puts that in
|
||||
the routing commit (duckworth-0022 N9, sage-1387).
|
||||
- REQ-LAND-2 and REQ-COS-3 meet at the coder. Coder declares Gitea credentials, so REQ-COS-3's
|
||||
credentials bound keeps the chief from hiring one until a later version lifts it. `git.push.remote` isn't
|
||||
on REQ-COS-3's fixed list either, so a coder holding it stays unhireable until a PRD version adds it
|
||||
(row 194 D2.3).
|
||||
- Adding to the fixed action list is a PRD change (mos-1035 item 3, sage-1395). The role file's
|
||||
`hireable` list stays a reviewed commit in `roles/`.
|
||||
- The credentials bound names no row number, so the text doesn't go stale when row 199 lands. A later
|
||||
version lifts it.
|
||||
- REQ-CRED-3 states only what Jason said in Q183 and Q184 (sage-1422). It names the outcome and the
|
||||
check, not how the stack reaches the host. Proxying the ssh and a signing socket are both designs, and
|
||||
the #424 design picks one (duckworth-0038 B1).
|
||||
- Open point: Q183 doesn't say who grants host access or how, so the text leaves it open and defines no
|
||||
mechanism. The #424 design settles it, Jason approves that design, and Duckworth reviews it because it
|
||||
gives agents sudo on hosts (Q184, Mos's reading, duckworth-0038 B2). The build is row 210 (#1707,
|
||||
owner Gizmoduck), a new row on top of row 199 (megavolt-0019, sage-1430).
|
||||
- This draft follows the PRD's own style: plain words, no semicolons in new sentences, and the status
|
||||
paragraph never edited in place except to append.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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-20T20:40:15Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 1 review, candidate 8862feccc (static) | CHANGES: B1 base drift, row 189 ace27dc5e conflicts in stack-release.sh, rebase + full gate rerun; N1-N7 notes; Gitea 29634, queue rev 2362 (uncommitted, left for Sage)
|
||||
|
||||
@@ -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,955 @@
|
||||
# Case 2 slice rows: the creative writing hub (#416, T2)
|
||||
|
||||
Gosalyn, 2026-10-20 (from `date -u`), for sage-1329. Every section follows `docs/plans/BRIEF-TEMPLATE.md`.
|
||||
The design they build is `agents/gosalyn/work/case2/DESIGN.md` on `gosalyn/case2-design`. Webby
|
||||
approved r5 at e1635308b in webby-0021, with notes at dba33aaf6. r6 and r7 change only sections 9 and 10
|
||||
and C2-13's row (mos-1057, Q183, webby-0023), and that diff is with Webby. Below, "the design" means that
|
||||
file, with its section number. A builder follows the design and doesn't reopen a decision in it. A
|
||||
departure goes to Sage before the build continues.
|
||||
|
||||
These are the slice rows from design 9.2, plus C2-11a, which the design starts in the first wave. The
|
||||
other rows (C2-02, C2-03b, C2-09b, C2-10c, C2-11b, C2-15 to C2-19) get briefs when a seat is free for
|
||||
them. Owners and models come from design section 8 (Q177, Q178). Where the design names only a model,
|
||||
Sage picks the seat. The reviewers are my proposals, and Sage can change any of them with `queue set`.
|
||||
Sage owns every gate.
|
||||
|
||||
Base: `refactor` at ae3c36f24. The design cites 9ae8c73ac. `git diff --stat 9ae8c73ac ae3c36f24 --
|
||||
packages adapters roles scripts` touches only `scripts/stack-release.sh` and its test, and no cited line
|
||||
is in either.
|
||||
|
||||
Order (design 8, "Parallel waves"):
|
||||
1. Start now: C2-01, C2-08, C2-11a.
|
||||
2. On C2-01's candidate: C2-04, C2-05a, C2-06, C2-12. On C2-08's: C2-09a, C2-10a, C2-20.
|
||||
3. Then C2-03a and C2-07. Then C2-05b, C2-10b and C2-13.
|
||||
4. Last: C2-14.
|
||||
|
||||
A row whose parent hasn't landed builds on the parent's candidate and rebases onto its landing (Q177).
|
||||
|
||||
Rules every row keeps:
|
||||
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, a live unit, a slot file, Melanie's books
|
||||
repo, git.mosaicstack.dev or any Discord server. Tests use scratch roots, scratch config dirs and a
|
||||
local bare repo the test builds in her layout's shape: spaces and capitals in folder names, a `.canvas`
|
||||
file and an unmapped top-level note.
|
||||
- No row reads her text. Fixtures are made up. A marker string in a fixture body stands in for her
|
||||
text wherever a test checks that output holds none.
|
||||
- No credential value in any output, test, log or message (CONSTITUTION gate 13). Tests use a fixture
|
||||
token file with a made-up value.
|
||||
- No live stack change. Nothing here re-pins, restarts or activates anything. C2-13's run on the host
|
||||
needs Jason's go (design 10).
|
||||
- Before in-review, grep `packages/*/tests` and `scripts/test-*.sh` for each changed path and for any
|
||||
64-hex pin of it, and run every changed or touched test file on its own. Suites need Sage's slot.
|
||||
- A refusal code named in the design is the code the row ships. A new code goes to Sage first.
|
||||
|
||||
---
|
||||
|
||||
## C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey
|
||||
|
||||
### Problem
|
||||
|
||||
Nothing in the stack reads a writing repo by a mapping of folders to books, types, canon and playground.
|
||||
`packages/project/src/read.mjs:19-26` reads blobs from a record repo but accepts executables (`:21`) and
|
||||
knows no scope. Design 1.1 to 1.3 and 7.4 set out what Case 2 needs, and every later row builds on it.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: Gosalyn, Opus 5.5.
|
||||
- Reviewer: Webby, who reviewed the design.
|
||||
- After: none.
|
||||
|
||||
### Files owned
|
||||
|
||||
- `packages/library/` (new): `package.json`, `src/mapping.mjs`, `src/scope.mjs`, `src/reader.mjs`,
|
||||
`src/links.mjs`, `src/problems.mjs`, `src/render.mjs`, `src/survey.mjs`, `src/index.mjs`, `tests/*.test.mjs`,
|
||||
`tests/fixtures/`, `README.md`.
|
||||
- `scripts/stack-copy.sh`: add `packages/library/src` to `COPY_PATHS`. Without it the live copy has no
|
||||
library code, and no test catches the gap today.
|
||||
- `packages/cli/tests/stack-copy.test.mjs`: add `packages/library/src/index.mjs` to the list at `:37`.
|
||||
- `scripts/mosaic` or `packages/cli/src`: only the dispatch line for `library survey`.
|
||||
|
||||
### What ships
|
||||
|
||||
- The mapping schema of design 1.1, including `library-playground-outside-book` and the rule that no
|
||||
playground prefix holds another. Business file loading wires it in C2-04, so here it is a pure
|
||||
validator.
|
||||
- The scope rule of design 1.2. Only `.md` files are playground or canon.
|
||||
- The commit reader of design 1.3: `git ls-tree -r -z` and `cat-file` on a commit, never a working tree,
|
||||
through the same `git()` and `gitEnv()` as `packages/project`. R1 to R7 as problems with path and line.
|
||||
- The link resolver, in the order 1.3 gives: repo root, `vaultPrefix/` stripped, relative to the linking
|
||||
file, then shortest path. `appears_in` and `relationships` wikilinks are read as links.
|
||||
- `renderItems(body)` (7.4), one pure function for the item kinds and the three notices.
|
||||
- `library survey <repo> [--mapping <file>]`, which prints folder names, file counts, front matter key
|
||||
sets and link-form counts, and never text.
|
||||
|
||||
Tests: one case per R-rule and per resolver step. A link into another repo resolves to nothing. An
|
||||
`appears_in` wikilink is indexed. `renderItems` has a snapshot for each kind and each notice. The survey
|
||||
prints names, counts, key sets and link-form counts, and a marker string in a fixture body never
|
||||
appears in its output. The stack-copy test lists the new path. The library tests and
|
||||
`packages/cli/tests/stack-copy.test.mjs` pass.
|
||||
|
||||
### Out of scope
|
||||
|
||||
Business file loading and consumer skips (C2-04), sync (C2-03a), the index and queries (C2-05a), the
|
||||
guard (C2-06). The survey run on the host is design 10 step 7, after the token slot exists.
|
||||
|
||||
### Gate
|
||||
|
||||
Webby approves the manifest. The library tests and the cli suite are green on Sage's gate, along with
|
||||
every `scripts/test-*.sh`.
|
||||
|
||||
---
|
||||
|
||||
## C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file
|
||||
|
||||
### Problem
|
||||
|
||||
The stack has no role that may only read and answer in cited items, no way to carry Bean's and Chet's
|
||||
personas, and several paths by which a non-human actor could change a creative instance (design 4, B3).
|
||||
Both adapters pass the whole prompt on the command line (`adapters/claude/adapter.sh:79`, `:82`,
|
||||
`adapters/pi/adapter.sh:106`, `:121`), so any local user can read it in `/proc/<pid>/cmdline`.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: an Opus 5.5 seat that Sage picks.
|
||||
- Reviewer: Webby.
|
||||
- After: none.
|
||||
|
||||
### Files owned
|
||||
|
||||
- `roles/companion.json`, `.md`, `roles/ideas.*`, `roles/research.*`, `roles/proofreader.*`,
|
||||
`roles/fact-checker.*`, `roles/continuity.*` (new).
|
||||
- `packages/business/src/vocabulary.mjs`: `THREAD_DEFINITIONS`, `END_PROTECTED` as `THREAD_DEFINITIONS`,
|
||||
`CREATIVE_DEFINITIONS`, `CREATIVE_KINDS`, `CREATIVE_TOOLS`.
|
||||
- `packages/business/src/role.mjs` (M1), `vars.mjs` (`persona.name`, `persona.sha256` in `REGISTRY`),
|
||||
`launch-settings.mjs` (the `instances` and `models` checks and the `setRoutes` branch),
|
||||
`instance-settings.mjs` (the creative branch).
|
||||
- `packages/bus/src/broker.mjs`: M6 `decision-not-allowed` only.
|
||||
- `packages/harness/src/bundle.mjs`: the persona file read and checks, the fenced block in `prompt()`, and
|
||||
the `webPlan` refusal.
|
||||
- `packages/harness/src/tools.mjs`: `raise_decision` left out for creative definitions.
|
||||
- `adapters/claude/adapter.sh`, `adapters/pi/adapter.sh`, `adapters/README.md` (`:89`, `:107`).
|
||||
- Tests in `packages/business/tests`, `packages/bus/tests`, `packages/harness/tests`.
|
||||
|
||||
### What ships
|
||||
|
||||
Design 4, 5.1 (M1, M2, M6, M10) and 5.3:
|
||||
- The six role files, version 2, with the tools, authority, messaging and network of the design 4 table.
|
||||
- The constants. `THREAD_DEFINITIONS` replaces `THREAD_DEFINITION` only where it means "may hold a
|
||||
thread", and that use is C2-09a's. The `assistants` lines at `launch-settings.mjs:140`, `:186` and
|
||||
`:191`, and `tools.mjs:186`, stay `THREAD_DEFINITION`.
|
||||
- The persona file at `<configDir>/personas/<business>/<instance>.md`, read with `O_NOFOLLOW`. The
|
||||
bundle refuses a non-regular file, another uid's, any group or other bit, more than 8,000 characters,
|
||||
a digest off the var (`persona-mismatch`), and `</persona-file>` or a line starting `# ` or `## `
|
||||
(`persona-invalid`). The block goes between the contract and "## This session", fenced as in design 4.
|
||||
- One actor rule on all five write paths (design 4): a write that touches a `CREATIVE_DEFINITIONS`
|
||||
instance is accepted only when its actor is the business file's `human`, otherwise
|
||||
`organization-creative-human-only`. The `instances` key of `limits.set` and `organization.set` refuses
|
||||
a creative instance for every actor with `organization-creative-instance`.
|
||||
- Both adapters pass the prompt file, not its text. Claude uses `--system-prompt-file`. Pi gets the path
|
||||
as `--system-prompt`, and its adapter checks `[ -r ]` first and refuses otherwise, because pi 0.85.1
|
||||
uses the path string itself as the prompt when the file is missing or unreadable
|
||||
(`resource-loader.js:21`, `:24-27`, `:30`).
|
||||
|
||||
Tests, all named in the design 8 C2-08 row:
|
||||
- Role, gate and messaging tests. A creative raise is refused and no decision row appears.
|
||||
`harness-builtin` is refused for `research` and `companion`. `scripts/test-config.sh` passes.
|
||||
- Persona vars are refused at other layers, when malformed and on a non-creative definition. Each
|
||||
persona file refusal above has a test. A prompt snapshot pins contract, persona, session, in that
|
||||
order.
|
||||
- M10:
|
||||
- Each path refuses with the file unchanged, and the human's `instances.set` and `routes.set` on
|
||||
`bean` work.
|
||||
- The human's `limits.set` change to Bean's `launch.models` key works, next to the chief of staff's
|
||||
`organization.set` refusal of the same change.
|
||||
- Tester cases run through the broker with a bound tester session, and chief of staff cases with a
|
||||
role session.
|
||||
- A mutant without the `models` check fails.
|
||||
- No `instances` read, `changes.jsonl` line or adapter argv holds a marker string from a fixture
|
||||
persona.
|
||||
- Adapter tests in `claude-session.test.mjs` and `pi-session.test.mjs`: argv holds the prompt path and no
|
||||
marker from the prompt. Pi's adapter refuses a missing prompt file and an unreadable one.
|
||||
- Business file, `.history` and `changes.jsonl` modes after an `instances.set` write.
|
||||
|
||||
### Out of scope
|
||||
|
||||
Thread creation per instance and the launcher (C2-09a), the lookup tools (C2-05b), the cited-reply guard
|
||||
(C2-06). Checking the host's claude for `--system-prompt-file` is C2-13's precheck. If the C2-20 dry run
|
||||
finds heading lines in a real persona, the fence rule changes in a follow-up to this row, reviewed,
|
||||
before the import (design 9.1).
|
||||
|
||||
### Gate
|
||||
|
||||
Webby approves the manifest. The business, bus and harness suites, `scripts/test-config.sh` and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the `models` mutant is killed.
|
||||
|
||||
---
|
||||
|
||||
## C2-11a editor wikilink node, after measuring whether [[ round-trips
|
||||
|
||||
### Problem
|
||||
|
||||
The Console's editor opens a file in visual mode only if `roundTrip(text) === text`
|
||||
(`packages/webui/src/public/bus.js:919-920`). Its commonmark rules (`packages/webui/editor/model.mjs:119-120`)
|
||||
have no wikilink rule, and nobody has measured whether the serializer escapes `[[`. If it does, every
|
||||
note with a wikilink opens in Source.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: Rocko, gpt-6.1-sol.
|
||||
- Reviewer: Filbert.
|
||||
- After: none.
|
||||
|
||||
### Files owned
|
||||
|
||||
`packages/webui/editor/model.mjs`, the editor's bundle output if the build commits one, and
|
||||
`packages/webui/tests/` editor test files.
|
||||
|
||||
### What ships
|
||||
|
||||
First a test that measures today's behaviour on `[[a]]`, `[[a|b]]`, `[[a#h]]`, in a list item, and next to
|
||||
front matter, with the result recorded in the manifest. Then a wikilink node that keeps each form byte
|
||||
for byte. `roundTrip` tests cover plain, labelled, heading, in a list, and next to front matter.
|
||||
|
||||
### Out of scope
|
||||
|
||||
Library pages and saving (C2-11b), link clicking to open a target (C2-11b), any `documentPath` change.
|
||||
|
||||
### Gate
|
||||
|
||||
Filbert approves the manifest. The webui suite is green on Sage's gate, along with every
|
||||
`scripts/test-*.sh`.
|
||||
|
||||
---
|
||||
|
||||
## C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status
|
||||
|
||||
### Problem
|
||||
|
||||
The business file accepts only `root` and `repo` per project (`packages/business/src/business.mjs:186-187`),
|
||||
and several consumers assume every project has a root (design 2.1). Nothing stops an organization write
|
||||
from changing `projects`, and nothing creates a mirror.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: Rocko, gpt-6.1-sol.
|
||||
- Reviewer: Filbert.
|
||||
- After: C2-01.
|
||||
|
||||
### Files owned
|
||||
|
||||
- `packages/business/src/business.mjs`, `organization.mjs` (M9 in `organizationWriter.write`, `:177`),
|
||||
`project.mjs`, `cli.mjs`, `resolve.mjs`, `instance-settings.mjs` (only the skip at `:142`).
|
||||
- `packages/seat/src/checkout.mjs` (`:310`, `:319`), `packages/cli/src/launcher.mjs` (`:353`).
|
||||
- `packages/project/src/paths.mjs` (the overlap loop at `:41-44`, and the mirror path).
|
||||
- `packages/bus/src/broker.mjs`: `library.file` and `library.status` only.
|
||||
- `packages/webui/src/cli.mjs`: dispatch lines for the two verbs.
|
||||
- Tests in the matching `tests/` directories.
|
||||
|
||||
### What ships
|
||||
|
||||
The `kind: "library"` entry of design 1.1, validated by C2-01's schema. Each consumer in design 2.1
|
||||
skips it, with its own test. M9: `organization-projects-protected` for any organization write whose
|
||||
`projects` differ from the file's in canonical JSON. The bare mirror is created at the `projectRepo` path
|
||||
with no remote in its config. `library.file` (read at the served commit) and `library.status` (reads the
|
||||
sync state, empty until C2-03a) are human only, refused from an agent session with `human-required`
|
||||
(M4).
|
||||
|
||||
Tests: business file tests. One test per consumer skip. A launcher test that the workspace has no clone.
|
||||
The M9 test calls `write` directly, once with a changed playground prefix and once with a changed hybrid
|
||||
entry, and the file and `changes.jsonl` are unchanged. Each verb from an agent session refuses.
|
||||
|
||||
### Out of scope
|
||||
|
||||
Fetching (C2-03a). Bus writes (C2-02). The Console's library pages (C2-11b).
|
||||
|
||||
### Gate
|
||||
|
||||
Filbert approves the manifest. The business, seat, cli, project, bus and webui suites are green on Sage's
|
||||
gate, along with every `scripts/test-*.sh`.
|
||||
|
||||
---
|
||||
|
||||
## C2-05a lookup index and queries
|
||||
|
||||
### Problem
|
||||
|
||||
Agents need typed lookups over her repo that keep universes apart and default to canon (design 3). The
|
||||
reader from C2-01 has no index or query layer.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: a Haiku 5.5 subagent at high effort, dispatched by Sage (lead decision 129).
|
||||
- Reviewer: Filbert.
|
||||
- After: C2-01.
|
||||
|
||||
### Files owned
|
||||
|
||||
`packages/library/src/index-build.mjs`, `packages/library/src/query.mjs`, their exports in
|
||||
`packages/library/src/index.mjs`, and `packages/library/tests/` query test files.
|
||||
|
||||
### What ships
|
||||
|
||||
An index per commit (types, names, links, backlinks), cached in memory by commit id. Queries for find, get,
|
||||
search, manuscript and universe, with the arguments and result shapes in the design 3 table. `universe`
|
||||
is required. Results never leave it, except a hybrid's declared sources minus exclusions. Every result
|
||||
carries `scope`. Unmapped files never appear. `links_out` and `links_in` drop other-universe and unmapped
|
||||
targets. Results page under `REPLY_CAP` (`packages/harness/src/tools.mjs:271-280`) and are never cut.
|
||||
|
||||
Tests: isolation, scope labels on every query, unmapped files never returned, paging under `REPLY_CAP`,
|
||||
and a rebuild time on a 3,000-file fixture recorded in the manifest.
|
||||
|
||||
### Out of scope
|
||||
|
||||
Broker verbs and typed tools (C2-05b). Hybrid entries in the business file (C2-15). The query code takes
|
||||
hybrids as a shape, and its tests use a hand-built hybrid mapping.
|
||||
|
||||
### Gate
|
||||
|
||||
Filbert approves the manifest. The library suite is green on Sage's gate, along with every
|
||||
`scripts/test-*.sh`.
|
||||
|
||||
---
|
||||
|
||||
## C2-06 the cited-reply guard, the page store and the three notices
|
||||
|
||||
### Problem
|
||||
|
||||
Today a creative agent's free text would reach her as a stored message. Jason's ruling says agents never
|
||||
write prose for her and always refer to the source (Q176). Design 5.2 puts one guard in the broker before
|
||||
every agent-to-human message in a library business.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: an Opus 5.5 seat that Sage picks.
|
||||
- Reviewer: Webby.
|
||||
- After: C2-05a.
|
||||
|
||||
### Files owned
|
||||
|
||||
- `packages/library/src/guard.mjs` and its export.
|
||||
- `packages/bus/src/broker.mjs`: the pre-check on `message.send` to `human`, and the page store beside the
|
||||
web verbs (`:1516`, `:1521`).
|
||||
- `packages/bus/src/web.mjs`, only if the page store needs the fetched text passed out.
|
||||
- Tests in `packages/library/tests` and `packages/bus/tests`.
|
||||
|
||||
### What ships
|
||||
|
||||
The guard as design 5.2 sets it out: the JSON body, the M5 limits, the checks and refusal codes of the
|
||||
5.2 table, and the three notices. It runs before the insert, outside the SQL transaction, as
|
||||
`#requestProject` does (`broker.mjs:1378-1430`). It doesn't look at the sender's definition to decide
|
||||
whether to run. The page store keeps the last 20 broker fetches per session, 200 KB each, in memory,
|
||||
dropped when the session ends.
|
||||
|
||||
Tests: one pass and one refusal for each check in M5 and 5.2, each limit refused one past its boundary
|
||||
and accepted at it. Each goes through the runner's RESULT path and through `send_message`, and once
|
||||
with a non-creative sender in a library business. The `messages` count is unchanged on each refusal.
|
||||
|
||||
### Out of scope
|
||||
|
||||
The runner retry and the runner's notices (C2-07). The Console's rendering (C2-10b). Any classifier of
|
||||
prose. The idea-text residual is Jason's or Melanie's to accept (design 5.1, 10 step 8).
|
||||
|
||||
### Gate
|
||||
|
||||
Webby approves the manifest. The library, bus and harness suites are green on Sage's gate, along with
|
||||
every `scripts/test-*.sh`.
|
||||
|
||||
---
|
||||
|
||||
## C2-12 the setup sheet for Melanie and the survey run sheet
|
||||
|
||||
### Problem
|
||||
|
||||
Melanie keeps writing in Obsidian, and she needs to know what the agents read, how a playground folder
|
||||
works, what changes for Bean and Chet, and two surprises (design 7.1, N2, N4). Whoever runs the survey
|
||||
on the host needs exact steps.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: gpt-6-luna.
|
||||
- Reviewer: Webby.
|
||||
- After: C2-01.
|
||||
|
||||
### Files owned
|
||||
|
||||
`docs/case2/SETUP-SHEET.md` and `docs/case2/SURVEY-RUN.md` (new).
|
||||
|
||||
### What ships
|
||||
|
||||
The setup sheet in plain words:
|
||||
- Which folders the agents read, and that a folder named in `playground` is her playground.
|
||||
- How to add a graph colour group for that path in her own vault.
|
||||
- That moving a note into a book folder makes it canon, so agents may cite it as fact.
|
||||
- That a short link to a name that also exists in another of her repos can point somewhere else for the
|
||||
agents, and that a full-path link avoids this.
|
||||
- What changes for Bean and Chet: short cited items, no passages (design 4).
|
||||
|
||||
The survey run sheet gives the exact `library survey` command with C2-01's real flags, and states what
|
||||
the output holds: names and counts only.
|
||||
|
||||
### Out of scope
|
||||
|
||||
Sending it to her. Design 10 step 7 makes that Jason's call.
|
||||
|
||||
### Gate
|
||||
|
||||
Doc only. Webby approves it against C2-01's real flags. Before landing, grep `packages/*/tests` and
|
||||
`scripts/test-*.sh` to confirm no test reads either file.
|
||||
|
||||
---
|
||||
|
||||
## C2-09a one thread per creative instance
|
||||
|
||||
### Problem
|
||||
|
||||
`thread.create` picks the single `assistant` instance and refuses more than one
|
||||
(`packages/bus/src/broker.mjs:2331-2334`). The same rule sits in `#threaded` (`:290-291`) and the launcher
|
||||
(`packages/cli/src/launcher.mjs:135`, `:236-238`). So she can't open a thread with Bean beside one with
|
||||
Chet.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: Rocko, gpt-6.1-sol.
|
||||
- Reviewer: Dewey.
|
||||
- After: C2-08.
|
||||
|
||||
### Files owned
|
||||
|
||||
`packages/bus/src/broker.mjs` (`#threaded`, `thread.create`), `packages/cli/src/launcher.mjs`,
|
||||
`packages/webui/src/public/bus.js` and `serve.mjs` (the new-thread control only), and tests in
|
||||
`packages/bus/tests`, `packages/cli/tests` and `packages/webui/tests`.
|
||||
|
||||
### What ships
|
||||
|
||||
Design 6, C2-09a: `#threaded` and the launcher read `THREAD_DEFINITIONS`. `thread.create` takes
|
||||
`instance`, which must be a `THREAD_DEFINITIONS` instance. With no `instance` and exactly one such
|
||||
instance, today's behaviour holds. With more than one, it refuses `thread-instance-ambiguous`. The
|
||||
Console's new-thread control lists thread instances by `persona.name` or instance name and passes the
|
||||
one she picks. No schema change.
|
||||
|
||||
Tests: a creative instance's thread starts a run, `thread.create` with `instance` binds it, and two thread
|
||||
instances with no `instance` still refuse.
|
||||
|
||||
### Out of scope
|
||||
|
||||
Rooms and @tags (C2-09b, C2-10c).
|
||||
|
||||
### Gate
|
||||
|
||||
Dewey approves the manifest. The bus, cli and webui suites are green on Sage's gate, along with every
|
||||
`scripts/test-*.sh`. Watch for hash pins on `bus.js` and `broker.mjs`
|
||||
(`packages/bus/tests/channel-proof.test.mjs` pins a webui file).
|
||||
|
||||
---
|
||||
|
||||
## C2-10a no raw session view (M7) and no raw Discord engine (M8)
|
||||
|
||||
### Problem
|
||||
|
||||
The Console's conversation view shows a model's raw text from session files
|
||||
(`packages/conversation/src/reader.mjs:225-240`, `events.mjs:58`, `:82`), so a refused answer would still
|
||||
show. The Discord connector posts its engine's output and the broker never sees it
|
||||
(`packages/discord/src/connector.mjs:381-387`).
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: a Haiku 5.5 subagent at high effort, dispatched by Sage.
|
||||
- Reviewer: Filbert.
|
||||
- After: C2-08.
|
||||
|
||||
### Files owned
|
||||
|
||||
`packages/conversation/src/reader.mjs`, `packages/discord/src/cli.mjs` (`check` and `run` only), and
|
||||
tests in `packages/conversation/tests` and `packages/discord/tests`.
|
||||
|
||||
### What ships
|
||||
|
||||
M7 and M8 as design 5.1 states them. On a business with a library project, `catalogue()` leaves creative
|
||||
sessions out and `open()` refuses them with `conversation-not-listed`. `discord check` and `discord run`
|
||||
refuse a binding whose engine isn't `mosaic-bus` with `discord-engine-not-allowed`, and also refuse a
|
||||
`mosaic-bus` binding with `tools`.
|
||||
|
||||
Tests: a creative session file under the root isn't listed and won't open by id, and an assistant session
|
||||
still lists. Both Discord verbs refuse on a library business, and a pi binding on a business without a
|
||||
library still checks clean.
|
||||
|
||||
### Out of scope
|
||||
|
||||
The relay engine itself (C2-18).
|
||||
|
||||
### Gate
|
||||
|
||||
Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are
|
||||
green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server.
|
||||
|
||||
---
|
||||
|
||||
## C2-20 Bean and Chet persona import, with a count-only dry run
|
||||
|
||||
### Problem
|
||||
|
||||
Bean's and Chet's personas live in her two Hermes profiles on 10.1.1.54. The hub needs each one as a
|
||||
0600 persona file with its digest in the business file (design 4), and nothing should read her memory,
|
||||
sessions or messages to get them (design 9.1).
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: Gosalyn, Opus 5.5. The script runs only under Jason's go, by whoever he names.
|
||||
- Reviewer: Webby.
|
||||
- After: C2-08.
|
||||
|
||||
### Files owned
|
||||
|
||||
`scripts/case2-persona-import.mjs` (new), `packages/cli/tests/case2-persona-import.test.mjs` (new) and its
|
||||
fixture tree.
|
||||
|
||||
### What ships
|
||||
|
||||
A script that reads only the persona definition of each of the two Hermes profiles. It confirms profile
|
||||
to name from each profile's own configured name, prints the pairing, and stops if a profile names
|
||||
neither Bean nor Chet. Per-channel prompts are listed by name and size only. It writes each persona to
|
||||
a 0600 staging file. A second step, run as `<user>`, installs it at
|
||||
`<configDir>/personas/<business>/<instance>.md` and prints `persona.name` and `persona.sha256`.
|
||||
Otherwise it prints names, byte counts and sha256 only. `--dry-run` writes nothing and prints only counts
|
||||
of files, lines, and lines starting `# ` or `## `.
|
||||
|
||||
Build note (gosalyn-0030, mos-1087). The dry run also counts lines that name a Hermes tool or channel,
|
||||
so Jason knows before the import whether a persona refers to things Bean and Chet won't have in
|
||||
Mosaic. The words come from a fixed list in the script: the tool names in the hermes-agent checkout at
|
||||
df5700ebe, read from its code and not her profiles, plus `Hermes` and `Discord`. It prints one count
|
||||
per persona file and never the line, its number or the matched word. A nonzero count changes nothing
|
||||
in the import. It goes to Jason with the other counts.
|
||||
|
||||
Tests, on fixture Hermes profile trees:
|
||||
- The right fields are taken.
|
||||
- Memory and session files are never opened, shown by a read trace on the fixture.
|
||||
- A profile with no persona field refuses, and a persona that fails C2-08's fence check refuses.
|
||||
- Output holds no persona text. Each written file is 0600 and its digest matches.
|
||||
- `--dry-run` writes nothing, and prints the right counts and no marker string for a fixture with two
|
||||
heading lines.
|
||||
- `--dry-run` counts three lines that name a listed Hermes tool or channel in a fixture. Its output
|
||||
holds none of the matched words.
|
||||
|
||||
### Out of scope
|
||||
|
||||
Running it. Design 10 step 6 and 9.1 make the dry run, the import and the pairing check Jason's go. If
|
||||
the dry run counts heading lines, the C2-08 follow-up changes the fence rule before the import, and her
|
||||
text is rewritten only if she says so through Jason.
|
||||
|
||||
### Gate
|
||||
|
||||
Webby approves the manifest. The test file runs green on its own and the cli suite is green on Sage's
|
||||
gate, along with every `scripts/test-*.sh`.
|
||||
|
||||
---
|
||||
|
||||
## C2-03a fetch-only sync
|
||||
|
||||
### Problem
|
||||
|
||||
The mirror from C2-04 holds nothing until something fetches her repo, safely and read-only, without the
|
||||
token ever reaching argv, config, logs or replies (design 7.2, CONSTITUTION gate 13).
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: an Opus 5.5 seat that Sage picks.
|
||||
- Reviewer: Dewey.
|
||||
- After: C2-04.
|
||||
|
||||
### Files owned
|
||||
|
||||
`packages/library/src/sync.mjs`, `packages/library/bin/askpass` (new), `packages/bus/src/broker.mjs`
|
||||
(`library.sync`, the 60-second timer and the library lock), `packages/webui/src/cli.mjs` (dispatch line),
|
||||
and tests in `packages/library/tests` and `packages/bus/tests`. If the helper sits outside
|
||||
`packages/library/src`, add its path to `COPY_PATHS` in `scripts/stack-copy.sh`.
|
||||
|
||||
### What ships
|
||||
|
||||
Design 7.2's slice:
|
||||
- `syncCommand(kind, url)`: its argv is `SAFE`, exactly one `-c protocol.https.allow=always`, `-C
|
||||
<mirror>`, then the command. Its env is `gitEnv()` plus `GIT_ASKPASS` naming a helper outside the
|
||||
mirror, which reads the `library-gitea` slot.
|
||||
- An `https://` URL only.
|
||||
- The served ref `refs/heads/served`, and `library-diverged`, held until a human `library.sync --accept
|
||||
<commit>`.
|
||||
- The sync state file, written atomically inside the mirror, and the 60-second timer.
|
||||
- `library.sync`, human only. A test reaches a local bare repo only through the module parameter that
|
||||
replaces `syncCommand`.
|
||||
|
||||
Tests: the C2-03a tests in design 7.2. A mutant that sets `protocol.allow=always` fails the argv test.
|
||||
|
||||
### Out of scope
|
||||
|
||||
Push and every bus write (C2-03b, C2-02). Creating the token slot, which is design 10 step 4.
|
||||
|
||||
### Gate
|
||||
|
||||
Dewey approves the manifest. The library and bus suites are green on Sage's gate, along with every
|
||||
`scripts/test-*.sh`, and the mutant is killed. A search of the test run's output for the fixture token
|
||||
finds nothing.
|
||||
|
||||
---
|
||||
|
||||
## C2-07 runner retry on a guard refusal, and the runner's notices
|
||||
|
||||
### Problem
|
||||
|
||||
When `message.send` refuses, the runner logs it and marks the message read, so the answer is lost
|
||||
(`packages/harness/src/runner.mjs:451-456`). Its empty and failed turn texts (`:449`) wouldn't pass the
|
||||
guard.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: a Haiku 5.5 subagent at high effort, dispatched by Sage.
|
||||
- Reviewer: Filbert.
|
||||
- After: C2-06.
|
||||
|
||||
### Files owned
|
||||
|
||||
`packages/harness/src/runner.mjs` and `packages/harness/tests/runner.test.mjs`.
|
||||
|
||||
### What ships
|
||||
|
||||
On a refusal with a guard code, a new turn in the same session with the code and reason, at most twice,
|
||||
then `{"items": [], "notice": "no-cited-answer"}`. In a library business, `no-answer` and `turn-failed`
|
||||
replace the two texts at `:449`. The failure reason stays in the run log.
|
||||
|
||||
Tests: a fake harness answers free text, then a valid reply, and exactly one message is stored. An empty
|
||||
turn and a failed turn store the fixed notices.
|
||||
|
||||
### Out of scope
|
||||
|
||||
The guard itself (C2-06).
|
||||
|
||||
### Gate
|
||||
|
||||
Filbert approves the manifest. The harness suite is green on Sage's gate, along with every
|
||||
`scripts/test-*.sh`.
|
||||
|
||||
---
|
||||
|
||||
## C2-05b broker lookup verbs and typed tools
|
||||
|
||||
### Problem
|
||||
|
||||
The queries from C2-05a aren't reachable from an agent session. Design 3 and M3 call for typed tools
|
||||
that the broker re-checks on every call.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: Rocko, gpt-6.1-sol.
|
||||
- Reviewer: Dewey.
|
||||
- After: C2-05a, C2-03a.
|
||||
|
||||
### Files owned
|
||||
|
||||
`packages/bus/src/broker.mjs` (the lookup verbs), `packages/harness/src/tools.mjs` (the five typed tools),
|
||||
and tests in `packages/bus/tests` and `packages/harness/tests`.
|
||||
|
||||
### What ships
|
||||
|
||||
The five lookup tools of design 3, for every agent session in a business with a library project. Each
|
||||
read is at the served commit, and a read whose last fetch is older than 60 seconds syncs first. The
|
||||
verbs refuse `library-not-configured` in a business without a library and refuse a missing `universe`.
|
||||
The creative tool set is exact: the lookups and `send_message`, with no write tool and no
|
||||
`raise_decision`.
|
||||
|
||||
Tests: broker verb tests and a snapshot of typed tool names per creative role (M3).
|
||||
|
||||
### Out of scope
|
||||
|
||||
The guard (C2-06). Write verbs (C2-02, C2-19).
|
||||
|
||||
### Gate
|
||||
|
||||
Dewey approves the manifest. The bus and harness suites are green on Sage's gate, along with every
|
||||
`scripts/test-*.sh`.
|
||||
|
||||
---
|
||||
|
||||
## C2-10b Console chat: cited items and the status line
|
||||
|
||||
### Problem
|
||||
|
||||
The Console would show a cited reply as raw JSON, and she can't see when the hub falls behind her repo
|
||||
(design 7.4).
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: a Haiku 5.5 subagent at high effort, dispatched by Sage.
|
||||
- Reviewer: Dewey.
|
||||
- After: C2-06, C2-09a, C2-03a.
|
||||
|
||||
### Files owned
|
||||
|
||||
`packages/webui/src/public/bus.js`, its stylesheet, `packages/webui/src/serve.mjs` (a `library.status`
|
||||
route only), and tests in `packages/webui/tests`.
|
||||
|
||||
### What ships
|
||||
|
||||
Design 7.4. The chat view renders a cited reply through `renderItems`: each item's kind, text and cites,
|
||||
with a scope badge (canon, playground or web) and "changed since" when the file's last commit is newer
|
||||
than the cited one. Each notice shows its fixed line. A status line reads `library.status`: the served
|
||||
commit and its age, `library-diverged`, a fetch error, and the count of R-problems with a link to the
|
||||
list.
|
||||
|
||||
Tests: webui tests for items, scope badges, notices, "changed since" and the status line.
|
||||
|
||||
### Out of scope
|
||||
|
||||
The room view (C2-10c), library pages (C2-11b). Her sign-in from her machine waits on SSO (#412) or
|
||||
Jason's interim ruling (design 10 step 5).
|
||||
|
||||
### Gate
|
||||
|
||||
Dewey approves the manifest. The webui suite is green on Sage's gate, along with every
|
||||
`scripts/test-*.sh`. Grep for hash pins on `bus.js` and `serve.mjs` before in-review.
|
||||
|
||||
---
|
||||
|
||||
## C2-13 install script and precheck for the host
|
||||
|
||||
### Problem
|
||||
|
||||
The hub runs on 10.1.1.54 as `mosaic` under `/srv/mosaic`, as `mosaic-stack.service` with
|
||||
`ProtectHome` (design 9, Q181). Nothing installs a library business there, or checks the host before it
|
||||
starts.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: Gosalyn, Opus 5.5. The script runs only under Jason's go, by him or his delegate (design 10).
|
||||
- Reviewer: Webby.
|
||||
- After: C2-04, C2-03a, C2-20.
|
||||
|
||||
### Files owned
|
||||
|
||||
`scripts/case2-install.sh`, `scripts/case2-precheck.sh` and `scripts/case2-unit.sh` (new), the template
|
||||
`packages/cli/systemd/mosaic-stack.service.in` (new), `packages/cli/tests/case2-install.test.mjs`
|
||||
(new), and one line of `packages/cli/README.md`: at `:713`, "nothing in the repository creates or reads
|
||||
it" changes to say that nothing creates it, that only the Case 2 precheck reads it, and that the
|
||||
precheck prints only variable names, never values (webby-0034 N2, webby-0035).
|
||||
|
||||
### What ships
|
||||
|
||||
The install does: bootstrap, release package and activate, the business file with the mapping, `bean`
|
||||
and `chet` with their persona vars and files, any specialists she picks, her human id, the token slot
|
||||
path. It runs as `<user>` through `sudo -u mosaic -H` from `localadmin`, and `mosaic` gets no key. It
|
||||
doesn't create `<user>` and writes no unit file. Root does those in design 10 steps 2a and 2c. Every
|
||||
host value is an input, so a later host move changes only inputs.
|
||||
|
||||
Row 195 runs these scripts by input for Case 1 on 10.1.1.53 (darkwing to gosalyn, design at
|
||||
`agents/darkwing/work/queue-195/DESIGN.md` section 2). They don't copy them. So the install and the
|
||||
precheck take `--profile factory|library`, default `library`:
|
||||
- `library` is everything this brief lists.
|
||||
- `factory` skips the library mapping, the persona files and digests, the token slot, and
|
||||
`library.sync` and `library.status`. It requires `bus.tester` on, because Case 1's acceptance uses the
|
||||
tester.
|
||||
- `factory` refuses a business file that has a `kind: "library"` project, with
|
||||
`profile-library-business`. So Case 2 can't reach a tester-on check through a wrong flag.
|
||||
- `library` refuses with `bus.tester` on, as before.
|
||||
|
||||
`case2-unit.sh` is the 2c step. Root runs it from a root-owned checkout of the reviewed commit outside
|
||||
`/srv/mosaic`, never from the release, because `mosaic` can write the release. It does the following:
|
||||
- It renders the unit from the checkout's template, with the user, the home and the copy id as inputs.
|
||||
- It refuses unless `User=`, `Group=`, `ExecStart=`, `EnvironmentFile=` and the hardening lines match
|
||||
fixed values. `EnvironmentFile=` is the line in `packages/cli/systemd/mosaic-bus.service.in:24`. The
|
||||
copy id is the one part of `ExecStart=` that varies, so a re-pin to a new copy can re-render the
|
||||
unit (row 195). It must be exactly 40 lowercase hex characters naming a copy that holds `scripts/mosaic`,
|
||||
directly under `<home>/.local/share/mosaic-stack/copies`. That is the rule `copy_root` uses
|
||||
(`scripts/stack-copy.sh:6`, `:20`). Anything else refuses before the render.
|
||||
- It refuses any `PermissionsStartOnly=`, and any `Exec*=` value whose leading run of prefix
|
||||
characters (`@`, `-`, `:`, `+`, `!`) holds a `+` or `!`. systemd takes those prefixes in any order,
|
||||
so `-+/srv/mosaic/x` runs as root even with `User=mosaic`. The named-line check doesn't read these
|
||||
lines (webby-0026 N1, webby-0027, defence in depth).
|
||||
- It writes `/etc/systemd/system/mosaic-stack.service` as `root:root` 0644. It never uses
|
||||
`systemctl link`, and it refuses a symlink at the unit path.
|
||||
- On a re-install, it refuses an installed unit that differs from the render.
|
||||
- It reads nothing from under `<user>`'s home.
|
||||
|
||||
The precheck refuses unless all of these hold:
|
||||
- The config validates.
|
||||
- `sudo -n true` fails as `<user>`, and `id -G` prints exactly one entry, its primary group. A system
|
||||
user always has one, so the check is for supplementary groups. `bus.tester` is not on.
|
||||
- The business file, `.history`, `changes.jsonl` and persona files have the modes in design 4, and
|
||||
each persona digest matches.
|
||||
- The run root is 0700.
|
||||
- `XDG_DATA_HOME` is unset or is `<home>/.local/share`. `copy_root` reads the copies dir from it
|
||||
(`scripts/stack-copy.sh:6`), and the unit fixes `<home>/.local/share`, so any other value would
|
||||
put the copy where the unit refuses it (webby-0032 N2). A test sets it elsewhere and the precheck
|
||||
refuses.
|
||||
- `node --version` is 24 or later, because every package's `engines` field says `>=24` (row 195: .53
|
||||
has v22.22.2).
|
||||
- `bwrap --unshare-all --ro-bind / / true` exits 0 as `<user>`. Without it no session starts. Row 195
|
||||
found no bwrap on .53, and `kernel.apparmor_restrict_unprivileged_userns=1`, and .54 may be the same.
|
||||
On a refusal, the precheck prints the sysctl's value and whether `bwrap` is on PATH.
|
||||
- When some creative instance's harness is claude-code, one marker prompt run through the host's claude
|
||||
with `--system-prompt-file` shows that it replaces the default prompt. The claude version is printed.
|
||||
With design r9's model path, Bean and Chet run pi, so this check is skipped unless she adds a
|
||||
claude-code specialist.
|
||||
- When an instance's provider is `zai`, `<configDir>/secrets/claude-session.env` exists, is a regular
|
||||
file owned by `<user>` at 0600, and sets a nonempty `ZAI_API_KEY` (design 10 step 2d). It parses the
|
||||
file the way systemd does, so the last assignment of a name wins (webby-0032 N3). A pi launch
|
||||
has no credential check, so without this the first turn is where a missing key shows. The precheck
|
||||
prints the name and never the value.
|
||||
- `library.sync` fetches her repo read-only, and `library.status` lists R-problems.
|
||||
- No Discord binding exists.
|
||||
|
||||
It also prints free memory and verifies HOME is `/srv/mosaic`.
|
||||
|
||||
Tests run against a scratch data root and a local bare repo. Each precheck refusal has a case. The
|
||||
claude check runs against a fake claude that appends rather than replaces, and that case refuses. With
|
||||
no claude-code instance the claude check doesn't run, and a test shows a missing claude passes. The key
|
||||
file refuses when missing, at 0644, a symlink, with `ZAI_API_KEY=` empty, and with `ZAI_API_KEY=x`
|
||||
followed by `ZAI_API_KEY=`. A search of the test
|
||||
output for the fixture key value finds nothing.
|
||||
`case2-unit.sh` runs against a scratch unit directory. These cases refuse:
|
||||
- an installed unit edited to `User=root`
|
||||
- an edited `ExecStart=`
|
||||
- a template with a hardening line removed
|
||||
- a template with the `EnvironmentFile=` line removed or pointing elsewhere
|
||||
- a symlink at the unit path
|
||||
- a template with `ExecStartPre=+/srv/mosaic/x`
|
||||
- a template with `ExecStartPre=-+/srv/mosaic/x`
|
||||
- a copy id that is short, uppercase, holds `/` or `..`, or names no directory
|
||||
|
||||
Precheck cases for the new lines: a fake `node` printing v22 refuses, and a fake `bwrap` that exits 1
|
||||
refuses. `--profile factory` passes with the tester on and no library items, and refuses a business
|
||||
file with a library project. `--profile library` refuses with the tester on.
|
||||
A clean render writes a 0644 file. A test checks the script for `systemctl link`.
|
||||
|
||||
### Out of scope
|
||||
|
||||
Running it on the host. Design 10 steps 2 to 7 are Jason's. Step 1 no longer waits on #421 (Q183), and
|
||||
Mos lifted its hold (mos-1074). The root steps 2a and 2c are ops-01's, over ssh as `localadmin`,
|
||||
so the install waits on #423 step 1 and on nothing else in #423. The Gitea bot account and the token slot are steps 3 and 4. Stopping Hermes
|
||||
is step 9 and her choice.
|
||||
|
||||
### Gate
|
||||
|
||||
Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every
|
||||
`scripts/test-*.sh`. The run on the host is a separate window with Jason's go.
|
||||
|
||||
---
|
||||
|
||||
## C2-14 end-to-end acceptance on a scratch root
|
||||
|
||||
### Problem
|
||||
|
||||
Each slice row tests its own part. Nothing yet shows the whole path from her push to a checked reply in
|
||||
the Console.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: an Opus 5.5 seat that Sage picks.
|
||||
- Reviewer: Dewey, who also runs the script.
|
||||
- After: every other slice row above.
|
||||
|
||||
### Files owned
|
||||
|
||||
`scripts/test-case2.sh` (new) and its fixtures.
|
||||
|
||||
### What ships
|
||||
|
||||
One script on a scratch data root and a bare repo in her layout. It syncs, asks a creative agent in its
|
||||
thread through a fake harness, and stores a reply whose cites verify. It refuses a playground cite on a
|
||||
`fact`, holds a force-push as `library-diverged`, and checks that the Console renders the reply as
|
||||
items.
|
||||
|
||||
### Out of scope
|
||||
|
||||
Any live host or real model call.
|
||||
|
||||
### Gate
|
||||
|
||||
Dewey runs the script and approves. It is green on Sage's gate with every `scripts/test-*.sh`.
|
||||
|
||||
---
|
||||
|
||||
## Brief: row A, ZAI_API_KEY in the session env allowlist
|
||||
|
||||
### Problem
|
||||
|
||||
For 10-27, Bean and Chet both run pi with provider `zai` on `glm-5.3` (Q186, mos-1090, sage-1469). Pi
|
||||
reads the zai key from `ZAI_API_KEY` (pi-ai `env-api-keys.js:89`, with `glm-5.3` in `dist/providers/data/zai.json`).
|
||||
`ENV_ALLOW` (`packages/seat/src/session.mjs:83-88`) doesn't hold that name, so `sessionEnv` (`:97-99`)
|
||||
drops it. A pi launch has no credential check (`packages/plugins/harness/pi/index.mjs:42`
|
||||
`countsAccount: false`, `packages/cli/src/launcher.mjs:299`), so today a zai role launches and fails at
|
||||
its first turn, as `packages/cli/README.md:439-444` says.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: a Haiku 5.5 subagent at high effort, dispatched by Sage (lead decision 129). Size XS.
|
||||
- Reviewer: Webby.
|
||||
- After: nothing. It doesn't wait on any Case 2 row.
|
||||
|
||||
### Files owned
|
||||
|
||||
`packages/seat/src/session.mjs`, `packages/seat/tests/session.test.mjs` and `packages/cli/README.md`.
|
||||
|
||||
### What ships
|
||||
|
||||
- `"ZAI_API_KEY"` joins the provider line of `ENV_ALLOW` (`session.mjs:86`), after `OPENROUTER_API_KEY`.
|
||||
Nothing else in the list changes. `SESSION_CREDENTIALS` and `hasSessionCredential` (`:94-95`) don't
|
||||
change. They are Claude Code's login sets, and a zai key isn't one.
|
||||
- `packages/cli/README.md`, after "A Pi session gets the same variables through the allowlist." (`:435`),
|
||||
one sentence: a Pi session on provider `zai` logs in with `ZAI_API_KEY`, which the allowlist passes. Like
|
||||
every allowlist variable, it reaches every session the host starts, Claude Code ones included, and a
|
||||
shell inside any of them can read it. Where the README names `claude-session.env` ("Unit", `:711`),
|
||||
"The file holds one line" becomes "The file holds one line per variable", followed by a sentence
|
||||
saying it holds every session provider key, not only Claude's (webby-0032, webby-0034 N1). Leave
|
||||
`:713`, "nothing in the repository creates or reads it", alone. C2-13 owns that line.
|
||||
- One test in `session.test.mjs`: `ENV_ALLOW` includes `ZAI_API_KEY`, and
|
||||
`sessionEnv({ ZAI_API_KEY: "fixture-zai", ZAI_TOKEN: "t" }, run)` returns `ZAI_API_KEY` with the fixture
|
||||
value and no `ZAI_TOKEN`. The existing allowlist test (`:51-59`) stays as it is.
|
||||
- One mutant: the same file with `"ZAI_API_KEY"` removed from `ENV_ALLOW`. The new test fails on it.
|
||||
|
||||
### Out of scope
|
||||
|
||||
Placing a key anywhere. The hub's own key on the Z.ai account is design 10 step 2d, and it is Jason's.
|
||||
Any pi launch or live check: running a pi role waits on #383. Chet's Codex login (row B, #428). A
|
||||
credential check for pi launches.
|
||||
|
||||
### Gate
|
||||
|
||||
Webby approves the manifest. The mutant is killed. The seat suite and the cli suite are green on
|
||||
Sage's gate with every `scripts/test-*.sh`.
|
||||
|
||||
---
|
||||
|
||||
## Brief: row B, a per-instance pi agent dir for Chet's Codex login (#428)
|
||||
|
||||
### Problem
|
||||
|
||||
After 10-27, Chet moves from Bean's Z.ai account to his own ChatGPT/Codex login (Q186). Pi's
|
||||
`openai-codex` provider logs in with OAuth and a refresh token, and keeps both in `auth.json` under
|
||||
`PI_CODING_AGENT_DIR` (pi `config.js:405-438`). A role session's `HOME` is the run's own, so pi never
|
||||
sees an agent dir from the host (`packages/cli/README.md:439-441`), and `ENV_ALLOW` doesn't pass
|
||||
`PI_CODING_AGENT_DIR`. So no env variable can carry this login. Each instance needs its own agent dir
|
||||
that survives runs, because a refresh rewrites the file.
|
||||
|
||||
Chet's Hermes profile uses the same login today. Until Hermes stops, two clients would hold one
|
||||
refresh token. If a refresh by one revokes the other's, one of them drops out mid-session.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Design: Gosalyn owns it (sage-1469). The build owner and reviewer are named when the design is
|
||||
approved.
|
||||
- Starts after 10-27. Nothing in this row is built or tested before then.
|
||||
|
||||
### Files owned
|
||||
|
||||
None yet. The design names them.
|
||||
|
||||
### What ships
|
||||
|
||||
The design first. It settles where the dir lives and who can read it, how the launcher mounts it into
|
||||
the sandbox writable for one instance only, how a refresh write survives the run, what a shell in the
|
||||
session can read, and how a launch with no login refuses. Build rows follow from it.
|
||||
|
||||
### Out of scope
|
||||
|
||||
Bean's model path. Any change to row A. Any change to Hermes or her server.
|
||||
|
||||
### Gate
|
||||
|
||||
A live check, run with Jason's or Mos's go, showing that two clients on one ChatGPT login don't revoke
|
||||
each other's refresh tokens. Until that check passes, no build row for this design starts.
|
||||
@@ -0,0 +1,120 @@
|
||||
# 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.
|
||||
|
||||
Context (sage-1419). `ssoSubjects` is row 192's `console.sso.providers[].subjects` flattened to `{provider,
|
||||
sub}` pairs; the design's "`console.sso.subjects`" in 3.5 is shorthand for it. `providers` is the list of
|
||||
`providers[].id`, read through `loadSystem` and row 192's `checkConsole`. `peers` is `[]` until R2 lands, so a grant
|
||||
naming a peer home refuses. R5 rebases onto row 192's landing as well as R1's.
|
||||
|
||||
Tests, in `packages/cli/tests/`: tester and agent callers are refused, a concurrent write refuses on a
|
||||
revision conflict, the receipt line is appended, and a bad grant is refused before anything is written. One
|
||||
mutant per refusal, each killed.
|
||||
|
||||
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). The brief follows r4.11, b93f098f0 on
|
||||
`gizmoduck/federation-design` (sha256 1bcee8df0a3a8628a6646535b756c936ca6d9e7bcfa7e1dd2bf1d3f01715c8e6),
|
||||
which Duckworth acked with r4.10 (duckworth-0034). Mos closed design rounds there (mos-1072). Run by Jason and
|
||||
Mos. Nothing here authorizes a step on any host; each step waits for the window's go.
|
||||
|
||||
What changed since r4.9 (DECISIONS Q182, Q183, mos-1055, mos-1063). R8 is a recorded acceptance. It starts
|
||||
with Jason's acceptance of the factory paths to .54 on record, quoted in section 8. The privilege and host
|
||||
checks still run, and each result goes into the R8 record with the name of whoever ran it. They report and
|
||||
don't refuse. The four functional checks named at the end of section 7's R8 row still stop R8 when they fail
|
||||
(mos-1066 item 1).
|
||||
|
||||
Order (mos-1042 item 2). R8 runs after the Case 2 install on .54 (#416, due by 2026-10-27, Jason's latest-by
|
||||
date in Q176, mos-1069), after Bean and Chet are live in Case 2, and after both Hermes gateways on 10.1.1.54
|
||||
have stopped. Case 2 go-live doesn't wait on R8. The risk, stated for 10-27: federation between the two
|
||||
instances depends on Melanie's switch-over date.
|
||||
|
||||
Entry checks, all before anything starts:
|
||||
- R7, #412 phase 2 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`. An agent or Jason runs them as localadmin (mos-1055), and the output goes
|
||||
into the R8 record with who ran them.
|
||||
- The factory privilege checks, the key fingerprint comparison and the section 8 privilege path table, each
|
||||
as section 7's R8 row states them.
|
||||
|
||||
Build notes:
|
||||
- N1 (Duckworth, duckworth-0034, mos-1072). A check that fails on a path Jason didn't name in Q182 or Q183 is
|
||||
marked "not named in Q182 or Q183" in the record, not filed under the acceptance. Examples: a factory mount
|
||||
of hub data, another agent on .54, a provider admin token. Mos takes each one to Jason.
|
||||
- N2 (Duckworth, doc-only). The Q183 and Q4 quotes in section 8 are excerpts. When the R8 record quotes them,
|
||||
it marks each omission with "…" and checks the full text at DECISIONS.md:1535-1541 in jason-master-restart
|
||||
(read only).
|
||||
|
||||
Done when every entry check has its output recorded, Jason signs in to the hub and sends one message, Melanie
|
||||
revokes, and his next request is refused.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426)
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-1078 (Vikunja P19 #426) and its go mos-1083 item 1. Owner Rocko,
|
||||
reviewer Dewey, gate owner Sage. Base: refactor at this brief's commit. Starts after row 191.
|
||||
|
||||
## Why
|
||||
|
||||
Morgana force-pushed a seat branch on 2026-10-20 (morgana-0016). Mos took it to Jason. Force-push is a
|
||||
Q173 exclusion, and canon says never force-push, but nothing mechanical stops one. Mos: "Done means a
|
||||
force-push from a seat worktree is refused, with a test that shows it."
|
||||
|
||||
Earlier the same day, a busy check chained as `| tail -1 &&` passed while the slot was BUSY. The script it
|
||||
called prints BUSY or CLEAR, but it exits 0 either way.
|
||||
|
||||
The guard has two layers:
|
||||
- (a) Gitea protection with force-push off on seat branch patterns and on refactor, next and main. ops-01
|
||||
sets it up, through Mos (mos-1082). That part isn't this row.
|
||||
- (b) The client hook below. It can be bypassed with `--no-verify`, so (a) is the real guard and (b) is the
|
||||
early refusal.
|
||||
|
||||
## What ships
|
||||
|
||||
1. `scripts/git-hooks/pre-push` (new). For each ref line git passes on stdin:
|
||||
- It refuses when the remote sha isn't all zeros and isn't an ancestor of the local sha (`git
|
||||
merge-base --is-ancestor`). The refusal names the ref and both shas, and the hook exits 1.
|
||||
- A new branch passes. A delete (local sha all zeros) is refused.
|
||||
- Any failure of the check refuses too, for example an unknown remote object. The hook fetches nothing.
|
||||
If the remote tip isn't present locally, it refuses and says to fetch first.
|
||||
2. Install, following `scripts/queue-commit.sh --install-hook`. Either add a `--install-push-hook` there or
|
||||
ship a small `scripts/install-git-hooks.sh`, whichever fits the existing code better. Say which in BUILD.md.
|
||||
- The install copies the hook into the common `.git/hooks`, which every seat worktree shares.
|
||||
- It refuses when `core.hooksPath` is set (the husky trap, 2026-10-09). It refuses when an installed
|
||||
pre-push differs from the repository copy, unless `--force-install` is given.
|
||||
- It never edits the installed copy in place.
|
||||
3. `scripts/slot-busy.sh` (new), the shared busy check.
|
||||
- It prints one BUSY line per matching process, or CLEAR.
|
||||
- It exits 0 on CLEAR, 1 on BUSY and 2 on its own error.
|
||||
- It matches `node --test`, `gate.sh`, `mutant`, `scripts/test-*.sh` and `docker compose` command lines.
|
||||
It skips its own process and shell snapshots.
|
||||
- It reads `/proc/<pid>/cmdline` and the cwd link only, never file contents under another seat's
|
||||
directory.
|
||||
- Usage in `docs/TOOLS.md`: `bash scripts/slot-busy.sh > LOG && gate`, with `set -o pipefail` in any
|
||||
script that pipes it. Never `| tail -1 &&`.
|
||||
|
||||
## Tests
|
||||
|
||||
Add them to `scripts/test-land-work.sh` or a new `scripts/test-git-guard.sh` (say which). Run against a scratch
|
||||
bare remote and a scratch clone with a worktree, never against origin:
|
||||
- A rewritten branch pushed with `--force` from a worktree is refused, and the remote ref is unchanged.
|
||||
- A `+refspec` push is refused.
|
||||
- A fast-forward push passes.
|
||||
- A new branch passes.
|
||||
- A delete is refused.
|
||||
- A remote tip that's missing locally is refused.
|
||||
- The install refuses with `core.hooksPath` set, and refuses a differing installed hook.
|
||||
- `slot-busy.sh` exits 1 with a fake `node --test` process running (a sleeping stand-in with that argv),
|
||||
and 0 without one.
|
||||
- `slot-busy.sh | tail -1 && false` doesn't apply here. The test runs `bash scripts/slot-busy.sh >/dev/null
|
||||
&& echo ran`, and asserts it doesn't print `ran` while BUSY.
|
||||
|
||||
Mutants, each killed:
|
||||
- drop the ancestor check
|
||||
- treat an unknown remote object as passing
|
||||
- let a delete pass
|
||||
- install over a set `core.hooksPath`
|
||||
- `slot-busy.sh` exits 0 on BUSY
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Install the hook in the canonical checkout only after landing, as its own step with Sage. Tests use
|
||||
scratch repositories.
|
||||
- No push to origin from a test. No change to Gitea config. That's ops-01's.
|
||||
- Don't touch `~/.mosaic`, `~/.mosaic-dev` or `~/.config/mosaic-dev`.
|
||||
|
||||
## Gate
|
||||
|
||||
Dewey approves the manifest. The new or changed `scripts/test-*.sh` file and every other `scripts/test-*.sh`
|
||||
are green on Sage's gate, and the mutants are killed. Done when the hook is installed in the canonical
|
||||
checkout and a scripted force-push from a seat worktree to a scratch remote is refused there. ops-01's
|
||||
server proof (a refused force-push, plus a fast-forward push and a new branch that go through) is recorded
|
||||
in Vikunja #426 by Mos.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Brief: agent host access with sudo through the credential store (#424)
|
||||
|
||||
Sage, 2026-10-20, for Jason's rulings Q183 and Q184 relayed by Mos (mos-1056 item 3, mos-1067). Gitea #1707, Vikunja P19
|
||||
#424, due 2026-10-27 (Q184: "Q5: by 10.27"). Owner Gizmoduck (design and build), reviewer Duckworth, gate owner
|
||||
Sage. Chained on row 199's candidate (Q177): row 199 design r6 (4d3007ec9 on `megavolt/row199`, approved in
|
||||
duckworth-0027) is not reopened. This row lands after 199 and rebases onto its landing.
|
||||
|
||||
## What Jason asked for
|
||||
|
||||
Q183, through Mos: "I envision a credential store within Mosaic Stack that works as some sort of variable for
|
||||
the agent to call. The system will enforce access to the credentials to keep the agents sandboxed with proper
|
||||
security... If we can utilize the credential store in Mosaic Stack and allow agents to perform their jobs with
|
||||
NOPASSWD sudo on various machines, I am a happy man." Q183 also says not every agent needs that access.
|
||||
|
||||
Mos's requirements (mos-1067 item 1):
|
||||
- An agent asks for a host credential by name.
|
||||
- The stack checks a grant for that agent and that host, and does the ssh and sudo for it.
|
||||
- The agent never sees the key or any secret value.
|
||||
- Only agents with a grant can do it.
|
||||
|
||||
The PRD text is REQ-CRED-3, drafted by Honker for PRD 1.7 (sage-1422). If it misses 1.7 it goes into 1.8, and
|
||||
this row lands only after the version that holds it is approved.
|
||||
|
||||
## What row 199 gives and what it doesn't (megavolt-0019)
|
||||
|
||||
It builds on 199 as 199 stands: the store layout under `<configDir>/secrets/agents/<business>/<agent>/`, the
|
||||
lifecycle verbs and their `changes.jsonl` receipts, `useFor(session, service, fn)` with the D5 allowlist test,
|
||||
`assertClean` on every result, and the grant shape of `publish` (a list on the role entry, checked by the
|
||||
broker, plus a hard-wait action that isn't routed).
|
||||
|
||||
This row's own changes:
|
||||
- A host kind in the closed kind set (vocabulary.mjs `SERVICES`, credentials.mjs, the broker's service check,
|
||||
the CLI date flag, the remove regex), with a naming rule for one credential per host or per agent.
|
||||
- A per-kind value check. An OpenSSH private key fails today's `TOKEN` pattern.
|
||||
- Handing the key to ssh without the agent seeing it: an ssh-agent on a private socket, or a 0600 temp file
|
||||
removed on every path, the way publish's one-shot helper works.
|
||||
- Pinned host keys per host, and no trust on first use.
|
||||
- The command boundary: what an agent may run under sudo, and how output returns without echoing the secret.
|
||||
This is the main design work.
|
||||
- The executor: a ticket and a host op running in the bus child, the same split row 199 uses for publish
|
||||
(Megavolt's deviation in 199's BUILD.md).
|
||||
|
||||
## Steps
|
||||
|
||||
1. DESIGN.md first, in `agents/gizmoduck/work/queue-ROW/`. It answers each item above, names the grant shape
|
||||
(`roles.<agent>.hosts` and a `host.sudo` action, or a reasoned alternative), says who grants (Jason, through
|
||||
a reviewed role change, unless REQ-CRED-3 says otherwise), and lists tests and mutants. Duckworth approves it
|
||||
before code. Sage reads it too, because it gives agents sudo on hosts.
|
||||
2. Build on 199's candidate. Single test files run freely. Suites and mutant batches need Sage's slot.
|
||||
3. The live part is not this row. Putting a key in the store and granting a host is a separate window with
|
||||
Jason or Mos, after landing.
|
||||
|
||||
## Hosts
|
||||
|
||||
The first targets are `localadmin` with NOPASSWD sudo on 10.1.1.53 and 10.1.1.54 (#423 step 1, applied by
|
||||
ops-01 at 20:05:47Z), and the servers agents already reach as localadmin (Q172). ops-01 owns the host side.
|
||||
Tests use scratch sshd fixtures on 127.0.0.1 or fakes, never a real host.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files, the live units or `~/.mosaic`. No key is
|
||||
read, copied or printed. No ssh to any real host from a test or a seat.
|
||||
- No widening of row 199's kinds or regex inside row 199. Both rows touch them, so this row rebases after 199
|
||||
lands.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, then the manifest. The bus, business and cli suites and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the mutants are killed (one per refusal, plus one that leaks
|
||||
the key into the agent's output and one that skips the host key pin).
|
||||
@@ -1,77 +1,66 @@
|
||||
# Brief: Melanie's hub instance on its own host (row 195, was 190b)
|
||||
# Brief: the Case 1 install on 10.1.1.53 (row 195)
|
||||
|
||||
Sage, 2026-10-20. Rewritten for Mos's rulings mos-0998 and mos-1005 (Vikunja #418, #419). The first version
|
||||
put the hub under its own Linux user on dragon-lin. That form can't pass Duckworth's B7 there, because the
|
||||
factory uid's `sudo -n` succeeds on dragon-lin (Mos's recon, brain-redux
|
||||
`projects/mosaic-mvp/MELANIE-HOST-RECON.md`). Melanie's own server is ruled out too: it has 1.2 GiB RAM free
|
||||
with her two Hermes bots running, and co-tenancy would share her user and her passwordless sudo. The hub runs
|
||||
on a separate host. Which host is Jason's decision (#419): hardware outside the cluster, a cluster VM with
|
||||
factory PVE root removed, or a cluster VM with that residual accepted. This row is written so the host choice
|
||||
doesn't change it. Gitea #1693. On the 10-27 MVP path. Federation R8 (Gizmoduck's design r4, 04a6112be) needs
|
||||
this row or nothing.
|
||||
Sage, 2026-10-20, third version, for Darkwing's rewrite request and sage-1439. Gitea #1693.
|
||||
|
||||
Owner Darkwing (Opus 5.5), reviewer Duckworth (author of B5 and B7), gate owner Sage. Chained on row 190
|
||||
(Q177): the hub runs under row 190's config canon. Design first. The first deliverable is
|
||||
`agents/darkwing/work/queue-190b/DESIGN.md`.
|
||||
Why it changed:
|
||||
- The first two versions put Melanie's hub on its own Linux user, then on its own host, Jason's choice under
|
||||
#419.
|
||||
- Q181 settled the hosts: Case 1 (Jason's software factory) runs on 10.1.1.53 and Case 2 (Melanie's hub) on
|
||||
10.1.1.54.
|
||||
- Q182 and Q183 made the hub's privilege checks a recorded acceptance. They report and don't refuse
|
||||
(federation R8, mos-1055, mos-1063, mos-1066).
|
||||
- The Case 2 install on .54 is Gosalyn's C2-13 plus its window (#416, due by 2026-10-27). It is not this row.
|
||||
|
||||
This row now installs Case 1 on .53. On the 10-27 MVP path (mos-0974, T1 item 1).
|
||||
|
||||
Owner Darkwing (Opus 5.5), reviewer Duckworth, gate owner Sage. Chained on row 190's candidate (the config
|
||||
canon) and on C2-13's candidate (the install script), Q177. It reuses C2-13's `case2-install.sh`,
|
||||
`case2-precheck.sh` and `case2-unit.sh` by input, not by copy. A gap in them goes to Gosalyn as a C2-13
|
||||
change, or into this row's manifest as a named, reviewed change to those files. Design first:
|
||||
`agents/darkwing/work/queue-195/DESIGN.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
Melanie's hub instance (Case 2, C2-13) runs on its own host. No factory agent can read or write the hub's
|
||||
config, data root or secrets, reach its bus socket, or log in to the hub host. Her writing reaches the hub only
|
||||
through the existing git.mosaicstack.dev remotes, books repo only.
|
||||
Jason's software factory runs on 10.1.1.53 as `mosaic` under `/srv/mosaic`, in the same unit form as Case 2
|
||||
(design 9, Q181). It uses row 190's config canon and the model account the factory uses today (Q185,
|
||||
mos-1081 item 1). No new provider account.
|
||||
|
||||
## Provision (blank until Jason answers #419)
|
||||
## Provision
|
||||
|
||||
Who provisions the host and how is left open. Sizing from Mos (mos-0998): 4 vCPU, 8 GB RAM, 64 GB disk,
|
||||
internal network only. Each agent process measures 300 to 390 MB RSS on dragon-lin. The design takes the host
|
||||
as given: a fresh Linux host with a named admin path, reachable by the deploying seat while the row runs.
|
||||
- ops-01 runs the root steps over ssh as `localadmin`: `useradd mosaic`, `/srv/mosaic` and the unit through
|
||||
`case2-unit.sh` from a root-owned checkout. #423 step 1 (localadmin with NOPASSWD sudo on .53 and .54) was
|
||||
applied at 20:05:47Z (mos-1066, mos-1077), and nothing else in #423 gates this row.
|
||||
- #423 step 4 (jarvis sudo) waits until Case 1 is live on .53.
|
||||
- The host list comes from Q181. Agents may run read-only checks on .53 as `localadmin`.
|
||||
|
||||
## The design must state
|
||||
|
||||
1. The deploy. Exactly what Darkwing installs on the hub host and how: the stack copy, node, the units, the
|
||||
config dir and data root under row 190's canon (O1: `~/.config/mosaic-NAME`, and "`~` is the home of the OS
|
||||
user that runs the instance"). Which commands run as which user on the hub host, and which need its root.
|
||||
2. Re-pin, rollback and backup on the hub host, and who runs them after the deploy key is gone.
|
||||
3. How the hub gets model access without a copy of jwoltje's `claude-session.env` or any factory credential.
|
||||
If that needs the credentials row (T1 item 6), say so and name the interim. If it needs a new subscription
|
||||
or a new provider account, flag it for Jason and don't pick one (mos-1008).
|
||||
4. The books repo path. Which git.mosaicstack.dev remote, which credential the hub holds for it, and that it
|
||||
can't read or push any other repository.
|
||||
5. The admin path and the end state. The host ends with no key the factory uid can read. The design names the
|
||||
admin path that remains (for example the hypervisor console, or a key kept off the factory uid) and who
|
||||
holds it.
|
||||
6. The acceptance script, as code Duckworth can read. It takes Gizmoduck's list of infrastructure gate items
|
||||
as its input (mos-1011), not a fixed set of checks. Each item names a path, its closed form and its
|
||||
accepted form, and the script reports which form holds for each, or fails. The list covers at least:
|
||||
- federation R8's separate-host form: the hub's bus socket, data root and config dir don't exist on the
|
||||
factory host, the factory host mounts nothing of the hub's, and no factory agent holds a login or key
|
||||
for the hub host
|
||||
- B7: from the factory uid, `sudo -n`, `doas -n`, `su`, polkit, and `ssh -o BatchMode=yes` to the hub
|
||||
host all fail
|
||||
- the hypervisor and backup paths ops-01 measured (mos-1011: one PVE node the factory key has root on,
|
||||
PBS on that node with unencrypted backups, a flat /16 LAN), each closed or accepted by Jason
|
||||
7. The identity provider and TLS front end (B8, mos-1008, mos-1011). Either the factory holds no credential
|
||||
that can create users, change groups, issue tokens or impersonate in the issuer the hub trusts, or the hub
|
||||
uses an issuer the factory can't administer. A brand, tenant or app inside the same Authentik does not
|
||||
count as separate. The same holds for the TLS front end that serves the hub. The proof is a failed admin
|
||||
call from every token the factory's root can read. No credential value is printed in that proof.
|
||||
1. The install as input values to C2-13's scripts: user, home, business file, roles, the human id, the
|
||||
token slot path. Which commands run as `mosaic` through `sudo -u mosaic -H`, and which as root through
|
||||
ops-01.
|
||||
2. Where Case 1 differs from Case 2: the factory roles in place of the library business, no library sync,
|
||||
and any precheck item that doesn't apply, each with a reason.
|
||||
3. Model access through the existing factory account (Q185). Placing the credential on .53 is a step under
|
||||
the Q173 secrets rule, done in the window. It is never copied by the script, and its value is never
|
||||
printed.
|
||||
4. Re-pin, rollback and backup on .53, and who runs them.
|
||||
5. The acceptance checks, as code Duckworth can read. Each check names a path and gives it one of three
|
||||
marks: closed, accepted under Q182 or Q183, or "not named in Q182 or Q183". An accepted path doesn't
|
||||
fail the row. An unnamed one is reported to Mos, who takes it to Jason (the R8 N1 rule). The functional
|
||||
checks fail the row when they fail: the unit runs as `mosaic`, the Console answers on its port on the
|
||||
internal network only, and one tester message gets a reply.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Provisioning, hypervisor access and any infra or k3s change are Jason's (Q173 exclusions). Mos takes the
|
||||
hypervisor residual (factory root on the PVE nodes, Q172) to Jason. It doesn't block this row.
|
||||
- Melanie's server (`[email protected]`) is reference only, read only, under Jason's authorization in
|
||||
mos-0995. Nothing on it changes, and her Hermes bots keep running.
|
||||
- No tunnel, Traefik or other exposure outside the internal network. That waits for Jason, after Q175 SSO.
|
||||
- No credential value is printed, logged or committed. Keys made for the deploy are removed at the end, and
|
||||
the acceptance script proves it.
|
||||
- The admin-call proof in point 7 and any call to the issuer or the TLS front end run only in a window Mos
|
||||
approves, never as a test or an anonymous probe (Q175).
|
||||
- Tests in the repository use scratch roots and stand-ins. The live deploy and the acceptance run on the hub
|
||||
host are a window with a backup first (where there is state) and a STATE report after.
|
||||
- Infra and k3s changes, exposure outside the internal network, and spend stay Jason's (Q173 exclusions).
|
||||
- No change to dragon-lin's `~/.mosaic-dev`, `~/.config/mosaic-dev`, its live units or `~/.mosaic`.
|
||||
- No credential value is printed, logged or committed.
|
||||
- Tests use scratch roots and stand-ins. Nothing in a test reaches .53.
|
||||
- The run on .53 is a window with Jason's or Mos's go, a backup first where state exists, and a STATE report
|
||||
after.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, then the manifest. Any repository change is green on Sage's gate (cli suite and
|
||||
every `scripts/test-*.sh`), with the design's mutants killed. The row is done when the acceptance script passes
|
||||
on the hub host from the factory uid, Duckworth reads its output, and Mos confirms.
|
||||
every `scripts/test-*.sh`), with the design's mutants killed. The row is done when the window's acceptance
|
||||
run passes on .53, Duckworth reads its output, and Mos confirms.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# 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.
|
||||
|
||||
Duckworth's N1 on R1 (duckworth-0033, sage-1417): with viewer added, `covers` called with no level would let a
|
||||
viewer grant cover a collaborator ask. This row makes `level` required in `covers`, with a test and a mutant that
|
||||
makes it optional again. R3 and R4 call `covers` with a level.
|
||||
|
||||
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, and the mutants are killed.
|
||||
|
||||
## 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.
|
||||
+23
-3
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
|
||||
<!-- mosaic-queue:begin -->
|
||||
|
||||
Generated from `docs/plans/queue.json` revision 2247 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 2361 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -225,9 +225,29 @@ Generated from `docs/plans/queue.json` revision 2247 by `queue render`. Do not e
|
||||
| 186 | stack-release --plan: the backup line says what the run will do (P19 404, row 185 N1/N2) | darkwing; reviewers filbert | #1684 | done | — | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the three brief mutants killed (sage) | `docs/plans/2026-10-20_plan-backup-line.md` § Brief: the activate plan's backup line says what the run will do | Landed 269134d8d (author Darkwing) on 3c5da3a91. Patch-id c97b9f6e25cd matches the r1 candidate (manifest 449e9121, Filbert approve 29419). Sage gate on b367f5730+patch 09:00:20Z-09:08:59Z: node 2681/0, scripts 422/0, queue 156, network removed; 3c5da3a91 adds only a SESSIONS line. Not live: the stack runs copy 490e2771d3d0. Follow-ups on P19 404: Filbert M6 gap, store-unclean reading. |
|
||||
| 187 | stack-release activate carries the row 178 proof pair (mos-0928 Part B A0) | darkwing; reviewers filbert | #1685 | done | 186 done | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-proof-pair.md` § Brief: activate carries the row 178 proof pair | Landed 9a71e4e80 (author Darkwing) on d17328a55; patch-id 6b57c47bc661 matches candidate 52a5ecc1d143 and Filbert's r1 reading. Sage gate passed 10:21:55Z: cli 446/446 and every package and scripts/test-*.sh green. Filbert approve 29424, rev 2204; follow-ups N1-N4 filed on P19. |
|
||||
| 188 | stack-release tests: a carried proof pair and a proof file inside the repository (P19 409, 410; gates Part B) | darkwing; reviewers filbert | #1686 | done | 187 settled | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_proof-test-gaps.md` § Brief: tests for the two proof-file paths row 187 left open | Landed 8fc0ee216 (author Darkwing) on 23a609c5c; patch-id c9a56b167c07 matches candidate 86aa549bad75 and Filbert's r1 reading; stack-release.sh blob 2586a2a37cd2 unchanged. Sage gate passed 10:58Z: cli 453/453 and every package and scripts/test-*.sh green. Filbert approve 29433, rev 2212; N1 (REPO logical path) to P19 after Part B (mos-0959). |
|
||||
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | in-review, round 1, request posted | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | — |
|
||||
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-progress | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
|
||||
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | done | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | Landed ace27dc5e on refactor (cherry-pick of candidate d6116771f onto a4a42dbac). Gate GATE_PASSED on 0c177e9ef (base 0217b71c8), 2026-10-20T19:09:37Z-19:20:36Z, 25 suites rc=0. patch-id ce89378718f6 for both 0c177e9ef and ace27dc5e; range-diff differs only in the message. Landed manifest hashes to 67af69bd (Dewey approve 29485). Base drift 0217b71c8..a4a42dbac: 7 doc/README files, none on manifest paths, no test reads them. Brief re-pinned to blob 29a9e6d2d. |
|
||||
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-review, round 1, request posted | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
|
||||
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | in-progress | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | — |
|
||||
| 192 | Q175 phase 1: the Console verifies SSO itself (--sso), OIDC RP with oauth4webapi 3.8.8, no live change (MVP T1.2, #412) | launchpad; reviewers webby | #1690 | in-review, round 2, request posted | — | Webby approves the manifest; webui and cli suites, scripts/test-config.sh and every scripts/test-*.sh green on Sage's gate; design section 9 mutants killed (sage) | `docs/plans/2026-10-20_sso-phase1.md` § Brief: Q175 phase 1, the Console verifies SSO itself (--sso) | — |
|
||||
| 193 | many humans per business, human roles, private and shared threads, one access.json store (MVP T1.3, Vikunja #414/#415) | morgana; reviewers duckworth | #1691 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, business, cli and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users.md` § Brief: many humans per business, human roles, private and shared threads | — |
|
||||
| 194 | agent lifecycle from the Console and the CLI (create, launch, terminate, regenerate, delete) with a per-agent hire/fire veto (MVP T1.5, Vikunja #414/#415) | honker; reviewers webby | #1692 | in-progress | — | Webby approves DESIGN.md then the manifest; bus, cli, business and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto | — |
|
||||
| 195 | row 195: the Case 1 install on 10.1.1.53 as mosaic under /srv/mosaic, reusing C2-13's scripts by input under row 190's canon (Q181, Q182/Q183, Q185; ops-01 root steps under #423 step 1) | darkwing; reviewers duckworth | #1693 | briefed | 190 done | Duckworth approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Brief: the Case 1 install on 10.1.1.53 (row 195) | sage-1439: rewritten to Case 1 on .53 (f24f2cd40). Chains on C2-13's candidate too once C2-13 is queued. Darkwing starts DESIGN.md after row 190's mutants. |
|
||||
| 196 | the human boundary across uids and humans: Console, tester listener, broker.sock and the path-bound human proof (P19 333, before federation R3, mos-0989) | gander; reviewers duckworth | #1694 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-boundary.md` § Brief: the human boundary across uids and humans (P19 333) | — |
|
||||
| 197 | a second gate lane: two full gates at once on dragon-lin, design first (mos-0977 item 6) | herb; reviewers webby | #1695 | in-progress | — | Webby approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate, singly and in the two-lane proof (sage) | `docs/plans/2026-10-20_second-gate-lane.md` § Brief: a second gate lane, two full gates at once on dragon-lin | Order with row 198 (Sage, mos-1040 N1): 197 lands first. After B1 the shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. Row 198 rebases onto 197's landing before its manifest review. |
|
||||
| 198 | retire the control board writer on 7331 (unauthenticated loopback writer, Q175; mos-1008) | bushroot; reviewers webby | #1696 | in-progress | 192 done, 197 done | Webby approves INVENTORY.md then the manifest; control-board, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_retire-control-board.md` § Brief: retire the control board writer on 7331 | Order with row 197 (Sage, mos-1040 N1): 197 lands first. Row 198 rebases onto 197's landing before its manifest review; shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. |
|
||||
| 199 | MVP T1 item 6: a credential store per agent, own git identity, pushes to a remote (G11, G13; mos-0974) | megavolt; reviewers duckworth | #1697 | in-progress | — | Duckworth approves DESIGN.md; Mos rules on the authority model; Duckworth approves the manifest; bus, business, cli and tasks suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: a credential store per agent, own git identity, pushes to a remote | — |
|
||||
| 200 | stack-release.sh activate --sso for the Q175 phase 2 window (row 192 F3; chained on 192) | launchpad; reviewers webby | #1698 | in-review, round 2, request posted | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite) | — |
|
||||
| 201 | FED R1: access.json loader and validator, and covers (#414) | gizmoduck; reviewers duckworth | #1699 | in-review, round 1, request posted | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R1, the access.json loader and validator | — |
|
||||
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | in-review, round 1, request posted | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
|
||||
| 203 | FED R2: the federation key in the system config (#414) | quackerjack; reviewers webby | #1701 | briefed | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R2, the federation key in the system config | Chained on row 192's candidate (Q177): starts on 192's candidate, lands after 192 (Sage) |
|
||||
| 204 | 193a: access level check widened to viewer (#1691) | morgana; reviewers duckworth | #1702 | in-review, round 1, request posted | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193a, the access level check widened to viewer | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
|
||||
| 205 | 193b1: viewer verbs, board refusals and the message filter (#1691; also after FED R3 and R4 when filed) | morgana; reviewers duckworth | #1703 | queued | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1, viewer verbs, board refusals and the message filter | — |
|
||||
| 206 | 193b2: thread shares, schema 3j (#1691; live needs a --migrate window with Jason) | morgana; reviewers duckworth | #1704 | queued | 205 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3j) | — |
|
||||
| 207 | 193c: mosaic access user verbs and --level viewer (#1691) | morgana; reviewers duckworth | #1705 | queued | 202 done | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193c, mosaic access user verbs and --level viewer | — |
|
||||
| 208 | 193d: Console shared list, share controls and viewer read-only (#1691; also after FED R6 when filed) | morgana; reviewers duckworth | #1706 | queued | 192 done, 206 done | Duckworth approves the manifest; webui suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193d, the Console's shared list, share controls and viewer read-only | — |
|
||||
| 209 | FED R8: the hub window on 10.1.1.54, a recorded acceptance (#414, r4.11 b93f098f0; live window run by Jason and Mos, after the Case 2 install #416, Bean and Chet live and both Hermes gateways stopped) | sage; reviewers duckworth | — | queued | — | Every entry check's output is in the R8 record with who ran it; Jason signs in to the hub and sends one message, Melanie revokes, his next request is refused; Duckworth checks the record (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R8, the hub window on 10.1.1.54 | Stays queued until its entry gates near: Case 2 install on .54 (#416, by 2026-10-27), Bean and Chet live, both Hermes gateways on .54 stopped, R7, #412 phase 2, #419 answer. Filed per mos-1073 after duckworth-0034 (Sage) |
|
||||
| 210 | Agent host access with sudo through the credential store: grant per agent and host, the stack does ssh and sudo, the agent never sees the key (#1707, Vikunja #424, Q183/Q184, due 10-27; chained on row 199's candidate) | gizmoduck; reviewers duckworth | #1707 | in-progress | — | Duckworth approves DESIGN.md, then the manifest; bus, business, cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_host-credentials.md` § Brief: agent host access with sudo through the credential store (#424) | Design runs before 199 lands (Q177 chain on 199's candidate). after cleared so Gizmoduck can claim; lands only after 199 and rebases onto it. |
|
||||
| 211 | Force-push guard: scripts/git-hooks/pre-push refuses non-fast-forward and delete pushes from every worktree, and scripts/slot-busy.sh exits 1 on BUSY (#1709, Vikunja #426, mos-1078/1083) | rocko; reviewers dewey | #1709 | briefed | — | Dewey approves the manifest; the new or changed scripts/test-*.sh and every other scripts/test-*.sh green on Sage's gate; the mutants killed; hook installed in the canonical checkout with a scripted refused force-push (sage) | `docs/plans/2026-10-20_force-push-guard.md` § Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426) | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+4263
-14
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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' })),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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)) };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -1009,19 +1009,6 @@ names no one; the restart itself needs Mos's go or Jason's hand. A vars or launc
|
||||
prints `no restart`, because the launcher reads it at its next launch check.
|
||||
The Console Settings page has the same read and write (`packages/webui`).
|
||||
|
||||
## credentials (row 199)
|
||||
|
||||
`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.
|
||||
|
||||
## Token usage
|
||||
|
||||
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
|
||||
|
||||
@@ -17,7 +17,6 @@ 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 { createChannelCaller } from "../../plugins/src/channel.mjs";
|
||||
import { PluginError } from "../../plugins/src/kinds.mjs";
|
||||
@@ -39,7 +38,6 @@ export const USAGE = `usage:
|
||||
${USAGE_USAGE}
|
||||
${LIMITS_USAGE}
|
||||
${PLUGINS_USAGE}
|
||||
${CREDENTIALS_USAGE}
|
||||
${INSTANCES_USAGE}
|
||||
${PROJECT_USAGE}
|
||||
${PROJECT_BUS_USAGE}
|
||||
@@ -578,8 +576,6 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 </);
|
||||
});
|
||||
@@ -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")));
|
||||
});
|
||||
@@ -34,7 +34,7 @@ export function setup(t, realPin=false){
|
||||
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{
|
||||
const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr);
|
||||
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
|
||||
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs'])assert.ok(existsSync(join(copy,f)),f);
|
||||
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/library/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs'])assert.ok(existsSync(join(copy,f)),f);
|
||||
const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755);
|
||||
const existing=s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]);assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm);
|
||||
git(s.repo,'update-ref','refs/remotes/origin/refactor',git(s.repo,'hash-object','-w','package.json'));
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# packages/library
|
||||
|
||||
Reads a writing repo by a mapping of folders to books, types, canon and playground. This is Case 2
|
||||
row C2-01 (`docs/plans/2026-10-20_case2-rows.md`), built from design sections 1.1 to 1.3 and 7.4 in
|
||||
`agents/gosalyn/work/case2/DESIGN.md` on `gosalyn/case2-design`.
|
||||
It is pure code with no state. Business file loading (C2-04), sync (C2-03a), the index (C2-05a) and the
|
||||
guard (C2-06) build on it.
|
||||
|
||||
## Mapping
|
||||
|
||||
`checkMapping(entry, where, { fail })` checks a `kind: "library"` project entry and returns a
|
||||
deep-frozen copy. `where` names the entry in messages. `fail(code, message)` defaults to throwing a
|
||||
`LibraryError`. C2-04 passes its own so a business file refusal carries the file name.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "library",
|
||||
"remote": "https://git.example.invalid/her/books.git",
|
||||
"branch": "main",
|
||||
"vaultPrefix": "books",
|
||||
"types": { "Characters": "character", "Places": "place", "Research": "research" },
|
||||
"manuscript": ["Chapters"],
|
||||
"universes": {
|
||||
"harbor": { "name": "Harbor", "books": ["Book One"], "playground": ["Book One/Playground/"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `remote` is https with no credentials, query or fragment. `branch` is a plain ref name.
|
||||
- `vaultPrefix` is optional, one folder name.
|
||||
- `types` maps a folder name to `character`, `place`, `research`, `tracker` or `summary`.
|
||||
`manuscript` lists folder names and shares none with `types`.
|
||||
- A universe has `name`, `books` and `playground`. A hybrid adds `kind: "hybrid"`, two or more
|
||||
non-hybrid `sources`, `constraints` (each one `{ "exclude": ... }` or
|
||||
`{ "pin": { "entity", "field", "value" } }`) and `notes`.
|
||||
|
||||
Refusals:
|
||||
|
||||
| Code | When |
|
||||
|---|---|
|
||||
| `library-playground-outside-book` | a playground prefix is not strictly inside a book of its own universe |
|
||||
| `library-mapping-invalid` | any other shape fault: unknown key, bad remote or branch, a book folder in two universes, a book folder that holds another, a playground prefix that holds another, an unknown hybrid source |
|
||||
|
||||
## Scope
|
||||
|
||||
`classify(mapping, path)` decides scope from the path alone. Only paths ending in lowercase `.md` are
|
||||
mapped. A path under a playground prefix is `playground`. Any other path under a book folder is
|
||||
`canon`. Everything else is `unmapped`. The type is the first folder after the book, or after the
|
||||
playground prefix: a `types` name, `manuscript`, or `null`.
|
||||
|
||||
## Reader
|
||||
|
||||
`readLibrary({ repo, commit, mapping })` reads one commit, never a working tree. `repo` may be a
|
||||
clone or a bare mirror. `commit` must be a full 40-hex id that names a commit, else `library-commit`.
|
||||
Git runs through `git()` and `gitBytes()` from `packages/project/src/git.mjs`, so the same safe config,
|
||||
environment and ceiling directory apply. A failed git call refuses `library-git`.
|
||||
|
||||
It returns `{ commit, files, problems }`, deep-frozen and sorted. Each file has its path, scope,
|
||||
universe, book, type, numbered lines, `bodyLine`, parsed `frontmatter` or `null`, `frontmatterRaw`
|
||||
and `links`. Front matter `appears_in` and `relationships` wikilinks count as links.
|
||||
|
||||
Problems carry `{ code, path, line, message }`:
|
||||
|
||||
| Rule | Meaning | File indexed |
|
||||
|---|---|---|
|
||||
| R1 | not a regular `100644` blob of at most 1 MiB, or not UTF-8, or holds a NUL | no |
|
||||
| R2 | path is unmapped | no |
|
||||
| R3 | front matter not fully parsed, `line` is where parsing stopped | yes, front matter kept raw |
|
||||
| R4 | link resolves to no file or to more than one | yes, link dropped |
|
||||
| R5 | canon file links into the playground | yes |
|
||||
| R6 | canon file links into another universe, a hybrid's sources excepted | yes |
|
||||
| R7 | two paths differ only in case | no, neither |
|
||||
|
||||
The front matter parser takes scalars, quoted strings, flow lists, block lists and wikilinks. Nested
|
||||
maps, anchors, block scalars and duplicate keys stop it with R3.
|
||||
|
||||
## Links
|
||||
|
||||
`resolver(paths, { vaultPrefix })` tries four steps in order, each with the target as written and
|
||||
then with `.md` added:
|
||||
|
||||
1. `root`: the path from the repo root.
|
||||
2. `vault-prefix`: the same path with `vaultPrefix/` stripped.
|
||||
3. `relative`: relative to the linking file, never above the repo root.
|
||||
4. `shortest`: a target with no `/` that matches the base name of exactly one indexed file.
|
||||
|
||||
It resolves only against indexed files. A link to a `.canvas` file or an attachment is R4. A link into
|
||||
another repo resolves to nothing. `extractLinks` skips fenced and inline code.
|
||||
|
||||
## renderItems
|
||||
|
||||
`renderItems(body, { scopeOf, changed })` turns a reply body into the plain text both channels show.
|
||||
It is pure. Item kinds are `fact`, `finding`, `correction` (with `from` and `to`), `question` and
|
||||
`idea`. Each cite is a path with commit, line range and quote, or a url with a quote.
|
||||
|
||||
```
|
||||
1. Correction: Chapter 1 says grey.
|
||||
From: "green eyes"
|
||||
To: "grey eyes"
|
||||
[canon] Book One/Characters/Mara Vell.md:9 @bbbbbbb "green eyes" (changed since)
|
||||
```
|
||||
|
||||
A body of `{ "items": [], "notice": ... }` gives `No cited answer`, `No answer` or `The turn failed`.
|
||||
Any other shape gives the fixed line `This reply could not be shown.` and none of the body's text.
|
||||
|
||||
## Survey
|
||||
|
||||
```sh
|
||||
mosaic library survey <repo> [--mapping <file>]
|
||||
```
|
||||
|
||||
It reads the repo's HEAD commit and prints folder names, file counts by extension, front matter key
|
||||
sets and link-form counts. It never prints file text, front matter values, file names or link targets.
|
||||
A key that is not a short identifier prints as `(other key)`, since it could be her words. With
|
||||
`--mapping` it adds scope counts, resolver step counts and problem counts by rule.
|
||||
|
||||
Exit codes follow `packages/project`: 0 done, 2 the repo can't be read (`library-git`,
|
||||
`library-commit`), 3 a bad mapping, 4 usage. `scripts/mosaic` sends `library survey` here. Other
|
||||
library verbs will be bus verbs.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
node --test packages/library/tests/
|
||||
```
|
||||
|
||||
Fixtures are made-up repos built per test in a temp dir. A marker string stands in for her text, and
|
||||
the survey tests check that it never appears in output.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@mosaic/library",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"exports": "./src/index.mjs",
|
||||
"scripts": {
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// packages/library: her writing repo read by a mapping (Case 2, design 1 and 7.4). See README.md.
|
||||
export { LibraryError, RULES, problem, sortProblems } from './problems.mjs';
|
||||
export { checkMapping, TYPES } from './mapping.mjs';
|
||||
export { classify } from './scope.mjs';
|
||||
export { extractLinks, parseWikilink, resolver, formOf } from './links.mjs';
|
||||
export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headCommit, MAX_BLOB, LINK_FIELDS } from './reader.mjs';
|
||||
export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
|
||||
export { survey, formatSurvey, surveyMain } from './survey.mjs';
|
||||
@@ -0,0 +1,79 @@
|
||||
// Wikilinks and the link resolver of design 1.3. Links are [[target]], [[target|label]] and
|
||||
// [[target#heading]], with ![[...]] embeds read the same way. Links inside fenced code blocks and
|
||||
// inline code spans are not links, as in Obsidian.
|
||||
import { posix } from 'node:path';
|
||||
|
||||
const WIKILINK = /(!?)\[\[([^\[\]\n]+?)\]\]/g;
|
||||
const MARKDOWN_LINK = /(?<!!)\[[^\[\]\n]*\]\(([^()\s]+)\)/g;
|
||||
const FENCE = /^\s{0,3}(`{3,}|~{3,})/;
|
||||
|
||||
// One wikilink's parts, or null for text that isn't one.
|
||||
export function parseWikilink(text) {
|
||||
const match = /^(!?)\[\[([^\[\]\n]+?)\]\]$/.exec(text);
|
||||
return match ? parts(match[2], match[1] === '!') : null;
|
||||
}
|
||||
function parts(inner, embed) {
|
||||
const bar = inner.indexOf('|');
|
||||
const target = bar < 0 ? inner : inner.slice(0, bar);
|
||||
const hash = target.indexOf('#');
|
||||
return {
|
||||
file: (hash < 0 ? target : target.slice(0, hash)).trim(),
|
||||
heading: hash < 0 ? null : target.slice(hash + 1).trim(),
|
||||
label: bar < 0 ? null : inner.slice(bar + 1),
|
||||
embed,
|
||||
};
|
||||
}
|
||||
// The form a link is counted under in the survey.
|
||||
export const formOf = link => link.markdown ? 'markdown' : link.embed ? 'embed' : !link.file ? 'self'
|
||||
: link.label !== null ? 'label' : link.heading !== null ? 'heading' : 'plain';
|
||||
|
||||
// Links in body lines. `lines` is [[number, text], ...]. Markdown links are returned only with
|
||||
// `markdown: true`, for the survey's counts. The resolver follows wikilinks only.
|
||||
export function extractLinks(lines, { markdown = false } = {}) {
|
||||
const found = [];
|
||||
let fence = null;
|
||||
for (const [line, text] of lines) {
|
||||
const open = FENCE.exec(text);
|
||||
if (fence) { if (open && open[1][0] === fence[0] && open[1].length >= fence.length) fence = null; continue; }
|
||||
if (open) { fence = open[1]; continue; }
|
||||
const plain = text.replace(/(`+)[^`]*?\1/g, match => ' '.repeat(match.length));
|
||||
for (const m of plain.matchAll(WIKILINK)) found.push({ ...parts(m[2], m[1] === '!'), line });
|
||||
if (markdown) {
|
||||
for (const m of plain.matchAll(MARKDOWN_LINK)) {
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(m[1]) && !m[1].startsWith('#'))
|
||||
found.push({ file: m[1], heading: null, label: null, embed: false, markdown: true, line });
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// A resolver over the indexed paths. resolve(from, file) tries, in order: a path from the repo
|
||||
// root, the same path with `vaultPrefix/` stripped, a path relative to the linking file, and
|
||||
// Obsidian's shortest-path form, a base name that matches exactly one indexed file. Each step tries
|
||||
// the target as written and then with `.md` added. A target in another of her repos is not in the
|
||||
// index, so it resolves to nothing.
|
||||
export function resolver(paths, { vaultPrefix } = {}) {
|
||||
const known = new Set(paths), byBase = new Map();
|
||||
for (const path of known) {
|
||||
const base = posix.basename(path);
|
||||
for (const key of new Set([base, base.replace(/\.md$/, '')])) byBase.set(key, [...(byBase.get(key) ?? []), path]);
|
||||
}
|
||||
const hit = candidate => [candidate, `${candidate}.md`].find(c => known.has(c));
|
||||
return function resolve(from, file) {
|
||||
if (typeof file !== 'string' || !file) return { path: null, step: 'missing' };
|
||||
let path = hit(file);
|
||||
if (path) return { path, step: 'root' };
|
||||
if (vaultPrefix && file.startsWith(`${vaultPrefix}/`) && (path = hit(file.slice(vaultPrefix.length + 1))))
|
||||
return { path, step: 'vault-prefix' };
|
||||
const relative = posix.normalize(posix.join(posix.dirname(from), file));
|
||||
if (relative !== '..' && !relative.startsWith('../') && !relative.startsWith('/') && (path = hit(relative)))
|
||||
return { path, step: 'relative' };
|
||||
if (!file.includes('/')) {
|
||||
const matches = byBase.get(file) ?? [];
|
||||
if (matches.length === 1) return { path: matches[0], step: 'shortest' };
|
||||
if (matches.length > 1) return { path: null, step: 'ambiguous', count: matches.length };
|
||||
}
|
||||
return { path: null, step: 'missing' };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// The library project entry of a business file (design 1.1): her folders mapped onto universes,
|
||||
// books, type folders, manuscript folders and playground prefixes. A pure validator. Business file
|
||||
// loading wires it in C2-04, which passes its own `fail` so the refusal reads as a business file
|
||||
// problem. Agents can't write this entry (M9, C2-04).
|
||||
import { raise } from './problems.mjs';
|
||||
|
||||
export const TYPES = Object.freeze(['character', 'place', 'research', 'tracker', 'summary']);
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
|
||||
const CONSTRAINT_KEYS = ['exclude', 'pin'];
|
||||
|
||||
// A folder segment from her vault: spaces and capitals are fine, separators, control characters
|
||||
// and dot names are not.
|
||||
function segment(value) {
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= 255 && value !== '.' && value !== '..'
|
||||
&& !/[\/\\\u0000-\u001f\u007f]/.test(value) && value.trim() === value;
|
||||
}
|
||||
function folder(value) {
|
||||
return typeof value === 'string' && value.length <= 1024 && value.split('/').every(segment);
|
||||
}
|
||||
const holds = (outer, inner) => inner.startsWith(outer.endsWith('/') ? outer : `${outer}/`);
|
||||
|
||||
export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
const bad = message => fail('library-mapping-invalid', `${where} ${message}`);
|
||||
const object = (value, name) => {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) bad(`${name} must be an object`);
|
||||
};
|
||||
const only = (value, keys, name) => {
|
||||
for (const key of Object.keys(value)) if (!keys.includes(key)) bad(`${name} has unknown key ${JSON.stringify(key)}`);
|
||||
};
|
||||
const list = (value, name, check, what) => {
|
||||
if (!Array.isArray(value)) bad(`${name} must be a list`);
|
||||
for (const item of value) if (!check(item)) bad(`${name} holds ${JSON.stringify(item)}, which is not ${what}`);
|
||||
if (new Set(value).size !== value.length) bad(`${name} names an entry twice`);
|
||||
return [...value];
|
||||
};
|
||||
|
||||
object(entry, '');
|
||||
only(entry, ['kind', 'remote', 'branch', 'vaultPrefix', 'universes', 'types', 'manuscript'], 'entry');
|
||||
if (entry.kind !== 'library') bad('kind must be "library"');
|
||||
let url;
|
||||
try { url = new URL(entry.remote); } catch { bad('remote must be an https:// URL'); }
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || entry.remote.includes('\0'))
|
||||
bad('remote must be an https:// URL with no credentials, query or fragment');
|
||||
if (typeof entry.branch !== 'string' || !BRANCH.test(entry.branch) || entry.branch.includes('..')
|
||||
|| entry.branch.includes('//') || entry.branch.endsWith('/') || entry.branch.endsWith('.lock'))
|
||||
bad('branch must be a plain branch name');
|
||||
if (Object.hasOwn(entry, 'vaultPrefix') && !segment(entry.vaultPrefix)) bad('vaultPrefix must be one folder name');
|
||||
|
||||
object(entry.types, 'types');
|
||||
for (const [name, type] of Object.entries(entry.types)) {
|
||||
if (!segment(name)) bad(`types has ${JSON.stringify(name)}, which is not one folder name`);
|
||||
if (!TYPES.includes(type)) bad(`types.${name} must be one of ${TYPES.join(', ')}`);
|
||||
}
|
||||
const manuscript = list(entry.manuscript, 'manuscript', segment, 'one folder name');
|
||||
for (const name of manuscript) if (Object.hasOwn(entry.types, name)) bad(`manuscript folder ${JSON.stringify(name)} is also a type folder`);
|
||||
|
||||
object(entry.universes, 'universes');
|
||||
if (!Object.keys(entry.universes).length) bad('universes must name at least one universe');
|
||||
const universes = {}, bookOwner = new Map(), prefixes = [];
|
||||
for (const [id, universe] of Object.entries(entry.universes)) {
|
||||
const name = `universes.${id}`;
|
||||
if (!ID.test(id)) bad(`universe id ${JSON.stringify(id)} is not a plain id`);
|
||||
object(universe, name);
|
||||
const hybrid = universe.kind === 'hybrid';
|
||||
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'constraints', 'notes'] : ['name', 'books', 'playground'], name);
|
||||
if (typeof universe.name !== 'string' || !universe.name.trim() || universe.name.length > 128 || /[\u0000-\u001f]/.test(universe.name))
|
||||
bad(`${name}.name must be a short display name`);
|
||||
const books = list(universe.books, `${name}.books`, folder, 'a folder path');
|
||||
if (!hybrid && !books.length) bad(`${name}.books must name at least one book folder`);
|
||||
for (const book of books) {
|
||||
if (bookOwner.has(book)) bad(`book folder ${JSON.stringify(book)} is in two universes`);
|
||||
bookOwner.set(book, id);
|
||||
}
|
||||
const playground = list(universe.playground ?? [], `${name}.playground`,
|
||||
value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1)), 'a folder path ending in /');
|
||||
for (const prefix of playground) {
|
||||
// Every playground file must have a universe and a book (1.1, B1).
|
||||
if (!books.some(book => holds(book, prefix) && prefix !== `${book}/`))
|
||||
fail('library-playground-outside-book', `${where} ${name}.playground ${JSON.stringify(prefix)} is not inside one of its books`);
|
||||
prefixes.push(prefix);
|
||||
}
|
||||
const out = { name: universe.name, books, playground };
|
||||
if (hybrid) {
|
||||
out.kind = 'hybrid';
|
||||
out.sources = list(universe.sources, `${name}.sources`, value => typeof value === 'string' && ID.test(value), 'a universe id');
|
||||
if (out.sources.length < 2) bad(`${name}.sources must name two or more universes`);
|
||||
if (!Array.isArray(universe.constraints ?? [])) bad(`${name}.constraints must be a list`);
|
||||
out.constraints = (universe.constraints ?? []).map((constraint, i) => {
|
||||
const at = `${name}.constraints[${i}]`;
|
||||
object(constraint, at);
|
||||
only(constraint, CONSTRAINT_KEYS, at);
|
||||
if (Object.keys(constraint).length !== 1) bad(`${at} must hold exactly one of ${CONSTRAINT_KEYS.join(', ')}`);
|
||||
if (Object.hasOwn(constraint, 'exclude')) {
|
||||
if (!folder(constraint.exclude)) bad(`${at}.exclude must be a source path`);
|
||||
return { exclude: constraint.exclude };
|
||||
}
|
||||
object(constraint.pin, `${at}.pin`);
|
||||
only(constraint.pin, ['entity', 'field', 'value'], `${at}.pin`);
|
||||
if (!folder(constraint.pin.entity) || typeof constraint.pin.field !== 'string' || !constraint.pin.field
|
||||
|| !['string', 'number', 'boolean'].includes(typeof constraint.pin.value))
|
||||
bad(`${at}.pin needs an entity path, a field name and a scalar value`);
|
||||
return { pin: { ...constraint.pin } };
|
||||
});
|
||||
if (Object.hasOwn(universe, 'notes') && (typeof universe.notes !== 'string' || universe.notes.length > 4000))
|
||||
bad(`${name}.notes must be text of at most 4000 characters`);
|
||||
if (Object.hasOwn(universe, 'notes')) out.notes = universe.notes;
|
||||
}
|
||||
universes[id] = out;
|
||||
}
|
||||
for (const [id, universe] of Object.entries(universes)) {
|
||||
for (const source of universe.sources ?? []) {
|
||||
if (!Object.hasOwn(universes, source) || universes[source].kind === 'hybrid')
|
||||
bad(`universes.${id}.sources names ${JSON.stringify(source)}, which is not a non-hybrid universe`);
|
||||
}
|
||||
}
|
||||
const books = [...bookOwner.keys()];
|
||||
for (const a of books) for (const b of books) if (a !== b && holds(a, b)) bad(`book folder ${JSON.stringify(a)} holds book folder ${JSON.stringify(b)}`);
|
||||
for (const a of prefixes) for (const b of prefixes) if (a !== b && holds(a, b)) bad(`playground ${JSON.stringify(a)} holds playground ${JSON.stringify(b)}`);
|
||||
if (new Set(prefixes).size !== prefixes.length) bad('a playground prefix is listed twice');
|
||||
|
||||
return deepFreeze({
|
||||
kind: 'library', remote: entry.remote, branch: entry.branch,
|
||||
...(Object.hasOwn(entry, 'vaultPrefix') ? { vaultPrefix: entry.vaultPrefix } : {}),
|
||||
universes, types: { ...entry.types }, manuscript,
|
||||
});
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (value && typeof value === 'object') { for (const item of Object.values(value)) deepFreeze(item); Object.freeze(value); }
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Errors and the R-rule problems of design 1.3. A problem never holds back a commit: it names a
|
||||
// path and, where there is one, a line, and the index leaves the file or link out.
|
||||
export class LibraryError extends Error {
|
||||
constructor(code, message) { super(`${code}: ${message}`); this.code = code; }
|
||||
}
|
||||
export const raise = (code, message) => { throw new LibraryError(code, message); };
|
||||
|
||||
export const RULES = Object.freeze({
|
||||
R1: 'not a regular blob up to 1 MiB of UTF-8 text',
|
||||
R2: 'path is unmapped',
|
||||
R3: 'front matter not fully parsed',
|
||||
R4: 'link resolves to no file or to more than one',
|
||||
R5: 'canon file refers into the playground',
|
||||
R6: 'canon file refers into another universe',
|
||||
R7: 'paths differ only in case',
|
||||
});
|
||||
|
||||
export function problem(code, path, line, message = RULES[code]) {
|
||||
if (!Object.hasOwn(RULES, code)) raise('library-problem-code', `unknown problem code ${code}`);
|
||||
return Object.freeze({ code, path, ...(Number.isSafeInteger(line) ? { line } : {}), message });
|
||||
}
|
||||
|
||||
// Stable order for status output and tests: path, then line, then code.
|
||||
export function sortProblems(list) {
|
||||
return [...list].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1
|
||||
: (a.line ?? 0) - (b.line ?? 0) || (a.code < b.code ? -1 : a.code > b.code ? 1 : 0));
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// The commit reader of design 1.3. It reads a commit of her repo, never a working tree: it lists the
|
||||
// tree with `git ls-tree -r -z` and reads blobs with `cat-file`, through the same bounded git()
|
||||
// and gitBytes() as packages/project, so hooks, protocols and the environment are fixed the same
|
||||
// way. Nothing in the repo is checked out or run (D7). Each R-rule is a problem, never a refusal
|
||||
// of the commit; the affected file or link is left out of the index.
|
||||
import { git, gitBytes } from '../../project/src/git.mjs';
|
||||
import { classify } from './scope.mjs';
|
||||
import { extractLinks, parseWikilink, resolver } from './links.mjs';
|
||||
import { LibraryError, problem, raise, sortProblems } from './problems.mjs';
|
||||
|
||||
export const MAX_BLOB = 1024 * 1024;
|
||||
const MAX_LISTING = 32 * 1024 * 1024;
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
const ENTRY = /^([0-7]{6}) (blob|tree|commit) ([0-9a-f]{40}) +(-|\d+)\t(.+)$/s;
|
||||
export const LINK_FIELDS = Object.freeze(['appears_in', 'relationships']);
|
||||
const READERS = 8;
|
||||
|
||||
async function local(promise, message) {
|
||||
try { return await promise; } catch (e) { raise('library-git', `${message} (${e.code ?? 'error'})`); }
|
||||
}
|
||||
|
||||
export async function headCommit(repo) {
|
||||
return local(git(repo, ['rev-parse', '--verify', '--end-of-options', 'HEAD^{commit}']), 'repository has no readable HEAD commit');
|
||||
}
|
||||
|
||||
// Every entry of the commit's tree: { mode, type, oid, size, path }. Sizes come from `-l`, so no
|
||||
// blob is opened to list it.
|
||||
export async function listTree(repo, commit) {
|
||||
if (typeof commit !== 'string' || !SHA.test(commit)) raise('library-commit', 'expected a full commit id');
|
||||
const kind = await local(git(repo, ['cat-file', '-t', commit]), 'commit is not in the repository');
|
||||
if (kind !== 'commit') raise('library-commit', 'not a commit');
|
||||
const bytes = await local(gitBytes(repo, ['ls-tree', '-r', '-z', '-l', '--full-tree', commit], MAX_LISTING), 'tree listing refused');
|
||||
let text;
|
||||
try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
|
||||
catch { raise('library-git', 'tree listing is not UTF-8'); }
|
||||
const entries = text.split('\0');
|
||||
if (entries.pop() !== '') raise('library-git', 'invalid tree listing');
|
||||
return entries.map(entry => {
|
||||
const m = ENTRY.exec(entry);
|
||||
if (!m) raise('library-git', 'invalid tree listing');
|
||||
return { mode: m[1], type: m[2], oid: m[3], size: m[4] === '-' ? null : Number(m[4]), path: m[5] };
|
||||
});
|
||||
}
|
||||
|
||||
// R1: only a 100644 blob up to 1 MiB of UTF-8 text without NUL is read.
|
||||
export const readable = entry => entry.mode === '100644' && entry.type === 'blob'
|
||||
&& Number.isSafeInteger(entry.size) && entry.size <= MAX_BLOB;
|
||||
|
||||
// Text of each readable entry, by path. An entry whose bytes aren't UTF-8 text is missing from the
|
||||
// map, and the caller reports it under R1.
|
||||
export async function readTexts(repo, entries) {
|
||||
const texts = new Map(), queue = [...entries];
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false });
|
||||
const worker = async () => {
|
||||
for (let entry = queue.shift(); entry; entry = queue.shift()) {
|
||||
const bytes = await local(gitBytes(repo, ['cat-file', 'blob', entry.oid], MAX_BLOB), 'blob read refused');
|
||||
let value;
|
||||
try { value = decoder.decode(bytes); } catch { continue; }
|
||||
if (!value.includes('\0')) texts.set(entry.path, value);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: READERS }, worker));
|
||||
return texts;
|
||||
}
|
||||
|
||||
const QUOTED = /^"(?:[^"\\]|\\.)*"$|^'(?:[^']|'')*'$/;
|
||||
const PLAIN_START = /^[^\s\[\]{}&*!|>%@`"'#,]/;
|
||||
function scalar(raw) {
|
||||
const value = raw.trim();
|
||||
if (parseWikilink(value)) return { ok: true, value };
|
||||
if (QUOTED.test(value)) {
|
||||
if (value[0] === "'") return { ok: true, value: value.slice(1, -1).replace(/''/g, "'") };
|
||||
try { return { ok: true, value: JSON.parse(value) }; } catch { return { ok: false }; }
|
||||
}
|
||||
if (value === '' || value === '~' || value === 'null') return { ok: true, value: null };
|
||||
if (!PLAIN_START.test(value) || /: |\s#/.test(value) || value.endsWith(':')) return { ok: false };
|
||||
if (value === 'true' || value === 'false') return { ok: true, value: value === 'true' };
|
||||
if (/^-?(0|[1-9]\d*)(\.\d+)?$/.test(value)) return { ok: true, value: Number(value) };
|
||||
return { ok: true, value };
|
||||
}
|
||||
function flow(raw) {
|
||||
const inner = raw.trim().slice(1, -1), items = [];
|
||||
let current = '', quote = null;
|
||||
for (const ch of inner) {
|
||||
if (quote) { current += ch; if (ch === quote) quote = null; continue; }
|
||||
if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
|
||||
if (ch === '[' || ch === ']' || ch === '{' || ch === '}') return { ok: false };
|
||||
if (ch === ',') { items.push(current); current = ''; continue; }
|
||||
current += ch;
|
||||
}
|
||||
if (quote) return { ok: false };
|
||||
if (current.trim() || items.length) items.push(current);
|
||||
const values = [];
|
||||
for (const item of items) { const s = scalar(item); if (!s.ok || !item.trim()) return { ok: false }; values.push(s.value); }
|
||||
return { ok: true, value: values };
|
||||
}
|
||||
function value(raw) {
|
||||
const text = raw.trim();
|
||||
if (text.startsWith('[') && !parseWikilink(text)) return text.endsWith(']') ? flow(text) : { ok: false };
|
||||
return scalar(text);
|
||||
}
|
||||
|
||||
// Front matter of design 1.3 (R3): scalars, quoted strings, flow lists and block lists of scalars or
|
||||
// wikilinks. Anything else, a nested map, a block scalar or an anchor for example, leaves the whole
|
||||
// front matter as raw text with one R3 problem at the line where parsing stopped.
|
||||
// Returns { frontmatter, raw, bodyLine, stop } with 1-based line numbers.
|
||||
export function parseFrontMatter(lines) {
|
||||
if (lines[0]?.replace(/^/, '').trimEnd() !== '---') return { frontmatter: null, raw: null, bodyLine: 1, stop: null };
|
||||
const close = lines.findIndex((text, i) => i > 0 && /^(---|\.\.\.)\s*$/.test(text));
|
||||
if (close < 0) return { frontmatter: null, raw: null, bodyLine: 1, stop: 1 };
|
||||
const raw = lines.slice(1, close).join('\n'), data = {};
|
||||
let list = null;
|
||||
for (let i = 1; i < close; i++) {
|
||||
const text = lines[i], line = i + 1;
|
||||
if (!text.trim() || /^\s*#/.test(text)) continue;
|
||||
const item = /^(\s*)- (.*)$|^(\s*)-$/.exec(text);
|
||||
if (item && list) {
|
||||
const parsed = scalar(item[2] ?? '');
|
||||
if (!parsed.ok) return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
|
||||
data[list].push(parsed.value);
|
||||
continue;
|
||||
}
|
||||
const pair = /^([^\s:#\-][^:]*?|"[^"]+"|'[^']+')\s*:(?:\s+(.*))?$/.exec(text);
|
||||
if (!pair) return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
|
||||
const key = pair[1].replace(/^(["'])(.*)\1$/, '$2');
|
||||
if (Object.hasOwn(data, key) || key === '__proto__') return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
|
||||
if (pair[2] === undefined || !pair[2].trim()) {
|
||||
const next = lines[i + 1];
|
||||
if (i + 1 < close && /^\s*-( |$)/.test(next)) { data[key] = []; list = key; }
|
||||
else { data[key] = null; list = null; }
|
||||
continue;
|
||||
}
|
||||
list = null;
|
||||
const parsed = value(pair[2]);
|
||||
if (!parsed.ok) return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
|
||||
data[key] = parsed.value;
|
||||
}
|
||||
return { frontmatter: data, raw: null, bodyLine: close + 2, stop: null };
|
||||
}
|
||||
|
||||
export const splitLines = text => text.replace(/^/, '').split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
|
||||
|
||||
// One parsed file: its lines (all of them, numbered from 1), front matter and the links it holds.
|
||||
// Front matter links come from LINK_FIELDS values that are wikilinks; other values are names.
|
||||
export function parseFile(text, { markdown = false } = {}) {
|
||||
const lines = splitLines(text);
|
||||
const fm = parseFrontMatter(lines);
|
||||
const numbered = lines.map((t, i) => [i + 1, t]);
|
||||
const links = extractLinks(numbered.slice(fm.bodyLine - 1), { markdown });
|
||||
if (fm.frontmatter) {
|
||||
for (const field of LINK_FIELDS) {
|
||||
const raw = fm.frontmatter[field];
|
||||
const values = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
|
||||
for (const v of values) {
|
||||
const link = typeof v === 'string' ? parseWikilink(v.trim()) : null;
|
||||
if (link) links.push({ ...link, field, line: frontMatterLine(lines, fm.bodyLine, field) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { lines: numbered, bodyLine: fm.bodyLine, frontmatter: fm.frontmatter, frontmatterRaw: fm.raw, stop: fm.stop, links };
|
||||
}
|
||||
function frontMatterLine(lines, bodyLine, field) {
|
||||
const i = lines.slice(0, bodyLine - 1).findIndex(t => new RegExp(`^["']?${field}["']?\\s*:`).test(t));
|
||||
return i < 0 ? undefined : i + 1;
|
||||
}
|
||||
|
||||
// Read a commit under a checked mapping (mapping.mjs). Returns the indexed files and every
|
||||
// R1-R7 problem: { commit, files: [...], problems: [...] }, frozen, sorted by path.
|
||||
export async function readLibrary({ repo, commit, mapping }) {
|
||||
if (!mapping || mapping.kind !== 'library') throw new LibraryError('library-mapping-invalid', 'expected a checked library mapping');
|
||||
const entries = await listTree(repo, commit);
|
||||
const problems = [], twins = new Set(), byLower = new Map();
|
||||
for (const e of entries) byLower.set(e.path.toLowerCase(), [...(byLower.get(e.path.toLowerCase()) ?? []), e.path]);
|
||||
for (const group of byLower.values()) if (group.length > 1) for (const path of group) twins.add(path);
|
||||
|
||||
const candidates = [];
|
||||
for (const entry of entries) {
|
||||
const where = classify(mapping, entry.path);
|
||||
if (where.scope === 'unmapped') { problems.push(problem('R2', entry.path)); continue; }
|
||||
if (twins.has(entry.path)) { problems.push(problem('R7', entry.path)); continue; }
|
||||
if (!readable(entry)) { problems.push(problem('R1', entry.path)); continue; }
|
||||
candidates.push({ entry, where });
|
||||
}
|
||||
const texts = await readTexts(repo, candidates.map(c => c.entry));
|
||||
const files = [];
|
||||
for (const { entry, where } of candidates) {
|
||||
if (!texts.has(entry.path)) { problems.push(problem('R1', entry.path)); continue; }
|
||||
const parsed = parseFile(texts.get(entry.path));
|
||||
if (parsed.stop !== null) problems.push(problem('R3', entry.path, parsed.stop));
|
||||
files.push({ path: entry.path, ...where, name: entry.path.split('/').pop().replace(/\.md$/, ''), ...parsed });
|
||||
}
|
||||
|
||||
const resolve = resolver(files.map(f => f.path), { vaultPrefix: mapping.vaultPrefix });
|
||||
const byPath = new Map(files.map(f => [f.path, f]));
|
||||
for (const file of files) {
|
||||
const kept = [];
|
||||
for (const link of file.links) {
|
||||
if (!link.file) continue;
|
||||
const target = resolve(file.path, link.file);
|
||||
if (!target.path) { problems.push(problem('R4', file.path, link.line)); continue; }
|
||||
const to = byPath.get(target.path);
|
||||
if (file.scope === 'canon') {
|
||||
if (to.scope === 'playground') problems.push(problem('R5', file.path, link.line));
|
||||
const sources = mapping.universes[file.universe].sources ?? [];
|
||||
if (to.universe !== file.universe && !sources.includes(to.universe)) problems.push(problem('R6', file.path, link.line));
|
||||
}
|
||||
kept.push({ ...link, path: target.path, step: target.step });
|
||||
}
|
||||
file.links = kept;
|
||||
delete file.stop;
|
||||
}
|
||||
files.sort((a, b) => a.path < b.path ? -1 : 1);
|
||||
return deepFreeze({ commit, files, problems: sortProblems(problems) });
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.freeze(value); for (const v of Object.values(value)) deepFreeze(v); }
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// renderItems(body): how a stored cited reply looks (design 7.4), shared by the Console chat (C2-10b)
|
||||
// and the Discord relay (C2-18). Pure: no git, no clock. The caller passes what it knows about cites.
|
||||
// The guard (C2-06) checks limits before a reply is stored. This only refuses to show a body that
|
||||
// doesn't have the reply shape, and then it shows a fixed line, never the body's text.
|
||||
export const KINDS = Object.freeze({ fact: 'Fact', finding: 'Finding', correction: 'Correction', question: 'Question', idea: 'Idea' });
|
||||
export const NOTICES = Object.freeze({ 'no-cited-answer': 'No cited answer', 'no-answer': 'No answer', 'turn-failed': 'The turn failed' });
|
||||
export const UNREADABLE = 'This reply could not be shown.';
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
|
||||
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const line = n => Number.isSafeInteger(n) && n > 0;
|
||||
function cite(value, { scopeOf, changed }) {
|
||||
if (!plain(value) || typeof value.quote !== 'string') return null;
|
||||
if (Object.hasOwn(value, 'url')) {
|
||||
if (typeof value.url !== 'string' || Object.hasOwn(value, 'path')) return null;
|
||||
return { badge: 'web', url: value.url, ref: value.url, quote: value.quote, changed: false };
|
||||
}
|
||||
const { path, commit, lines } = value;
|
||||
if (typeof path !== 'string' || !SHA.test(commit ?? '') || !Array.isArray(lines) || lines.length !== 2
|
||||
|| !line(lines[0]) || !line(lines[1]) || lines[1] < lines[0]) return null;
|
||||
const badge = scopeOf?.(path);
|
||||
const ref = `${path}:${lines[0] === lines[1] ? lines[0] : `${lines[0]}-${lines[1]}`} @${commit.slice(0, 7)}`;
|
||||
return { badge: badge === 'canon' || badge === 'playground' ? badge : null, path, commit, lines: [...lines], ref,
|
||||
quote: value.quote, changed: changed?.({ path, commit }) === true };
|
||||
}
|
||||
const quoted = text => JSON.stringify(text);
|
||||
|
||||
// body: the stored message body (a JSON string or the parsed object).
|
||||
// options.scopeOf(path) -> 'canon' | 'playground' | undefined, for the scope badge.
|
||||
// options.changed({path, commit}) -> true when the file's last commit is newer than the cited one.
|
||||
// Returns { kind: 'items', items, text } | { kind: 'notice', notice, text } | { kind: 'unreadable', text }.
|
||||
export function renderItems(body, options = {}) {
|
||||
const unreadable = { kind: 'unreadable', text: UNREADABLE };
|
||||
let value = body;
|
||||
if (typeof body === 'string') { try { value = JSON.parse(body); } catch { return unreadable; } }
|
||||
if (!plain(value) || !Array.isArray(value.items)) return unreadable;
|
||||
const keys = Object.keys(value);
|
||||
if (value.items.length === 0) {
|
||||
if (keys.length !== 2 || !Object.hasOwn(NOTICES, value.notice)) return unreadable;
|
||||
return { kind: 'notice', notice: value.notice, text: NOTICES[value.notice] };
|
||||
}
|
||||
if (keys.length !== 1) return unreadable;
|
||||
const items = [];
|
||||
for (const item of value.items) {
|
||||
if (!plain(item) || !Object.hasOwn(KINDS, item.kind) || typeof item.text !== 'string' || !Array.isArray(item.cites)) return unreadable;
|
||||
const correction = item.kind === 'correction';
|
||||
if (correction && (typeof item.from !== 'string' || typeof item.to !== 'string')) return unreadable;
|
||||
const cites = item.cites.map(c => cite(c, options));
|
||||
if (cites.some(c => c === null)) return unreadable;
|
||||
items.push({ kind: item.kind, label: KINDS[item.kind], text: item.text,
|
||||
...(correction ? { from: item.from, to: item.to } : {}), cites });
|
||||
}
|
||||
const text = items.map((item, i) => [
|
||||
`${i + 1}. ${item.label}: ${item.text}`,
|
||||
...(item.kind === 'correction' ? [` From: ${quoted(item.from)}`, ` To: ${quoted(item.to)}`] : []),
|
||||
...item.cites.map(c => ` ${c.badge ? `[${c.badge}] ` : ''}${c.ref} ${quoted(c.quote)}${c.changed ? ' (changed since)' : ''}`),
|
||||
].join('\n')).join('\n');
|
||||
return { kind: 'items', items, text };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// The scope rule of design 1.2. Scope comes from the path alone (D2): no file content can change it.
|
||||
// Paths are repo-relative and compared exactly, case included (R7 handles case-only twins).
|
||||
const inside = (folder, path) => path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
|
||||
|
||||
// { scope: 'playground' | 'canon', universe, book, type, folder } or { scope: 'unmapped' }.
|
||||
// `type` is the mapped type of the first folder under the book (canon) or under the playground
|
||||
// prefix (playground), 'manuscript' for a manuscript folder, else null. `folder` is that folder name.
|
||||
export function classify(mapping, path) {
|
||||
if (typeof path !== 'string' || !path.endsWith('.md')) return { scope: 'unmapped' };
|
||||
for (const [universe, entry] of Object.entries(mapping.universes)) {
|
||||
const book = entry.books.find(b => inside(b, path));
|
||||
if (!book) continue;
|
||||
const prefix = entry.playground.find(p => inside(p, path));
|
||||
const rest = path.slice((prefix ?? `${book}/`).length).split('/');
|
||||
const folder = rest.length > 1 ? rest[0] : null;
|
||||
const type = folder === null ? null : Object.hasOwn(mapping.types, folder) ? mapping.types[folder]
|
||||
: mapping.manuscript.includes(folder) ? 'manuscript' : null;
|
||||
return { scope: prefix ? 'playground' : 'canon', universe, book, type, folder };
|
||||
}
|
||||
return { scope: 'unmapped' };
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// `mosaic library survey <repo> [--mapping <file>]` (design 1.1, 10 step 7). It prints what a mapping
|
||||
// needs: folder names, file counts by extension, front matter key sets and link-form counts. It
|
||||
// never prints file text, front matter values, file names or link targets. With a mapping it adds
|
||||
// scope counts, resolver step counts and R-problem counts by code. It reads the repo's HEAD commit
|
||||
// through the commit reader, never the working tree.
|
||||
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { checkMapping } from './mapping.mjs';
|
||||
import { classify } from './scope.mjs';
|
||||
import { formOf } from './links.mjs';
|
||||
import { headCommit, listTree, parseFile, readable, readLibrary, readTexts } from './reader.mjs';
|
||||
import { LibraryError, RULES } from './problems.mjs';
|
||||
|
||||
export const USAGE = 'usage: mosaic library survey <repo> [--mapping <file>]';
|
||||
// Keys are printed only in the short identifier forms property names take. A key with spaces or of
|
||||
// any other shape could be a phrase of hers, so it is counted as one "(other key)".
|
||||
const KEY = /^[A-Za-z_][A-Za-z0-9_-]{0,31}$/;
|
||||
const OTHER_KEY = '(other key)';
|
||||
const FORMS = ['plain', 'label', 'heading', 'embed', 'self', 'markdown'];
|
||||
const STEPS = ['root', 'vault-prefix', 'relative', 'shortest'];
|
||||
const extension = path => /\.md$/.test(path) ? 'md' : /\.canvas$/.test(path) ? 'canvas' : /\.base$/.test(path) ? 'base' : 'other';
|
||||
|
||||
// The survey as data. `mapping` is a checked mapping or undefined.
|
||||
export async function survey({ repo, commit, mapping }) {
|
||||
const head = commit ?? await headCommit(repo);
|
||||
const entries = await listTree(repo, head);
|
||||
const folders = new Map();
|
||||
for (const entry of entries) {
|
||||
const slash = entry.path.lastIndexOf('/');
|
||||
const name = slash < 0 ? '' : entry.path.slice(0, slash);
|
||||
const counts = folders.get(name) ?? { md: 0, canvas: 0, base: 0, other: 0 };
|
||||
counts[entry.type === 'blob' ? extension(entry.path) : 'other']++;
|
||||
folders.set(name, counts);
|
||||
}
|
||||
const markdown = entries.filter(e => e.path.endsWith('.md'));
|
||||
const texts = await readTexts(repo, markdown.filter(readable));
|
||||
const keySets = new Map(), forms = Object.fromEntries(FORMS.map(f => [f, 0]));
|
||||
const fields = { appears_in: { wikilink: 0, name: 0 }, relationships: { wikilink: 0, name: 0 } };
|
||||
let noFrontMatter = 0, unparsed = 0;
|
||||
for (const text of texts.values()) {
|
||||
const file = parseFile(text, { markdown: true });
|
||||
if (file.stop !== null) unparsed++;
|
||||
else if (!file.frontmatter) noFrontMatter++;
|
||||
else {
|
||||
const set = [...new Set(Object.keys(file.frontmatter).map(k => KEY.test(k) ? k : OTHER_KEY))].sort().join(', ');
|
||||
keySets.set(set, (keySets.get(set) ?? 0) + 1);
|
||||
for (const field of Object.keys(fields)) {
|
||||
const raw = file.frontmatter[field];
|
||||
for (const v of Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw])
|
||||
fields[field][typeof v === 'string' && /^\s*!?\[\[[^\]]+\]\]\s*$/.test(v) ? 'wikilink' : 'name']++;
|
||||
}
|
||||
}
|
||||
for (const link of file.links) if (!link.field) forms[formOf(link)]++;
|
||||
}
|
||||
const result = {
|
||||
commit: head, entries: entries.length,
|
||||
folders: [...folders].sort(([a], [b]) => a < b ? -1 : 1).map(([name, counts]) => ({ name, ...counts })),
|
||||
markdown: { files: markdown.length, read: texts.size, noFrontMatter, unparsed },
|
||||
keySets: [...keySets].sort(([a, x], [b, y]) => y - x || (a < b ? -1 : 1)).map(([keys, count]) => ({ keys, count })),
|
||||
forms, fields,
|
||||
};
|
||||
if (mapping) {
|
||||
const scopes = { canon: 0, playground: 0, unmapped: 0 };
|
||||
for (const entry of entries) scopes[classify(mapping, entry.path).scope]++;
|
||||
const library = await readLibrary({ repo, commit: head, mapping });
|
||||
const steps = Object.fromEntries(STEPS.map(s => [s, 0]));
|
||||
for (const file of library.files) for (const link of file.links) steps[link.step]++;
|
||||
const problems = Object.fromEntries(Object.keys(RULES).map(code => [code, 0]));
|
||||
for (const p of library.problems) problems[p.code]++;
|
||||
steps.unresolved = problems.R4;
|
||||
Object.assign(result, { scopes, indexed: library.files.length, steps, problems });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Folder names are quoted as JSON strings, so no name can carry a control sequence to the terminal.
|
||||
export function formatSurvey(s) {
|
||||
const out = [`commit ${s.commit}`, `entries ${s.entries}`, 'folders (direct children: md canvas base other)'];
|
||||
for (const f of s.folders) out.push(` ${f.name === '' ? '(root)' : JSON.stringify(f.name)} ${f.md} ${f.canvas} ${f.base} ${f.other}`);
|
||||
out.push(`markdown ${s.markdown.files} files, ${s.markdown.read} read, ${s.markdown.noFrontMatter} without front matter, ${s.markdown.unparsed} with front matter not fully parsed`);
|
||||
out.push('front matter key sets');
|
||||
for (const k of s.keySets) out.push(` ${k.count} [${k.keys}]`);
|
||||
out.push(`body links ${FORMS.map(f => `${f} ${s.forms[f]}`).join(', ')}`);
|
||||
for (const [field, c] of Object.entries(s.fields)) out.push(`field ${field} wikilink ${c.wikilink}, name ${c.name}`);
|
||||
if (s.scopes) {
|
||||
out.push(`scopes canon ${s.scopes.canon}, playground ${s.scopes.playground}, unmapped ${s.scopes.unmapped}; indexed ${s.indexed}`);
|
||||
out.push(`resolved ${STEPS.map(st => `${st} ${s.steps[st]}`).join(', ')}, unresolved ${s.steps.unresolved}`);
|
||||
out.push(`problems ${Object.entries(s.problems).map(([code, n]) => `${code} ${n}`).join(', ')}`);
|
||||
}
|
||||
return `${out.join('\n')}\n`;
|
||||
}
|
||||
|
||||
// Exit codes as packages/project's CLI: 0 done, 2 the repo can't be read, 3 a bad mapping, 4 usage.
|
||||
export async function surveyMain(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
|
||||
const fail = (code, message) => { io.stderr.write(`library survey: ${message}\n`); return code; };
|
||||
const [verb, ...rest] = argv;
|
||||
if (verb !== 'survey') return fail(4, USAGE);
|
||||
let repo, mappingFile;
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
if (rest[i] === '--mapping' && i + 1 < rest.length && mappingFile === undefined) mappingFile = rest[++i];
|
||||
else if (!rest[i].startsWith('-') && repo === undefined) repo = rest[i];
|
||||
else return fail(4, USAGE);
|
||||
}
|
||||
if (repo === undefined) return fail(4, USAGE);
|
||||
let mapping;
|
||||
if (mappingFile !== undefined) {
|
||||
let entry;
|
||||
try { entry = JSON.parse(readFileSync(mappingFile, 'utf8')); }
|
||||
catch { return fail(3, `library-mapping-invalid: ${mappingFile} is not a readable JSON file`); }
|
||||
try { mapping = checkMapping(entry, mappingFile); }
|
||||
catch (e) { if (e instanceof LibraryError) return fail(3, e.message); throw e; }
|
||||
}
|
||||
const path = resolvePath(repo);
|
||||
try { if (!statSync(path).isDirectory()) return fail(2, `library-git: ${repo} is not a directory`); }
|
||||
catch { return fail(2, `library-git: ${repo} is not a directory`); }
|
||||
try { io.stdout.write(formatSurvey(await survey({ repo: path, mapping }))); }
|
||||
catch (e) { if (e instanceof LibraryError) return fail(2, e.message); throw e; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
const entry = () => { try { return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; } catch { return false; } };
|
||||
if (process.argv[1] && entry()) {
|
||||
const code = await surveyMain(process.argv.slice(2));
|
||||
if (code !== 0) process.exitCode = code;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"kind": "library",
|
||||
"remote": "https://git.example.invalid/writer/books.git",
|
||||
"branch": "main",
|
||||
"vaultPrefix": "books",
|
||||
"universes": {
|
||||
"harbor": {"name": "The Harbor", "books": ["Book One"], "playground": ["Book One/Playground/"]},
|
||||
"second": {"name": "Second World", "books": ["Second World"]}
|
||||
},
|
||||
"types": {"Characters": "character", "Places": "place", "Research": "research", "Tracker": "tracker", "Summaries": "summary"},
|
||||
"manuscript": ["Chapters"]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// A repo the test builds in her layout's shape: spaces and capitals in folder names, a .canvas file
|
||||
// and an unmapped top-level note. Bodies are made up; MARKER stands in for her text.
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, symlinkSync, chmodSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { checkMapping } from '../src/mapping.mjs';
|
||||
|
||||
export const MARKER = 'MARKER-7f3a-HER-TEXT';
|
||||
export const FIXTURES = fileURLToPath(new URL('./fixtures/', import.meta.url));
|
||||
export const mappingEntry = () => JSON.parse(readFileSync(join(FIXTURES, 'mapping.json'), 'utf8'));
|
||||
export const mapping = (change = e => e) => checkMapping(change(mappingEntry()));
|
||||
|
||||
export const git = (repo, ...args) => execFileSync('git', ['-c', 'user.name=fixture', '-c', '[email protected]',
|
||||
'-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim();
|
||||
|
||||
export const BASE = {
|
||||
'Book One/Characters/Mara Vell.md': [
|
||||
'---', 'type: character', 'name: Mara Vell', 'appears_in: "[[Book One/Chapters/Chapter 01]]"',
|
||||
'relationships:', ' - "[[Tomas]]"', ' - The Ferryman', '---', `Grey eyes. ${MARKER}`, ''].join('\n'),
|
||||
'Book One/Characters/Tomas.md': ['---', 'type: character', 'name: Tomas', 'appears_in: ["[[Chapter 01]]"]', '---', MARKER, ''].join('\n'),
|
||||
'Book One/Chapters/Chapter 01.md': [
|
||||
'# One', `${MARKER} [[Mara Vell]] met [[Book One/Places/Harbor Town|the harbor]].`,
|
||||
'See [[../Places/Harbor Town#Docks]] and [[books/Book One/Characters/Tomas]].',
|
||||
'```', '[[Not A Link]]', '```', 'Inline `[[Not Either]]` and [a note](Notes.md).', ''].join('\n'),
|
||||
'Book One/Places/Harbor Town.md': ['---', 'type: place', '---', `Salt. ${MARKER}`, ''].join('\n'),
|
||||
'Book One/Playground/What If.md': ['---', 'name: What If', '---', `${MARKER} [[Mara Vell]]`, ''].join('\n'),
|
||||
'Book One/Board.canvas': '{"nodes":[]}\n',
|
||||
'Book One/Playground/Sketch.canvas': '{"nodes":[]}\n',
|
||||
'Inbox Note.md': `${MARKER}\n`,
|
||||
'Second World/Chapters/01.md': `# Elsewhere ${MARKER}\n`,
|
||||
};
|
||||
|
||||
// repo({files, extra}) writes files (path -> text, or { link } / { mode, text }), commits, and
|
||||
// returns { repo, commit }.
|
||||
export function repo(t, files = BASE) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'library-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const dir = join(root, 'books');
|
||||
mkdirSync(dir);
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
for (const [path, value] of Object.entries(files)) {
|
||||
const file = join(dir, path);
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
if (typeof value === 'object' && value.link) symlinkSync(value.link, file);
|
||||
else if (typeof value === 'object' && value.bytes) writeFileSync(file, value.bytes);
|
||||
else writeFileSync(file, typeof value === 'object' ? value.text : value);
|
||||
if (typeof value === 'object' && value.mode) chmodSync(file, value.mode);
|
||||
}
|
||||
git(dir, 'add', '-A');
|
||||
git(dir, 'commit', '-qm', 'fixture');
|
||||
return { root, repo: dir, commit: git(dir, 'rev-parse', 'HEAD') };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { extractLinks, parseWikilink, resolver } from '../src/index.mjs';
|
||||
|
||||
const PATHS = ['Book One/Characters/Mara Vell.md', 'Book One/Characters/Tomas.md', 'Book One/Places/Harbor Town.md',
|
||||
'Book One/Chapters/Chapter 01.md', 'Book One/Places/Notes.md', 'Book One/Characters/Notes.md', 'Mara Vell.md'];
|
||||
const resolve = resolver(PATHS, { vaultPrefix: 'books' });
|
||||
const FROM = 'Book One/Chapters/Chapter 01.md';
|
||||
|
||||
test('resolver step 1: a path from the repo root, with or without .md', () => {
|
||||
assert.deepEqual(resolve(FROM, 'Book One/Places/Harbor Town'), { path: 'Book One/Places/Harbor Town.md', step: 'root' });
|
||||
assert.deepEqual(resolve(FROM, 'Book One/Places/Harbor Town.md'), { path: 'Book One/Places/Harbor Town.md', step: 'root' });
|
||||
// Root wins over shortest: Mara Vell.md at the root, not the character page.
|
||||
assert.deepEqual(resolve(FROM, 'Mara Vell'), { path: 'Mara Vell.md', step: 'root' });
|
||||
});
|
||||
|
||||
test('resolver step 2: the same path with vaultPrefix/ stripped', () => {
|
||||
assert.deepEqual(resolve(FROM, 'books/Book One/Characters/Tomas'), { path: 'Book One/Characters/Tomas.md', step: 'vault-prefix' });
|
||||
assert.deepEqual(resolver(PATHS)(FROM, 'books/Book One/Characters/Tomas'), { path: null, step: 'missing' });
|
||||
});
|
||||
|
||||
test('resolver step 3: a path relative to the linking file, never above the repo root', () => {
|
||||
assert.deepEqual(resolve(FROM, '../Places/Harbor Town'), { path: 'Book One/Places/Harbor Town.md', step: 'relative' });
|
||||
assert.deepEqual(resolve('Book One/Places/Harbor Town.md', './Notes'), { path: 'Book One/Places/Notes.md', step: 'relative' });
|
||||
assert.deepEqual(resolve(FROM, '../../../Book One/Places/Harbor Town'), { path: null, step: 'missing' });
|
||||
});
|
||||
|
||||
test('resolver step 4: a base name that matches exactly one indexed file', () => {
|
||||
assert.deepEqual(resolve(FROM, 'Tomas'), { path: 'Book One/Characters/Tomas.md', step: 'shortest' });
|
||||
assert.deepEqual(resolve(FROM, 'Harbor Town.md'), { path: 'Book One/Places/Harbor Town.md', step: 'shortest' });
|
||||
assert.deepEqual(resolve(FROM, 'Notes'), { path: null, step: 'ambiguous', count: 2 });
|
||||
assert.deepEqual(resolve(FROM, 'Nobody'), { path: null, step: 'missing' });
|
||||
// A partial path is not a base name.
|
||||
assert.deepEqual(resolve(FROM, 'Places/Harbor Town'), { path: null, step: 'missing' });
|
||||
});
|
||||
|
||||
test('a link into another of her repos resolves to nothing', () => {
|
||||
assert.deepEqual(resolve(FROM, 'bean-brain/Journal/Today'), { path: null, step: 'missing' });
|
||||
assert.deepEqual(resolve(FROM, '../bean-brain/Journal/Today'), { path: null, step: 'missing' });
|
||||
});
|
||||
|
||||
test('wikilink forms, embeds, and links skipped inside code', () => {
|
||||
assert.deepEqual(parseWikilink('[[A/B#Head|Label]]'), { file: 'A/B', heading: 'Head', label: 'Label', embed: false });
|
||||
assert.deepEqual(parseWikilink('![[pic.png]]'), { file: 'pic.png', heading: null, label: null, embed: true });
|
||||
assert.equal(parseWikilink('[[a]] and more'), null);
|
||||
const lines = [[1, '[[One]] [[Two|2]] [[Three#H]] ![[Four]] [[#Self]]'], [2, '```js'], [3, '[[Fenced]]'], [4, '```'],
|
||||
[5, 'Inline `[[Code]]` and [md](Five.md) and [web](https://example.invalid/x)']];
|
||||
assert.deepEqual(extractLinks(lines).map(l => [l.file, l.line]), [['One', 1], ['Two', 1], ['Three', 1], ['Four', 1], ['', 1]]);
|
||||
assert.deepEqual(extractLinks(lines, { markdown: true }).filter(l => l.markdown).map(l => l.file), ['Five.md']);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { checkMapping, classify, LibraryError } from '../src/index.mjs';
|
||||
import { mapping, mappingEntry } from './helpers.mjs';
|
||||
|
||||
const refuses = (change, code, pattern) => assert.throws(() => checkMapping(change(mappingEntry())),
|
||||
e => e instanceof LibraryError && e.code === code && (!pattern || pattern.test(e.message)));
|
||||
|
||||
test('the fixture mapping checks and comes back frozen', () => {
|
||||
const m = mapping();
|
||||
assert.equal(m.universes.harbor.playground[0], 'Book One/Playground/');
|
||||
assert.ok(Object.isFrozen(m.universes.harbor.books));
|
||||
assert.throws(() => { m.types.Extra = 'place'; });
|
||||
});
|
||||
|
||||
test('a playground prefix outside its universe books refuses library-playground-outside-book', () => {
|
||||
refuses(e => { e.universes.harbor.playground = ['Second World/Playground/']; return e; }, 'library-playground-outside-book');
|
||||
refuses(e => { e.universes.harbor.playground = ['Elsewhere/']; return e; }, 'library-playground-outside-book');
|
||||
// The whole book as its playground would leave no canon: not inside the book.
|
||||
refuses(e => { e.universes.harbor.playground = ['Book One/']; return e; }, 'library-playground-outside-book');
|
||||
});
|
||||
|
||||
test('no playground prefix holds another, and no book folder holds another', () => {
|
||||
refuses(e => { e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Playground/Deep/']; return e; },
|
||||
'library-mapping-invalid', /holds playground/);
|
||||
refuses(e => { e.universes.second.books = ['Second World', 'Book One/Part Two']; return e; }, 'library-mapping-invalid', /holds book folder/);
|
||||
refuses(e => { e.universes.second.books = ['Book One']; return e; }, 'library-mapping-invalid', /in two universes/);
|
||||
});
|
||||
|
||||
test('shape refusals: kind, remote, branch, unknown keys, types, manuscript, folder names', () => {
|
||||
refuses(e => ({ ...e, kind: 'code' }), 'library-mapping-invalid', /kind/);
|
||||
refuses(e => ({ ...e, remote: 'http://git.example.invalid/x.git' }), 'library-mapping-invalid', /https/);
|
||||
refuses(e => ({ ...e, remote: 'https://user:[email protected]/x.git' }), 'library-mapping-invalid', /credentials/);
|
||||
refuses(e => ({ ...e, remote: '[email protected]:x.git' }), 'library-mapping-invalid', /https/);
|
||||
refuses(e => ({ ...e, branch: 'main..x' }), 'library-mapping-invalid', /branch/);
|
||||
refuses(e => ({ ...e, root: '/tmp' }), 'library-mapping-invalid', /unknown key "root"/);
|
||||
refuses(e => { e.universes.harbor.extra = 1; return e; }, 'library-mapping-invalid', /unknown key/);
|
||||
refuses(e => { e.types.Characters = 'villain'; return e; }, 'library-mapping-invalid', /types.Characters/);
|
||||
refuses(e => { e.types['A/B'] = 'place'; return e; }, 'library-mapping-invalid', /one folder name/);
|
||||
refuses(e => ({ ...e, manuscript: ['Characters'] }), 'library-mapping-invalid', /also a type folder/);
|
||||
refuses(e => { e.universes.harbor.books = ['../Book One']; return e; }, 'library-mapping-invalid', /folder path/);
|
||||
refuses(e => ({ ...e, vaultPrefix: 'a/b' }), 'library-mapping-invalid', /vaultPrefix/);
|
||||
refuses(e => ({ ...e, universes: {} }), 'library-mapping-invalid', /at least one universe/);
|
||||
refuses(e => { e.universes['bad id'] = e.universes.second; delete e.universes.second; return e; }, 'library-mapping-invalid', /plain id/);
|
||||
});
|
||||
|
||||
test('a hybrid universe is a shape the query code can take (1.4), with sources that must exist', () => {
|
||||
const hybrid = e => { e.universes.cross = { kind: 'hybrid', name: 'Crossover', sources: ['harbor', 'second'], books: ['Crossover'],
|
||||
constraints: [{ exclude: 'second/Characters' }, { pin: { entity: 'Book One/Characters/Mara Vell.md', field: 'eyes', value: 'grey' } }],
|
||||
notes: 'Set after book one.' }; return e; };
|
||||
const m = checkMapping(hybrid(mappingEntry()));
|
||||
assert.deepEqual(m.universes.cross.sources, ['harbor', 'second']);
|
||||
refuses(e => { hybrid(e); e.universes.cross.sources = ['harbor', 'nowhere']; return e; }, 'library-mapping-invalid', /non-hybrid universe/);
|
||||
refuses(e => { hybrid(e); e.universes.cross.sources = ['harbor']; return e; }, 'library-mapping-invalid', /two or more/);
|
||||
refuses(e => { hybrid(e); e.universes.cross.constraints = [{ exclude: 'x', pin: {} }]; return e; }, 'library-mapping-invalid', /exactly one/);
|
||||
});
|
||||
|
||||
test('a caller-supplied fail receives the code, as business file loading will pass it (C2-04)', () => {
|
||||
const seen = [];
|
||||
const fail = (code, message) => { seen.push(code); throw new Error(message); };
|
||||
const entry = mappingEntry();
|
||||
entry.universes.harbor.playground = ['Elsewhere/'];
|
||||
assert.throws(() => checkMapping(entry, 'businesses/acme.json projects.books', { fail }), /businesses\/acme.json projects.books/);
|
||||
assert.deepEqual(seen, ['library-playground-outside-book']);
|
||||
});
|
||||
|
||||
test('scope comes from the path alone (design 1.2)', () => {
|
||||
const m = mapping();
|
||||
assert.deepEqual(classify(m, 'Book One/Characters/Mara Vell.md'),
|
||||
{ scope: 'canon', universe: 'harbor', book: 'Book One', type: 'character', folder: 'Characters' });
|
||||
assert.deepEqual(classify(m, 'Book One/Chapters/Chapter 01.md'),
|
||||
{ scope: 'canon', universe: 'harbor', book: 'Book One', type: 'manuscript', folder: 'Chapters' });
|
||||
assert.deepEqual(classify(m, 'Book One/Playground/What If.md'),
|
||||
{ scope: 'playground', universe: 'harbor', book: 'Book One', type: null, folder: null });
|
||||
assert.deepEqual(classify(m, 'Book One/Playground/Characters/New.md'),
|
||||
{ scope: 'playground', universe: 'harbor', book: 'Book One', type: 'character', folder: 'Characters' });
|
||||
assert.deepEqual(classify(m, 'Book One/Notes.md'), { scope: 'canon', universe: 'harbor', book: 'Book One', type: null, folder: null });
|
||||
assert.equal(classify(m, 'Second World/Chapters/01.md').universe, 'second');
|
||||
for (const path of ['Inbox Note.md', 'Book One/Board.canvas', 'Book One/Playground/Sketch.canvas', 'Book One/Board.base',
|
||||
'Book One/cover.png', 'Book One/Upper.MD', 'Book Onex/a.md', 'book one/a.md', 'Elsewhere/Book One/a.md'])
|
||||
assert.deepEqual(classify(m, path), { scope: 'unmapped' }, path);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { readLibrary, parseFrontMatter, LibraryError, MAX_BLOB } from '../src/index.mjs';
|
||||
import { BASE, git, mapping, repo } from './helpers.mjs';
|
||||
|
||||
const codes = (result, path) => result.problems.filter(p => p.path === path).map(p => p.code);
|
||||
const file = (result, path) => result.files.find(f => f.path === path);
|
||||
|
||||
test('the fixture reads clean: every resolver step, appears_in and relationships wikilinks indexed', async t => {
|
||||
const r = repo(t);
|
||||
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
|
||||
assert.equal(result.commit, r.commit);
|
||||
assert.deepEqual(result.files.map(f => [f.path, f.scope]), [
|
||||
['Book One/Chapters/Chapter 01.md', 'canon'], ['Book One/Characters/Mara Vell.md', 'canon'],
|
||||
['Book One/Characters/Tomas.md', 'canon'], ['Book One/Places/Harbor Town.md', 'canon'],
|
||||
['Book One/Playground/What If.md', 'playground'], ['Second World/Chapters/01.md', 'canon']]);
|
||||
const chapter = file(result, 'Book One/Chapters/Chapter 01.md');
|
||||
assert.deepEqual(chapter.links.map(l => [l.path, l.step, l.line]), [
|
||||
['Book One/Characters/Mara Vell.md', 'shortest', 2], ['Book One/Places/Harbor Town.md', 'root', 2],
|
||||
['Book One/Places/Harbor Town.md', 'relative', 3], ['Book One/Characters/Tomas.md', 'vault-prefix', 3]]);
|
||||
const mara = file(result, 'Book One/Characters/Mara Vell.md');
|
||||
assert.deepEqual(mara.frontmatter, { type: 'character', name: 'Mara Vell', appears_in: '[[Book One/Chapters/Chapter 01]]',
|
||||
relationships: ['[[Tomas]]', 'The Ferryman'] });
|
||||
assert.deepEqual(mara.links.map(l => [l.field, l.path, l.line]), [
|
||||
['appears_in', 'Book One/Chapters/Chapter 01.md', 4], ['relationships', 'Book One/Characters/Tomas.md', 5]]);
|
||||
assert.deepEqual(file(result, 'Book One/Characters/Tomas.md').links.map(l => [l.field, l.path]),
|
||||
[['appears_in', 'Book One/Chapters/Chapter 01.md']]);
|
||||
assert.equal(mara.bodyLine, 9);
|
||||
assert.equal(mara.lines[8][0], 9);
|
||||
assert.equal(mara.type, 'character');
|
||||
// Only the unmapped paths are problems: the top-level note and both .canvas files.
|
||||
assert.deepEqual(result.problems.map(p => [p.code, p.path]), [
|
||||
['R2', 'Book One/Board.canvas'], ['R2', 'Book One/Playground/Sketch.canvas'], ['R2', 'Inbox Note.md']]);
|
||||
assert.ok(Object.isFrozen(result.files[0].links));
|
||||
});
|
||||
|
||||
test('R1: executables, symlinks, gitlinks, blobs over 1 MiB and non-UTF-8 text are not indexed', async t => {
|
||||
const r = repo(t, { ...BASE,
|
||||
'Book One/Places/Run.md': { text: 'x\n', mode: 0o755 },
|
||||
'Book One/Places/Link.md': { link: 'Harbor Town.md' },
|
||||
'Book One/Places/Big.md': 'x'.repeat(MAX_BLOB + 1),
|
||||
'Book One/Places/Edge.md': 'y'.repeat(MAX_BLOB),
|
||||
'Book One/Places/Latin.md': { bytes: Buffer.from([0x63, 0x61, 0x66, 0xe9, 0x0a]) },
|
||||
'Book One/Places/Nul.md': 'a\0b\n' });
|
||||
git(r.repo, 'update-index', '--add', '--cacheinfo', `160000,${r.commit},Book One/Places/Sub.md`);
|
||||
git(r.repo, 'commit', '-qm', 'gitlink');
|
||||
const result = await readLibrary({ repo: r.repo, commit: git(r.repo, 'rev-parse', 'HEAD'), mapping: mapping() });
|
||||
for (const name of ['Run', 'Link', 'Big', 'Latin', 'Nul', 'Sub']) {
|
||||
assert.deepEqual(codes(result, `Book One/Places/${name}.md`), ['R1'], name);
|
||||
assert.equal(file(result, `Book One/Places/${name}.md`), undefined, name);
|
||||
}
|
||||
assert.ok(file(result, 'Book One/Places/Edge.md'), 'exactly 1 MiB is read');
|
||||
});
|
||||
|
||||
test('R2: unmapped paths are reported and not indexed', async t => {
|
||||
const r = repo(t);
|
||||
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
|
||||
for (const path of ['Inbox Note.md', 'Book One/Board.canvas', 'Book One/Playground/Sketch.canvas']) {
|
||||
assert.deepEqual(codes(result, path), ['R2']);
|
||||
assert.equal(file(result, path), undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('R3: front matter not fully parsed stays raw, with the line where parsing stopped', async t => {
|
||||
const r = repo(t, { ...BASE,
|
||||
'Book One/Places/Nested.md': ['---', 'type: place', 'coords:', ' lat: 1', '---', 'Body [[Tomas]]', ''].join('\n'),
|
||||
'Book One/Places/Block.md': ['---', 'summary: |', ' text', '---', ''].join('\n'),
|
||||
'Book One/Places/Open.md': ['---', 'type: place', 'never closed', ''].join('\n') });
|
||||
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
|
||||
const nested = file(result, 'Book One/Places/Nested.md');
|
||||
assert.deepEqual(result.problems.filter(p => p.path === 'Book One/Places/Nested.md'), [{ code: 'R3', path: 'Book One/Places/Nested.md', line: 4, message: 'front matter not fully parsed' }]);
|
||||
assert.equal(nested.frontmatter, null);
|
||||
assert.equal(nested.frontmatterRaw, 'type: place\ncoords:\n lat: 1');
|
||||
assert.deepEqual(nested.links.map(l => l.path), ['Book One/Characters/Tomas.md'], 'still indexed by body');
|
||||
assert.equal(result.problems.find(p => p.path === 'Book One/Places/Block.md').line, 2);
|
||||
assert.equal(result.problems.find(p => p.path === 'Book One/Places/Open.md').line, 1);
|
||||
assert.equal(file(result, 'Book One/Places/Open.md').bodyLine, 1);
|
||||
});
|
||||
|
||||
test('front matter forms the reader accepts and refuses', () => {
|
||||
const parse = text => parseFrontMatter(text.split('\n'));
|
||||
assert.deepEqual(parse('---\na: plain words\nb: "quoted: yes"\nc: \'it\'\'s\'\nd: [x, "y z", 3]\ne:\n- one\n- "[[Two]]"\nf: [[Wiki]]\ng: true\nh:\n---').frontmatter,
|
||||
{ a: 'plain words', b: 'quoted: yes', c: "it's", d: ['x', 'y z', 3], e: ['one', '[[Two]]'], f: '[[Wiki]]', g: true, h: null });
|
||||
for (const bad of ['a: {x: 1}', 'a: &anchor x', 'a: [x, [y]]', 'a: x: y', 'a: 1\na: 2', ' a: 1', 'a: "open', '- loose'])
|
||||
assert.notEqual(parse(`---\n${bad}\n---`).stop, null, bad);
|
||||
assert.deepEqual(parse('no front matter'), { frontmatter: null, raw: null, bodyLine: 1, stop: null });
|
||||
});
|
||||
|
||||
test('R4: a link to no file, to more than one, or into another repo is reported and not followed', async t => {
|
||||
const r = repo(t, { ...BASE,
|
||||
'Book One/Research/Notes.md': 'a\n', 'Book One/Places/Notes.md': 'b\n',
|
||||
'Book One/Chapters/Links.md': ['[[Nobody]]', '[[Notes]]', '[[bean-brain/Journal/Today]]', '[[Tomas]]', ''].join('\n') });
|
||||
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
|
||||
assert.deepEqual(result.problems.filter(p => p.path === 'Book One/Chapters/Links.md').map(p => [p.code, p.line]), [['R4', 1], ['R4', 2], ['R4', 3]]);
|
||||
assert.deepEqual(file(result, 'Book One/Chapters/Links.md').links.map(l => l.path), ['Book One/Characters/Tomas.md']);
|
||||
});
|
||||
|
||||
test('R5: a canon file that refers into the playground is reported, and the link still goes by path', async t => {
|
||||
const r = repo(t, { ...BASE, 'Book One/Places/Dock.md': 'See [[What If]].\n' });
|
||||
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
|
||||
assert.deepEqual(codes(result, 'Book One/Places/Dock.md'), ['R5']);
|
||||
assert.deepEqual(file(result, 'Book One/Places/Dock.md').links.map(l => l.path), ['Book One/Playground/What If.md']);
|
||||
// The playground file links to canon: no problem, R5 is only about canon.
|
||||
assert.deepEqual(codes(result, 'Book One/Playground/What If.md'), []);
|
||||
});
|
||||
|
||||
test('R6: a canon file that refers into another universe is reported; a hybrid may refer into its sources', async t => {
|
||||
const r = repo(t, { ...BASE, 'Book One/Places/Far.md': 'Like [[Second World/Chapters/01]].\n',
|
||||
'Crossover/Chapters/A.md': 'From [[Mara Vell]] and [[Second World/Chapters/01]].\n' });
|
||||
const hybrid = mapping(e => { e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'] }; return e; });
|
||||
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: hybrid });
|
||||
assert.deepEqual(codes(result, 'Book One/Places/Far.md'), ['R6']);
|
||||
assert.deepEqual(file(result, 'Book One/Places/Far.md').links.map(l => l.path), ['Second World/Chapters/01.md']);
|
||||
assert.deepEqual(codes(result, 'Crossover/Chapters/A.md'), []);
|
||||
});
|
||||
|
||||
test('R7: two paths that differ only in case are both left out', async t => {
|
||||
const r = repo(t, BASE);
|
||||
writeFileSync(join(r.root, 'twin'), 'twin\n');
|
||||
const blob = git(r.repo, 'hash-object', '-w', join(r.root, 'twin'));
|
||||
git(r.repo, 'update-index', '--add', '--cacheinfo', `100644,${blob},Book One/Characters/tomas.md`);
|
||||
git(r.repo, 'commit', '-qm', 'case twin');
|
||||
const result = await readLibrary({ repo: r.repo, commit: git(r.repo, 'rev-parse', 'HEAD'), mapping: mapping() });
|
||||
for (const path of ['Book One/Characters/Tomas.md', 'Book One/Characters/tomas.md']) {
|
||||
assert.deepEqual(codes(result, path), ['R7'], path);
|
||||
assert.equal(file(result, path), undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('the reader reads the commit, never the working tree, and refuses a bad commit id', async t => {
|
||||
const r = repo(t);
|
||||
writeFileSync(join(r.repo, 'Book One/Places/Harbor Town.md'), 'unsaved [[Nobody]]\n');
|
||||
writeFileSync(join(r.repo, 'Book One/Places/Untracked.md'), 'x\n');
|
||||
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
|
||||
assert.equal(file(result, 'Book One/Places/Harbor Town.md').lines[0][1], '---');
|
||||
assert.equal(file(result, 'Book One/Places/Untracked.md'), undefined);
|
||||
for (const commit of ['HEAD', r.commit.slice(0, 12), '0'.repeat(40), git(r.repo, 'rev-parse', 'HEAD^{tree}')])
|
||||
await assert.rejects(readLibrary({ repo: r.repo, commit, mapping: mapping() }), e => e instanceof LibraryError, commit);
|
||||
await assert.rejects(readLibrary({ repo: r.repo, commit: r.commit, mapping: {} }), /library-mapping-invalid/);
|
||||
});
|
||||
|
||||
test('a bare mirror reads the same as the clone', async t => {
|
||||
const r = repo(t);
|
||||
const bare = join(r.root, 'mirror.git');
|
||||
git(r.root, 'clone', '-q', '--bare', r.repo, bare);
|
||||
const a = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
|
||||
const b = await readLibrary({ repo: bare, commit: r.commit, mapping: mapping() });
|
||||
assert.deepEqual(b, a);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { renderItems, UNREADABLE } from '../src/index.mjs';
|
||||
|
||||
const A = 'a'.repeat(40), B = 'b'.repeat(40);
|
||||
const cite = (path, lines, quote, commit = A) => ({ path, commit, lines, quote });
|
||||
const options = {
|
||||
scopeOf: path => path.includes('/Playground/') ? 'playground' : path.endsWith('.md') ? 'canon' : undefined,
|
||||
changed: ({ path, commit }) => path === 'Book One/Characters/Mara Vell.md' && commit === A,
|
||||
};
|
||||
const render = items => renderItems(JSON.stringify({ items }), options);
|
||||
|
||||
// One snapshot per item kind: the plain text both channels show.
|
||||
const SNAPSHOTS = {
|
||||
fact: [{ kind: 'fact', text: 'Tomas is the ferryman.', cites: [cite('Book One/Characters/Tomas.md', [3, 3], 'the ferryman', B)] }],
|
||||
finding: [{ kind: 'finding', text: 'Eye colour differs.', cites: [cite('Book One/Chapters/Chapter 01.md', [41, 41], 'her grey eyes'),
|
||||
cite('Book One/Characters/Mara Vell.md', [9, 10], 'green eyes')] }],
|
||||
correction: [{ kind: 'correction', text: 'Chapter 1 says grey.', from: 'green eyes', to: 'grey eyes',
|
||||
cites: [cite('Book One/Characters/Mara Vell.md', [9, 9], 'green eyes', B)] }],
|
||||
question: [{ kind: 'question', text: 'Is the harbor north of the town?', cites: [cite('Book One/Places/Harbor Town.md', [2, 4], 'the harbor', B)] }],
|
||||
idea: [{ kind: 'idea', text: 'A storm could close the harbor.', cites: [cite('Book One/Playground/What If.md', [5, 5], 'a storm', B),
|
||||
{ url: 'https://example.invalid/tides', quote: 'spring tides' }] }],
|
||||
};
|
||||
const EXPECTED = {
|
||||
fact: '1. Fact: Tomas is the ferryman.\n [canon] Book One/Characters/Tomas.md:3 @bbbbbbb "the ferryman"',
|
||||
finding: '1. Finding: Eye colour differs.\n [canon] Book One/Chapters/Chapter 01.md:41 @aaaaaaa "her grey eyes"\n'
|
||||
+ ' [canon] Book One/Characters/Mara Vell.md:9-10 @aaaaaaa "green eyes" (changed since)',
|
||||
correction: '1. Correction: Chapter 1 says grey.\n From: "green eyes"\n To: "grey eyes"\n'
|
||||
+ ' [canon] Book One/Characters/Mara Vell.md:9 @bbbbbbb "green eyes"',
|
||||
question: '1. Question: Is the harbor north of the town?\n [canon] Book One/Places/Harbor Town.md:2-4 @bbbbbbb "the harbor"',
|
||||
idea: '1. Idea: A storm could close the harbor.\n [playground] Book One/Playground/What If.md:5 @bbbbbbb "a storm"\n'
|
||||
+ ' [web] https://example.invalid/tides "spring tides"',
|
||||
};
|
||||
|
||||
for (const kind of Object.keys(SNAPSHOTS)) {
|
||||
test(`renderItems snapshot: ${kind}`, () => {
|
||||
const out = render(SNAPSHOTS[kind]);
|
||||
assert.equal(out.kind, 'items');
|
||||
assert.equal(out.text, EXPECTED[kind]);
|
||||
assert.equal(out.items[0].kind, kind);
|
||||
});
|
||||
}
|
||||
|
||||
test('renderItems numbers several items and takes a parsed body as well as a string', () => {
|
||||
const items = [...SNAPSHOTS.fact, ...SNAPSHOTS.question];
|
||||
const out = renderItems({ items }, options);
|
||||
assert.equal(out.text, `${EXPECTED.fact}\n${EXPECTED.question.replace(/^1\./, '2.')}`);
|
||||
assert.deepEqual(out.items[1].cites[0], { badge: 'canon', path: 'Book One/Places/Harbor Town.md', commit: B, lines: [2, 4],
|
||||
ref: 'Book One/Places/Harbor Town.md:2-4 @bbbbbbb', quote: 'the harbor', changed: false });
|
||||
});
|
||||
|
||||
test('without options a path cite has no badge and is never marked changed', () => {
|
||||
assert.equal(renderItems({ items: SNAPSHOTS.finding }).text,
|
||||
'1. Finding: Eye colour differs.\n Book One/Chapters/Chapter 01.md:41 @aaaaaaa "her grey eyes"\n'
|
||||
+ ' Book One/Characters/Mara Vell.md:9-10 @aaaaaaa "green eyes"');
|
||||
});
|
||||
|
||||
const NOTICE_SNAPSHOTS = { 'no-cited-answer': 'No cited answer', 'no-answer': 'No answer', 'turn-failed': 'The turn failed' };
|
||||
for (const [notice, text] of Object.entries(NOTICE_SNAPSHOTS)) {
|
||||
test(`renderItems snapshot: notice ${notice}`, () => {
|
||||
assert.deepEqual(renderItems(JSON.stringify({ items: [], notice }), options), { kind: 'notice', notice, text });
|
||||
});
|
||||
}
|
||||
|
||||
test('a body without the reply shape shows a fixed line and none of its text', () => {
|
||||
const secret = 'free prose the guard would refuse';
|
||||
const bodies = [secret, JSON.stringify(secret), '{"items": [], "notice": "other"}', JSON.stringify({ items: [], notice: 'no-answer', extra: secret }),
|
||||
JSON.stringify({ items: [] }), JSON.stringify({ items: [{ kind: 'story', text: secret, cites: [] }] }),
|
||||
JSON.stringify({ items: [{ kind: 'fact', text: secret }] }), JSON.stringify({ items: SNAPSHOTS.fact, prose: secret }),
|
||||
JSON.stringify({ items: [{ kind: 'correction', text: secret, cites: [] }] }),
|
||||
JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [cite('a.md', [2, 1], 'q')] }] }),
|
||||
JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [{ path: 'a.md', commit: 'abc', lines: [1, 1], quote: 'q' }] }] }),
|
||||
JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [{ url: 'https://x.invalid', path: 'a.md', quote: 'q' }] }] }),
|
||||
null, 42, '[]'];
|
||||
for (const body of bodies) {
|
||||
const out = renderItems(body, options);
|
||||
assert.deepEqual(out, { kind: 'unreadable', text: UNREADABLE }, String(body));
|
||||
assert.ok(!JSON.stringify(out).includes(secret));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { surveyMain } from '../src/index.mjs';
|
||||
import { BASE, FIXTURES, MARKER, repo } from './helpers.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('../../..', import.meta.url));
|
||||
async function run(args) {
|
||||
let stdout = '', stderr = '';
|
||||
const code = await surveyMain(args, { stdout: { write: s => { stdout += s; } }, stderr: { write: s => { stderr += s; } } });
|
||||
return { code, stdout, stderr };
|
||||
}
|
||||
// Strings from the fixture that are text, values, file names or link targets: none may be printed.
|
||||
const NEVER = [MARKER, 'Grey eyes', 'The Ferryman', 'Mara Vell', 'Tomas', 'Harbor Town', 'What If', 'Inbox Note', 'Chapter 01',
|
||||
'Sketch', 'Board', 'Not A Link', 'the harbor', 'Docks', 'character'];
|
||||
|
||||
test('the survey prints folder names, counts, key sets and link-form counts, and never text', async t => {
|
||||
const r = repo(t, { ...BASE, 'Book One/Places/Odd.md': ['---', `"${MARKER} key": 1`, '---', ''].join('\n') });
|
||||
const out = await run(['survey', r.repo]);
|
||||
assert.equal(out.code, 0, out.stderr);
|
||||
assert.equal(out.stdout, [
|
||||
`commit ${r.commit}`,
|
||||
'entries 10',
|
||||
'folders (direct children: md canvas base other)',
|
||||
' (root) 1 0 0 0',
|
||||
' "Book One" 0 1 0 0',
|
||||
' "Book One/Chapters" 1 0 0 0',
|
||||
' "Book One/Characters" 2 0 0 0',
|
||||
' "Book One/Places" 2 0 0 0',
|
||||
' "Book One/Playground" 1 1 0 0',
|
||||
' "Second World/Chapters" 1 0 0 0',
|
||||
'markdown 8 files, 8 read, 3 without front matter, 0 with front matter not fully parsed',
|
||||
'front matter key sets',
|
||||
' 1 [(other key)]',
|
||||
' 1 [appears_in, name, relationships, type]',
|
||||
' 1 [appears_in, name, type]',
|
||||
' 1 [name]',
|
||||
' 1 [type]',
|
||||
'body links plain 3, label 1, heading 1, embed 0, self 0, markdown 1',
|
||||
'field appears_in wikilink 2, name 0',
|
||||
'field relationships wikilink 1, name 1',
|
||||
''].join('\n'));
|
||||
for (const s of NEVER) assert.ok(!out.stdout.includes(s), s);
|
||||
});
|
||||
|
||||
test('with --mapping the survey adds scope, resolver step and problem counts', async t => {
|
||||
const r = repo(t);
|
||||
const out = await run(['survey', r.repo, '--mapping', join(FIXTURES, 'mapping.json')]);
|
||||
assert.equal(out.code, 0, out.stderr);
|
||||
const tail = out.stdout.trimEnd().split('\n').slice(-3);
|
||||
assert.deepEqual(tail, [
|
||||
'scopes canon 5, playground 1, unmapped 3; indexed 6',
|
||||
'resolved root 2, vault-prefix 1, relative 2, shortest 3, unresolved 0',
|
||||
'problems R1 0, R2 3, R3 0, R4 0, R5 0, R6 0, R7 0']);
|
||||
for (const s of NEVER) assert.ok(!out.stdout.includes(s), s);
|
||||
});
|
||||
|
||||
test('survey exit codes: usage 4, bad mapping 3, unreadable repo 2', async t => {
|
||||
const r = repo(t);
|
||||
for (const args of [[], ['scan', r.repo], ['survey'], ['survey', r.repo, '--mapping'], ['survey', r.repo, '--other'], ['survey', r.repo, r.repo]])
|
||||
assert.equal((await run(args)).code, 4, args.join(' '));
|
||||
const bad = join(r.root, 'bad.json');
|
||||
writeFileSync(bad, JSON.stringify({ kind: 'library' }));
|
||||
const refused = await run(['survey', r.repo, '--mapping', bad]);
|
||||
assert.equal(refused.code, 3);
|
||||
assert.match(refused.stderr, /library-mapping-invalid/);
|
||||
writeFileSync(bad, 'not json');
|
||||
assert.equal((await run(['survey', r.repo, '--mapping', bad])).code, 3);
|
||||
assert.equal((await run(['survey', join(r.root, 'missing')])).code, 2);
|
||||
assert.equal((await run(['survey', r.root])).code, 2, 'a directory that is not a repo');
|
||||
});
|
||||
|
||||
test('scripts/mosaic library survey reaches the survey', t => {
|
||||
const r = repo(t);
|
||||
const out = spawnSync(join(ROOT, 'scripts/mosaic'), ['library', 'survey', r.repo], { encoding: 'utf8', timeout: 30000 });
|
||||
assert.equal(out.status, 0, out.stderr);
|
||||
assert.match(out.stdout, new RegExp(`^commit ${r.commit}\n`));
|
||||
assert.ok(!out.stdout.includes(MARKER));
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
+7
-1
@@ -34,7 +34,13 @@ if [ "${1:-}" = business ]; then
|
||||
shift
|
||||
exec node "$REPO/packages/business/src/cli.mjs" "$@"
|
||||
fi
|
||||
# `mosaic library survey`: names and counts from a writing repo, never its text
|
||||
# (packages/library/README.md). Other library verbs are bus verbs.
|
||||
if [ "${1:-}" = library ] && [ "${2:-}" = survey ]; then
|
||||
shift
|
||||
exec node "$REPO/packages/library/src/survey.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) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
|
||||
esac
|
||||
exec node "$REPO/packages/seat/src/cli.mjs" "$@"
|
||||
|
||||
@@ -27,7 +27,7 @@ COPY_PATHS=(
|
||||
packages/conversation/src packages/discord/src packages/discord/bin packages/discord/extension packages/harness/src
|
||||
packages/seat/src packages/tasks/src packages/tasks/fields.json packages/tasks/requirements.json
|
||||
packages/webui/src packages/webui/README.md packages/cli/README.md
|
||||
packages/queue/src
|
||||
packages/queue/src packages/library/src
|
||||
# Whole directory: every in-repo plug-in module ships with the copy (row 131).
|
||||
packages/plugins
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user