Compare commits

..
Author SHA1 Message Date
jason.woltje e021dfa260 row 210 (#1707): DESIGN.md r3, duckworth-0043 C1 (Q-J0 revoke line matches section 6), N1-N4, sage-1468 scope line (harness tool out, Q-S3) and 1.7 approval (Gizmoduck) 2026-10-20 15:41:07 -05:00
jason.woltje b726e10f44 row 210 (#1707): DESIGN.md r2, sage-1450 rulings (argv allowlist only, 1.7 hard wait, 1.8 proposal) and duckworth-0042 B1-B4 with notes, Q-J0 for Jason (Gizmoduck) 2026-10-20 15:36:54 -05:00
jason.woltje 7b10bc0fff row 210 (#1707): DESIGN.md r1, agent host access with sudo through the credential store (Gizmoduck) 2026-10-20 15:30:28 -05:00
jason.woltje 9ba31b5b3f agents: Honker's PRD 1.7 draft r2 on refactor for the prd.approve raise (11cb0423b, sha256 e896746df595d1b5155130ca163fbf63b0dc38a87a8dd154af77f1a6d1328647, Duckworth approved duckworth-0039, Vikunja #420) 2026-10-20 15:20:37 -05:00
jason.woltje 9db578ff54 plans: agent host access with sudo through the credential store brief (#1707, Vikunja #424, Q183/Q184, chained on row 199) 2026-10-20 15:09:59 -05:00
jason.woltje 8eb9ddd12c plans: FED R8 brief to r4.11 (b93f098f0, recorded acceptance, Duckworth N1/N2, #416 date), R5 context source (sage-1419), 193a makes covers level required (duckworth-0033 N1) 2026-10-20 15:06:22 -05:00
jason.woltje ae3c36f248 agents: Gizmoduck seat dir and roster row (FED R1 owner, #414) 2026-10-20 14:42:15 -05:00
jason.woltje a2af7ab495 plans: FED R1, R5, R2 and R8 briefs (#414, r4.9 a1daa3c07) and row 193a-d build briefs (#1691); Quackerjack seat dir and roster row 2026-10-20 14:41:03 -05:00
45 changed files with 1055 additions and 2918 deletions
+2
View File
@@ -23,6 +23,8 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
| Herb | Builder, a second gate lane (#1695) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
| Bushroot | Builder, retire the control board writer on 7331 (#1696) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1008 | none, T3 only |
| Megavolt | Builder, MVP T1 item 6 (agent credentials and git identity, #1697) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
| Quackerjack | Builder, federation R5 and R2 (#414) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1023 | none, T3 only |
| Gizmoduck | Federation design (#414) and builder of FED R1 | T3 session (Claude, Opus 5.5, thread 9312a896) | none, T3 only |
| Webby | Reviewer: Case 2 designs, rows 192 and 194 | T3 session (Claude, Opus 5.5, thread 03086006), started by Mos (mos-0981) | none, T3 only |
| Duckworth | Reviewer: federation designs, row 193 | T3 session (Claude, Opus 5.5, thread ffceafec), started by Mos (mos-0977) | none, T3 only |
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
+9
View File
@@ -0,0 +1,9 @@
# Gizmoduck
Design and builder seat, Claude Opus 5.5, a T3 thread (9312a896). Author of the federation design
(`agents/gizmoduck/work/federation/DESIGN.md` on `gizmoduck/federation-design`, #414). Builds FED R1, the
access.json loader and validator, under Mos's order mos-1023 item 1.
No launcher in this directory. The seat works in its own worktree and has no credentials of its own; until
the credentials row settles per-agent identity, it pushes only its own `gizmoduck/*` branches through the
repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
+538
View File
@@ -0,0 +1,538 @@
# Row 210 (#424, Gitea #1707): agent host access with sudo through the credential store
Gizmoduck, 2026-10-20, r3. Design only, no code. Reviewer Duckworth, gate owner Sage. Due 2026-10-27 (Q184).
- Brief: `docs/plans/2026-10-20_host-credentials.md` at 9db578ff5.
- Base read: `origin/refactor` 9ba31b5b3. Row 199 read as DESIGN.md r6 (4d3007ec9) and its code and
BUILD.md at addee664a on `megavolt/row199`.
- PRD: REQ-CRED-3 in Honker's 1.7 draft (`agents/honker/work/queue-194/PRD-1.7-DRAFT.md:111-115`, 11cb0423b).
Jason approved PRD 1.7 (d52febb5 yes at 20:36:47Z, mos-1089), so REQ-CRED-3 is in.
- Scope (sage-1468): this row ships the `host.run` broker op, the grant check, the executor and the receipts,
all testable over `broker.sock`. The `host_run` harness tool is out, as 199 left its publish tool out
(199 BUILD.md deviation 12). Sage files one follow-up row for the agent-facing harness tools, publish and
`host_run`, in `packages/harness`. Duckworth's r2 N5 (the tool help says a gated call leaves stdin under
4 KiB) belongs to that row.
- Branch `gizmoduck/row210`. Row claimed in-progress (rev 2361) after Sage cleared the after (rev 2359). It
lands only after 199, rebased onto 199's landing (Q177).
Rounds:
- r3 (duckworth-0043, REVIEW-r2.md at 52b5fee63, approved with C1, and sage-1468, sage-1472): C1 Q-J0's revoke
line now matches section 6, the connection is cut and a started command may keep running. Q-S3 answered,
the harness tool is out (scope line above, D10). N1 the runner list takes Duckworth's whole list, and D6
says the per-call decision is the control. N2 numeric shorthand, unspecified and mapped addresses and
`*.localhost` refuse (D3, T1). N3 the pids file records each pid's start time (D7, T27). N4 the mutant
table is in order.
- r2 (sage-1450): no `"any"` grant. The command boundary is an argv allowlist per host grant, and load refuses
a wildcard (D6, T26, M6, M28). PRD 1.7 is frozen, so `host.sudo` stays a hard wait and Jason decides each
call. Section 5 now holds the proposed 1.8 routing requirement. D5 is confirmed against 199's deviation 11.
The seat network gap is a known limit with what closes it (section 6). Cites now point at 199's code.
- r2 (duckworth-0042, REVIEW-r1.md at 8140b7f39): B1 events name host, user and argv in clear (D5 step 6,
T22, M30). B2 a gated call's question shows stdin in full (D4, T9, M31). B3 the key and processes die with
the bus, and bus start sweeps leftovers (D7, T27, M32 to M34). B4 Q-J0 for Jason in section 10, worded to
stand alone (mos-1088). Notes taken: N1 (E12, runGit's kill shape), N2 (loopback refused, own-host limit),
N3 (process groups), N4 (stdin can make a prefix root), N5 (`-l` and a separate address), N6 (D8 framing),
N7 (section 7).
## 1. What the code does today
| # | Fact | Cite |
|---|---|---|
| E1 | The bus reads credentials for two kinds only, `gitea` and `vikunja` | `bus/src/credentials.mjs:22`, `business/src/vocabulary.mjs:103` |
| E2 | A credential value must match `^[-A-Za-z0-9._~]+\n?$`, at least 16 and at most 16384 bytes. An OpenSSH private key fails it at the first `-----BEGIN` space | `bus/src/credentials.mjs:84-87` |
| E3 | `assertClean` searches `JSON.stringify(result)` for each token. JSON escapes a newline, so a multi-line secret is never found: `JSON.stringify("a\nb").includes("a\nb")` is `false` (run on node here) | `bus/src/credentials.mjs:95-103` |
| E4 | `use()` turns any exception in the operation into `service-failed`, then runs `assertClean` | `bus/src/credentials.mjs:104-117` |
| E5 | Host to bus-child IPC is serial, one request at a time, with a 10 s timeout. A slow reply breaks the channel | `cli/src/host.mjs:31`, `:270` |
| E6 | The web verbs run in the bus child over `broker.sock`. `requestWeb` checks keys, runs `#secretCheck`, checks the grant `roles[s.role].web`, records `action.allowed` (requested), calls `onAdmit()` so the server raises the socket timeout, runs the service and records completion | `bus/src/broker.mjs:1492-1530`, `bus/src/server.mjs:87-95` |
| E7 | `roles[role].web` comes from the resolved tool limit. A role entry in the bus holds `authority`, `web` and `credentials` | `bus/src/business.mjs:17-20` |
| E8 | A business-file role entry takes `definition`, `holder`, `vars`, `tracker`, `credentials`. Anything else refuses at load | `business/src/business.mjs:89` |
| E9 | The chief's `organization.set` takes only `ORGANIZATION_KEYS`. The human-only `instances.set` takes `INSTANCE_KEYS` and refuses any definition that declares a credential (`organization-credentials-out-of-scope`). Holder, credentials and tracker stay hand edits | `bus/src/broker.mjs:145-147`, `:1248-1298`, `business/src/instance-settings.mjs:1-5`, `:22-31` |
| E10 | An action outside ROUTED is a hard wait and classifies `gated` for every role. No role file may grant one | `bus/src/broker.mjs:44-70`, `business/src/vocabulary.mjs:58-88`, PRD REQ-WAIT-2 and 3 |
| E11 | The seat sandbox does not confine the network, abstract Unix sockets or the host UID | `seat/src/sandbox.mjs:29-31` |
| E12 | On refactor the bus child spawns nothing. With 199 it spawns git: `publishRemote` (`bus/src/runtime.mjs:245` at addee664a) runs `runGit`, a detached group leader that gets SIGTERM at the limit, then SIGKILL to the group after a grace period | `seat/src/checkout.mjs:95-138` at addee664a |
| E13 | Role networks are `none`, `api-only`, `open`. The bundle carries the value, and the sandbox's own comment says the network is not confined (E11) | `business/src/vocabulary.mjs:101`, `harness/src/bundle.mjs:252` |
Row 199 gives, in code at addee664a: `useFor` (`bus/src/credentials.mjs:228`), `issue` (`:156`), `revoke`
(`:197`), `remove` (`:211`), the kind list (`:49`), the token pattern (`:19`, `:143`), the store dir's file
regex `^(gitea|vikunja)(\.tmp)?$` (`:222`) and `withGitea` (`bus/src/runtime.mjs:233`). From its design: the store at `<configDir>/secrets/agents/<business>/<agent>/<kind>` (D1), the `{store: true}`
reference (D2), the read path (D3, D3a), `useFor(session, kind, op)` with the call-site allowlist test (D5),
the `publish` grant shape, a list on the role entry plus a hard-wait action whose decision binds the commit
(D9), and the human-only lifecycle verbs with `changes.jsonl` receipts (section 6).
## 2. Probe (throwaway keys, scratch sshd on 127.0.0.1:22410, OpenSSH 10.5p1)
Run in `/mnt/storage/scratch/gizmo-210-probe` with keys made for the probe and deleted after. No real key or
host was touched.
| Probe | Result |
|---|---|
| `ssh-add -` with the private key on stdin, to an agent on a private socket | works, "Identity added: (stdin)" |
| `IdentityAgent=<sock>` with `IdentitiesOnly=yes` and `IdentityFile=none` | refused, `Permission denied (publickey)`. With `IdentitiesOnly`, ssh offers only agent keys that match a configured identity |
| same with `IdentityFile=<the public key file>` | works, rc 0 |
| `-i /dev/fd/3` with the key on fd 3 | fails, "Identity file /dev/fd/3 not accessible". ssh closes inherited fds, so the fd route is out |
| a known_hosts file pinning the wrong key under `HostKeyAlias` with `StrictHostKeyChecking=yes` | refused, "Host key verification failed", rc 255 |
| `printf '%s|'` with `'a b'` and `'it'\''s'` single-quoted, plus an unquoted `$((1+1))` as a control | prints `a b|it's|2|`. The quoted elements arrive whole, and only the unquoted control expanded |
| remote `exit 7` | ssh exits 7 |
| killing the agent | its socket file is gone |
## 3. Decisions
### D1. The kind and its naming
One key per (agent, host). The kind name is `host.<hostId>`, so the store file is
`<configDir>/secrets/agents/<business>/<agent>/host.<hostId>` (199 D1, unchanged). `hostId` matches
`^[a-z0-9][a-z0-9-]{0,31}$`.
The closed kind set becomes `gitea`, `vikunja` and the family `host.<hostId>`. One exported helper,
`credentialKind(name)` in `vocabulary.mjs`, answers whether a name is a kind, and each place that checks
kinds calls it: `SERVICES` users (`role.mjs:105-118`), 199's `bus/src/credentials.mjs:49` and the store
file regex at `:222`, the broker's service
check, the CLI `issue` date flag and 199's remove regex. A build test greps for literal `'gitea', 'vikunja'`
lists outside `vocabulary.mjs` and fails on a new one.
Why per host and not per agent. A key that leaks, or a host that is retired, is revoked for that host alone.
ops-01 installs one public key per host, and an `authorized_keys` line names one agent. The cost is more keys
for Jason to issue, one per grant.
A role definition declares the kind once as `{"service": "host"}` with no scopes. The business file binds the
concrete hosts (D3). A definition can't name a host, because hosts belong to one stack instance.
The date is `--rotate-by DATE`, as for gitea. An ssh key doesn't expire on its own, so `status()` reports
`rotation-due` or `valid`, the gitea branch.
### D2. The value check
`checkValue(kind, text)` replaces the one regex at `credentials.mjs:84`. For `gitea` and `vikunja` it is
today's check, unchanged. For `host.*` the value must be:
- PEM framed by `-----BEGIN OPENSSH PRIVATE KEY-----` and `-----END OPENSSH PRIVATE KEY-----`, LF line ends,
at most 16384 bytes (the existing cap).
- An `openssh-key-v1` blob with cipher `none`, kdf `none`, exactly one key, type `ssh-ed25519`. Parsed in
node with `Buffer`, no ssh-keygen call, so the check runs at bus start without a process.
- The two check integers equal, and the public key in the private section equals the one in the header.
Anything else refuses `credential-format`. An encrypted key refuses, because the bus has no way to ask for a
passphrase and `BatchMode=yes` would fail at use time instead of at load. The parsed public key is kept with
the entry so `status` and D7 can print its fingerprint without touching the private part.
### D3. The grant
Four layers. Each must hold, and the code checks all four on every call.
1. **Role definition, reviewed commit.** `roles/<def>.json` lists `{"service": "host"}` in `credentials`. That
is the "reviewed role change" in the brief. Once a PRD version routes `host.sudo` (section 5), the same
reviewed commit puts `host.sudo` in that definition's `authority.withinRole`.
2. **Host registry, business file, hand edit.** A new top-level key:
```
"hosts": { "<hostId>": { "address": "10.1.1.53", "port": 22, "user": "localadmin",
"hostKeys": ["ssh-ed25519 AAAA..."] } }
```
`address` is an IPv4 or IPv6 literal or a DNS name matching `^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$`.
An address of this machine refuses at load (r2 N2, r3 N2). For an IP literal, which `net.isIP` decides,
that is `127.0.0.0/8`, `0.0.0.0/8`, `::1`, `::`, and an IPv4-mapped `::ffff:` form of either IPv4 range.
For a name, it is `localhost` and any name ending in `.localhost`. A name made only of digits and dots
refuses unless `net.isIP` reads it as IPv4, so the shorthand `127.1` and the integer `2130706433`, which
pass the DNS pattern and which the resolver reads as 127.0.0.1, refuse. A label starting `0x` refuses for
the same reason. `port` is 1 to 65535. `user` matches `^[a-z_][a-z0-9_-]{0,31}$`. `hostKeys` is a non-empty list of
`ssh-ed25519`, `ecdsa-sha2-nistp256` or `ssh-rsa` public key lines, type and base64 only, no comment.
3. **Agent grant, business file, hand edit.** A new role-entry key beside 199's `publish`:
```
"hosts": { "<hostId>": { "commands": [["systemctl", "status"], ["journalctl", "-u"]] } }
```
Each `hostId` must be in the registry, and the role's definition must declare `host`. A role entry with
`hosts` whose definition lacks `host` refuses at load and names `roles.<agent>.hosts`. `commands` is the
command boundary (D6).
4. **The key.** `credentials["host.<hostId>"] = {"store": true, "rotateBy": DATE}` with the file present
(199 D2, D3a). A grant without an issued key refuses `credential-unavailable` at call time, as 199 does.
Who writes each layer is in section 4. Neither the chief nor any agent can reach layers 2 to 4: `hosts` is
outside `ORGANIZATION_KEYS` and `INSTANCE_KEYS` (E9), `instances.set` already refuses a definition that
declares credentials (E9), and 199's `issue` is human-only.
### D4. The action and the decision binding
`host.sudo` is a new action in ACTIONS and in neither ROUTED list. Until a PRD version routes it, it is a hard
wait and classifies `gated` for every role (E10). Each call then needs a decision the human resolved. PRD 1.7
is frozen (sage-1450, decision d52febb5 cites its sha256), so under 1.7 Jason decides every `host.run`.
The decision binds one call, the way 199 D9 binds `pushCommit`. When the action is `host.sudo`, the
`decision.raise` event body gets `hostRun = {host, argvSha256, stdinSha256}`, from the raise's own args. The
question shown to the human ends with the host id, the user, the argv with each element JSON-quoted, and the
stdin in full as one JSON string, so Jason sees all of what runs (B2). The broker appends that text itself, and
the agent can't shape it. The whole question keeps the 4096-character cap (`broker.mjs:126`, `:1654`), so on a
gated call stdin is as large as the cap allows and no larger. A raise that doesn't fit refuses
`invalid-request`. `argv ["bash"]` can't be granted at all (D6), and `["tee", "/etc/x"]` with a stdin Jason
can't see is ruled out by showing it. `#authorityFor` refuses `decision-mismatch` when the call's host, argv hash or stdin hash differs, as
`holderRun` does at `broker.mjs:796`. A decision is spent once (`decision-consumed`).
After routing, a definition that lists `host.sudo` within-role runs without a decision, and `decision` is
optional for that class only. The grant layers in D3 still apply.
### D5. The executor: a `broker.sock` verb in the bus child
```
host.run {"host", "argv": [..], "stdin"?, "timeout"?, "decision"?}
-> {"exit", "signal", "stdout", "stderr", "truncated": {"stdout", "stderr"}, "ms"} or a refusal
```
It runs in the bus child, shaped like `requestWeb` (E6), not as a host IPC op. A sudo command can take
minutes and the host channel times out at 10 s and serves one request at a time (E5). The key is in the bus
child's memory already (199 D3), so no ticket has to cross to the CLI host. Row 199's publish needs a ticket
because git and the working repo live in the CLI host. ssh needs neither. 199's BUILD.md deviation 11
(addee664a) keeps credential use in the bus child too: the push runs there under `withGitea`, and the host
socket only checks and polls. Sage confirmed D5 matches that shape (sage-1450). `host.run` needs no host
socket and no poll, because the agent calls `broker.sock` directly. The helper is `withHost(session, host,
fn)`, beside `withGitea`, calling `useFor(session, "host." + host, fn)`.
Order of checks. Each refusal goes through `broker.refuse()` and logs `action.refused`.
1. `keys(args)`, then `#secretCheck(args)`, then identity from the cap.
2. `host` is in the registry (`host-unknown`).
3. `host` is in `roles[s.role].hosts` (`host-not-granted`). This is the REQ-CRED-3 check "a grant exists for
that agent and that host".
4. `argv` shape (D6) and `commands` match (`command-not-granted`).
5. `#checkAuthority(s, "host.sudo", {decision, target: host, context})` (D4).
6. Record `action.allowed` phase `requested` with host, address, user and argv in clear (B1), and stdin as
size and sha256. Every arg already passed `#secretCheck` in step 1, as the web verbs log their url
(`broker.mjs:1521`). After routing, the event log is the record of what ran as root. Then `onAdmit()`, which
sets the socket timeout to the call's timeout plus 10 s, as the web branch does (`server.mjs:95`).
7. `credentials.useFor(s, "host." + host, run)`, where `run` is D7.
8. D8's leak check on the output, then `#secretCheck` on the result.
9. Record phase `completed` with host, argv, exit, signal, ms, and size and sha256 of stdout and stderr. The
output itself never goes into an event. A failure records `host-failed` with the same fields.
Only one `host.run` per (agent, host) runs at a time. A second refuses `host-busy`. That keeps a stuck call
from piling up ssh processes and makes revoke (D9) simple.
Spawns reuse `runGit`'s shape (E12): an argv array, never a shell, `detached: true` so each process leads its
own group, SIGTERM to the group at the limit and SIGKILL after a grace period. If the helper can be shared, it
is, rather than a second copy. `env` is `{PATH: "/usr/bin:/bin"}` only, plus `SSH_AUTH_SOCK` for `ssh-add`.
Each spawn is wrapped in `setpriv --pdeathsig KILL --`, the prefix the seat sandbox already uses
(`seat/src/sandbox.mjs:44`), so it dies when the bus child dies (B3).
### D6. The command boundary
`argv` is an array of 1 to 256 strings. Each is 1 to 4096 bytes of UTF-8 with no NUL, and the total is at most
32768 bytes. `argv[0]` must not contain `/` unless it starts with `/`. `stdin` is optional, a string of at most
65536 bytes, and goes through `#secretCheck` with the args.
The remote command is built by the bus, never taken as a string:
```
sudo -n -- 'argv0' 'argv1' ...
```
Each element is POSIX single-quoted: wrap in `'`, and replace each `'` with `'\''`. The probe shows the
remote shell gets each element back exactly (section 2). `sudo -n` fails instead of prompting, so a host
without NOPASSWD returns sudo's error, not a hang. The host's login shell for `user` must be POSIX. That is a
host precondition for ops-01, listed in section 7.
`commands` in the grant is an argv allowlist and nothing else. It is a non-empty list of argv prefixes. An
argv passes when its first N elements equal some prefix of length N, compared element by element as literal
strings, with no globbing. `[["systemctl", "status"]]` admits `systemctl status nginx` and refuses
`systemctl restart nginx`.
There is no wildcard grant (sage-1450). Business-file load refuses, naming `roles.<agent>.hosts.<id>.commands`:
- any value that isn't a list, including the strings `"any"` and `"*"`,
- an empty list, an empty prefix, a non-string element, or a duplicate prefix,
- an element containing `*`, `?` or `[`, so nobody writes a glob that the matcher would read literally,
- a prefix whose first element, by basename, is a shell, a command runner or an interpreter. Shells: `sh`,
`bash`, `dash`, `zsh`, `ksh`, `fish`, `busybox`. Runners: `su`, `sudo`, `doas`, `pkexec`, `runuser`, `env`,
`xargs`, `nohup`, `setsid`, `timeout`, `nice`, `systemd-run`, `chroot`, `nsenter`, `unshare`, `setpriv`,
`capsh`, `flock`, `stdbuf`, `ionice`, `chrt`, `taskset`, `strace`, `script`, `watch`. Interpreters:
`python3`, `perl`, `ruby`, `node`, `awk` (Sage's ruling on duckworth-0043 N1). Each would turn the prefix
back into "any". T26 has one case per name.
That list can't be complete, and it isn't the control. Under 1.7 the control is Jason's decision on each
call, bound to host, argv and stdin (D4, Q-S1). The list only refuses the obvious cases at load, early. Many commands can still start a shell or an editor as root (`systemctl edit`,
`find -exec`, interpreters). The allowlist limits mistakes and narrows what a grant means, but it is not a
sandbox. Stdin goes to the command, so some prefixes are root on their own: `[["tee"]]` or `[["dd"]]` writes
any file as root from stdin (N4). Section 6 says what remains.
Output: stdout and stderr each capped at 1 MiB. Past the cap the bus keeps reading and drops the rest so the
remote side doesn't block, and sets `truncated`. `timeout` defaults to 60 s, maximum 900 s. At the timeout
the bus sends SIGTERM to the ssh process group, then SIGKILL after 5 s, and answers `signal`. ssh's own exit
code 255 and a remote command exiting 255 can't be told apart. The result returns stderr so the agent can see
which, and the doc says so.
### D7. Handing the key to ssh
Per call, inside `useFor`'s operation:
1. `mkdtemp` a dir under `<configDir>/secrets/run/` with mode 0700. `<configDir>` is never bound into a sandbox
(199 D3). The socket path is checked against the 107-byte Unix socket limit before use, refusing
`host-failed` if over.
2. Write two files in it, mode 0600: `known_hosts` with one line per pinned key, `mosaic-host-<hostId> <type>
<base64>`, and `id.pub` with the entry's public key (D2).
3. Write `<dir>/pids` with the bus child's pid. Each line in it is a pid and that process's start time,
field 22 of `/proc/<pid>/stat`, so a reused pid never matches (duckworth-0043 N3). Start `ssh-agent -D -a <dir>/a.sock` as its own group leader,
append its pid, and wait for the socket. Run `ssh-add -t <timeout + 30> -` with `SSH_AUTH_SOCK` set and the
private key written to its stdin. The key never touches disk, argv or env. `-t` makes ssh-agent drop the key
at the call's timeout plus 30 s even if nothing else runs (B3). ssh is the second group leader, and its pid
is appended too. Timeout, revoke and the `finally` kill both groups, ssh-agent's and ssh's (N3).
4. Run ssh with exactly these options and nothing from any config file:
```
ssh -F /dev/null -T -p <port>
-o BatchMode=yes -o IdentitiesOnly=yes -o IdentityFile=<dir>/id.pub -o IdentityAgent=<dir>/a.sock
-o StrictHostKeyChecking=yes -o UserKnownHostsFile=<dir>/known_hosts -o GlobalKnownHostsFile=/dev/null
-o HostKeyAlias=mosaic-host-<hostId> -o UpdateHostKeys=no -o CheckHostIP=no -o VerifyHostKeyDNS=no
-o ForwardAgent=no -o ForwardX11=no -o ClearAllForwardings=yes -o PermitLocalCommand=no
-o ProxyCommand=none -o ProxyJump=none -o ControlMaster=no -o ControlPath=none
-o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3
-l <user> -- <address> <remote command from D6>
```
The user goes in `-l` and the address is its own argument, so nothing depends on how ssh splits `@` or on
an IPv6 literal (N5). `-F /dev/null` stops `~/.ssh/config` from adding a ProxyCommand, a different key or forwarding. The pin is
`StrictHostKeyChecking=yes` against a file the bus wrote from the registry, so there is no trust on first
use: an unknown or changed host key refuses with rc 255 (probe, section 2) and the bus answers
`host-key-mismatch` when stderr has "Host key verification failed".
5. In a `finally`: kill both groups, remove the dir. A test asserts the dir is gone after success, refusal,
timeout and an exception thrown inside the operation.
6. If the bus child is killed, the `finally` never runs. `--pdeathsig` kills ssh-agent and ssh with it, and
`-t` drops the key anyway. At bus start, before `broker.sock` listens, the bus sweeps
`<configDir>/secrets/run/`. A recorded pid counts as alive only when it exists and its start time matches.
For each dir whose bus pid is no longer alive, it sends SIGKILL to each recorded pid that is alive and
whose `/proc/<pid>/cmdline` names that dir, then removes the dir. A dir whose bus pid is alive is left
alone. Any other entry there refuses start, as 199 refuses a stray file
in the store (`credentials.mjs:222`). Revoke after a restart has nothing in flight to reach.
The 0600 temp key file route the brief allows is not used. The agent route keeps the private key off disk,
and the fd route doesn't work (probe).
### D8. The leak check
This is defense in depth (N6). The remote never holds the private key and forwarding is off, so output can
carry the key only if the agent already had it. `assertClean` misses a multi-line key (E3). A `host.*` entry
in the shared token map makes its loop compare against a value that never matches, which is harmless. For a `host.*` entry, `useFor` checks the result with
`assertCleanKey(result, entry)`, which searches the JSON text for:
- each base64 body line of the PEM of 24 characters or more,
- the whole body with line breaks removed,
- the 32-byte ed25519 private seed as lowercase hex and as base64 at each of the three byte offsets.
A match refuses `credential-leak`. The output is dropped, and the event records only sizes and hashes. The
existing `assertClean` still runs for the token kinds. The same function runs on `stdin` before the call, so
an agent can't round-trip a key it somehow has back through the host.
### D9. Lifecycle
199's verbs carry over with `KIND = host.<hostId>`. Two additions:
- `issue AGENT host.<id>` checks D2, then prints the public key line and a suggested `authorized_keys` line
for ops-01: `from="<stack host address>",restrict <type> <base64> mosaic-<business>-<agent>-<id>`. It
prints only public material.
- `revoke` also kills a `host.run` in flight for that agent and host (process group SIGKILL) before it drops
the key. The call answers `credential-unavailable`. Remote processes that the killed ssh started may keep
running (section 6).
`status` shows each host entry with its fingerprint (SHA256 of the public key), the rotate date and state.
The tester gets `status` and `revoke` only, as in 199.
### D10. Where the files change
| File | Change |
|---|---|
| `business/src/vocabulary.mjs` | `host.sudo` in ACTIONS, `credentialKind`, host id pattern |
| `business/src/business.mjs` | top-level `hosts` registry, role-entry `hosts`, load checks from D3 |
| `business/src/role.mjs` | `{"service": "host"}` in `checkCredentials` with no scopes |
| `business/src/credentials.mjs` | `host.*` refs take `rotateBy` |
| `bus/src/credentials.mjs` | kind check via `credentialKind`, `checkValue` (D2), `assertCleanKey` (D8), busy and kill map |
| `bus/src/host-run.mjs` (new) | D6 argv and quoting, D7 ssh-agent and ssh spawn, cleanup |
| `bus/src/broker.mjs` | `host.run` verb (D5), `host.sudo` in `#authorityFor` with `hostRun` (D4) |
| `bus/src/server.mjs` | `host.run` branch beside the web branch with `onAdmit` |
| `bus/src/business.mjs` | carry `hosts` onto the bus role entry |
| `cli` | 199's `credentials issue` accepts `host.<id>`, prints D9's lines |
No `packages/harness` file changes. The `host_run` tool is Sage's follow-up row (sage-1468).
Row 199's own kind and regex are not changed inside row 199. This row rebases onto 199's landing and changes
them here.
## 4. Who grants (brief item, "yours to propose with reasons")
Proposal: **Jason, at every layer that widens access. No agent, chief included, and not the tester.**
| Layer | Who | How | Why |
|---|---|---|---|
| Definition declares `host` (and later lists `host.sudo`) | Jason | reviewed commit to `roles/*.json`, as for any role authority (AGENTS.md invariant 7) | It decides which kinds of agent may ever hold a host key. Review puts a second seat on it |
| Host registry and pins | Jason, with ops-01's key fingerprints | hand edit of the business file | The pin is the host identity. A wrong pin is a man-in-the-middle on root. It must come from the host side, not from a first connection |
| Agent grant `roles.<agent>.hosts` | Jason | hand edit of the business file | Q183: "not all agents will need to have that access". The per-agent, per-host choice is his |
| Key `issue` | Jason | 199's human-only `issue` | The key passes through the caller. The tester is an agent (199 section 6, duckworth N1) |
| `authorized_keys` on the host | ops-01 | host side, from the line `issue` prints | ops-01 owns the hosts (brief) |
| Revoke | Jason, Mos, or the tester | `revoke` | Taking access away should be quick and needs no new trust |
Why not a reviewed commit for the agent grant too. The business file lives in `<configDir>`, outside the
repository (AGENTS.md invariant 2), and per-instance grants already stay hand edits there (E9, 199 D9 step 2).
Moving grants into the repo would put host addresses and the per-instance roster under review, which is a
bigger change than this row. The definition layer is the reviewed one.
Why not the chief. A chief with this power could hire an agent and grant it root. 199 already keeps hires
uncredentialed (199 D13). `instances.set` refuses credentialed definitions today (E9), and this row keeps it
that way.
REQ-CRED-3 says the who-grants answer is set by this design and approved by Jason. The table above is that
answer.
## 5. The routing gap and a proposed 1.8 requirement
PRD 1.7 routes `role.hire`, `role.fire` and `git.push.remote`, not `host.sudo`, and 1.7 is frozen (sage-1450).
This row builds for 1.7 as it stands. `host.sudo` is a hard wait under REQ-WAIT-3, and Jason decides each
`host.run`, bound to host, argv and stdin (D4). That is safe, and slower than Q183's "allow agents to perform
their jobs". The routing goes in 1.8. Sage takes the line below to Mos for Jason. I don't edit the PRD.
Proposed for PRD 1.8, beside REQ-CRED-3:
> **REQ-CRED-4.** The action `host.sudo` is routed. A role whose definition lists it within-role may run a
> command with sudo on a host without a decision for each call, but only when all of these hold: the
> definition declares the host credential kind, the business file grants that agent that host, the command
> matches the grant's argv allowlist, and the agent's key for that host is issued. A grant names exact
> argv prefixes. A wildcard grant is refused. A role without `host.sudo` within-role still needs a decision
> for each call. Jason adds `host.sudo` to a definition only through a reviewed role change.
Routing is then one reviewed commit: `host.sudo` into both ROUTED lists and into the chosen definitions'
`withinRole`, as 199 does for `git.push.remote`.
The brief also says this row lands only after the PRD version holding REQ-CRED-3 is approved. Jason approved
1.7 (d52febb5 yes at 20:36:47Z, mos-1089), so that holds. 1.7 doesn't route `host.sudo`, so Q-S1 stands.
## 6. Limits, said plainly
- **Known limit: the seat network isn't confined, so root can outlive the grant.** `seat/src/sandbox.mjs:29-31`
says the sandbox doesn't confine the network (E11). An agent whose allowlist admits any command that can
write a file as root, or start a shell, can add its own key to `authorized_keys` on the host. It can then
ssh straight from its sandbox, around the bus, the events and revoke. An `authorized_keys` `from=` option
doesn't help, because the sandbox shares the stack host's address. The allowlist (D6) narrows this but
doesn't close it.
What closes it: a seat sandbox with its own network namespace (`bwrap --unshare-net`), where the only way
out is the bus sockets and the model proxy, so role network `none` and `api-only` are enforced and not just
carried in the bundle (E13). With that in place, a planted key is useless from the sandbox. Until then,
ops-01's host-side audit (sudo log, `authorized_keys` changes) is the check after the fact. Sage is
reporting the gap to Mos as its own follow-up (sage-1450). It is not this row.
- **The stack's own machine.** A registry entry for the machine the stack runs on, or any machine holding the
stack's `<configDir>`, gives an agent with a root-writing command every agent's keys and tokens, not only
root there (N2). Load refuses loopback addresses, but it can't know every address of the stack's machine.
Jason doesn't register it.
- **Revoke stops the stack's path, not remote processes.** Killing ssh may leave a remote process running.
`-T` gives no tty, so there is no SIGHUP from a closing terminal.
- **Same OS user.** Anything running as the stack's OS user outside a sandbox can read the key file, as 199 D6
says for every kind.
- **Exit 255** is ssh's failure code and also a remote exit code (D6).
- **The pin trusts the registry.** Whoever writes the business file controls which host key is trusted. That
is Jason (section 4).
## 7. Host preconditions (ops-01, not this row)
- `user` has NOPASSWD sudo and a POSIX login shell. `sudo` has no `requiretty` for that user.
- The agent's key is in `authorized_keys` with `from="<stack host>",restrict`.
- The host's public keys are handed to Jason for the registry.
- Duckworth's #423 r5 condition (duckworth-0035) applies. If ops-01 changes #423 step 1 to fit this design,
for example per-agent `from=...,restrict` lines for localadmin, that change goes to Duckworth as a diff
before it runs. That is the live part, not this row (N7).
## 8. Tests
All tests use a scratch sshd on 127.0.0.1 started by the test, with keys made by the test, and a fake `sudo`
on the test user's PATH that prints its argv as JSON and runs nothing as root. The sshd config sets
`AuthorizedKeysFile` and `HostKey` in the test's `mkdtemp` dir. A helper skips the ssh tests with a visible
reason only when `/usr/bin/sshd` is missing, and the gate fails if any of them skipped. No test reaches
another host.
| # | Test | Refusal or result |
|---|---|---|
| T1 | business load: `hosts` registry field checks, bad address, each own-machine form from D3 (`127.0.0.2`, `0.0.0.0`, `::1`, `::`, `::ffff:127.0.0.1`, `::ffff:0.0.0.0`, `localhost`, `a.localhost`, `127.1`, `2130706433`, `0x7f.1`), with `10.1.1.53` and `host.example` as controls, port, user, empty `hostKeys`, a key line with a comment | load refuses, names the key path |
| T2 | role entry `hosts` naming an unknown host, or on a definition without `host` | load refuses `roles.<agent>.hosts` |
| T3 | `organization.set` and `instances.set` with `hosts` in args | `invalid-request`, and the file is unchanged |
| T4 | value check: a valid unencrypted ed25519 key loads. An encrypted key, an RSA key, two keys, CRLF, a gitea token in a `host.*` slot, and a PEM in a `gitea` slot each refuse | `credential-format` |
| T5 | `host.run` with an unknown host | `host-unknown` |
| T6 | `host.run` on a host not in the caller's grant, including a host granted to another agent | `host-not-granted` |
| T7 | argv shape: empty, a NUL, too long, too many, a relative path with `/`, non-string | `invalid-request` |
| T8 | allowlist: a matching argv runs, a longer argv runs, a shorter one and a differing element refuse | `command-not-granted` |
| T9 | before routing: no decision refuses, a decision for another argv, another host or another stdin refuses, a spent one refuses. The stored question carries host, user, argv and the full stdin, and a raise whose question would pass 4096 characters refuses | `gated`, `decision-mismatch`, `decision-consumed`, `invalid-request` |
| T10 | quoting: argv with spaces, quotes, `$()`, backticks, `;`, newline and glob characters reaches fake sudo unchanged | fake sudo's JSON equals argv |
| T11 | the remote command starts `sudo -n --` | fake sudo sees `-n` and `--` first |
| T12 | exit code and signal pass through, timeout kills the process group and answers `signal` | result fields |
| T13 | output cap: 2 MiB of stdout returns 1 MiB and `truncated.stdout: true`, and the call doesn't hang | result fields |
| T14 | host key pin: the sshd presents a key not in `hostKeys` | `host-key-mismatch`, and no command ran (fake sudo's marker file absent) |
| T15 | a registry with the right key passes on a fresh dir with no `~/.ssh/known_hosts` in HOME. A HOME with a `~/.ssh/config` setting a ProxyCommand doesn't change the call | runs, and the proxy marker is absent |
| T16 | leak: the fake sudo prints the private key file (multi-line), and in a second case prints only one body line, and in a third the seed in hex | `credential-leak`, the event has no output |
| T17 | `stdin` containing a body line of the key | `credential-leak` before any spawn |
| T18 | cleanup: after success, refusal, timeout and a thrown error, the run dir is gone and no `ssh-agent` or `ssh` with that dir in argv survives | dir absent, pgrep empty |
| T19 | the private key never appears in the argv or env of any spawned process (read from `/proc/<pid>/cmdline` and `environ` while the fake sudo sleeps) | absent |
| T20 | second concurrent call for the same agent and host | `host-busy` |
| T21 | revoke during a sleeping call kills it, and the next call refuses | `credential-unavailable` |
| T22 | events: `action.allowed` requested and completed carry host, address, user and argv in clear, and sizes and hashes of stdin, stdout and stderr. Never the output, the stdin or the key | event bodies |
| T23 | `issue` of a `host.*` kind prints the public line and the `authorized_keys` suggestion and never the private key | stdout and stderr checked |
| T24 | build grep: no literal `'gitea', 'vikunja'` kind list outside `vocabulary.mjs`, and 199's `credentials.use(` allowlist unchanged | grep |
| T25 | no role file in `roles/` lists `host.sudo` while it is a hard wait (REQ-WAIT-2) | grep |
| T27 | SIGKILL the bus child while fake sudo sleeps: the ssh and ssh-agent pids are gone within 2 s. A call with `timeout` 1 leaves an agent whose key is gone after 31 s (`ssh-add -l` on its socket, agent kept alive by the test). The next bus start removes the run dir, kills a planted live process whose cmdline names it, leaves a dir whose bus pid is alive, and leaves a live process whose pid matches but whose start time doesn't | pids, `ssh-add -l`, dir state |
| T26 | load refuses `commands` as `"any"`, `"*"`, `{}`, `[]`, `[[]]`, a non-string element, a duplicate prefix, an element with `*`, `?` or `[`, and a prefix starting with each listed shell or runner, `/bin/bash` included | load refuses, names `roles.<agent>.hosts.<id>.commands` |
## 9. Mutants
One per refusal, plus the two the brief names. Each must be killed.
| # | Mutant | Killed by |
|---|---|---|
| M1 | registry check skipped | T5 |
| M2 | agent grant check skipped | T6 |
| M3 | grant check reads any agent's `hosts` | T6 |
| M4 | each argv shape check removed, one mutant each (5) | T7 |
| M5 | prefix match compares only `argv[0]` | T8 |
| M6 | load accepts a string `commands` (wildcard grant) | T26 |
| M7 | `#checkAuthority` call removed | T9 |
| M8 | `hostRun` argv hash not compared | T9 |
| M9 | `hostRun` stdin hash not compared | T9 |
| M10 | quoting drops the `'\''` replacement | T10 |
| M11 | `sudo -n` loses `-n` | T11 |
| M12 | **host key pin skipped**: `StrictHostKeyChecking=accept-new` | T14 |
| M13 | **pin skipped another way**: `UserKnownHostsFile` set to the user's default | T14, T15 |
| M14 | `-F /dev/null` removed | T15 |
| M15 | **key leaks into output**: `assertCleanKey` not called | T16 |
| M16 | leak check uses only the joined body | T16 (one-line case) |
| M17 | stdin leak check removed | T17 |
| M18 | `finally` cleanup removed | T18 |
| M19 | key passed with `-i` from a temp file left on disk | T18, T19 |
| M20 | output cap removed | T13 |
| M21 | timeout kill removed | T12 |
| M22 | busy check removed | T20 |
| M23 | revoke doesn't kill the in-flight call | T21 |
| M24 | event records stdout | T22 |
| M25 | each value check removed, one mutant each (encrypted, type, count, CRLF) | T4 |
| M26 | `organization.set` accepts `hosts` | T3 |
| M27 | load check for a definition without `host` removed | T2 |
| M28 | shell and runner check removed, and a second mutant compares `argv[0]` without taking the basename | T26 |
| M29 | glob character check removed | T26 |
| M30 | `requested` event drops argv | T22 |
| M31 | the question omits stdin | T9 |
| M32 | `--pdeathsig` dropped from the spawns | T27 |
| M33 | `ssh-add` without `-t` | T27 |
| M34 | start sweep removed, a second mutant sweeps without checking the bus pid, and a third ignores the start time | T27 |
| M35 | loopback check removed | T1 |
The mutant script follows row 201's shape: copy the packages to scratch, anchor each mutant once, run each
test file in its own process group with a timeout, control first, exit 1 on any survivor. It runs on Sage's
slot.
## 10. Questions
- Q-S1, answered (sage-1450): 1.7 is frozen. Hard wait under 1.7, and section 5 holds the 1.8 proposal.
- Q-S1 still holds after 1.7's approval, which doesn't route `host.sudo` (sage-1468).
- Q-S2, answered (sage-1450): 199's deviation 11 keeps credential use in the bus child, so D5 stays.
- **Q-J0 (Jason, through Mos, before landing, B4, mos-1088).** Word for word:
> Q-J0 (#424). Who may give an agent sudo on another machine?
> Proposal: only you. No agent can, and that includes the chief of staff and the tester.
> You make a grant in four steps, all yours. A reviewed change to the agent's role allows machine keys at all.
> You list the machine and its fingerprint. You give that agent that machine with an exact list of allowed
> commands. You issue the key, and ops-01 installs it. Until a later PRD says otherwise, you also approve
> each command before it runs. You, Mos or the tester can revoke at once. The stack drops the key and cuts
> its connection. A command already started on the machine may keep running. A grant of "any" command is
> root on that machine, so the design refuses it. Some listed commands, such as a file writer, still reach
> root, so grant a machine only to an agent you'd trust as root there.
> Do you approve: only you grant machine access, as above? Yes or no. I recommend yes.
Mos records the answer in DECISIONS.md in jason-master-restart.
- **Q-J1 (Jason, through Mos, at the live window):** the first grants, each an argv allowlist per agent and
host. Not needed for the build.
- Q-S3, answered (sage-1468): the `host_run` harness tool is out of this row. Sage files a follow-up row for
publish and `host_run` in `packages/harness` (scope line at the top).
@@ -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.
-167
View File
@@ -1,167 +0,0 @@
# Row 199 (#1697): per-agent credential store, each agent's own git identity, publish to its fork
Builder: Megavolt. Brief: `docs/plans/2026-10-20_agent-credentials.md`. Design: `DESIGN.md` r6 at
4d3007ec9, approved by Duckworth in duckworth-0027 (REVIEW-r6.md at 640011518). Authority model ruled
by Mos in mos-1027. Branch: megavolt/row199. Reviewer: Duckworth.
Status: in progress. The full gate has not run; it is Sage's. Only single test files have run on
this branch, apart from one slip noted under "Runs".
## Commits so far
- 286bf598c business: agent identity, the publish grant, the project upstream with the D9c parse, store
credential references; git.push.remote as a hard wait.
- a2c387dba bus: broker publish authority (checkPublish, recordPublish, publishRefused, pushCommit on
the raise); the D9c parse moves to `business/src/vocabulary.mjs`; pm.md and chief-of-staff.md list
git.push.remote among the hard waits.
- 609dc9364 bus: the per-agent credential store in `credentials.mjs`.
- 504336797 bus: runtime wiring, the store root and the four publish entries in the op table.
- a18fc6cf8 tasks: request calls use `useFor` keyed from the session; the start probe keeps `use`. The
D5 test in `credential-store.test.mjs` pins the `use(` call sites to `credentials.mjs` (useFor's own
call) and `tasks/src/adapter.mjs` (the host's @sync and start probe).
- aa740cc71 bus: the lifecycle verbs `credentials.issue`, `credentials.revoke` and `credentials.delete`
in `credential-verbs.mjs`, the broker branch, the runtime wiring; `organizationWriter` gets a
`detail` on `write` and a receipt-only `record`.
- 0cdace415 cli: `mosaic credentials status|issue|rotate|revoke|delete`, the scripts/mosaic route, docs.
## Deviations from DESIGN.md r6
1. **Identity is optional on an agent.** D8 reads as identity on every agent. Today's business files
carry none, so identity stays optional and `publish` requires it (N10's load refusal). An agent
without identity commits as E7 (T6).
2. **Revoke of a `{file}` or `{env}` reference.** Section 6 describes revoke for store references.
For a reference outside the store, revoke forgets the token in memory and never unlinks the file;
the lifecycle verb then turns the business file's reference into `store`, so a restart finds
nothing. The test: `credential-store.test.mjs`, "revoke of a {file} reference".
3. **Issue order.** Issue writes the token file (tmp, fsync, rename, dir fsync), then runs the
caller's commit (the business file's reference becomes `store`), then sets memory. A failed commit
leaves the file on disk and the token out of memory, so the agent stays `not-issued` until the
issue is run again. The test: "a failed commit leaves the token on disk but not in memory".
4. **The D9c parse lives in `business/src/vocabulary.mjs`, not `business.mjs`.** The broker reads
`forkProblem` and `forkUrl`, and `project-runtime.test.mjs` pins the broker's import closure to
files that import nothing. `business.mjs` wraps `parseGiteaUrl` with the loader's refusal
messages. The pin test names the two new imports.
5. **`withGitea` takes the publish ticket, not a cap (D9b).** The key is the session checkPublish
checked with `#agent` (a run holding its claim; never a tester, reader or human), read through
`broker.publishSession(ticket)`. The token is usable only between checkPublish and recordPublish
or publishRefused. Its refusals are filed once, by publishRefused on the task's trail, and not
also by `refusing()`. The test: `publish.test.mjs`, "runtime: withGitea keys from the open ticket
only".
6. **Role contracts.** `workflow-roles.test.mjs` requires the hard waits listed in `roles/pm.md` and
`roles/chief-of-staff.md` to equal GATED_ONLY. Adding git.push.remote to the vocabulary made it
fail, so both lists now name git.push.remote. Prose only; no hash pin reads either file. Duckworth
should review this hunk because `roles/` is reviewed authority.
7. **Delete takes a reason.** Section 6 lists `delete AGENT` with no reason. The broker requires
`reason` on delete as on revoke, so every receipt that ends a credential says why.
8. **Rotate is `credentials.issue`.** There are three bus verbs, not four. An issue over an entry the
bus holds is recorded as `rotate` in the receipt and the answer. The CLI's `rotate` reads the
status first and refuses before reading stdin when nothing is issued.
9. **Issue covers entries the bus loaded.** The business file must already name the kind for the
agent, and the bus must have loaded that reference at start; otherwise `credential-unknown`. An
agent added after the bus started needs the restart row 165 already asks for. Row 194's human
`create` is where a new agent's `store` references come from.
10. **No Console page yet.** Section 6 puts the verbs in the Console's agent page too. This branch has
the bus verbs and the CLI. The Console page is a separate piece (packages/webui, its `BUS_VERBS`
and `STATUS` pins); I raise the split with Sage before the manifest. Ruled in sage-1421: the
Console page is out of row 199 and becomes a follow-up row with Webby as reviewer. The manifest
holds bus, business, tasks, cli and seat.
11. **The push runs in the bus child, and the git socket polls for it (D9b).** The git socket runs in
the host process. The broker, the runtime and the token are in the bus child that
`packages/cli/src/host.mjs` forks, and host ops cross that IPC one at a time with a 10 s answer
limit. ls-remote and the push can run longer than that, so `withGitea(ticket, fn)` can't wrap a
host-side git. The split: the socket runs D9 steps 3 and 4 (tip and identity under the lock,
`working.publish`), then op `publishPush`, which starts `publishRemote` in the bus child under
`withGitea` and answers `{state: 'running'}`. The socket polls op `publishResult` every 200 ms
until it reads `pushed` or `refused`. Fork, user, source and destination come from the ticket
(`broker.publishTarget`), never from the host's message. `recordPublish` also refuses `ref-moved`
when the pushed ref isn't the ticket's destination. A second `publishPush` on a running ticket
refuses `publish-busy`. The token reaches git on fd 3 only (`runGit` option `pass`), read once
by a one-shot credential helper that answers `get` for https on the fork's exact host and port.
On close, a running publish's git isn't awaited; git's own time limit ends it, and the ticket
goes with the process. The 10 s IPC limit stays as it is (sage-1440). The poll has a bound
(sage-1440): `publishDeadlineMs`, by default four times git's limit and grace (publishRemote runs
at most four git commands) plus 30 s. Past it the socket refuses `publish-timeout`, filed once by
publishRefused, and logs that the bus process's push may still reach the fork. A failure that is not a refusal is filed as `git-failed` and logged on
the bus's stderr by error name and code only. Tests: `seat/tests/publish.test.mjs` (11),
`bus/tests/publish.test.mjs` (the process ops), `cli/tests/publish-socket.test.mjs` (7, end to
end through the socket, the in-process runtime, the store and a TLS fake Gitea; one is the
publish-timeout outcome).
12. **No harness tool for publish.** `packages/harness/src/git-tools.mjs` has push, fetch and land
clients. A runner tool that calls the socket's `publish` op is in packages/harness, outside the
manifest scope sage-1421 set. Today a publish needs a socket client; the tool is a follow-up.
13. **The bind rule's `git.push.remote` clause has no test of its own.** D11 adds it and the
launcher has it. A role can publish only a push its own role made (`work-not-yours`), and
`recordPush` needs `git.push.working`, which already binds the socket. No role reaches the
clause alone, so dropping it changes no outcome today. It matters once a role can publish work
it didn't push. Ruled in sage-1440: keep it; its mutant goes in the section 11 list as
equivalent, with this reasoning, and Duckworth judges it.
14. **A push between checkPublish and the git push refuses; it never lands a different commit.**
T3 says such a push "doesn't change the commit that lands". Under the hard wait it refuses:
`working.publish` checks the work ref's tip against the ticket's source under the lock
(`ref-moved`, `seat/tests/publish.test.mjs`), and `recordPublish` checks `pushCommit` again
(`decision-mismatch`, `bus/tests/publish.test.mjs` "r4 B4"). The fork gets only the ticket's
oid either way. N9's routing-commit test still applies. Ruled in sage-1440: T3's wording in
DESIGN.md now reads "refuses with `ref-moved` or `decision-mismatch`", and an end-to-end test
asserts each code mid-flight in `cli/tests/publish-socket.test.mjs`: a push after checkPublish
refuses `ref-moved` with no request to the fork; a push recorded while the bus process pushes
refuses `decision-mismatch`, filed once, and the fork holds only the ticket's oid.
15. **Fix: rotate on a due gitea key (gizmoduck-0050, sage-1494).** The CLI's `ISSUED` set
(`packages/cli/src/credentials.mjs`) listed valid, expiring and expired, but the bus's `status()`
gives a gitea key past its rotate-by date `rotation-due`. So `credentials rotate` refused the one
case it exists for. `ISSUED` now names every state status() gives a held token. The test:
`cli/tests/credentials.test.mjs`, "rotate goes ahead on each held state". Its mutant (the old set)
fails that test and goes in the section 11 list.
## N9, for the routing commit (sage-1388)
Today git.push.remote is a hard wait, so every publish carries a decision, and the decision's
`pushCommit` fixes the commit. If a later commit routes git.push.remote within role, a publish has
no decision and no `pushCommit`. `#publishFacts` then takes the commit from `#latestPush`, and the
git socket's tip check under the lock is the only thing that holds it. That commit needs a test
that a push between checkPublish and the git push doesn't change what lands.
## R1, the backup set
Checked at 504336797. Row 185's `backup_store` (`scripts/stack-release.sh:372-392`) copies only
`<dataRoot>/bus/bus.sqlite`, plus its sums, modes, listing and stamps, into
`<dataRoot>/backups/stack/<activation>/`. Nothing under `<configDir>` is copied, so
`<configDir>/secrets/agents` isn't in the backup set. `rehearse` copies the live business file
through `R_BUSINESS_JS` (`:1268-1288`). Its `swap` replaces every credential reference, a `store`
one included, with a placeholder file in the rehearsal's own dir. The guard then refuses
`credential-ref-live` unless every reference is such a placeholder (`:1329-1337`), so a rehearsal
never reads a store token. A backup tool outside this repository that copies `<configDir>` would
copy tokens. That is R1's open half, and it is Jason's to check on each VM.
## T1 limit
A wrong owner on a store dir or file can't be set up without root, so T1 covers modes, symlinks and
dirs. The owner check (`st.uid !== process.getuid()`) is in `privateDir` and `#read`, and is untested.
## Runs
Single files, each green on its commit: bus broker, decision-close, decision-route, decisions,
hard-waits, land, project-runtime, launch-notice, review, publish, credential-store, credentials,
credential-status, limits, tester, plugins-verb, routes-verb, limits-runtime, instances-verb, web,
usage-runtime, limit-checks, daily-quota-runtime, chief-of-staff, tasks, tester-writes; every
business test file. Since: credential-verbs (2/2), credential-store (11/11, with D5), each tasks file,
tester, credential-status, plugins-verb, routes-verb, instances-verb; business launch-settings,
plugin-settings, instance-settings, token-limit-settings; cli credentials (3/3), cli, app,
tester-reads, tester, other-tree, transport, instances, limits, verbs. Hash pins: none on a changed
file (`thread-migration.test.mjs:46` pins DDL only).
Publish, single files, each green before its commit: seat
checkout (77), working (26), publish (11), landing (8); bus process (8), land (17), publish (12),
credential-verbs (2); cli publish-socket (4), git-socket (24), launcher (29); harness tools (22).
Hash pins: none on a changed file.
sage-1440 changes (the poll bound, the mid-flight tests), single files, green before the commit: cli
publish-socket (7), git-socket (24), launcher (29). Hash pins: none on a changed file.
The rotation-due fix, single files: cli credentials (4), green; the old-set mutant fails it 3/1.
Hash pins: none on a changed file.
Slip, 2026-10-20 about 19:51Z: I ran `node --test packages/tasks/tests/*.test.mjs` in one command,
which is a package suite, without asking Sage for the slot. It passed, 97 pass and 0 fail, in under a
minute. Reported to Sage in megavolt-0017.
-416
View File
@@ -1,416 +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.
## 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.
- Q-J2. Gitea users and tokens for the agents that need them (A1 to A3).
## 10. What this closes (brief item 7)
- G11: closes the store (D1 to D6) and lifecycle (section 6) for `gitea` and `vikunja`. Still open: per-agent
model credentials (D7), and the same-uid limit inside one stack instance (D6).
- G13: closes "own credentials for git" (D8 to D10). Still open: Gitea API verbs (section 4).
- C2.5 (git remote): Case 2's books repo can use `publish` once its project names a remote.
## 11. Tests and mutants
All tests use scratch roots, fake tokens and a fake Gitea: `git http-backend` behind a node http server on
127.0.0.1 that checks basic auth against a fake token and records each ref update. No request leaves the host.
- T1. Layout: wrong mode or owner on a dir or file refuses start. A `{file}` reference into another agent's
path refuses `credential-not-yours`. A bus restart after a create without `issue` starts, and the agent's
status is `not-issued`. A missing `{file}` still refuses start.
- T2. `useFor` keys from the cap only. A request naming another agent is refused and leaves one
`action.refused`.
- T3. The fake Gitea serves https on 127.0.0.1 with a cert from a scratch CA made by the test. It reaches git
only through the `tls` parameter (D9 step 5). Each refusal test that should reach the network asserts that
the first port received the request, so no test passes because git refused http first. `publish`: the
happy path lands `refs/heads/<user>/<branch>` on the agent's fork in the fake, which shares root's history, at
exactly the decision's commit. Refusals for each step in D9, one test each, including `work-not-yours`. Until routing, a publish with
no decision refuses, one with a decision for another task or commit refuses, and a second publish on a
spent decision refuses `decision-consumed`. Approve while the latest push is A, push B, publish: refuses
`decision-mismatch`, and the fork receives nothing. A push to the working repo between `checkPublish` and
the git push refuses with `ref-moved` or `decision-mismatch` (sage-1440, BUILD.md deviation 14). A raise with no recorded push refuses `no-push`. A test pins that `git.push.remote` is in neither ROUTED list
and that no `roles/*.json` grants it. A force-push attempt is
refused by git's non-fast-forward. A fake fork that answers 302 to a second 127.0.0.1 port: the push
fails, and the second port receives no request with an Authorization header. A remote on the right host
and a different port is refused at step 2. Two agents granted one project each land on their own fork:
agent A's push reaches `/<A's user>/REPO` only and B's reaches `/<B's user>/REPO` only, and the fake's
upstream repository receives nothing. An upstream owned by the agent's own user refuses `remote-not-fork`,
and so does one whose owner differs from `gitea.user` only in case. With an upstream that shares root's
history, a first publish to an empty fork and to an unrelated fork each refuse `remote-unrelated`, and
neither fork nor upstream receives objects. A fork that doesn't exist refuses `fork-unavailable`, and the
fake records no push. `ls-remote` and the push each reach the fork's path only (the fake records every
path). Load refusals (D9c), one test each: a dot segment, an extra path segment, a missing segment,
a trailing slash, userinfo, a query, a fragment, an explicit `:443`, a name outside Gitea's rule, and a `gitea.url` with a
sub-path. A `.git` suffix loads and builds the fork without it. A `publish` grant on an agent without
`identity`, and on one whose definition lacks `gitea`, each refuse at load with the N10 message.
- T4. Token handoff: the token never appears in argv, env, the fork URL, the working repo config,
`changes.jsonl`, events or logs (scan every file under the scratch roots).
- T5. Lifecycle: issue, rotate, revoke and delete, each live with no restart. After revoke, `publish` and the
Vikunja path refuse `credential-unavailable`, and so they do after a bus restart that follows the revoke.
The tester can run `status` and `revoke`, and `issue` and `rotate` refuse it.
- T6. Default: an agent without `identity` commits as E7. Existing `{file}` references load unchanged.
Mutants to kill: drop the cap-derived key in `useFor`, let the request name the destination ref, add `+` to
the refspec, skip the identity check, put the token in the URL, revoke file before memory, skip the dir mode
check, allow a `{file}` reference into another agent's dir, make ENOENT on a `store` path fatal again, make
ENOENT on a `{file}` path silent, a helper that answers any host, drop `http.followRedirects=false`, drop the
`work-not-yours` check, drop the `remote-unrelated` check, add `git.push.remote` to ROUTED, skip the spent
check on the decision, drop the `pushCommit` comparison in `#authorityFor`, push by ref name in place of the
oid, push to the upstream URL in place of the derived fork, run `ls-remote` against the upstream in place of
the fork, take the fork's owner from anywhere but the agent's `gitea.user`, compare the owner with case, run the
D9c checks on the raw path in place of the normalized one, read a failed `ls-remote` as empty, skip the tip
check under the lock, and pass a CA flag in production.
## 12. Risks
- R1. Backups: if a backup tool copies `<configDir>`, it copies tokens. The build checks row 185's backup set
and says what it includes.
- R2. Withdrawn. O1 was ruled, so probe P1 isn't needed.
- R3. Outbound HTTPS from the bus host to Gitea is new network reach for the host process. On the VMs this is
Jason's network to confirm.
+10
View File
@@ -0,0 +1,10 @@
# Quackerjack
Builder seat, Claude Haiku 5.5 at high effort, started by Sage under mos-1023 item 1 and Q177 for federation
rows R5 (`mosaic access list, grant, revoke`) and R2 (the `federation` config key), per Gizmoduck's design
section 7 (#414).
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own
`quackerjack/*` branches through the repository credential helper (mos-0981), never `refactor`, `main` or
another seat's branch.
-6
View File
@@ -238,12 +238,6 @@ Exit codes: `0` ok · `2` invalid file or credential reference · `3` system
config problem · `4` usage or a required file missing. Details:
`packages/business/README.md`.
`mosaic credentials status|issue|rotate|revoke|delete` (row 199) goes to
`packages/cli` and the bus's human-only credential verbs: an agent's own
token in `<configDir>/secrets/agents/<business>/<agent>/<kind>`, read on
stdin, never printed. The tester may run `status` and `revoke`. Details:
`packages/cli/README.md`.
`instances` and `instance add|set` (row 165) go to `packages/cli` and the
bus's human-only `instances` and `instances.set` verbs, so they need a live
bus host and refuse inside an agent session. `instances` prints the revision,
@@ -0,0 +1,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.
+70
View File
@@ -0,0 +1,70 @@
# Brief: agent host access with sudo through the credential store (#424)
Sage, 2026-10-20, for Jason's rulings Q183 and Q184 relayed by Mos (mos-1056 item 3, mos-1067). Gitea #1707, Vikunja P19
#424, due 2026-10-27 (Q184: "Q5: by 10.27"). Owner Gizmoduck (design and build), reviewer Duckworth, gate owner
Sage. Chained on row 199's candidate (Q177): row 199 design r6 (4d3007ec9 on `megavolt/row199`, approved in
duckworth-0027) is not reopened. This row lands after 199 and rebases onto its landing.
## What Jason asked for
Q183, through Mos: "I envision a credential store within Mosaic Stack that works as some sort of variable for
the agent to call. The system will enforce access to the credentials to keep the agents sandboxed with proper
security... If we can utilize the credential store in Mosaic Stack and allow agents to perform their jobs with
NOPASSWD sudo on various machines, I am a happy man." Q183 also says not every agent needs that access.
Mos's requirements (mos-1067 item 1):
- An agent asks for a host credential by name.
- The stack checks a grant for that agent and that host, and does the ssh and sudo for it.
- The agent never sees the key or any secret value.
- Only agents with a grant can do it.
The PRD text is REQ-CRED-3, drafted by Honker for PRD 1.7 (sage-1422). If it misses 1.7 it goes into 1.8, and
this row lands only after the version that holds it is approved.
## What row 199 gives and what it doesn't (megavolt-0019)
It builds on 199 as 199 stands: the store layout under `<configDir>/secrets/agents/<business>/<agent>/`, the
lifecycle verbs and their `changes.jsonl` receipts, `useFor(session, service, fn)` with the D5 allowlist test,
`assertClean` on every result, and the grant shape of `publish` (a list on the role entry, checked by the
broker, plus a hard-wait action that isn't routed).
This row's own changes:
- A host kind in the closed kind set (vocabulary.mjs `SERVICES`, credentials.mjs, the broker's service check,
the CLI date flag, the remove regex), with a naming rule for one credential per host or per agent.
- A per-kind value check. An OpenSSH private key fails today's `TOKEN` pattern.
- Handing the key to ssh without the agent seeing it: an ssh-agent on a private socket, or a 0600 temp file
removed on every path, the way publish's one-shot helper works.
- Pinned host keys per host, and no trust on first use.
- The command boundary: what an agent may run under sudo, and how output returns without echoing the secret.
This is the main design work.
- The executor: a ticket and a host op running in the bus child, the same split row 199 uses for publish
(Megavolt's deviation in 199's BUILD.md).
## Steps
1. DESIGN.md first, in `agents/gizmoduck/work/queue-ROW/`. It answers each item above, names the grant shape
(`roles.<agent>.hosts` and a `host.sudo` action, or a reasoned alternative), says who grants (Jason, through
a reviewed role change, unless REQ-CRED-3 says otherwise), and lists tests and mutants. Duckworth approves it
before code. Sage reads it too, because it gives agents sudo on hosts.
2. Build on 199's candidate. Single test files run freely. Suites and mutant batches need Sage's slot.
3. The live part is not this row. Putting a key in the store and granting a host is a separate window with
Jason or Mos, after landing.
## Hosts
The first targets are `localadmin` with NOPASSWD sudo on 10.1.1.53 and 10.1.1.54 (#423 step 1, applied by
ops-01 at 20:05:47Z), and the servers agents already reach as localadmin (Q172). ops-01 owns the host side.
Tests use scratch sshd fixtures on 127.0.0.1 or fakes, never a real host.
## Boundaries
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files, the live units or `~/.mosaic`. No key is
read, copied or printed. No ssh to any real host from a test or a seat.
- No widening of row 199's kinds or regex inside row 199. Both rows touch them, so this row rebases after 199
lands.
## Gate
Duckworth approves DESIGN.md, then the manifest. The bus, business and cli suites and every
`scripts/test-*.sh` are green on Sage's gate, and the mutants are killed (one per refusal, plus one that leaks
the key into the agent's output and one that skips the host key pin).
@@ -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.
-7
View File
@@ -215,13 +215,6 @@ an operator inspect content and delivery evidence after an uncertain result.
| `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. |
| `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. |
| `credentials` | No args; human and reader sessions only. `[{service,account,role,dateKind,date,state}]` for the capability's business, never a token, file path or variable name. |
| `credentials.issue` | `{agent,service,token,date}`; human only (row 199, #1697). Writes the token to `<configDir>/secrets/agents/<business>/<agent>/<service>` (0600, dirs 0700: tmp, fsync, rename), rewrites the agent's reference in the business file to `{"store":true,<rotateBy or expires>:date}`, then holds the token in memory, with no restart. Over an issued entry it is a rotate: the old value is gone from disk and memory when it returns. `service` is `gitea` or `vikunja` and the business file must already name it for the agent (`credential-unknown`). A token the bus already holds is `credential-leak`; a bad token `credential-format`; a bad date `credential-date`. Returns `{agent,verb,service,date,state}`, `verb` `issue` or `rotate`. The tester gets `tester-refused`. |
| `credentials.revoke` | `{agent,service?,reason}`; human, or the tester (Q173). Drops the entry from memory, then unlinks a store file; a `file` or `env` reference keeps its file and becomes `store`, so a restart loads nothing. Without `service`, every kind the agent has. Returns `{agent,verb,services:[{service,state}]}`. |
| `credentials.delete` | `{agent,reason}`; human only. `credentials.revoke` for every kind, then removes the agent's store dir; a file there other than a token is `credential-file`. While the agent or one of its threads holds a claim, `agent-running`. |
Each of the three leaves one receipt in the organization's `changes.jsonl`: the actor and a
`detail` of `{verb,agent,kind,date or reason}`, never a value. A change to the store alone writes
a receipt with empty `changes`. A refusal is `action.refused` with its code alone.
| `requirements` | No args; role sessions only, as `task.create` is (row 90, #1584). The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. |
| `state` | No args; role, human and reader sessions (row 109, #1603). `{launch:{state,at,event},tasks:{open:[{task_ref,title}],total,cap}}` for the capability's business. `launch` is the latest `launch.revoked` or `launch.restored` event: `allowed` with `at` and `event` null when none exists. `open` is the tracker snapshot's open tasks in `task_ref` order, at most `cap` (the `tasks` page size, 50); `total` counts every open task. The harness runner reads it before each turn. Writes nothing. |
| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. |
+6 -153
View File
@@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path';
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl } from '../../business/src/vocabulary.mjs';
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -18,8 +18,6 @@ export const ACTIONS = Object.freeze([
'git.push.working',
'git.push.protected',
'git.merge.protected',
// Row 199 (#1697, mos-1027 Q-M2): publish to the agent's fork. Not in ROUTED, so a hard wait.
'git.push.remote',
'review.request',
'review.verdict',
'record.write',
@@ -103,11 +101,6 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
// Row 199 (#1697, design section 6): the credential lifecycle, human-only. Under Q173 the tester may
// revoke; issue (rotate is an issue over an issued entry) and delete refuse it, because a token it
// issued would pass through its session (duckworth N1). The status read stays the `credentials` verb.
export const CREDENTIAL_VERBS = Object.freeze(['credentials.issue', 'credentials.revoke', 'credentials.delete']);
const TESTER_CREDENTIAL_VERBS = Object.freeze(['credentials.revoke']);
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
const POLL_KINDS = new Set([
'task.changed.external',
@@ -198,13 +191,10 @@ export class Broker {
#received = new Set();
// Row 137 (#1631): landings checkLand let start, by ticket, until recordLand or landRefused ends them.
#landings = new Map();
// Row 199: a publish between checkPublish and recordPublish or publishRefused.
#publishes = new Map();
#secretCheck;
#credentialStatus;
#requirements;
#organization;
#credentialVerbs;
#project;
#usage;
#limits;
@@ -213,15 +203,13 @@ export class Broker {
#stops = new Map();
#clock = 0;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
this.#businesses = structuredClone(businesses);
this.#store = store;
this.#secretCheck = secretCheck;
this.#credentialStatus = credentialStatus;
this.#organization = organization;
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
this.#credentialVerbs = credentialVerbs;
this.#project = project;
this.#usage = usage;
this.#limits = limits;
@@ -807,12 +795,7 @@ export class Broker {
// records `?? null` and the current holder reads undefined.
if (action === 'role.revoke' && (context.holderRun ?? null) !== (this.#holder(s.business, target)?.holder_run ?? null))
fail('decision-mismatch');
// Row 199 (r4 B4): approve at A, push B, publish refuses.
if (action === 'git.push.remote' && (context.pushCommit ?? null) !== (this.#latestPush(s.business, target)?.commit ?? null))
fail('decision-mismatch');
return action === 'git.push.remote'
? { class: cls, decision: d.id, pushCommit: context.pushCommit ?? null }
: { class: cls, decision: d.id };
return { class: cls, decision: d.id };
}
// The raise's context: its target and approval option, from the decision.raise action.allowed.
#raised(business, decision) {
@@ -875,14 +858,13 @@ export class Broker {
if (body.commit !== push.commit) fail('verdict-stale');
}
// A push is the action.allowed recordPush wrote, the only one for git.push.working with a commit.
// role is the role that pushed (row 199, work-not-yours).
#latestPush(business, task) {
const r = this.#store.get(
"SELECT body, actor_role FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.action')='git.push.working' AND json_type(body,'$.commit')='text' AND subject=? ORDER BY seq DESC LIMIT 1",
"SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.action')='git.push.working' AND json_type(body,'$.commit')='text' AND subject=? ORDER BY seq DESC LIMIT 1",
business,
task,
);
return r ? { ...JSON.parse(r.body), role: r.actor_role } : null;
return r ? JSON.parse(r.body) : null;
}
// Row 93: the bus host's git socket asks before it takes a push's bytes. Nothing is recorded
// unless it refuses: the push is recorded, and its authority consumed, once the ref moved.
@@ -1016,98 +998,6 @@ export class Broker {
this.#landings.delete(ticket);
return code;
}
// Row 199 (#1697, design D9 steps 1 to 3, D9b): the bus host's git socket publishes a task's
// recorded work commit to the agent's own fork. checkPublish runs before anything touches the
// network. The run holds its claim; git.push.remote is a hard wait, so it cites a decision the
// human resolved for this task, bound to the task's latest push (#authorityFor). The project must
// be in the agent's publish grant with an upstream; the fork is built from that upstream and the
// agent's own Gitea user, never from the request. The ticket carries the session and decision to
// recordPublish or publishRefused. No token is in the result.
checkPublish(cap, args) {
const s = this.#session(cap);
return this.#store.transaction(() => {
keys(args, ['task_ref', 'project', 'branch', 'decision'], ['task_ref', 'project', 'branch']);
if (!taskRef(args.task_ref) || (args.decision !== undefined && !id(args.decision))) fail('invalid-request');
identifier(args.project);
string(args.branch, 200);
this.#agent(s);
const { task_ref, project, branch } = args;
const decision = args.decision ?? null;
const result = this.#checkAuthority(s, 'git.push.remote', { decision, target: task_ref });
const facts = this.#publishFacts(s, task_ref, project, result);
const ticket = randomUUID();
const dst = `refs/heads/${facts.user}/${branch}`;
this.#publishes.set(ticket, { s, task_ref, project, decision, dst, ...facts });
return { ticket, task_ref, project, fork: facts.fork, user: facts.user, email: facts.email, source: facts.source, dst };
});
}
// Steps 2 and 3, checked again at recordPublish. After routing a within-role publish has no
// decision, so the commit is the latest push's (N9); the git socket still checks the work ref tip.
#publishFacts(s, task_ref, project, result) {
if (result.decision && this.#spent(s.business, result.decision)) fail('decision-consumed');
const b = this.#business(s.business);
const role = b.roles?.[s.role];
const upstream = b.projects?.[project]?.remote;
if (!role?.identity || !(role.publish ?? []).includes(project) || !upstream) fail('remote-not-granted');
const problem = forkProblem(upstream, role.identity);
if (problem) fail(problem);
const push = this.#latestPush(s.business, task_ref);
if (!push) fail('no-push');
if (push.role !== s.role) fail('work-not-yours');
if (push.project !== project) fail('work-ref');
const source = result.pushCommit ?? push.commit;
if (source !== push.commit) fail('decision-mismatch');
const user = role.identity.gitea.user;
return { fork: forkUrl(upstream, user), user, email: role.identity.email, source };
}
#publish(ticket) {
return (typeof ticket === 'string' && this.#publishes.get(ticket)) || fail('publish-unknown');
}
// Design D5: the only key the git socket's gitea token use gets is the agent checkPublish checked
// (#agent: a run holding its claim, never a tester, reader or human). Trusted host only.
publishSession(ticket) {
const { s } = this.#publish(ticket);
return Object.freeze({ business: s.business, role: s.role });
}
// Where the bus process's git sends the push (D9 step 5): all from the ticket, none from the
// host's message. Trusted host only.
publishTarget(ticket) {
const { s, project, fork, user, source, dst } = this.#publish(ticket);
return Object.freeze({ business: s.business, project, fork, user, source, dst });
}
// Step 6: records the push the git socket made as the action.allowed for git.push.remote, which
// spends the decision. The checks run again; the run may have ended while git ran, so the holder
// check is not repeated (as recordLand). Trusted host only.
recordPublish(ticket, pushed) {
const t = this.#publish(ticket);
const done = this.#store.transaction(() => {
keys(pushed, ['ref', 'commit', 'previous'], ['ref', 'commit', 'previous']);
if (!oid(pushed.commit) || (pushed.previous !== null && !oid(pushed.previous))) fail('invalid-request');
string(pushed.ref, 260);
const result = this.#authorityFor(t.s, 'git.push.remote', { decision: t.decision, target: t.task_ref });
const facts = this.#publishFacts(t.s, t.task_ref, t.project, result);
if (facts.source !== t.source || pushed.commit !== t.source || facts.fork !== t.fork || pushed.ref !== t.dst) fail('ref-moved');
const { ref, commit, previous } = pushed;
this.#event(
t.s,
'action.allowed',
{ project: t.project, fork: t.fork, ref, commit, previous, action: 'git.push.remote', ...result, target: t.task_ref },
t.task_ref,
);
return { task_ref: t.task_ref, fork: t.fork, ref, commit, previous };
});
this.#publishes.delete(ticket);
return done;
}
// Ends a publish that didn't push: action.refused with the code on the task's trail. The git
// socket answers the run with the same code. Trusted host only.
publishRefused(ticket, { code } = {}) {
const t = this.#publish(ticket);
if (!/^[a-z-]{1,64}$/.test(code ?? '')) fail('invalid-request');
this.#store.transaction(() => this.#event(t.s, 'action.refused', { code }, t.task_ref));
this.#publishes.delete(ticket);
return code;
}
// The outcome as a RESULT from the bus, as #notice sends: the runner takes a turn on it and sends
// no automatic reply. It goes to the role in the thread the decision was raised in.
#landMessage(s, decision, body) {
@@ -1355,7 +1245,6 @@ export class Broker {
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
if (PROJECT_FILE_VERBS.includes(request.verb))
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set"].includes(request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
@@ -1427,35 +1316,6 @@ export class Broker {
throw this.#refused(s, e, request);
}
}
// Row 199 (#1697, design section 6). The token of an issue comes in args from the human's stdin; the
// secret check above has refused one the bus already holds, a refusal records its code alone, and
// nothing returned carries it. Outside the store transaction, as the organization branch.
#requestCredential(s, verb, a) {
if (s.tester ? !TESTER_CREDENTIAL_VERBS.includes(verb) : !s.human) fail(s.tester ? 'tester-refused' : 'human-required');
if (!this.#credentialVerbs) fail('not-configured');
if (verb === 'credentials.issue') keys(a, ['agent', 'service', 'token', 'date'], ['agent', 'service', 'token', 'date']);
else keys(a, verb === 'credentials.revoke' ? ['agent', 'service', 'reason'] : ['agent', 'reason'], ['agent', 'reason']);
string(a.agent, 64);
if (a.service !== undefined && !['gitea', 'vikunja'].includes(a.service)) fail('invalid-request');
if (a.token !== undefined) string(a.token, 16384);
if (a.date !== undefined) string(a.date, 10);
if (a.reason !== undefined) string(a.reason, 500);
if (!Object.hasOwn(this.#business(s.business).roles, a.agent)) fail('credential-unknown');
// A running agent keeps its tokens until its runs end: delete waits for every claim to end.
if (verb === 'credentials.delete' && (this.#holder(s.business, a.agent)?.op === 'claim' || this.#threadHolders(s.business, a.agent).length))
fail('agent-running');
let result;
try {
result = this.#credentialVerbs(s.business, verb, a, s.tester ? 'tester' : s.human);
} catch (e) {
if (e instanceof BusError && e.code.startsWith('credential-')) throw e;
if (e.code === 'revision-conflict' || e.code?.startsWith('organization-')) fail(e.code);
if (e.name === 'BusinessError') fail('invalid-request');
throw new BusError('outcome-unknown');
}
this.#secretCheck(result);
return result;
}
// Only the runner's bound session and delivered message identify a turn; no host path crosses the wire.
// Row 156 (#1651, sage-0808): a role run's landing turn is a merge decision id instead. Its begin
// needs the decision held for the run's role; its end needs that begin, since the landing may
@@ -1838,12 +1698,6 @@ export class Broker {
by,
);
}
// Row 199 (#1697, design D9 step 1, r4 B4): a publish decision binds one commit, the task's
// latest recorded push now, read here and never taken from the agent. The human sees it.
const pushCommit =
a.action === 'git.push.remote' ? (this.#latestPush(s.business, a.target)?.commit ?? fail('no-push')) : null;
const question = pushCommit ? `${a.question} (commit ${pushCommit})` : a.question;
string(question);
const did = randomUUID();
this.#store.run(
'INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,task_ref,requirement_ref,blocking,supersedes,spec_path,spec_commit) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
@@ -1856,7 +1710,7 @@ export class Broker {
cls,
a.action,
route,
question,
a.question,
JSON.stringify(a.options),
a.recommendation,
a.task_ref ?? null,
@@ -1878,7 +1732,6 @@ export class Broker {
approvalChoice,
holderRun:
a.action === 'role.revoke' ? (this.#holder(s.business, a.target)?.holder_run ?? null) : null,
...(pushCommit ? { pushCommit } : {}),
},
a.task_ref ?? (taskRef(a.target) ? a.target : did),
);
-53
View File
@@ -1,53 +0,0 @@
import { BusError } from './broker.mjs';
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete of one agent's credentials, for
// the broker's human-only verbs. The broker checks the session, the keys and a running claim first.
// Each verb leaves one receipt in the organization's changes.jsonl: the actor and a detail naming the
// verb, agent, kinds, date or reason, never a value. Nothing here logs, and a result carries no token.
const DATE_KEY = Object.freeze({ gitea: 'rotateBy', vikunja: 'expires' });
const deny = (code) => {
throw new BusError(code);
};
export function credentialVerbs({ credentials, writer }) {
const state = (instance, service) =>
credentials.status().find((e) => e.instance === instance && e.service === service)?.state;
return (id, verb, args, actor) => {
const { agent } = args;
const instance = id + '/' + agent;
const { document, revision } = writer.read(id);
const current = document.roles?.[agent]?.credentials;
if (!current) deny('credential-unknown');
if (verb === 'credentials.issue') {
const { service, token, date } = args;
if (!current[service]) deny('credential-unknown');
// rotate is an issue over an entry the bus holds now; the old value is gone when this returns.
const kind = ['not-issued', 'revoked', undefined].includes(state(instance, service)) ? 'issue' : 'rotate';
const next = structuredClone(document);
next.roles[agent].credentials[service] = { store: true, [DATE_KEY[service]]: date };
const detail = { verb: kind, agent, kind: [service], date };
const status = credentials.issue(instance, service, token, date, () =>
writer.write(id, next, { revision, actor, detail }),
);
return { agent, verb: kind, service, date: status.date, state: status.state };
}
const services = verb === 'credentials.revoke' && args.service ? [args.service] : Object.keys(current);
if (services.some((service) => !current[service])) deny('credential-unknown');
// Memory first, then the store file (Credentials.revoke), then the business file. A reference
// outside the store becomes `store` with its date, so a restart loads nothing for it. When that
// write fails, the entry stays revoked in memory and the refusal says to run the verb again.
if (verb === 'credentials.delete') credentials.remove(instance);
else for (const service of services) credentials.revoke(instance, service);
const detail = { verb: verb.slice('credentials.'.length), agent, kind: services, reason: args.reason };
const moved = services.filter((service) => current[service].store !== true);
if (moved.length) {
const next = structuredClone(document);
for (const service of moved)
next.roles[agent].credentials[service] = { store: true, [DATE_KEY[service]]: current[service][DATE_KEY[service]] };
writer.write(id, next, { revision, actor, detail });
} else writer.record(id, { actor, detail });
return {
agent,
verb: detail.verb,
services: services.map((service) => ({ service, state: state(instance, service) ?? 'not-issued' })),
};
};
}
+18 -162
View File
@@ -1,8 +1,5 @@
import {
openSync, closeSync, fstatSync, readFileSync, lstatSync, realpathSync, constants, mkdirSync, writeSync, fsyncSync,
renameSync, unlinkSync, rmdirSync, readdirSync,
} from 'node:fs';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { openSync, closeSync, fstatSync, readFileSync, lstatSync, realpathSync, constants } from 'node:fs';
import { isAbsolute, relative, resolve } from 'node:path';
import { BusError } from './broker.mjs';
const deny = (code) => {
throw new BusError(code);
@@ -16,33 +13,9 @@ const day = (value) =>
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
!Number.isNaN(Date.parse(value)) &&
new Date(value).toISOString().slice(0, 10) === value;
const TOKEN = /^[-A-Za-z0-9._~]+\n?$/;
// Row 199 (#1697, design D1, D4): a store dir is the stack instance's user's own and nobody else's.
const privateDir = (path, { missing = false } = {}) => {
let st;
try {
st = lstatSync(path);
} catch (e) {
if (missing && e.code === 'ENOENT') return false;
deny('credential-file');
}
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077) !== 0) deny('credential-file');
return true;
};
export class Credentials {
#tokens = new Map();
// Row 199: every reference the bus loaded, so issue, rotate, revoke and status work per entry.
#refs = new Map();
#revoked = new Set();
#storeRoot;
#repoRoots;
#dataRoot;
// storeRoot is <configDir>/secrets/agents (design D1); a reference with store: true must name the
// path under it for its own business, agent and service.
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null }) {
this.#storeRoot = storeRoot;
this.#repoRoots = repoRoots;
this.#dataRoot = dataRoot;
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env }) {
try {
for (const [instance, services] of Object.entries(references))
for (const [service, ref] of Object.entries(services)) {
@@ -53,41 +26,12 @@ export class Credentials {
if (
(ref.service !== undefined && ref.service !== service) ||
Object.keys(ref).some(
(k) => !['service', 'file', 'env', 'store', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
(k) => !['service', 'file', 'env', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
) ||
Boolean(ref.file) === Boolean(ref.env) ||
(ref.store !== undefined && (ref.store !== true || ref.file !== this.#storePath(instance, service)))
Boolean(ref.file) === Boolean(ref.env)
)
deny('credential-reference');
this.#refs.set(instance + ':' + service, { ref, date });
const token = this.#read(ref, env);
if (token !== null) this.#tokens.set(instance + ':' + service, { token, date });
}
} catch (e) {
this.close();
throw e;
}
}
#storePath(instance, service) {
if (!this.#storeRoot) deny('credential-reference');
const [business, agent, ...rest] = instance.split('/');
if (!business || !agent || rest.length || agent.startsWith('@')) deny('credential-reference');
return join(this.#storeRoot, business, agent, service);
}
// One token, read and checked, or null for a store reference with no file yet (design D3a).
#read(ref, env) {
let token;
if (ref.store) {
const agentDir = dirname(ref.file);
privateDir(this.#storeRoot);
if (!privateDir(dirname(agentDir), { missing: true }) || !privateDir(agentDir, { missing: true })) return null;
try {
lstatSync(ref.file);
} catch (e) {
if (e.code === 'ENOENT') return null;
deny('credential-file');
}
}
if (ref.file) {
if (!isAbsolute(ref.file)) deny('credential-location');
let resolved, original;
@@ -99,7 +43,7 @@ export class Credentials {
}
if (original.isSymbolicLink() || !original.isFile()) deny('credential-file');
if (
[...this.#repoRoots, this.#dataRoot].filter(Boolean).some((root) =>
[...repoRoots, dataRoot].filter(Boolean).some((root) =>
inside(
(() => {
try {
@@ -137,98 +81,16 @@ export class Credentials {
if (!/^[A-Z_][A-Z0-9_]*$/.test(ref.env)) deny('credential-reference');
token = env[ref.env];
}
return Credentials.#format(token);
}
static #format(token) {
if (typeof token !== 'string' || token.length > 16384 || !TOKEN.test(token)) deny('credential-format');
token = token.replace(/\n$/, '');
if (token.length < 16) deny('credential-format');
return token;
}
#known(instance, service) {
const known = this.#refs.get(instance + ':' + service);
if (!known) deny('credential-unknown');
return known;
}
// Row 199 (design section 6): write the token to the agent's store path (tmp, fsync, rename), then
// `commit` (the business file's reference becomes store), then memory. The token comes from the
// caller's stdin and is never logged. Returns the entry's new status.
issue(instance, service, token, date, commit = () => {}) {
this.#known(instance, service);
if (!day(date)) deny('credential-date');
token = Credentials.#format(token);
const file = this.#storePath(instance, service);
const agentDir = dirname(file);
mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 });
privateDir(this.#storeRoot);
for (const dir of [dirname(agentDir), agentDir]) {
if (!privateDir(dir, { missing: true })) mkdirSync(dir, { mode: 0o700 });
privateDir(dir);
}
const tmp = file + '.tmp';
try {
unlinkSync(tmp);
if (typeof token !== 'string' || token.length > 16384 || !/^[-A-Za-z0-9._~]+\n?$/.test(token))
deny('credential-format');
token = token.replace(/\n$/, '');
if (token.length < 16) deny('credential-format');
this.#tokens.set(instance + ':' + service, { token, date });
}
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
this.close();
throw e;
}
let fd;
try {
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
writeSync(fd, token + '\n');
fsyncSync(fd);
} finally {
if (fd !== undefined) closeSync(fd);
}
renameSync(tmp, file);
const dirFd = openSync(agentDir, constants.O_RDONLY);
try {
fsyncSync(dirFd);
} finally {
closeSync(dirFd);
}
commit();
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
this.#tokens.set(instance + ':' + service, { token, date });
this.#revoked.delete(instance + ':' + service);
return this.status().find((e) => e.instance === instance && e.service === service);
}
// Memory first, then the store file (design section 6, D12). A file or env reference outside the
// store is never unlinked; the caller turns its reference into store, so a restart finds nothing.
revoke(instance, service) {
const { ref } = this.#known(instance, service);
this.#tokens.delete(instance + ':' + service);
this.#revoked.add(instance + ':' + service);
if (ref.store) {
try {
unlinkSync(ref.file);
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
}
}
return { file: Boolean(ref.store) };
}
// revoke for every service, then the agent's dir. A file other than a token or its tmp refuses.
remove(instance) {
const services = [...this.#refs.keys()].filter((k) => k.startsWith(instance + ':')).map((k) => k.slice(instance.length + 1));
for (const service of services) this.revoke(instance, service);
const agentDir = dirname(this.#storePath(instance, 'gitea'));
let names;
try {
names = readdirSync(agentDir);
} catch (e) {
if (e.code === 'ENOENT') return { services };
deny('credential-file');
}
if (names.some((n) => !/^(gitea|vikunja)(\.tmp)?$/.test(n))) deny('credential-file');
for (const n of names) unlinkSync(join(agentDir, n));
rmdirSync(agentDir);
return { services };
}
// The agent-facing use (design D5): the key comes only from the broker's verified session.
async useFor(session, service, operation) {
if (!session || typeof session.business !== 'string' || typeof session.role !== 'string' || session.role.startsWith('@'))
deny('credential-not-yours');
return this.use(session.business + '/' + session.role, service, operation);
}
assertClean(value) {
let encoded;
@@ -247,16 +109,14 @@ export class Credentials {
let result;
try {
result = await operation(entry.token);
} catch (e) {
// Row 199: a host step's own refusal (publish) keeps its code; anything else is the service's.
if (e instanceof BusError) throw e;
} catch {
deny('service-failed');
}
this.assertClean(result);
return result;
}
status() {
return [...this.#refs.entries()].map(([key, { date }]) => {
return [...this.#tokens.entries()].map(([key, { date }]) => {
const i = key.lastIndexOf(':'),
service = key.slice(i + 1),
left = Date.parse(date + 'T00:00:00Z') - Date.now();
@@ -264,11 +124,8 @@ export class Credentials {
instance: key.slice(0, i),
service,
date,
state: !this.#tokens.has(key)
? this.#revoked.has(key)
? 'revoked'
: 'not-issued'
: service === 'gitea'
state:
service === 'gitea'
? left <= 0
? 'rotation-due'
: 'valid'
@@ -282,6 +139,5 @@ export class Credentials {
}
close() {
this.#tokens.clear();
this.#refs.clear();
}
}
+1 -13
View File
@@ -14,11 +14,6 @@ const LAUNCH_OPS = new Set([
'checkLand',
'recordLand',
'landRefused',
'checkPublish',
'publishPush',
'publishResult',
'recordPublish',
'publishRefused',
'landHeld',
'threadsState',
'threadIdle',
@@ -39,12 +34,6 @@ function launchOp(m) {
if (m.op === 'checkLand') return runtime.checkLand(m.cap, m.args);
if (m.op === 'recordLand') return runtime.recordLand(m.ticket, m.land);
if (m.op === 'landRefused') return runtime.landRefused(m.ticket, m.args);
// Row 199 (#1697, design D9b): the git socket's publish op. No entry returns a token.
if (m.op === 'checkPublish') return runtime.checkPublish(m.cap, m.args);
if (m.op === 'publishPush') return runtime.publishPush(m.ticket);
if (m.op === 'publishResult') return runtime.publishResult(m.ticket);
if (m.op === 'recordPublish') return runtime.recordPublish(m.ticket, m.pushed);
if (m.op === 'publishRefused') return runtime.publishRefused(m.ticket, m.args);
if (m.op === 'landHeld') return runtime.landHeld(m.record);
// Row 121 (#1615): the launcher's view of the business's threads, and its idle release.
if (m.op === 'threadsState') return runtime.threadsState(m.business);
@@ -110,8 +99,7 @@ if (!process.send) {
booted = true;
clearTimeout(timer);
// `trackers` is S3's plain-data boot config; the adapter is loaded only when a business has one.
// Row 199: publishTls is the tests' scratch CA; no boot config sets it.
const { trackers, publishTls: _tls, ...config } = message.config ?? {};
const { trackers, ...config } = message.config ?? {};
if (trackers) {
const { tasksAdapter } = await import('../../tasks/src/adapter.mjs');
config.tasks = tasksAdapter({ trackers });
+1 -56
View File
@@ -8,12 +8,9 @@ import { Credentials } from './credentials.mjs';
import { serve } from './server.mjs';
import { launchSettings } from '../../business/src/launch-settings.mjs';
import { readSessions } from '../../seat/src/session.mjs';
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { organizationWriter } from '../../business/src/organization.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { webService } from './web.mjs';
import { verifyHuman } from './human.mjs';
import { createUsage } from './usage.mjs';
@@ -75,9 +72,6 @@ export async function startBroker({
repoRoots = [],
tasks = null,
organization = null,
// Row 199: the tests' scratch CA for publish's git (`-c http.sslCAInfo=...`). process.mjs drops
// it from the boot config, so only an in-process caller sets it.
publishTls = [],
}) {
let store, credentials, server, adapter;
try {
@@ -92,9 +86,7 @@ export async function startBroker({
...repoRoots,
...Object.values(businesses).flatMap((b) => Object.values(b.projects ?? {}).map((p) => p.root)),
];
// Row 199 (#1697, design D1): the per-agent store is <configDir>/secrets/agents.
const storeRoot = organization ? join(organization.dir, 'secrets', 'agents') : null;
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot });
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot });
store = new Store(dataRoot);
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
@@ -141,11 +133,6 @@ export async function startBroker({
}:null,
secretCheck: (value) => credentials.assertClean(value),
credentialStatus: () => credentials.status(),
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete, live with no restart; each
// rewrites or records through the organization writer, so changes.jsonl holds the receipt.
credentialVerbs: organization
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) })
: null,
// Row 65: a new launch binds only on a harness its business switches on, read from the file
// as it stands now; an unreadable file switches none on.
harnessOn: (id, name) => {
@@ -225,42 +212,6 @@ export async function startBroker({
// Row 137 (#1631): the git socket's land op. checkLand's refusal is the run's, like checkPush's.
// A landing then ends in recordLand or landRefused under its ticket, which outlive the run's cap.
const checkLand = (cap, args) => refusing(cap, () => broker.checkLand(cap, args), args?.task_ref);
// Row 199 (#1697, design D9b): the git socket's publish op. checkPublish's refusal is the run's.
// The gitea token is used only under the open ticket, keyed from the agent checkPublish checked,
// and the op table returns no token. A publish then ends in recordPublish or publishRefused,
// which files any refusal once, withGitea's included.
const checkPublish = (cap, args) => refusing(cap, () => broker.checkPublish(cap, args), args?.task_ref);
const withGitea = async (ticket, fn) => credentials.useFor(broker.publishSession(ticket), 'gitea', fn);
// The push itself runs here, where the token is (D9 step 5), and the git socket's host polls
// for its end: the host's op channel answers one op at a time within 10 s, and ls-remote and
// push can take longer. Fork, user, source and dst all come from the ticket. A WorkingError's
// code is the refusal; git's stderr stays here.
const remotes = new Map();
function publishPush(ticket) {
if (remotes.has(ticket)) throw new BusError('publish-busy');
const { business, project, fork, user, source, dst } = broker.publishTarget(ticket);
const entry = { state: 'running' };
remotes.set(ticket, entry);
withGitea(ticket, (token) =>
publishRemote({ repo: workingRepo(dataRoot, business, project), fork, user, source, dst, token, tls: publishTls }).catch((e) => {
throw e instanceof WorkingError ? new BusError(e.code) : e;
}),
).then(
(r) => Object.assign(entry, { state: 'pushed', ...r }),
(e) => {
// Not a refusal: the error's name and code only, never a message that could carry git's output.
if (!(e instanceof BusError)) process.stderr.write(`bus: publish git-failed: ${JSON.stringify({ name: e?.name, code: e?.code })}\n`);
Object.assign(entry, { state: 'refused', code: e instanceof BusError ? e.code : 'git-failed' });
},
);
return { state: 'running' };
}
function publishResult(ticket) {
const entry = remotes.get(ticket);
if (!entry) throw new BusError('publish-unknown');
if (entry.state !== 'running') remotes.delete(ticket);
return { ...entry };
}
// Metadata only (service, date, state); the token stays in Credentials.
function credentialStatus(business, role) {
return credentials
@@ -286,12 +237,6 @@ export async function startBroker({
checkLand,
recordLand: (ticket, land) => broker.recordLand(ticket, land),
landRefused: (ticket, args) => broker.landRefused(ticket, args),
checkPublish,
withGitea,
publishPush,
publishResult,
recordPublish: (ticket, pushed) => broker.recordPublish(ticket, pushed),
publishRefused: (ticket, args) => broker.publishRefused(ticket, args),
landHeld: (record) => broker.landHeld(record),
// Row 156 (#1651): the launcher's landing pass.
landHolders: (business) => broker.landHolders(business),
@@ -1,182 +0,0 @@
// Row 199 (#1697, design D1, D3a, D4, section 6, T1 and T5): the per-agent credential store under
// <configDir>/secrets/agents/<business>/<agent>/<service>. Fake tokens in a scratch root only.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Credentials } from '../src/credentials.mjs';
const TOKEN = 'fake_store_token_0123456789';
const ROTATED = 'fake_rotated_token_9876543210';
const AGENT = { business: 'acme', role: 'coder' };
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-store-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const storeRoot = join(root, 'secrets', 'agents');
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets'), 0o700);
const file = join(storeRoot, 'acme', 'coder', 'gitea');
const references = { 'acme/coder': { gitea: { service: 'gitea', store: true, file, rotateBy: '2099-01-01' } } };
const open = (extra = {}) => new Credentials({ references, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data', ...extra });
const state = (c) => c.status().find((e) => e.instance === 'acme/coder' && e.service === 'gitea').state;
const read = (c, session = AGENT) => c.useFor(session, 'gitea', async (token) => ({ same: token === TOKEN, rotated: token === ROTATED }));
return { root, storeRoot, file, references, open, state, read };
}
test('T1: a store reference with no file yet starts, and the agent is not-issued until issue', async (t) => {
const s = setup(t);
const c = s.open();
assert.equal(s.state(c), 'not-issued');
await assert.rejects(s.read(c), /credential-unavailable/);
const status = c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
assert.equal(status.state, 'valid');
assert.deepEqual(await s.read(c), { same: true, rotated: false });
assert.equal(statSync(s.file).mode & 0o777, 0o600);
for (const dir of [join(s.storeRoot, 'acme'), join(s.storeRoot, 'acme', 'coder')]) assert.equal(statSync(dir).mode & 0o777, 0o700);
assert.deepEqual(readdirSync(join(s.storeRoot, 'acme', 'coder')), ['gitea']);
// A restart reads the issued file.
assert.deepEqual(await s.read(s.open()), { same: true, rotated: false });
});
test('T1: a store path the reference does not own, or no store root, refuses the reference', (t) => {
const s = setup(t);
const other = join(s.storeRoot, 'acme', 'pm', 'gitea');
assert.throws(() => s.open({ references: { 'acme/coder': { gitea: { store: true, file: other, rotateBy: '2099-01-01' } } } }), /credential-reference/);
assert.throws(() => new Credentials({ references: s.references, repoRoots: [], dataRoot: '/nonexistent-data' }), /credential-reference/);
assert.throws(() => s.open({ references: { 'acme/@tester': { gitea: { store: true, file: join(s.storeRoot, 'acme', '@tester', 'gitea'), rotateBy: '2099-01-01' } } } }),
/credential-reference/);
});
test('T1: a wrong mode on the store root, a business dir, an agent dir or the file refuses start', (t) => {
const s = setup(t);
s.open().issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
for (const [path, bad, good] of [
[s.storeRoot, 0o750, 0o700],
[join(s.storeRoot, 'acme'), 0o705, 0o700],
[join(s.storeRoot, 'acme', 'coder'), 0o770, 0o700],
[s.file, 0o640, 0o600],
]) {
chmodSync(path, bad);
assert.throws(() => s.open(), /credential-file/, path);
chmodSync(path, good);
}
s.open();
});
test('T1: a missing store root refuses start; a missing {file} reference still refuses start', (t) => {
const s = setup(t);
rmSync(s.storeRoot, { recursive: true });
assert.throws(() => s.open(), /credential-file/);
assert.throws(() => s.open({ references: { coder: { gitea: { file: join(s.root, 'absent'), rotateBy: '2099-01-01' } } } }), /credential-file/);
});
test('T1: a store file that is a directory or a symlink refuses start', (t) => {
const s = setup(t);
mkdirSync(join(s.storeRoot, 'acme', 'coder'), { recursive: true, mode: 0o700 });
chmodSync(join(s.storeRoot, 'acme'), 0o700);
mkdirSync(s.file, { mode: 0o700 });
assert.throws(() => s.open(), /credential-file/);
rmSync(s.file, { recursive: true });
const target = join(s.root, 'target');
writeFileSync(target, TOKEN + '\n', { mode: 0o600 });
symlinkSync(target, s.file);
assert.throws(() => s.open(), /credential-file/);
});
test('T2: useFor keys from the session only', async (t) => {
const s = setup(t);
const c = s.open();
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
await assert.rejects(s.read(c, { business: 'acme', role: 'pm' }), /credential-unavailable/);
await assert.rejects(s.read(c, { business: 'acme', role: '@tester' }), /credential-not-yours/);
await assert.rejects(s.read(c, null), /credential-not-yours/);
await assert.rejects(s.read(c, { business: 'acme' }), /credential-not-yours/);
});
test('T5: issue, rotate, revoke and delete are live with no restart, and revoke holds across one', async (t) => {
const s = setup(t);
const c = s.open();
let committed = 0;
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01', () => committed++);
assert.equal(committed, 1);
// Rotate is a second issue: the new token is used at once, with no tmp file left.
c.issue('acme/coder', 'gitea', ROTATED + '\n', '2099-06-01');
assert.deepEqual(await s.read(c), { same: false, rotated: true });
assert.equal(c.status()[0].date, '2099-06-01');
assert.deepEqual(readdirSync(join(s.storeRoot, 'acme', 'coder')), ['gitea']);
// Revoke: memory first, then the file.
assert.deepEqual(c.revoke('acme/coder', 'gitea'), { file: true });
assert.equal(s.state(c), 'revoked');
assert.equal(existsSync(s.file), false);
await assert.rejects(s.read(c), /credential-unavailable/);
const restarted = s.open();
await assert.rejects(s.read(restarted), /credential-unavailable/);
assert.equal(s.state(restarted), 'not-issued');
// Delete removes the agent's dir.
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
assert.deepEqual(c.remove('acme/coder'), { services: ['gitea'] });
assert.equal(existsSync(join(s.storeRoot, 'acme', 'coder')), false);
await assert.rejects(s.read(c), /credential-unavailable/);
});
test('T5: a failed commit leaves the token on disk but not in memory, and a bad token or date writes nothing', async (t) => {
const s = setup(t);
const c = s.open();
assert.throws(() => c.issue('acme/coder', 'gitea', 'short', '2099-01-01'), /credential-format/);
assert.throws(() => c.issue('acme/coder', 'gitea', TOKEN, '2099-13-01'), /credential-date/);
assert.throws(() => c.issue('acme/pm', 'gitea', TOKEN, '2099-01-01'), /credential-unknown/);
assert.equal(existsSync(s.file), false);
assert.throws(() => c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01', () => { throw new Error('commit failed'); }), /commit failed/);
assert.equal(existsSync(s.file), true);
assert.equal(s.state(c), 'not-issued');
await assert.rejects(s.read(c), /credential-unavailable/);
});
test('T5: revoke of a {file} reference forgets the token and never unlinks the file', async (t) => {
const s = setup(t);
const path = join(s.root, 'outside-token');
writeFileSync(path, TOKEN + '\n', { mode: 0o600 });
const c = s.open({ references: { 'acme/coder': { gitea: { file: path, rotateBy: '2099-01-01' } } } });
assert.deepEqual(await s.read(c), { same: true, rotated: false });
assert.deepEqual(c.revoke('acme/coder', 'gitea'), { file: false });
assert.equal(existsSync(path), true);
await assert.rejects(s.read(c), /credential-unavailable/);
});
test('T5: delete refuses an agent dir holding anything but tokens', (t) => {
const s = setup(t);
const c = s.open();
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
writeFileSync(join(s.storeRoot, 'acme', 'coder', 'notes'), 'x', { mode: 0o600 });
assert.throws(() => c.remove('acme/coder'), /credential-file/);
assert.equal(existsSync(join(s.storeRoot, 'acme', 'coder', 'notes')), true);
});
// Design D5: the name-keyed use() is the host's own, for @sync and the tracker's start check of each
// role's token. Every other credential use goes through useFor, keyed from the broker's session. A
// new use( call site outside this list fails here.
test('D5: every credentials use( call site is on the allowlist', () => {
const packages = new URL('../../', import.meta.url);
const allowed = new Map([
['bus/src/credentials.mjs', 1], // useFor's own call
['tasks/src/adapter.mjs', 1], // the host's use(instance) for @sync and probe
]);
const found = new Map();
const walk = (dir, rel) => {
for (const name of readdirSync(dir)) {
if (name === 'node_modules' || name === 'tests') continue;
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path, `${rel}${name}/`);
else if (name.endsWith('.mjs') || name.endsWith('.js')) {
const code = readFileSync(path, 'utf8').split('\n').filter((line) => !/^\s*\/\//.test(line)).join('\n');
const n = (code.match(/(?:credentials|this)\.use\(/g) ?? []).length;
if (n) found.set(`${rel}${name}`, n);
}
}
};
for (const pkg of readdirSync(packages)) if (statSync(new URL(`${pkg}/src`, packages), { throwIfNoEntry: false })?.isDirectory())
walk(new URL(`${pkg}/src`, packages).pathname, `${pkg}/src/`);
assert.deepEqual(Object.fromEntries([...found].sort()), Object.fromEntries([...allowed].sort()));
});
@@ -1,163 +0,0 @@
// Row 199 (#1697, design section 6, T4, T5): credentials.issue, credentials.revoke and
// credentials.delete through the runtime. Human-only; the tester revokes and never issues. Each verb
// is live with no restart and leaves one receipt in changes.jsonl with no value in it. Fake tokens in a
// scratch root only.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { Broker, CREDENTIAL_VERBS } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
const TOKEN = 'fake_issued_token_0123456789';
const ROTATED = 'fake_rotated_token_9876543210';
const OTHER = 'fake_other_token_5555555555';
const DATE = '2099-01-01';
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
async function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE };
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
// The bus's resolved business carries the same references; a store one names its derived file.
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
roles: {
coder: { ...role, credentials: { gitea: { store: true, file: store('coder', 'gitea'), rotateBy: DATE }, vikunja: doc.roles.coder.credentials.vikunja } },
reviewer: { ...role, credentials: doc.roles.reviewer.credentials },
},
};
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(() => bus.close());
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const tester = bus.broker.bindTester({ business: 'demo' });
const ask = (cap, verb, args) => bus.broker.request(cap, { verb, args });
const file = () => JSON.parse(readFileSync(join(root, 'businesses/demo.json'), 'utf8'));
const receipts = () => {
const log = join(root, 'businesses/.history/demo/changes.jsonl');
return existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse) : [];
};
const committed = () => receipts().filter((r) => r.state === 'committed');
const status = (agent) => Object.fromEntries(bus.credentialStatus('demo', agent).map((e) => [e.service, e.state]));
return { root, bus, human, tester, ask, file, receipts, committed, status, store };
}
test('row 199: the lifecycle verbs refuse every session but the human, and the tester revokes only', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-gate-'));
const s = new Store(root);
t.after(() => { s.close(); rmSync(root, { recursive: true, force: true }); });
const calls = [];
const b = new Broker({
store: s,
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, roles: { coder: role } } },
credentialVerbs: (id, verb, args, actor) => (calls.push([verb, actor]), { ok: true }),
});
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const tester = b.bindTester({ business: 'demo' });
const reader = b.bindReader({ business: 'demo' });
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' });
const args = {
'credentials.issue': { agent: 'coder', service: 'gitea', token: TOKEN, date: DATE },
'credentials.revoke': { agent: 'coder', reason: 'fired' },
'credentials.delete': { agent: 'coder', reason: 'fired' },
};
assert.deepEqual(CREDENTIAL_VERBS, Object.keys(args));
for (const verb of CREDENTIAL_VERBS) {
assert.throws(() => b.request(reader, { verb, args: args[verb] }), /human-required/, verb);
assert.throws(() => b.request(coder, { verb, args: args[verb] }), /human-required/, verb);
}
assert.throws(() => b.request(tester, { verb: 'credentials.issue', args: args['credentials.issue'] }), /tester-refused/);
assert.throws(() => b.request(tester, { verb: 'credentials.delete', args: args['credentials.delete'] }), /tester-refused/);
// Keys: a token on revoke, a missing reason, an unknown service, an unknown agent, an @ agent.
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { ...args['credentials.revoke'], token: TOKEN } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { agent: 'coder' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], service: 'model' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], file: '/x' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], agent: 'pm' } }), /credential-unknown/);
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { agent: '@sync', reason: 'x' } }), /credential-unknown/);
assert.equal(calls.length, 0);
// A refusal records its code alone, never the token.
const refused = s.all("SELECT body FROM events WHERE kind='action.refused'");
assert.ok(refused.length >= 10);
for (const { body } of refused) assert.equal(body.includes(TOKEN), false);
// delete waits for the agent's claim to end.
b.request(coder, { verb: 'role.claim' });
assert.throws(() => b.request(human, { verb: 'credentials.delete', args: args['credentials.delete'] }), /agent-running/);
b.request(coder, { verb: 'role.release' });
for (const verb of CREDENTIAL_VERBS) b.request(human, { verb, args: args[verb] });
b.request(tester, { verb: 'credentials.revoke', args: args['credentials.revoke'] });
assert.deepEqual(calls, [['credentials.issue', 'jason'], ['credentials.revoke', 'jason'], ['credentials.delete', 'jason'], ['credentials.revoke', 'tester']]);
// No lifecycle without the runtime's store.
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } } } });
const h2 = bare.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
assert.throws(() => bare.request(h2, { verb: 'credentials.revoke', args: args['credentials.revoke'] }), /not-configured/);
});
test('T5: issue, rotate, revoke and delete are live, rewrite the business file and leave one receipt each, with no value anywhere', async (t) => {
const c = await setup(t);
const coder = { business: 'demo', role: 'coder' };
const read = (service) => c.bus.credentials.useFor(coder, service, async (token) => token === TOKEN ? 'issued' : token === ROTATED ? 'rotated' : 'other');
assert.deepEqual(c.status('coder'), { gitea: 'not-issued', vikunja: 'valid' });
// issue: the file, then the business file's reference, then memory.
const issued = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN + '\n', date: DATE });
assert.deepEqual(issued, { agent: 'coder', verb: 'issue', service: 'gitea', date: DATE, state: 'valid' });
assert.equal(await read('gitea'), 'issued');
assert.equal(statSync(c.store('coder', 'gitea')).mode & 0o777, 0o600);
assert.deepEqual(c.file().roles.coder.credentials.gitea, { store: true, rotateBy: DATE });
let last = c.committed().at(-1);
assert.equal(last.actor, 'jason');
assert.deepEqual(last.detail, { verb: 'issue', agent: 'coder', kind: ['gitea'], date: DATE });
// The same token again is one the bus holds: the secret check refuses it before any write.
const count = c.receipts().length;
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN, date: DATE }), /credential-leak/);
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: 'short', date: DATE }), /credential-format/);
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: OTHER, date: '2099-02-30' }), /credential-date/);
assert.equal(c.receipts().length, count);
// rotate: an issue over an issued entry, used at once.
const rotated = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: ROTATED, date: '2099-06-01' });
assert.equal(rotated.verb, 'rotate');
assert.equal(await read('gitea'), 'rotated');
assert.deepEqual(c.file().roles.coder.credentials.gitea, { store: true, rotateBy: '2099-06-01' });
assert.deepEqual(c.committed().at(-1).detail, { verb: 'rotate', agent: 'coder', kind: ['gitea'], date: '2099-06-01' });
// revoke of a store entry by the tester: memory and file, no document change, a receipt.
const bytes = readFileSync(join(c.root, 'businesses/demo.json'), 'utf8');
const revoked = c.ask(c.tester, 'credentials.revoke', { agent: 'coder', service: 'gitea', reason: 'test' });
assert.deepEqual(revoked, { agent: 'coder', verb: 'revoke', services: [{ service: 'gitea', state: 'revoked' }] });
await assert.rejects(read('gitea'), /credential-unavailable/);
assert.equal(existsSync(c.store('coder', 'gitea')), false);
assert.equal(readFileSync(join(c.root, 'businesses/demo.json'), 'utf8'), bytes);
last = c.committed().at(-1);
assert.equal(last.actor, 'tester');
assert.deepEqual(last.changes, []);
assert.deepEqual(last.detail, { verb: 'revoke', agent: 'coder', kind: ['gitea'], reason: 'test' });
// revoke of a {file} reference: memory, the outside file kept, the reference becomes store.
const outside = c.file().roles.coder.credentials.vikunja.file;
c.ask(c.human, 'credentials.revoke', { agent: 'coder', service: 'vikunja', reason: 'moved' });
await assert.rejects(read('vikunja'), /credential-unavailable/);
assert.equal(existsSync(outside), true);
assert.deepEqual(c.file().roles.coder.credentials.vikunja, { store: true, expires: DATE });
assert.ok(c.committed().at(-1).changes.some((e) => e.path === 'roles.coder.credentials.vikunja.store' && e.new === true));
// delete: every kind revoked, the agent's dir gone, both references store.
c.ask(c.human, 'credentials.issue', { agent: 'reviewer', service: 'gitea', token: OTHER, date: DATE });
assert.equal(existsSync(c.store('reviewer', 'gitea')), true);
const deleted = c.ask(c.human, 'credentials.delete', { agent: 'reviewer', reason: 'fired' });
assert.deepEqual(deleted.services.map((e) => e.state), ['revoked', 'revoked']);
assert.equal(existsSync(join(c.root, 'secrets', 'agents', 'demo', 'reviewer')), false);
assert.deepEqual(c.file().roles.reviewer.credentials, { gitea: { store: true, rotateBy: DATE }, vikunja: { store: true, expires: DATE } });
assert.deepEqual(c.committed().at(-1).detail, { verb: 'delete', agent: 'reviewer', kind: ['gitea', 'vikunja'], reason: 'fired' });
// The status read never carries a value.
assert.deepEqual(c.ask(c.human, 'credentials', {}).map((e) => e.state).sort(), ['revoked', 'revoked', 'revoked', 'revoked']);
// T4: no token in any file under the root (receipts, business file, events, logs).
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? walk(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
for (const path of walk(c.root))
for (const token of [TOKEN, ROTATED, OTHER]) assert.equal(readFileSync(path).includes(token), false, path);
});
+3 -7
View File
@@ -10,7 +10,7 @@ import { fixture } from './spec-fixtures.mjs';
// PRD 1.5 REQ-WAIT-1, REQ-WAIT-2 and REQ-DEC-2 (row 139, #1633).
const WAITS = ['spend', 'data.delete', 'network.expose', 'account.create', 'credential.mint', 'message.external',
'git.push.protected', 'git.push.remote', 'deploy'];
'git.push.protected', 'deploy'];
const LEFT = ['git.merge.protected', 'policy.change', 'prd.approve', 'role.revoke'];
const cite = f => ({ project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
const routed = (f, d) => ({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) });
@@ -36,14 +36,10 @@ test('REQ-WAIT-2: a business granting a hard wait at either class is refused; th
});
test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbiter cannot resolve it', async t => {
const f = await fixture(t, { roles: { coder: { definition: 'coder',
authority: { withinRole: ['message.send', 'record.write', 'git.push.working'], crossRole: [] } } } });
// Row 199: a git.push.remote raise binds the task's latest push, so the task has one.
f.broker.recordPush(f.coder, { task_ref: 'vikunja:1/2', project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2',
commit: 'a'.repeat(40), previous: null });
const f = await fixture(t);
for (const action of WAITS) {
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }]) {
const d = await f.raise({ action, ...(action === 'git.push.remote' ? { target: 'vikunja:1/2' } : {}), ...args });
const d = await f.raise({ action, ...args });
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, `${action} ${JSON.stringify(args)}`);
}
}
+2 -2
View File
@@ -22,8 +22,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
"import { MERGE, LandingRefusal, readLanding } from './landing.mjs';",
// Row 141 (#1635): the business ID_PATTERN for instance names. The vocabulary is already in
// RUNNER_FILES and imports nothing. Row 155 (#1649) reads THREAD_DEFINITION and END_PROTECTED
// from it too. Row 199 (#1697) reads the D9c fork parse, forkProblem and forkUrl, from it.
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl } from '../../business/src/vocabulary.mjs';",
// from it too.
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
-274
View File
@@ -1,274 +0,0 @@
// Row 199 (#1697, design D9 steps 1 to 3 and 6, D9b): the broker side of publish. git.push.remote
// is a hard wait; its decision binds the task's latest push (pushCommit). checkPublish builds the
// agent's fork from the project's upstream and the agent's own Gitea user; recordPublish spends the
// decision; publishRefused files the refusal.
import test from 'node:test';
import assert from 'node:assert/strict';
import { fork } from 'node:child_process';
import { once } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
const TASK = 'vikunja:1/2';
const A = 'a'.repeat(40);
const B = 'b'.repeat(40);
const C = 'c'.repeat(40);
const identity = (user) => ({ name: user, email: `${user}@example.com`, gitea: { url: 'https://git.example', user } });
const pusher = { withinRole: ['git.push.working', 'message.send'], crossRole: [] };
function businesses(upstream = 'https://git.example/mosaic/stack') {
return {
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'reviewer', delivery: 'reviewer' },
projects: { stack: { root: '/nonexistent', remote: upstream }, books: { root: '/nonexistent-books' } },
roles: {
coder: { authority: pusher, identity: identity('acme-coder'), publish: ['stack'] },
second: { authority: pusher, identity: identity('acme-second'), publish: ['stack'] },
plain: { authority: pusher },
reviewer: { authority: { withinRole: ['message.send'], crossRole: [] } },
},
},
};
}
function setup(t, upstream) {
const root = mkdtempSync(join(tmpdir(), 'bus-publish-'));
const store = new Store(root);
const b = new Broker({ store, businesses: businesses(upstream) });
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const caps = {};
for (const role of ['coder', 'second', 'plain', 'reviewer']) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
b.request(caps[role], { verb: 'role.claim', args: {} });
}
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const call = (cap, verb, args) => b.request(cap, { verb, args });
const push = (commit, previous = null, role = 'coder', project = 'stack') => {
b.checkPush(caps[role], TASK);
return b.recordPush(caps[role], { task_ref: TASK, project, branch: 'feat', ref: 'work/vikunja-stack-2', commit, previous });
};
const raise = (role = 'coder') =>
call(caps[role], 'decision.raise', {
action: 'git.push.remote', target: TASK, task_ref: TASK, question: 'Publish?', blocking: false, recommendation: 'yes',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }],
});
const approve = (role = 'coder') => {
const d = raise(role);
call(human, 'decision.resolve', { id: d.id, choice: 'yes' });
return d;
};
const publish = (decision, role = 'coder', extra = {}) =>
b.checkPublish(caps[role], { task_ref: TASK, project: 'stack', branch: 'feat', ...(decision ? { decision: decision.id } : {}), ...extra });
const events = (kind) => store.all('SELECT * FROM events WHERE kind=? ORDER BY seq', kind).map((e) => ({ ...e, body: JSON.parse(e.body) }));
return { b, store, caps, human, call, push, raise, approve, publish, events };
}
test('a raise with no recorded push refuses no-push; a raise binds the latest push and shows it', (t) => {
const f = setup(t);
assert.throws(() => f.raise(), /no-push/);
f.push(A);
const d = f.raise();
assert.match(d.question, new RegExp(`commit ${A}\\)$`));
const raised = f.events('action.allowed').find((e) => e.body.operation === 'decision.raise');
assert.equal(raised.body.pushCommit, A);
assert.deepEqual({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) }, { class: 'gated', route_to: 'human' });
});
test('happy path: the fork is the upstream repo under the agent\'s own user, the destination is host-built', (t) => {
const f = setup(t);
f.push(A);
const d = f.approve();
const r = f.publish(d);
assert.deepEqual({ ...r, ticket: typeof r.ticket }, {
ticket: 'string', task_ref: TASK, project: 'stack', fork: 'https://git.example/acme-coder/stack',
user: 'acme-coder', email: '[email protected]', source: A, dst: 'refs/heads/acme-coder/feat',
});
const done = f.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null });
assert.deepEqual(done, { task_ref: TASK, fork: r.fork, ref: r.dst, commit: A, previous: null });
const allowed = f.events('action.allowed').filter((e) => e.body.action === 'git.push.remote' && e.body.operation !== 'decision.raise');
assert.equal(allowed.length, 1);
assert.equal(allowed[0].subject, TASK);
assert.equal(allowed[0].body.decision, d.id);
assert.equal(allowed[0].body.fork, r.fork);
// Spent once.
assert.throws(() => f.publish(d), /decision-consumed/);
assert.throws(() => f.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null }), /publish-unknown/);
});
test('a hard wait: no decision refuses, and a decision for another task refuses', (t) => {
const f = setup(t);
f.push(A);
assert.throws(() => f.publish(null), /decision-required/);
const other = f.call(f.caps.coder, 'decision.raise', {
action: 'deploy', target: 'vikunja:1/3', question: 'Ship?', blocking: false, recommendation: 'yes',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }],
});
f.call(f.human, 'decision.resolve', { id: other.id, choice: 'yes' });
assert.throws(() => f.publish(other), /decision-mismatch/);
const d = f.raise();
assert.throws(() => f.publish(d), /decision-not-approved/);
});
test('r4 B4: approve while the latest push is A, push B, publish refuses decision-mismatch', (t) => {
const f = setup(t);
f.push(A);
const d = f.approve();
f.push(B, A);
assert.throws(() => f.publish(d), /decision-mismatch/);
// And a push between checkPublish and recordPublish refuses the record.
const f2 = setup(t);
f2.push(A);
const d2 = f2.approve();
const r = f2.publish(d2);
f2.push(C, A);
assert.throws(() => f2.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null }), /decision-mismatch/);
assert.throws(() => f2.b.recordPublish(r.ticket, { ref: r.dst, commit: C, previous: null }), /decision-mismatch/);
});
test('grant and fork refusals', (t) => {
const f = setup(t);
f.push(A, null, 'plain');
// plain has no identity and no grant; its raise still binds the push, the publish refuses.
const d = f.approve('plain');
assert.throws(() => f.publish(d, 'plain'), /remote-not-granted/);
assert.throws(() => f.publish(d, 'plain', { project: 'books' }), /remote-not-granted|decision-mismatch/);
// work-not-yours: second publishes a push coder made.
const g = setup(t);
g.push(A);
const d2 = g.approve('second');
assert.throws(() => g.publish(d2, 'second'), /work-not-yours/);
});
test('remote-not-fork, compared without case, and remote-host-mismatch, at push', (t) => {
for (const [upstream, code] of [
['https://git.example/acme-coder/stack', /remote-not-fork/],
['https://git.example/ACME-Coder/stack', /remote-not-fork/],
['https://other.example/mosaic/stack', /remote-host-mismatch/],
['https://git.example:3000/mosaic/stack', /remote-host-mismatch/],
]) {
const f = setup(t, upstream);
f.push(A);
const d = f.approve();
assert.throws(() => f.publish(d), code, upstream);
}
});
test('two agents granted one project each get their own fork', (t) => {
const f = setup(t);
f.push(A);
const r1 = f.publish(f.approve());
assert.equal(r1.fork, 'https://git.example/acme-coder/stack');
const g = setup(t);
g.push(A, null, 'second');
const r2 = g.publish(g.approve('second'), 'second');
assert.equal(r2.fork, 'https://git.example/acme-second/stack');
assert.equal(r2.dst, 'refs/heads/acme-second/feat');
});
test('the request cannot name the fork, the user or the destination', (t) => {
const f = setup(t);
f.push(A);
const d = f.approve();
for (const extra of [{ fork: 'https://git.example/x/stack' }, { dst: 'refs/heads/main' }, { user: 'x' }, { remote: 'https://x' }])
assert.throws(() => f.publish(d, 'coder', extra), /invalid-request/);
});
test('publishRefused files the code on the task\'s trail and drops the ticket', (t) => {
const f = setup(t);
f.push(A);
const r = f.publish(f.approve());
assert.equal(f.b.publishRefused(r.ticket, { code: 'remote-unrelated' }), 'remote-unrelated');
const refused = f.events('action.refused').filter((e) => e.subject === TASK);
assert.deepEqual(refused.at(-1).body, { code: 'remote-unrelated' });
assert.throws(() => f.b.publishRefused(r.ticket, { code: 'x' }), /publish-unknown/);
});
test('git.push.remote is in neither ROUTED list and no shipped role grants it', async () => {
const { ACTIONS, HARD_WAITS } = await import('../src/broker.mjs');
const vocabulary = await import('../../business/src/vocabulary.mjs');
assert.ok(ACTIONS.includes('git.push.remote') && HARD_WAITS.includes('git.push.remote'));
assert.ok(!vocabulary.ROUTED.includes('git.push.remote'));
const { readdirSync, readFileSync } = await import('node:fs');
const dir = new URL('../../../roles/', import.meta.url);
for (const name of readdirSync(dir).filter((n) => n.endsWith('.json')))
assert.ok(!readFileSync(new URL(name, dir), 'utf8').includes('git.push.remote'), name);
});
// D1 and D9b through the real startBroker: the store is <configDir>/secrets/agents, and the gitea
// token is used only under an open publish ticket, keyed from the agent checkPublish checked.
test('runtime: withGitea keys from the open ticket only, and the store root is the organization dir', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const { chmodSync, mkdirSync } = await import('node:fs');
const root = mkdtempSync(join(tmpdir(), 'bus-publish-rt-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
// The runtime reads the harness switch from the organization file as it stands.
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets'), 0o700);
const demo = businesses().demo;
demo.roles.coder.credentials = { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: '2099-01-01' } };
const organization = { dir: root, rolesDir, system: systemFor(root) };
// No organization, no store root: a store reference refuses the start.
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo } }), /credential-reference/);
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization });
t.after(() => bus.close());
assert.deepEqual(bus.credentialStatus('demo', 'coder'), [{ service: 'gitea', date: '2099-01-01', state: 'not-issued' }]);
const coder = bus.broker.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' });
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
bus.broker.request(coder, { verb: 'role.claim', args: {} });
bus.checkPush(coder, TASK);
bus.recordPush(coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2', commit: A, previous: null });
const d = bus.broker.request(coder, { verb: 'decision.raise', args: {
action: 'git.push.remote', target: TASK, task_ref: TASK, question: 'Publish?', blocking: false, recommendation: 'yes',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }] } });
bus.broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
assert.throws(() => bus.checkPublish(coder, { task_ref: TASK, project: 'stack', branch: 'feat' }), /decision-required/);
const ticket = bus.checkPublish(coder, { task_ref: TASK, project: 'stack', branch: 'feat', decision: d.id }).ticket;
await assert.rejects(bus.withGitea(ticket, async () => 1), /credential-unavailable/);
bus.credentials.issue('demo/coder', 'gitea', 'fake_publish_token_0123456789', '2099-01-01');
assert.equal(await bus.withGitea(ticket, async (token) => token === 'fake_publish_token_0123456789'), true);
await assert.rejects(bus.withGitea(ticket, async (token) => ({ echo: token })), /credential-leak/);
await assert.rejects(bus.withGitea('not-a-ticket', async () => 1), /publish-unknown/);
await assert.rejects(bus.withGitea(coder, async () => 1), /publish-unknown/);
bus.recordPublish(ticket, { ref: 'refs/heads/acme-coder/feat', commit: A, previous: null });
await assert.rejects(bus.withGitea(ticket, async () => 1), /publish-unknown/);
});
// Row 199 (D9b, deviation 11): the bus process answers the socket's five publish ops from their own
// launchOp lines. A LAUNCH_OPS name without one would get launch-op-refused. A boot config that
// carries publishTls still boots; process.mjs drops it before startBroker.
test('the bus process answers the publish ops from their own lines, not launch-op-refused', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-publish-ops-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const child = fork(new URL('../src/process.mjs', import.meta.url), [], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
t.after(() => {
if (child.exitCode === null) child.kill('SIGKILL');
});
const ask = async (m) => {
const reply = once(child, 'message');
child.send(m);
return (await reply)[0];
};
const config = { dataRoot: root, businesses: businesses(), launches: [], readers: [], publishTls: ['-c', 'http.sslCAInfo=/nonexistent'] };
assert.equal((await ask({ op: 'boot', config })).ok, true);
const unknown = 'not-a-ticket';
assert.deepEqual(await ask({ op: 'publishPush', ticket: unknown, id: 1 }), { ok: false, error: 'publish-unknown', id: 1 });
assert.deepEqual(await ask({ op: 'publishResult', ticket: unknown, id: 2 }), { ok: false, error: 'publish-unknown', id: 2 });
assert.deepEqual(await ask({ op: 'recordPublish', ticket: unknown, pushed: { ref: 'refs/heads/x/y', commit: A, previous: null }, id: 3 }), { ok: false, error: 'publish-unknown', id: 3 });
assert.deepEqual(await ask({ op: 'publishRefused', ticket: unknown, args: { code: 'x' }, id: 4 }), { ok: false, error: 'publish-unknown', id: 4 });
assert.deepEqual(await ask({ op: 'checkPublish', cap: 'not-a-cap', args: { task_ref: TASK, project: 'stack', branch: 'feat' }, id: 5 }), { ok: false, error: 'unauthenticated', id: 5 });
const exit = once(child, 'exit');
child.send({ op: 'close' });
assert.equal((await exit)[0], 0);
});
+8 -57
View File
@@ -10,7 +10,6 @@ import { checkVars } from "./vars.mjs";
import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
import { checkPlugins } from "../../plugins/src/kinds.mjs";
import { forkProblem, giteaName, parseGiteaUrl } from "./vocabulary.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
} from "./util.mjs";
@@ -41,7 +40,7 @@ function checkBot(tracker, where) {
return { bot: tracker.bot, botId: tracker.botId };
}
function checkCredentialMap(map, services, where, store = null) {
function checkCredentialMap(map, services, where) {
requireObject(map, where);
const names = Object.keys(map).sort();
const wanted = [...services].sort();
@@ -49,37 +48,10 @@ function checkCredentialMap(map, services, where, store = null) {
refuse(`${where} must reference exactly the services its role definition needs (${wanted.join(", ") || "none"}; got ${names.join(", ") || "none"})`);
}
const out = {};
for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`, { store });
for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`);
return out;
}
// Row 199 (#1697, design D8): the agent's own git identity. The email is the one its Gitea user
// has, so Gitea links the commits to that user; gitea.url is the instance its pushes go to.
const EMAIL = /^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,190}\.[A-Za-z]{2,24}$/;
const NAME = /^[^\0\n\r<>]{1,100}$/;
// Row 199 (design D9c): the parse is vocabulary.mjs's, where the broker reads it; this wraps it with
// the loader's refusals. Returns {url, owner, repo}.
export function httpsUrl(value, where, { repo = false } = {}) {
const parsed = parseGiteaUrl(value, { repo });
if (parsed.problem === "form") refuse(`${where} must be a normalized https URL with no user, query or fragment`);
if (parsed.problem === "instance") refuse(`${where} must name the Gitea instance only, https://HOST[:PORT]`);
if (parsed.problem === "repository") refuse(`${where} must name a repository, https://HOST/OWNER/REPO`);
return parsed;
}
function checkIdentity(identity, where) {
requireObject(identity, where);
rejectUnknownKeys(identity, ["name", "email", "gitea"], where);
if (typeof identity.name !== "string" || !NAME.test(identity.name)) refuse(`${where}.name must be 1 to 100 characters with no <, > or line break`);
if (typeof identity.email !== "string" || !EMAIL.test(identity.email)) refuse(`${where}.email must be an email address`);
requireObject(identity.gitea, `${where}.gitea`);
rejectUnknownKeys(identity.gitea, ["url", "user"], `${where}.gitea`);
httpsUrl(identity.gitea.url, `${where}.gitea.url`);
if (!giteaName(identity.gitea.user)) refuse(`${where}.gitea.user must be a Gitea user name`);
return { name: identity.name, email: identity.email, gitea: { url: identity.gitea.url, user: identity.gitea.user } };
}
function checkTracker(tracker, file) {
const where = `${file} tracker`;
requireObject(tracker, where);
@@ -102,7 +74,7 @@ function checkTracker(tracker, file) {
return { sync, labels };
}
function checkRoles(roles, file, rolesDir, business) {
function checkRoles(roles, file, rolesDir) {
const where = `${file} roles`;
requireObject(roles, where);
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
@@ -114,7 +86,7 @@ function checkRoles(roles, file, rolesDir, business) {
requireId(instance, `${where} instance name`);
const at = `${where}.${instance}`;
requireObject(entry, at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish"], at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials"], at);
requireId(entry.definition, `${at}.definition`);
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
definitions[entry.definition] = definition;
@@ -136,18 +108,8 @@ function checkRoles(roles, file, rolesDir, business) {
holder: entry.holder === undefined ? null : requireId(entry.holder, `${at}.holder`),
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
tracker,
credentials: checkCredentialMap(entry.credentials ?? {}, services, `${at}.credentials`,
{ configDir: dirname(dirname(file)), business, agent: instance }),
credentials: checkCredentialMap(entry.credentials ?? {}, services, `${at}.credentials`),
};
// Row 199 (#1697, design D8, D9 step 2): identity and publish are optional, so today's files
// load unchanged. publish needs both an identity and a gitea credential; the project ids it
// names are checked against projects once those are read.
if (entry.identity !== undefined) out[instance].identity = checkIdentity(entry.identity, `${at}.identity`);
if (entry.publish !== undefined) {
// N10: one message for both, naming the grant.
if (!out[instance].identity || !services.includes("gitea")) refuse(`${at}.publish needs identity and a gitea definition`);
out[instance].publish = requireDistinctList(entry.publish, `${at}.publish`, (p) => requireId(p, `${at}.publish entry`), { nonEmpty: true });
}
}
return { roles: out, definitions };
}
@@ -197,7 +159,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
}
const human = requireId(document.human, "business human");
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id);
const { roles, definitions } = checkRoles(document.roles, file, rolesDir);
requireObject(document.arbiters, `${file} arbiters`);
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
@@ -221,7 +183,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
for (const [id, entry] of Object.entries(document.projects)) {
requireId(id, `${file} project id`);
requireObject(entry, `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root", "repo", "remote"], `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root", "repo"], `${file} projects.${id}`);
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
}
@@ -229,18 +191,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
normalize(entry.repo) !== entry.repo || entry.repo.includes("\0"))) {
refuse(`${file} projects.${id}.repo must be a normalized absolute path`);
}
// Row 199 (#1697, design D9 step 2): the upstream; a granted agent's work goes to its fork.
if (Object.hasOwn(entry, "remote")) httpsUrl(entry.remote, `${file} projects.${id}.remote`, { repo: true });
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
...(Object.hasOwn(entry, "remote") ? { remote: entry.remote } : {}) };
}
for (const [instance, role] of Object.entries(roles)) {
for (const project of role.publish ?? []) {
if (!Object.hasOwn(projects, project)) refuse(`${file} roles.${instance}.publish names ${project}, which the business file doesn't declare`);
if (!projects[project].remote) refuse(`${file} roles.${instance}.publish names ${project}, which has no "remote"`);
const problem = forkProblem(projects[project].remote, role.identity);
if (problem) refuse(`${file} roles.${instance}.publish names ${project}: ${problem}`);
}
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}) };
}
const tracker = checkTracker(document.tracker, file);
+8 -29
View File
@@ -3,7 +3,7 @@
// lstat and realpath only; nothing in this package opens a token file.
import { lstatSync, realpathSync } from "node:fs";
import { isAbsolute, join, normalize, relative, sep } from "node:path";
import { isAbsolute, normalize, relative, sep } from "node:path";
import { refuse } from "./errors.mjs";
import { SERVICES } from "./vocabulary.mjs";
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
@@ -14,35 +14,21 @@ const WARN_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
// Shape check for one reference. Returns a frozen { service, file | env,
// rotateBy | expires }. Row 199 (#1697, design D1 and D2): `store` is the
// file at <configDir>/secrets/agents/<business>/<agent>/<service>, derived
// here and never written, so an entry can't name another agent's file. A
// reference with no agent (the tracker's sync bot) passes store = null and
// can't use the form. A `file` reference into the agents' store must be the
// agent's own path (`credential-not-yours`).
export function parseCredentialRef(ref, service, where, { store = null } = {}) {
// rotateBy | expires }.
export function parseCredentialRef(ref, service, where) {
if (!SERVICES.includes(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
requireObject(ref, where);
const dateKey = DATE_KEY[service];
rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where);
rejectUnknownKeys(ref, ["file", "env", dateKey], where);
const hasFile = ref.file !== undefined;
const hasEnv = ref.env !== undefined;
const hasStore = ref.store !== undefined;
if (Number(hasFile) + Number(hasEnv) + Number(hasStore) !== 1) refuse(`${where} must name exactly one of "file", "env" or "store"`);
if (hasFile === hasEnv) refuse(`${where} must name exactly one of "file" or "env"`);
const out = { service };
if (hasStore) {
if (ref.store !== true) refuse(`${where}.store must be true`);
if (!store) refuse(`${where}: a "store" reference needs an agent`);
out.store = true;
out.file = storePath(store, service);
} else if (hasFile) {
if (hasFile) {
if (typeof ref.file !== "string" || !isAbsolute(ref.file) || normalize(ref.file) !== ref.file || ref.file.includes("\0")) {
refuse(`${where}.file must be a normalized absolute path`);
}
out.file = ref.file;
if (store && inside(join(store.configDir, "secrets", "agents"), ref.file) && ref.file !== storePath(store, service)) {
refuse(`${where}.file credential-not-yours: ${ref.file} is in the agents' store but not this agent's path`);
}
} else {
if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`);
out.env = ref.env;
@@ -52,11 +38,6 @@ export function parseCredentialRef(ref, service, where, { store = null } = {}) {
return Object.freeze(out);
}
// The store path for one agent and service (design D1).
export function storePath({ configDir, business, agent }, service) {
return join(configDir, "secrets", "agents", business, agent, service);
}
function inside(root, path) {
const rel = relative(root, path);
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
@@ -74,10 +55,8 @@ export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(),
let stat = null;
try {
stat = lstatSync(ref.file);
} catch (e) {
// Row 199 (design D3a): a store reference with no file is an agent not issued yet.
if (ref.store && e.code === "ENOENT") warnings.push(`${label}: not issued`);
else problems.push(`${label}: not found`);
} catch {
problems.push(`${label}: not found`);
}
if (stat) {
if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`);
+3 -22
View File
@@ -174,9 +174,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
}
}
// Row 199 (#1697, design section 6): `detail` says what a credential verb did (verb, agent, kind,
// date, reason), never a value; the other setters pass none.
function write(id, document, { revision, actor, detail }) {
function write(id, document, { revision, actor }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
return withLock(id, ({ home, parent }) => {
@@ -203,7 +201,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
syncDir(tx);
const event = {
transaction, actor, business: id, before, after,
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
changes: changes(old, written), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
syncDir(home);
@@ -221,22 +219,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
});
}
// Row 199: a receipt with no document change, for a credential verb that changes only the agents'
// store (a revoke or delete of store references). Committed at once; there is nothing to recover.
function record(id, { actor, detail }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
return withLock(id, () => {
recover(id);
const hash = sha256(regular(businessFilePath(id, dir)));
const transaction = randomUUID();
log(id, {
transaction, actor, business: id, before: hash, after: hash, changes: [], detail,
at: new Date().toISOString(), state: "committed",
});
return { revision: hash, transaction };
});
}
return { logFile, read, write, record, recover: id => withLock(id, () => recover(id)) };
return { logFile, read, write, recover: id => withLock(id, () => recover(id)) };
}
+2 -52
View File
@@ -20,9 +20,6 @@ export const ACTIONS = Object.freeze([
"git.push.working",
"git.push.protected",
"git.merge.protected",
// Row 199 (#1697, mos-1027 Q-M2): the host pushes an agent's recorded work commit to the fork its
// business grants it. A hard wait under REQ-WAIT-3 until a PRD version routes it.
"git.push.remote",
"review.request",
"review.verdict",
"record.write",
@@ -86,8 +83,8 @@ export const ROUTED = Object.freeze([
// The hard waits (PRD 1.5 REQ-WAIT-1): always gated, always the human's,
// cited spec or not. A role file may not list these under withinRole or
// crossRole (REQ-WAIT-2). Today this is spend, data.delete, network.expose,
// account.create, credential.mint, message.external, git.push.protected,
// git.push.remote and deploy; a test pins the list.
// account.create, credential.mint, message.external, git.push.protected and
// deploy; a test pins the list.
export const GATED_ONLY = Object.freeze(ACTIONS.filter((action) => !ROUTED.includes(action)));
// pi's documented built-in tools; the same list scripts/mosaic-task.mjs
@@ -136,50 +133,3 @@ export const THREAD_DEFINITION = "assistant";
// the human resolved through the CLI, may end or revoke. Row 155 moves the list here, and no
// organization write may give an instance one of them.
export const END_PROTECTED = Object.freeze([THREAD_DEFINITION]);
// Row 199 (#1697, design D9c): the Gitea URL parse. It lives here because the broker reads it and
// this file imports nothing. Gitea's name rule for users and repositories: letters, digits, "_", "."
// and "-", not "." or "..".
export const giteaName = (name) =>
typeof name === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(name) && name !== "." && name !== "..";
// An https URL that is already its own normalized form, so a dot segment, an encoded dot, userinfo, a
// query, a fragment, an upper-case host or an explicit :443 each fail, and the path checked is the
// path used. With repo, the path is exactly /OWNER/REPO (one ".git" stripped); without, it is "/".
// Returns {url, owner, repo}, or {problem} naming the rule that failed: form, instance or repository.
export function parseGiteaUrl(value, { repo = false } = {}) {
let url = null;
try {
url = typeof value === "string" ? new URL(value) : null;
} catch {
url = null;
}
const normal = url && (repo ? [url.origin + url.pathname] : [url.origin, url.origin + "/"]);
if (!url || url.protocol !== "https:" || !normal.includes(value)) return { problem: "form" };
if (!repo) return url.pathname === "/" ? { url } : { problem: "instance" };
const segments = url.pathname.split("/").slice(1);
const name = segments[1]?.endsWith(".git") ? segments[1].slice(0, -4) : segments[1];
if (segments.length !== 2 || !giteaName(segments[0]) || !giteaName(name)) return { problem: "repository" };
return { url, owner: segments[0], repo: name };
}
// Design D9 step 2: the agent's fork of an upstream, https://HOST[:PORT]/<user>/REPO, or null for an
// upstream that doesn't parse. The owner is always the agent's own Gitea user; nothing else names it.
// URL.host drops a default port, so 443 counts the same on both sides.
export function forkUrl(upstream, user) {
const { url, repo } = parseGiteaUrl(upstream, { repo: true });
return url ? `https://${url.host}/${user}/${repo}` : null;
}
// The refusal code for publishing `upstream` as an agent with `identity`, or null. Gitea compares
// names without case (lower_name), so the owner check does too. An upstream or gitea.url that doesn't
// parse is remote-not-granted: the business loader refuses both, so only a hand-built business gets
// here.
export function forkProblem(upstream, identity) {
const { url, owner } = parseGiteaUrl(upstream, { repo: true });
const instance = parseGiteaUrl(identity?.gitea?.url).url;
if (!url || !instance || typeof identity.gitea.user !== "string") return "remote-not-granted";
if (url.host !== instance.host) return "remote-host-mismatch";
if (owner.toLowerCase() === identity.gitea.user.toLowerCase()) return "remote-not-fork";
return null;
}
@@ -1,126 +0,0 @@
// Row 199 (#1697): an agent's own git identity, its publish grant, the project's upstream and the
// credential store reference. Design D1, D8, D9 step 2 and D9c.
import { test } from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { BusinessError, parseCredentialRef, validateBusinessDocument } from "../src/index.mjs";
import { forkProblem, forkUrl } from "../src/vocabulary.mjs";
import { businessDoc, FAR_DATE, REPO_ROLES, tmp } from "./helpers.mjs";
function refuses(fn, pattern) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
assert.equal(error.exitCode, 2);
assert.match(error.message, pattern);
return true;
});
}
const IDENTITY = { name: "Coder", email: "[email protected]", gitea: { url: "https://git.example", user: "acme-coder" } };
// The fixture with coder granted publish on stack, whose upstream is mosaic/stack.
function check(mutate) {
const root = tmp();
const doc = businessDoc(root);
doc.projects.stack.remote = "https://git.example/mosaic/stack";
doc.roles.coder.identity = structuredClone(IDENTITY);
doc.roles.coder.publish = ["stack"];
mutate?.(doc, root);
return validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
}
test("today's files load unchanged: identity, publish and remote are optional", () => {
const root = tmp();
const business = validateBusinessDocument(businessDoc(root), join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
assert.equal(business.roles.coder.identity, undefined);
assert.equal(business.roles.coder.publish, undefined);
assert.equal(business.projects.stack.remote, undefined);
});
test("a granted agent loads with its identity, grant and the project's upstream", () => {
const business = check();
assert.deepEqual({ ...business.roles.coder.identity, gitea: { ...business.roles.coder.identity.gitea } }, IDENTITY);
assert.deepEqual([...business.roles.coder.publish], ["stack"]);
assert.equal(business.projects.stack.remote, "https://git.example/mosaic/stack");
});
test("N10: publish needs identity and a gitea definition, and the error names the grant", () => {
refuses(() => check((d) => { delete d.roles.coder.identity; }), /roles\.coder\.publish needs identity and a gitea definition/);
refuses(() => check((d) => { d.roles.chief.identity = structuredClone(IDENTITY); d.roles.chief.publish = ["stack"]; }),
/roles\.chief\.publish needs identity and a gitea definition/);
});
test("publish names declared projects that have a remote, each once", () => {
refuses(() => check((d) => { d.roles.coder.publish = ["books"]; }), /publish names books, which the business file doesn't declare/);
refuses(() => check((d) => { delete d.projects.stack.remote; }), /publish names stack, which has no "remote"/);
refuses(() => check((d) => { d.roles.coder.publish = []; }), /publish/);
refuses(() => check((d) => { d.roles.coder.publish = ["stack", "stack"]; }), /publish/);
});
test("identity fields are checked", () => {
refuses(() => check((d) => { d.roles.coder.identity.email = "coder"; }), /identity\.email/);
refuses(() => check((d) => { d.roles.coder.identity.name = "a\nb"; }), /identity\.name/);
refuses(() => check((d) => { d.roles.coder.identity.extra = 1; }), /identity/);
refuses(() => check((d) => { d.roles.coder.identity.gitea.user = ".."; }), /gitea\.user/);
refuses(() => check((d) => { d.roles.coder.identity.gitea.user = "a/b"; }), /gitea\.user/);
});
test("D9c: the upstream must be its own normalized form, exactly /OWNER/REPO", () => {
const bad = {
"a dot segment": "https://git.example/acme-coder/../mosaic/stack",
"an encoded dot segment": "https://git.example/acme-coder/%2e%2e/mosaic/stack",
"an extra segment": "https://git.example/mosaic/stack/more",
"a missing segment": "https://git.example/stack",
"a trailing slash": "https://git.example/mosaic/stack/",
userinfo: "https://u:[email protected]/mosaic/stack",
"a query": "https://git.example/mosaic/stack?x=1",
"a fragment": "https://git.example/mosaic/stack#f",
"an explicit :443": "https://git.example:443/mosaic/stack",
"an upper-case host": "https://GIT.example/mosaic/stack",
"a backslash": "https://git.example\\mosaic\\stack",
"a name outside Gitea's rule": "https://git.example/mosaic/st%41ck",
"a dot-dot repo": "https://git.example/mosaic/...git",
http: "http://git.example/mosaic/stack",
};
for (const [what, remote] of Object.entries(bad)) {
assert.throws(() => check((d) => { d.projects.stack.remote = remote; }), BusinessError, what);
}
});
test("D9c: one .git is stripped, and the fork is built from the stripped name", () => {
assert.equal(check((d) => { d.projects.stack.remote = "https://git.example/mosaic/stack.git"; }).projects.stack.remote,
"https://git.example/mosaic/stack.git");
assert.equal(forkUrl("https://git.example/mosaic/stack.git", "acme-coder"), "https://git.example/acme-coder/stack");
assert.equal(forkUrl("https://git.example:3000/mosaic/stack", "acme-coder"), "https://git.example:3000/acme-coder/stack");
});
test("D9c: gitea.url names the instance only", () => {
for (const url of ["https://git.example/gitea", "https://git.example/gitea/", "https://git.example/?x", "https://git.example:443", "http://git.example"]) {
assert.throws(() => check((d) => { d.roles.coder.identity.gitea.url = url; }), BusinessError, url);
}
check((d) => { d.roles.coder.identity.gitea.url = "https://git.example/"; });
});
test("the upstream's host and port must be the agent's Gitea's", () => {
refuses(() => check((d) => { d.projects.stack.remote = "https://other.example/mosaic/stack"; }), /remote-host-mismatch/);
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example:3000/mosaic/stack"; }), /remote-host-mismatch/);
assert.equal(forkProblem("https://git.example/mosaic/stack", { gitea: { url: "https://git.example/", user: "u" } }), null);
});
test("remote-not-fork: an upstream the agent's own user owns, compared without case", () => {
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example/acme-coder/stack"; }), /remote-not-fork/);
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example/ACME-Coder/stack"; }), /remote-not-fork/);
});
test("store references: own path only (design D1, D3a)", () => {
const store = { configDir: "/cfg", business: "acme", agent: "coder" };
const ref = parseCredentialRef({ store: true, rotateBy: FAR_DATE }, "gitea", "r", { store });
assert.equal(ref.file, "/cfg/secrets/agents/acme/coder/gitea");
assert.equal(ref.store, true);
refuses(() => parseCredentialRef({ store: true, rotateBy: FAR_DATE }, "gitea", "r"), /store/);
refuses(() => parseCredentialRef({ store: true, file: "/a", rotateBy: FAR_DATE }, "gitea", "r", { store }), /exactly one/);
refuses(() => parseCredentialRef({ file: "/cfg/secrets/agents/acme/pm/gitea", rotateBy: FAR_DATE }, "gitea", "r", { store }),
/credential-not-yours/);
assert.equal(parseCredentialRef({ file: "/cfg/secrets/agents/acme/coder/gitea", rotateBy: FAR_DATE }, "gitea", "r", { store }).file,
"/cfg/secrets/agents/acme/coder/gitea");
});
+1 -1
View File
@@ -9,7 +9,7 @@ import { validateRoleDocument } from "../src/index.mjs";
import { tmp } from "./helpers.mjs";
const WAITS = ["spend", "data.delete", "network.expose", "account.create", "credential.mint", "message.external",
"git.push.protected", "deploy", "git.push.remote"];
"git.push.protected", "deploy"];
const LEFT = ["git.merge.protected", "policy.change", "prd.approve", "role.revoke"];
const role = (authority) => {
const dir = tmp();
-13
View File
@@ -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
-4
View File
@@ -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);
-83
View File
@@ -1,83 +0,0 @@
// `mosaic credentials` (row 199, #1697, design section 6): an agent's per-agent credentials through
// the broker's credentials, credentials.issue, credentials.revoke and credentials.delete. Every verb
// is the human's; with --tester (Q173) only status and revoke. issue and rotate read the token on
// stdin, never argv, and nothing here prints it: the broker's answer carries the kind, date and state.
import { CliError } from "./errors.mjs";
export const CREDENTIALS_USAGE = "mosaic credentials [status [AGENT]] [--json] [--tester] | issue|rotate AGENT gitea --rotate-by DATE | " +
"issue|rotate AGENT vikunja --expires DATE | revoke AGENT [KIND] --reason TEXT [--tester] | delete AGENT --reason TEXT [--business ID]";
const DATE_FLAG = Object.freeze({ gitea: "--rotate-by", vikunja: "--expires" });
const MAX_TOKEN = 16384;
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--business", "--rotate-by", "--expires", "--reason"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(CREDENTIALS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(CREDENTIALS_USAGE, 4);
else out.positional.push(arg);
}
return out;
}
// The whole of stdin, one token; a terminal shows what is typed, so a pipe or a paste then EOF.
async function readToken(stdin) {
let text = "";
for await (const chunk of stdin) {
text += chunk;
if (text.length > MAX_TOKEN + 2) throw new CliError("refused: the token on stdin is too long", 2);
}
if (!text.trim()) throw new CliError("refused: no token on stdin; pipe or paste it, then end input", 4);
return text;
}
// The states the bus's status() gives a held token: gitea is valid or rotation-due, vikunja valid,
// expiring or expired. not-issued and revoked hold none.
const ISSUED = new Set(["valid", "rotation-due", "expiring", "expired"]);
export async function credentialsCommand(args, io, callFor) {
const parsed = parse(args);
const [verb = "status", agent, kind, ...extra] = parsed.positional;
const v = parsed.values;
const shape = {
status: () => kind === undefined && !v["--rotate-by"] && !v["--expires"] && !v["--reason"],
issue: () => agent && DATE_FLAG[kind] && v[DATE_FLAG[kind]] && !v[kind === "gitea" ? "--expires" : "--rotate-by"] && !v["--reason"],
revoke: () => agent && (kind === undefined || DATE_FLAG[kind]) && v["--reason"] && !v["--rotate-by"] && !v["--expires"],
delete: () => agent && kind === undefined && v["--reason"] && !v["--rotate-by"] && !v["--expires"],
};
shape.rotate = shape.issue;
if (extra.length || !shape[verb]?.()) throw new CliError(CREDENTIALS_USAGE, 4);
// Q173 (duckworth N1): a token the tester issued would pass through its session.
if (parsed.flags.has("--tester") && !["status", "revoke"].includes(verb)) {
throw new CliError(`--tester takes only status and revoke\n${CREDENTIALS_USAGE}`, 4);
}
try {
const call = callFor(parsed);
let result;
if (verb === "status") {
const rows = await call("credentials", {});
result = agent ? rows.filter((r) => r.role === agent) : rows;
if (!parsed.flags.has("--json")) {
io.stdout.write(result.map((r) => `${r.role}\t${r.service}\t${r.state}\t${r.dateKind} ${r.date}`).join("\n") + (result.length ? "\n" : ""));
return;
}
} else if (verb === "issue" || verb === "rotate") {
// rotate names an entry the bus holds now; checked before stdin is read.
if (verb === "rotate") {
const held = (await call("credentials", {})).find((r) => r.role === agent && r.service === kind);
if (!ISSUED.has(held?.state)) throw new CliError(`refused: ${agent} has no issued ${kind} credential to rotate; use issue`, 2);
}
const token = await readToken(io.stdin);
result = await call("credentials.issue", { agent, service: kind, token, date: v[DATE_FLAG[kind]] });
} else if (verb === "revoke") {
result = await call("credentials.revoke", { agent, ...(kind ? { service: kind } : {}), reason: v["--reason"] });
} else result = await call("credentials.delete", { agent, reason: v["--reason"] });
io.stdout.write(JSON.stringify(result, null, 2) + "\n");
} catch (error) {
if (error instanceof CliError) throw error;
throw new CliError(error.message, 2);
}
}
+3 -72
View File
@@ -26,19 +26,6 @@
// squash that will land. The gate runs after the answer, and its
// outcome is the broker's: recordLand or landRefused, each on the
// task's trail and told to the role as a RESULT message.
// publish {"cap", "op": "publish", "task_ref", "project", "branch", "decision"}
// (row 199, #1697, design D9 to D11). The host answers {"ok": true,
// "result": {fork, ref, commit, previous}} or a refusal. The broker's
// checkPublish names the fork, the agent's email, the source commit
// and the destination; the request names none of them. publish
// (working.mjs) checks the work ref and the commits' identity under
// the repository's lock, then the bus process runs ls-remote and the
// push with the agent's gitea token (publishPush), and the host polls
// publishResult every publishPollMs: the host's op channel answers
// one op at a time within 10 s. The poll ends at publishDeadlineMs
// (publish-timeout), past git's own limit on each of the four git
// runs. recordPublish files the push; any refusal after checkPublish
// is filed once, by publishRefused.
//
// A refusal is {"ok": false, "error": "<code>"}, and the connection ends.
// The host checks, in order: identity (a role of this business), the request
@@ -77,10 +64,9 @@ import { randomBytes } from "node:crypto";
import { closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, rmSync, statSync, symlinkSync, writeSync, chmodSync } from "node:fs";
import { createServer } from "node:net";
import { join } from "node:path";
import { GIT_GRACE_MS, GIT_TIMEOUT_MS } from "../../seat/src/checkout.mjs";
import { gitSocketPath } from "../../seat/src/session.mjs";
import { gateEnv, isMosaicStack, rootHits, runGate } from "../../seat/src/landing.mjs";
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, publish, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
import { CliError } from "./errors.mjs";
const HEADER_MAX = 4096;
@@ -98,11 +84,6 @@ export const bodyDeadline = (size) => BODY_IDLE_MS + Math.ceil(size / BODY_MIN_R
const MAX_ACTIVE = 8;
// The longest close() waits for its host-closing answers to leave.
export const CLOSE_WAIT_MS = 2000;
// How often the host asks the bus process whether a publish's push ended.
const PUBLISH_POLL_MS = 200;
// The longest the host polls: publishRemote runs at most four git commands
// (ls-remote, cat-file, merge-base, push), each ended by git's own limit.
const PUBLISH_DEADLINE_MS = 4 * (GIT_TIMEOUT_MS + GIT_GRACE_MS) + 30_000;
const CAP = /^[0-9a-f]{64}$/;
const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
@@ -291,7 +272,7 @@ Applied-under: git socket land (bus host)
// host: startHost()'s handle; business: loadBusiness() output. bundleMax,
// bodyDeadline, bodyIdle, maxActive, fsck and gate (LAND_GATE's shape) are
// for the tests.
export function createGitSocket({ host, business: b, dataRoot, env = process.env, log = () => {}, bundleMax = BUNDLE_MAX, bodyDeadline: deadlineOf = bodyDeadline, bodyIdle = BODY_IDLE_MS, maxActive = MAX_ACTIVE, fsck = FSCK, gate = LAND_GATE, publishPollMs = PUBLISH_POLL_MS, publishDeadlineMs = PUBLISH_DEADLINE_MS }) {
export function createGitSocket({ host, business: b, dataRoot, env = process.env, log = () => {}, bundleMax = BUNDLE_MAX, bodyDeadline: deadlineOf = bodyDeadline, bodyIdle = BODY_IDLE_MS, maxActive = MAX_ACTIVE, fsck = FSCK, gate = LAND_GATE }) {
const path = gitSocketPath(dataRoot);
const dir = join(dataRoot, "bus-host");
let server = null;
@@ -405,55 +386,6 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
})();
}
async function doPublish(socket, cap, req) {
const { task_ref, branch, decision } = req;
const p = project(req.project);
if (!p || !validBranch(branch) || (decision !== undefined && typeof decision !== "string")) throw new Refusal("invalid-request");
workRef(task_ref);
const facts = await op({ op: "checkPublish", cap, args: { task_ref, project: req.project, branch, ...(decision === undefined ? {} : { decision }) } });
const { ticket } = facts;
// From the ticket on, a refusal is the publish's own, filed once by publishRefused.
const refused = async (e) => {
const code = e instanceof Refusal || e instanceof Recorded || e instanceof WorkingError ? e.code : "git-failed";
if (code === "git-failed") log(`git socket: publish ${task_ref}: ${code}: ${e.message}`);
await host.op({ op: "publishRefused", ticket, args: { code } }).catch((x) => log(`git socket: publish ${task_ref}: refusal ${code} not recorded: ${x.code ?? x.message}`));
return new Recorded(code);
};
let pushed;
try {
pushed = await publish({
dataRoot, business: b.id, project: req.project, root: p.root, taskRef: task_ref, source: facts.source, email: facts.email, env,
remote: async () => {
await op({ op: "publishPush", ticket });
const until = Date.now() + publishDeadlineMs;
for (;;) {
// The bus's git may still finish after this; the fork could then hold the commit with
// no record, as when recordPublish refuses after a push.
if (Date.now() >= until) {
log(`git socket: publish ${task_ref}: publish-timeout; the bus process's push may still reach the fork`);
throw new Refusal("publish-timeout");
}
await new Promise((r) => setTimeout(r, publishPollMs));
const r = await op({ op: "publishResult", ticket });
if (r.state === "pushed") return { ref: r.ref, commit: r.commit, previous: r.previous };
if (r.state === "refused") throw new Recorded(r.code);
}
},
});
} catch (e) {
throw await refused(e);
}
// The commit is on the fork now; a record the broker refuses is filed as that refusal.
let result;
try {
result = await op({ op: "recordPublish", ticket, pushed });
} catch (e) {
log(`git socket: publish ${task_ref}: ${pushed.commit} is on ${facts.fork} but wasn't recorded: ${e.code}`);
throw await refused(e);
}
socket.end(`${JSON.stringify({ ok: true, result: { fork: result.fork, ref: result.ref, commit: result.commit, previous: result.previous } })}\n`);
}
async function serve(socket) {
let cap = null;
let subject = null;
@@ -478,8 +410,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
if (req.op === "push") await doPush(socket, cap, req, rest, file);
else if (req.op === "fetch") await doFetch(socket, cap, req, file);
else if (req.op === "land") await doLand(socket, cap, req);
else if (req.op === "publish") await doPublish(socket, cap, req);
else throw new Refusal("invalid-request", "op is push, fetch, land or publish");
else throw new Refusal("invalid-request", "op is push, fetch or land");
} catch (e) {
if (e instanceof Recorded) return fail(e.code);
const code = e instanceof Refusal || e instanceof WorkingError ? e.code : "git-failed";
+3 -5
View File
@@ -47,9 +47,8 @@
// launchedBy = the business's human and no caller capability.
//
// listen() also serves the git socket (src/git-socket.mjs, row 93), which
// the sandbox binds for a role that holds git.push.working,
// review.verdict or git.push.remote (row 199), or a merge decision to land
// (landHeld, row 137);
// the sandbox binds for a role that holds git.push.working or
// review.verdict, or a merge decision to land (landHeld, row 137);
// session.json names it as gitSocket.
//
// Thread sessions (row 121, #1615). A thread instance (an instance whose
@@ -398,9 +397,8 @@ export function createLauncher({
const transcript = call(model.harness, "transcript", () => harness.transcript({ runDir, store }));
const harnessMounts = call(model.harness, "mounts", () => harness.mounts({ env }));
// Row 93: the git socket, for a role that pushes work or reviews it. Row 137: or lands it.
// Row 199 (D11): or publishes it, once a reviewed commit routes git.push.remote within-role.
const held = [...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole];
const git = land || ["git.push.working", "review.verdict", "git.push.remote"].some((a) => held.includes(a)) ? gitSocketPath(dataRoot) : null;
const git = land || held.includes("git.push.working") || held.includes("review.verdict") ? gitSocketPath(dataRoot) : null;
writeSessionFile(runDir, {
...sessionDefaults,
business: b.id,
-114
View File
@@ -1,114 +0,0 @@
// Row 199 (#1697, design section 6, T4, T5): `mosaic credentials`. issue and rotate read the token on
// stdin and pass it once to credentials.issue; nothing printed carries it. --tester takes status and
// revoke only. A fake transport records the calls; no bus runs here.
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { main } from "../src/cli.mjs";
import { io, tmp } from "./helpers.mjs";
const TOKEN = "fake_cli_token_0123456789";
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
const ROWS = [
{ service: "gitea", account: null, role: "coder", dateKind: "rotateBy", date: "2099-01-01", state: "valid" },
{ service: "vikunja", account: "bot", role: "coder", dateKind: "expires", date: "2099-01-01", state: "not-issued" },
{ service: "gitea", account: null, role: "reviewer", dateKind: "rotateBy", date: "2099-01-01", state: "revoked" },
];
function deps(calls, rows = ROWS) {
const answer = (who) => (business) => async (verb, args) => {
calls.push({ who, business, verb, args });
if (verb === "credentials") return rows;
if (verb === "credentials.issue") return { agent: args.agent, verb: "issue", service: args.service, date: args.date, state: "valid" };
return { agent: args.agent, verb: verb.slice(12), services: [] };
};
return { system: { dataRoot: "/nonexistent" }, transport: answer("human"), testerTransport: answer("tester") };
}
const withStdin = (x, text) => {
x.stdin.end(text);
return x;
};
test("row 199: issue and rotate send the stdin token once to credentials.issue and print no value", async () => {
const calls = [];
const d = deps(calls);
const x = withStdin(io(), TOKEN + "\n");
await main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], x, d);
assert.deepEqual(calls, [{ who: "human", business: "acme", verb: "credentials.issue", args: { agent: "coder", service: "gitea", token: TOKEN + "\n", date: "2099-01-01" } }]);
assert.equal(x.out.text.includes(TOKEN), false);
assert.equal(JSON.parse(x.out.text).state, "valid");
// rotate checks the entry is issued before it reads stdin.
calls.length = 0;
await main(["credentials", "rotate", "coder", "gitea", "--rotate-by", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), d);
assert.deepEqual(calls.map((c) => c.verb), ["credentials", "credentials.issue"]);
calls.length = 0;
await assert.rejects(main(["credentials", "rotate", "coder", "vikunja", "--expires", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), d), /no issued vikunja credential to rotate; use issue/);
assert.deepEqual(calls.map((c) => c.verb), ["credentials"]);
// No token on stdin, the date flag of the other kind, a token in argv: refused before any write.
calls.length = 0;
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io(), "\n"), d), /no token on stdin/);
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--expires", "2099-01-01", "--business", "acme"], withStdin(io(), TOKEN), d), /mosaic credentials/);
await assert.rejects(main(["credentials", "issue", "coder", "gitea", TOKEN, "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io(), TOKEN), d), /mosaic credentials/);
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io(), "x".repeat(17000)), d), /too long/);
assert.equal(calls.filter((c) => c.verb === "credentials.issue").length, 0);
// From inside an agent run, the human's verbs refuse before any call.
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io({ CLAUDECODE: "1" }), TOKEN), d), /outside any agent run/);
});
// gizmoduck-0050: a gitea key past its rotate-by date is rotation-due, and rotate is how a human
// clears it. Every state the bus's status() gives a held token rotates; the other two refuse.
test("row 199: rotate goes ahead on each held state, rotation-due included, and refuses not-issued, revoked and an unknown entry", async () => {
const states = { valid: true, "rotation-due": true, expiring: true, expired: true, "not-issued": false, revoked: false };
for (const [state, held] of Object.entries(states)) {
const calls = [];
const d = deps(calls, [{ service: "gitea", account: null, role: "coder", dateKind: "rotateBy", date: "2020-01-01", state }]);
const run = main(["credentials", "rotate", "coder", "gitea", "--rotate-by", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), d);
if (held) await run;
else await assert.rejects(run, /no issued gitea credential to rotate; use issue/);
assert.deepEqual(calls.map((c) => c.verb), held ? ["credentials", "credentials.issue"] : ["credentials"], state);
}
const calls = [];
await assert.rejects(main(["credentials", "rotate", "tester", "gitea", "--rotate-by", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), deps(calls)), /no issued gitea credential/);
assert.deepEqual(calls.map((c) => c.verb), ["credentials"]);
});
test("row 199: status, revoke and delete; the tester runs status and revoke only", async () => {
const calls = [];
const d = deps(calls);
const x = io();
await main(["credentials", "status", "coder", "--business", "acme"], x, d);
assert.equal(x.out.text, "coder\tgitea\tvalid\trotateBy 2099-01-01\ncoder\tvikunja\tnot-issued\texpires 2099-01-01\n");
const j = io();
await main(["credentials", "--json", "--business", "acme", "--tester"], j, d);
assert.equal(JSON.parse(j.out.text).length, 3);
await main(["credentials", "revoke", "coder", "gitea", "--reason", "fired", "--business", "acme", "--tester"], io(), d);
await main(["credentials", "revoke", "coder", "--reason", "fired", "--business", "acme"], io(), d);
await main(["credentials", "delete", "coder", "--reason", "fired", "--business", "acme"], io(), d);
assert.deepEqual(calls.map((c) => [c.who, c.verb, c.args]), [
["human", "credentials", {}],
["tester", "credentials", {}],
["tester", "credentials.revoke", { agent: "coder", service: "gitea", reason: "fired" }],
["human", "credentials.revoke", { agent: "coder", reason: "fired" }],
["human", "credentials.delete", { agent: "coder", reason: "fired" }],
]);
calls.length = 0;
for (const args of [
["issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--tester"],
["rotate", "coder", "gitea", "--rotate-by", "2099-01-01", "--tester"],
["delete", "coder", "--reason", "x", "--tester"],
]) await assert.rejects(main(["credentials", ...args, "--business", "acme"], withStdin(io(), TOKEN), d), /--tester takes only status and revoke/);
for (const args of [["revoke", "coder"], ["delete", "coder"], ["delete", "coder", "gitea", "--reason", "x"], ["revoke", "coder", "model", "--reason", "x"], ["purge", "coder"]])
await assert.rejects(main(["credentials", ...args, "--business", "acme"], io(), d), /mosaic credentials/, args.join(" "));
assert.equal(calls.length, 0);
});
test("row 199: scripts/mosaic sends credentials to packages/cli, not the seat CLI", (t) => {
const root = tmp(t, "mosaic-cli-cred-route-");
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
const r = spawnSync(MOSAIC, ["credentials", "purge"], { env, encoding: "utf8" });
assert.equal(r.status, 4);
assert.match(r.stderr, /^mosaic: mosaic credentials \[status \[AGENT\]\]/);
assert.doesNotMatch(r.stderr, /mosaic launch </);
});
-281
View File
@@ -1,281 +0,0 @@
// Row 199 (#1697, design D9, D9b, D11, T3): the git socket's publish op end to end. The socket,
// working.mjs, the bus runtime (in this process, so its publishTls can name the scratch CA), the
// per-agent store and a TLS fake Gitea on 127.0.0.1. The host stand-in answers the socket's ops
// the way packages/bus/src/process.mjs does. Fake tokens in a scratch root only.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { connect } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { startBroker } from "../../bus/src/runtime.mjs";
import { businessDoc, rolesCopy, systemFor, writeJson } from "../../business/tests/helpers.mjs";
import { pushWorking } from "../../harness/src/git-tools.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { workRef } from "../../seat/src/working.mjs";
import { fakeGitea, scratchCa } from "../../seat/tests/fake-gitea.mjs";
import { createGitSocket } from "../src/git-socket.mjs";
const TASK = "vikunja:1/2";
const TASK2 = "vikunja:1/3";
const TOKEN = "fake_socket_token_0123456789";
const TOKEN2 = "fake_second_token_0123456789";
function git(cwd, ...args) {
const r = spawnSync("git", args, { cwd, encoding: "utf8", env: gitEnv() });
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
return r.stdout.trim();
}
function commit(dir, text, email) {
writeFileSync(join(dir, "README"), `${text}\n`);
git(dir, "add", "README");
git(dir, "-c", `user.name=${text}`, "-c", `user.email=${email}`, "commit", "-q", "-m", text);
return git(dir, "rev-parse", "HEAD");
}
function raw(path, req) {
return new Promise((resolve, reject) => {
const s = connect(path);
let buf = "";
s.on("connect", () => s.write(`${JSON.stringify(req)}\n`));
s.on("data", (b) => (buf += b));
s.on("end", () => resolve(JSON.parse(buf.split("\n")[0])));
s.on("error", reject);
});
}
// The socket's ops, answered as process.mjs's launchOp answers them.
const OPS = {
identity: (r, m) => r.identity(m.cap),
refuse: (r, m) => r.refuse(m.cap, m.code, m.subject),
checkPush: (r, m) => r.checkPush(m.cap, m.task_ref),
recordPush: (r, m) => r.recordPush(m.cap, m.push),
checkPublish: (r, m) => r.checkPublish(m.cap, m.args),
publishPush: (r, m) => r.publishPush(m.ticket),
publishResult: (r, m) => r.publishResult(m.ticket),
recordPublish: (r, m) => r.recordPublish(m.ticket, m.pushed),
publishRefused: (r, m) => r.publishRefused(m.ticket, m.args),
};
async function setup(t, { publishDeadlineMs } = {}) {
const root = mkdtempSync(join(tmpdir(), "cli-publish-"));
const down = [];
t.after(async () => {
for (const fn of down) await fn();
rmSync(root, { recursive: true, force: true });
});
const ca = scratchCa(join(root, "ca"));
const gitea = await fakeGitea({ dir: join(root, "gitea"), tls: ca, users: { coder: TOKEN, second: TOKEN2 } });
down.push(() => gitea.close());
const project = join(root, "project");
mkdirSync(project);
git(project, "init", "-q", "-b", "main");
commit(project, "one", "[email protected]");
// Each agent's fork and the upstream, mosaic/stack, all clones of the root.
for (const owner of ["coder", "second", "mosaic"]) {
mkdirSync(join(gitea.repo(owner, "stack"), ".."), { recursive: true });
git(root, "clone", "-q", "--bare", project, gitea.repo(owner, "stack"));
}
const forkRepo = gitea.repo("coder", "stack");
const rolesDir = rolesCopy(root);
writeJson(join(root, "businesses/demo.json"), { ...businessDoc(root, "demo"), plugins: { harness: ["pi"] } });
mkdirSync(join(root, "data"), { mode: 0o700 });
mkdirSync(join(root, "secrets", "agents"), { recursive: true, mode: 0o700 });
chmodSync(join(root, "secrets"), 0o700);
const agent = (user) => ({
authority: { withinRole: ["git.push.working", "message.send"], crossRole: [] },
identity: { name: user, email: `${user}@example.com`, gitea: { url: gitea.origin, user } },
publish: ["stack"],
credentials: { gitea: { store: true, file: join(root, "secrets", "agents", "demo", user, "gitea"), rotateBy: "2099-01-01" } },
});
const demo = {
id: "demo", human: "jason", arbiters: { technical: "reviewer", delivery: "reviewer" },
projects: { stack: { root: project, remote: `${gitea.origin}/mosaic/stack` } },
roles: {
coder: agent("coder"),
second: agent("second"),
reviewer: { authority: { withinRole: ["message.send"], crossRole: [] } },
},
};
const dataRoot = join(root, "data");
const bus = await startBroker({ dataRoot, businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) }, publishTls: ["-c", `http.sslCAInfo=${ca.ca}`] });
down.unshift(() => bus.close());
// A test sets hooks[op] to act around one op: hook(message, next), where next() answers it.
const hooks = {};
const host = { op: async (m) => (hooks[m.op] ? hooks[m.op](m, () => OPS[m.op](bus, m)) : OPS[m.op](bus, m)) };
const logs = [];
mkdirSync(join(dataRoot, "bus-host"), { mode: 0o700 });
const sock = createGitSocket({ host, business: demo, dataRoot, env: process.env, log: (l) => logs.push(l), publishPollMs: 20, ...(publishDeadlineMs ? { publishDeadlineMs } : {}) });
down.unshift(() => sock.close());
await sock.listen();
const caps = {};
for (const role of ["coder", "second"]) {
caps[role] = bus.broker.bindLaunch({ business: "demo", role, run: `${role}-run`, harness: "pi" });
bus.broker.request(caps[role], { verb: "role.claim", args: {} });
}
const human = bus.broker.bindHuman({ business: "demo", human: "jason", via: "cli", outsideAgent: true });
// Each agent's workspace: a clone of the root with one commit of its own on feat, pushed to the
// working repo for its task.
const work = async (role, task) => {
const workspace = join(root, `ws-${role}`);
const tmp = join(root, `tmp-${role}`);
mkdirSync(workspace);
mkdirSync(tmp);
git(workspace, "clone", "-q", project, "stack");
const clone = join(workspace, "stack");
git(clone, "checkout", "-q", "-b", "feat");
const commitId = commit(clone, `work by ${role}`, `${role}@example.com`);
await pushWorking({ path: sock.path, cap: caps[role], workspace, tmp, task_ref: task, project: "stack", branch: "feat" });
return commitId;
};
// A second commit on the coder's feat, pushed to the working repo through the socket.
const again = async () => {
const workspace = join(root, "ws-coder");
const commitId = commit(join(workspace, "stack"), "more work", "[email protected]");
await pushWorking({ path: sock.path, cap: caps.coder, workspace, tmp: join(root, "tmp-coder"), task_ref: TASK, project: "stack", branch: "feat" });
return commitId;
};
const approve = (role = "coder", task = TASK) => {
const d = bus.broker.request(caps[role], { verb: "decision.raise", args: {
action: "git.push.remote", target: task, task_ref: task, question: "Publish?", blocking: false, recommendation: "yes",
options: [{ key: "yes", text: "Yes" }, { key: "no", text: "No" }] } });
bus.broker.request(human, { verb: "decision.resolve", args: { id: d.id, choice: "yes" } });
return d.id;
};
const publish = (decision, extra = {}, role = "coder", task = TASK) =>
raw(sock.path, { cap: caps[role], op: "publish", task_ref: task, project: "stack", branch: "feat", decision, ...extra });
// The bus holds the writer lock; the test reads its events through a read-only handle.
const events = (kind, task = TASK) => {
const db = new DatabaseSync(join(dataRoot, "bus", "bus.sqlite"), { readOnly: true });
try {
return db.prepare("SELECT * FROM events WHERE kind=? AND subject=? ORDER BY seq").all(kind, task).map((e) => ({ ...e, body: JSON.parse(e.body) }));
} finally {
db.close();
}
};
return { root, bus, gitea, forkRepo, work: await work("coder", TASK), more: work, again, hooks, caps, approve, publish, logs, events };
}
test("T3 end to end: publish under an approved decision sends the work commit to the agent's own fork and records it once", async (t) => {
const w = await setup(t);
// Nothing issued: the store has no token, and the refusal is filed once.
const first = w.approve();
assert.deepEqual(await w.publish(first), { ok: false, error: "credential-unavailable" });
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["credential-unavailable"]);
assert.equal(w.gitea.requests.length, 0);
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
const reply = await w.publish(first);
assert.deepEqual(reply, { ok: true, result: { fork: `${w.gitea.origin}/coder/stack`, ref: "refs/heads/coder/feat", commit: w.work, previous: null } });
assert.equal(git(w.root, `--git-dir=${w.forkRepo}`, "rev-parse", "refs/heads/coder/feat"), w.work);
// Every request went to the agent's fork; the upstream, mosaic/stack, received none.
assert.ok(w.gitea.requests.some((r) => r.path === "/coder/stack/git-receive-pack" && r.user === "coder"));
for (const r of w.gitea.requests) assert.match(r.path, /^\/coder\/stack\//);
const allowed = w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise");
assert.equal(allowed.length, 1);
assert.equal(allowed[0].body.fork, `${w.gitea.origin}/coder/stack`);
assert.equal(allowed[0].body.commit, w.work);
// The decision is spent.
assert.deepEqual(await w.publish(first), { ok: false, error: "decision-consumed" });
// T4: no token in any answer, event or log line, nor in any file under the scratch root but the
// agent's own store file.
assert.equal(JSON.stringify([reply, w.events("action.allowed"), w.events("action.refused"), w.logs]).includes(TOKEN), false);
const store = join(w.root, "secrets", "agents", "demo", "coder", "gitea");
const holding = readdirSync(w.root, { recursive: true })
.map((name) => join(w.root, name))
.filter((file) => lstatSync(file).isFile() && readFileSync(file).includes(TOKEN));
assert.deepEqual(holding, [store]);
});
test("T3 end to end: a fork the token can't reach is fork-unavailable, filed once, and nothing is recorded as allowed", async (t) => {
const w = await setup(t);
w.bus.credentials.issue("demo/coder", "gitea", "fake_wrong_token_0123456789", "2099-01-01");
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "fork-unavailable" });
assert.ok(w.gitea.requests.length > 0);
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["fork-unavailable"]);
assert.equal(w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise").length, 0);
});
test("a bad branch or project refuses before the broker; a fork, user or ref in the request is ignored", async (t) => {
const w = await setup(t);
const d = w.approve();
assert.deepEqual(await w.publish(d, { branch: "../main" }), { ok: false, error: "invalid-request" });
assert.deepEqual(await w.publish(d, { project: "nope" }), { ok: false, error: "invalid-request" });
// Filed through the socket's refuse op, as a bad push request is; no publish ticket opened.
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["invalid-request", "invalid-request"]);
assert.equal(w.gitea.requests.length, 0);
// The broker builds the fork and the destination; the request's own are never read.
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
const reply = await w.publish(d, { fork: "https://127.0.0.1:1/other/stack", user: "other", dst: "refs/heads/main", ref: "refs/heads/main" });
assert.deepEqual(reply.result, { fork: `${w.gitea.origin}/coder/stack`, ref: "refs/heads/coder/feat", commit: w.work, previous: null });
for (const r of w.gitea.requests) assert.match(r.path, /^\/coder\/stack\//);
assert.equal(spawnSync("git", [`--git-dir=${w.forkRepo}`, "rev-parse", "-q", "--verify", "refs/heads/other/feat"], { env: gitEnv() }).status, 1);
});
test("T3 end to end: two agents, one project, each lands on its own fork only; the upstream receives nothing", async (t) => {
const w = await setup(t);
const second = await w.more("second", TASK2);
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
w.bus.credentials.issue("demo/second", "gitea", TOKEN2, "2099-01-01");
assert.equal((await w.publish(w.approve())).ok, true);
const coderRequests = w.gitea.requests.length;
for (const r of w.gitea.requests) assert.deepEqual([r.path.split("/")[1], r.user ?? "coder"], ["coder", "coder"]);
const reply = await w.publish(w.approve("second", TASK2), {}, "second", TASK2);
assert.deepEqual(reply.result, { fork: `${w.gitea.origin}/second/stack`, ref: "refs/heads/second/feat", commit: second, previous: null });
for (const r of w.gitea.requests.slice(coderRequests)) assert.deepEqual([r.path.split("/")[1], r.user ?? "second"], ["second", "second"]);
const refs = (owner) => git(w.root, `--git-dir=${w.gitea.repo(owner, "stack")}`, "for-each-ref", "--format=%(refname) %(objectname)");
assert.equal(refs("coder").includes(`refs/heads/coder/feat ${w.work}`), true);
assert.equal(refs("coder").includes("refs/heads/second/"), false);
assert.equal(refs("second").includes(`refs/heads/second/feat ${second}`), true);
assert.equal(refs("second").includes("refs/heads/coder/"), false);
assert.equal(refs("mosaic").split("\n").length, 1, "the upstream holds only main");
});
// sage-1440 on BUILD.md 14: a push between checkPublish and the git push refuses, with one code
// for each place it can come in, and the fork never receives a commit the decision didn't bind.
test("T3: a push after checkPublish and before the lock refuses ref-moved; the fork receives nothing", async (t) => {
const w = await setup(t);
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
w.hooks.checkPublish = async (m, next) => {
const facts = await next();
await w.again();
return facts;
};
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "ref-moved" });
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["ref-moved"]);
assert.equal(w.gitea.requests.length, 0);
assert.equal(git(w.root, `--git-dir=${w.forkRepo}`, "for-each-ref", "refs/heads/coder/"), "");
});
test("T3: a push recorded while the bus process pushes refuses decision-mismatch; only the decision's commit reached the fork", async (t) => {
const w = await setup(t);
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
// The working repo's lock is held through the git push, so a socket push would wait for it; the
// broker's record of a newer push is what a push that won the lock leaves.
w.hooks.publishPush = async (m, next) => {
w.bus.checkPush(w.caps.coder, TASK);
w.bus.recordPush(w.caps.coder, { task_ref: TASK, project: "stack", branch: "feat", ref: workRef(TASK), commit: "c".repeat(40), previous: w.work });
return next();
};
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "decision-mismatch" });
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["decision-mismatch"]);
assert.equal(w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise").length, 0);
assert.equal(git(w.root, `--git-dir=${w.forkRepo}`, "rev-parse", "refs/heads/coder/feat"), w.work);
assert.ok(w.logs.some((l) => l.includes("wasn't recorded")));
});
test("sage-1440: the publishResult poll is bounded: publish-timeout, filed once, and nothing recorded", async (t) => {
const w = await setup(t, { publishDeadlineMs: 150 });
let polls = 0;
w.hooks.publishResult = async () => {
polls++;
return { state: "running" };
};
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "publish-timeout" });
assert.ok(polls > 0);
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["publish-timeout"]);
assert.equal(w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise").length, 0);
assert.ok(w.logs.some((l) => l.includes("publish-timeout")));
});
+3 -7
View File
@@ -101,10 +101,8 @@ export function gitEnv(env = process.env) {
// consequence: a Ctrl-C at the terminal of a host run by hand no longer
// reaches its git, though a systemd stop still does (it kills the cgroup).
// `stdin` names a file git reads as its input (row 137: patch-id); without
// it git gets none. `pass` (row 199, publish) is written once to a pipe on
// fd 3, which git and what it starts inherit; publish's credential helper
// reads its token there, so the token is in no argv, env, URL or file.
export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null, pass = null } = {}) {
// it git gets none.
export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null } = {}) {
return new Promise((resolve) => {
let input = "ignore";
try {
@@ -112,10 +110,8 @@ export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null, pass = nu
} catch (e) {
return resolve({ ok: false, out: "", err: "", why: e.code ?? e.message });
}
const child = spawn("git", argv, { env, detached: true, stdio: [input, "pipe", "pipe", ...(pass === null ? [] : ["pipe"])] });
const child = spawn("git", argv, { env, detached: true, stdio: [input, "pipe", "pipe"] });
if (typeof input === "number") closeSync(input);
// A git that never asked for it closes the pipe unread: EPIPE is no fault.
if (pass !== null) child.stdio[3].on("error", () => {}).end(pass);
let out = "";
let err = "";
let signalled = false;
+1 -89
View File
@@ -63,22 +63,6 @@
// compare and swap (base-moved), then record() runs; when it throws, main
// goes back, and a main that can't go back refuses rollback-failed.
// dropLand removes the worktree; the caller runs it on every path.
//
// publish (row 199, #1697, design D9 to D9b) sends a recorded work commit to
// the agent's own fork. It runs in two processes, because the gitea token
// lives in the bus process and the lock in the host's:
// publish, in the host, under the lock: the seed; the work ref is at the
// source commit (ref-moved); every commit in main..source has author and
// committer email equal to the agent's (identity-mismatch); then
// remote(), which the caller wires to publishRemote in the bus process.
// publishRemote, in the bus process, with the token on fd 3 only:
// `ls-remote --symref <fork> HEAD <dst>`; a non-zero exit refuses
// fork-unavailable. The fork's HEAD must be a commit this repository has
// and an ancestor of main (remote-unrelated, an empty fork included).
// Then `push <fork> <source>:<dst>` by oid, with no `+`. Only https
// crosses (protocol.https.allow), redirects are refused, and output
// holding the token refuses credential-leak. Git's stderr is never
// returned: it can carry the URL.
import { randomBytes } from "node:crypto";
import { closeSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
@@ -117,7 +101,7 @@ export const FSCK = ["-c", "transfer.fsckObjects=true"];
// `limits` is { timeoutMs, graceMs }; the tests shorten them. runGit
// (checkout.mjs) runs git in its own process group and signals the group.
const git = (args, env, { pre = [], limits = GIT_LIMITS, stdin = null, pass = null } = {}) => runGit([...SAFE, ...pre, ...args], gitEnv(env), limits, { stdin, pass });
const git = (args, env, { pre = [], limits = GIT_LIMITS, stdin = null } = {}) => runGit([...SAFE, ...pre, ...args], gitEnv(env), limits, { stdin });
// git with this call's env and limits, for the helpers below.
const bound = (env, limits) => (args, opts = {}) => git(args, env, { ...opts, limits });
@@ -418,75 +402,3 @@ async function removeWorktree(repo, worktree, g) {
if (!r.ok) rmSync(worktree, { recursive: true, force: true });
await g([`--git-dir=${repo}`, "worktree", "prune"]);
}
// await publish({ dataRoot, business, project, root, taskRef, source, email, remote, env, limits })
// Resolves what remote() resolves; rejects with WorkingError.
export function publish({ dataRoot, business, project, root, taskRef, source, email, remote, env = process.env, limits = GIT_LIMITS }) {
if (!PROJECT.test(project ?? "") || !OID.test(source ?? "") || typeof email !== "string" || !email) refuse("invalid-request");
const ref = `refs/heads/${workRef(taskRef)}`;
const repo = workingRepo(dataRoot, business, project);
const g = bound(env, limits);
return locked(repo, async () => {
try {
if (!lstatSync(repo).isDirectory()) refuse("working-repo-invalid");
} catch (e) {
if (e instanceof WorkingError) throw e;
refuse("working-repo-invalid", `${repo} can't be read: ${e.code}`);
}
await seed(repo, root, g);
const at = `--git-dir=${repo}`;
if ((await tip(repo, ref, g)) !== source) refuse("ref-moved", `${ref} isn't at ${source}`);
const range = await g([at, "log", "--format=%ae%x00%ce", `refs/heads/main..${source}`]);
if (!range.ok) refuse("working-repo-invalid", range.why);
for (const line of range.out.split("\n").filter(Boolean)) {
if (line !== `${email}\0${email}`) refuse("identity-mismatch", `a commit in main..${source.slice(0, 12)} isn't by ${email}`);
}
return remote();
});
}
const HOST = /^[a-z0-9.-]{1,253}(:[0-9]{1,5})?$/;
const NAME = /^[A-Za-z0-9_.-]{1,100}$/;
// The one-shot helper: for a `get` on https at exactly the fork's host and
// port, it answers the user and the line it reads from fd 3. Anything else,
// and `store` and `erase`, gets no answer. HOST and NAME keep both shell-safe.
const helper = (host, user) =>
`!f() { test "$1" = get || exit 0; p=; h=; while IFS= read -r l; do case "$l" in protocol=*) p=\${l#protocol=};; host=*) h=\${l#host=};; "") break;; esac; done; ` +
`test "$p" = https && test "$h" = '${host}' || exit 0; IFS= read -r t <&3 || exit 0; printf 'username=%s\\npassword=%s\\n' '${user}' "$t"; }; f`;
// await publishRemote({ repo, fork, user, source, dst, token, tls, env, limits })
// Resolves { ref, commit, previous }, previous being dst's tip on the fork
// before the push, or null. `tls` is for the tests' scratch CA only.
export async function publishRemote({ repo, fork, user, source, dst, token, tls = [], env = process.env, limits = GIT_LIMITS }) {
let url;
try {
url = new URL(fork);
} catch {
refuse("invalid-request");
}
if (url.protocol !== "https:" || !HOST.test(url.host) || !NAME.test(user ?? "") || !OID.test(source ?? "") || !dst?.startsWith(`refs/heads/${user}/`)) refuse("invalid-request");
const secret = String(token).trim();
const net = async (args) => {
const pre = [...tls, "-c", "protocol.https.allow=always", "-c", "http.followRedirects=false", "-c", "credential.helper=", "-c", `credential.helper=${helper(url.host, user)}`];
const r = await git([`--git-dir=${repo}`, ...args], env, { pre, limits, pass: `${secret}\n` });
if (r.out.includes(secret) || r.err.includes(secret)) refuse("credential-leak");
return r;
};
const g = bound(env, limits);
const listed = await net(["ls-remote", "--symref", fork, "HEAD", dst]);
if (!listed.ok) refuse("fork-unavailable");
let head = null;
let previous = null;
for (const line of listed.out.split("\n")) {
const [oid, name] = line.split("\t");
if (!OID.test(oid ?? "")) continue;
if (name === "HEAD") head = oid;
else if (name === dst) previous = oid;
}
if (!head) refuse("remote-unrelated", "the fork is empty");
const known = await g([`--git-dir=${repo}`, "cat-file", "-e", `${head}^{commit}`]);
if (!known.ok || !(await g([`--git-dir=${repo}`, "merge-base", "--is-ancestor", head, "refs/heads/main"])).ok) refuse("remote-unrelated", "the fork's HEAD isn't in main's history");
const pushed = await net(["push", "--porcelain", "--no-follow-tags", fork, `${source}:${dst}`]);
if (!pushed.ok) refuse(/\((non-fast-forward|fetch first)\)/.test(pushed.out) ? "non-fast-forward" : "push-failed");
return { ref: dst, commit: source, previous };
}
-95
View File
@@ -1,95 +0,0 @@
// Row 199 (#1697, design T3): a fake Gitea for publish, on 127.0.0.1 over TLS with a scratch CA,
// serving bare repositories under `<dir>/repos/<user>/<repo>.git` through git http-backend. Every
// request needs Basic auth with the user and token it was made with, or with any pair in `users`,
// as a private repository on Gitea does. It records each request's method, path and user, never the password. `redirect`
// makes every request a 302 to `redirect`. A repository's own hooks run on a push, so a test can
// make the fork echo something back as `remote:` lines. Nothing here leaves 127.0.0.1.
import { spawn, spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createServer } from "node:https";
import { join } from "node:path";
// A CA and a server certificate for IP 127.0.0.1, made once per directory with openssl.
export function scratchCa(dir) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
const ssl = (...args) => {
const r = spawnSync("openssl", args, { cwd: dir, encoding: "utf8" });
if (r.status !== 0) throw new Error(`openssl ${args[0]}: ${r.stderr}`);
};
ssl("req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes", "-keyout", "ca.key", "-out", "ca.pem", "-days", "2", "-subj", "/CN=mosaic test CA");
ssl("req", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes", "-keyout", "server.key", "-out", "server.csr", "-subj", "/CN=127.0.0.1");
writeFileSync(join(dir, "san.ext"), "subjectAltName=IP:127.0.0.1\n");
ssl("x509", "-req", "-in", "server.csr", "-CA", "ca.pem", "-CAkey", "ca.key", "-CAcreateserial", "-out", "server.pem", "-days", "2", "-extfile", "san.ext");
return { ca: join(dir, "ca.pem"), key: readFileSync(join(dir, "server.key")), cert: readFileSync(join(dir, "server.pem")) };
}
export async function fakeGitea({ dir, tls, user, token, users = { [user]: token }, redirect = null }) {
const root = join(dir, "repos");
mkdirSync(root, { recursive: true });
const requests = [];
const server = createServer({ key: tls.key, cert: tls.cert }, (req, res) => {
const url = new URL(req.url, "https://127.0.0.1");
const auth = /^Basic (.+)$/.exec(req.headers.authorization ?? "");
const [who, pass] = auth ? Buffer.from(auth[1], "base64").toString().split(":") : [null, null];
requests.push({ method: req.method, path: url.pathname, user: who });
if (redirect) {
res.writeHead(302, { location: redirect + req.url });
return res.end();
}
if (!who || !Object.hasOwn(users, who) || pass !== users[who]) {
res.writeHead(401, { "www-authenticate": 'Basic realm="Gitea"' });
return res.end();
}
// /<owner>/<repo>/info/refs and the like, on <root>/<owner>/<repo>.git.
const m = /^\/([^/]+)\/([^/]+?)(?:\.git)?(\/.*)$/.exec(url.pathname);
if (!m) {
res.writeHead(404);
return res.end();
}
const cgi = spawn("git", ["http-backend"], {
env: {
PATH: process.env.PATH,
GIT_PROJECT_ROOT: root,
GIT_HTTP_EXPORT_ALL: "1",
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: "/dev/null",
HOME: "/nonexistent",
PATH_INFO: `/${m[1]}/${m[2]}.git${m[3]}`,
REQUEST_METHOD: req.method,
QUERY_STRING: url.search.slice(1),
CONTENT_TYPE: req.headers["content-type"] ?? "",
HTTP_CONTENT_ENCODING: req.headers["content-encoding"] ?? "",
GIT_PROTOCOL: req.headers["git-protocol"] ?? "",
REMOTE_USER: who,
REMOTE_ADDR: "127.0.0.1",
},
stdio: ["pipe", "pipe", "ignore"],
});
req.pipe(cgi.stdin);
const chunks = [];
cgi.stdout.on("data", (c) => chunks.push(c));
cgi.on("close", () => {
const out = Buffer.concat(chunks);
const split = out.indexOf("\r\n\r\n");
const head = out.subarray(0, split).toString().split("\r\n");
const headers = {};
let status = 200;
for (const line of head) {
const [k, ...v] = line.split(":");
if (k.toLowerCase() === "status") status = Number.parseInt(v.join(":").trim(), 10);
else headers[k] = v.join(":").trim();
}
res.writeHead(status, headers);
res.end(out.subarray(split + 4));
});
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const port = server.address().port;
return {
port,
origin: `https://127.0.0.1:${port}`,
requests,
repo: (owner, name) => join(root, owner, `${name}.git`),
close: () => new Promise((r) => server.close(r)),
};
}
-185
View File
@@ -1,185 +0,0 @@
// Row 199 (#1697, design D9 step 3 to 5, D9a, T3): publish and publishRemote (src/working.mjs)
// against a TLS fake Gitea on 127.0.0.1 (fake-gitea.mjs) with a scratch CA, passed only through
// the `tls` parameter. Fake tokens in a scratch root. Each refusal that should reach the network
// checks the fake saw the request, so no case passes because git refused https first.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gitEnv } from "../src/checkout.mjs";
import { publish, publishRemote, push, workingRepo } from "../src/working.mjs";
import { fakeGitea, scratchCa } from "./fake-gitea.mjs";
const TASK = "vikunja:1/2";
const TOKEN = "fake_publish_token_0123456789";
const USER = "coder";
const EMAIL = "[email protected]";
const DST = "refs/heads/coder/feat";
function git(cwd, ...args) {
const r = spawnSync("git", args, { cwd, encoding: "utf8", env: gitEnv() });
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
return r.stdout.trim();
}
function commit(dir, text, email = EMAIL) {
writeFileSync(join(dir, "README"), `${text}\n`);
git(dir, "add", "README");
git(dir, "-c", `user.name=${text}`, "-c", `user.email=${email}`, "commit", "-q", "-m", text);
return git(dir, "rev-parse", "HEAD");
}
const rejects = (p, code) => assert.rejects(p, (e) => e.code === code || assert.fail(`${e.code}: ${e.message}, wanted ${code}`));
// A root, a coder's clone with work on feat pushed to the working repo, and a fake Gitea whose
// coder/stack fork is a bare clone of the root.
async function world(t, { fork = true, redirect = null } = {}) {
const dir = mkdtempSync(join(tmpdir(), "mosaic-publish-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const root = join(dir, "root");
mkdirSync(root);
git(root, "init", "-q", "-b", "main");
const first = commit(root, "one", "[email protected]");
const clone = join(dir, "clone");
git(dir, "clone", "-q", root, clone);
git(clone, "checkout", "-q", "-b", "feat");
const work = commit(clone, "work");
const dataRoot = join(dir, "data");
mkdirSync(dataRoot, { mode: 0o700 });
const file = join(dir, "b.bundle");
git(clone, "bundle", "create", "-q", file, "refs/heads/feat", "--not", "--remotes=origin");
await push({ dataRoot, business: "acme", project: "stack", root, taskRef: TASK, branch: "feat", bundle: file });
const ca = scratchCa(join(dir, "ca"));
const gitea = await fakeGitea({ dir: join(dir, "gitea"), tls: ca, user: USER, token: TOKEN, redirect });
t.after(() => gitea.close());
const forkRepo = gitea.repo(USER, "stack");
if (fork) {
mkdirSync(join(forkRepo, ".."), { recursive: true });
git(dir, "clone", "-q", "--bare", root, forkRepo);
}
const tls = ["-c", `http.sslCAInfo=${ca.ca}`];
const repo = workingRepo(dataRoot, "acme", "stack");
const remote = (extra = {}) => publishRemote({ repo, fork: `${gitea.origin}/${USER}/stack`, user: USER, source: work, dst: DST, token: TOKEN + "\n", tls, ...extra });
const go = (extra = {}) => publish({ dataRoot, business: "acme", project: "stack", root, taskRef: TASK, source: work, email: EMAIL, remote: () => remote(), ...extra });
return { dir, root, first, clone, work, dataRoot, repo, gitea, forkRepo, tls, remote, go };
}
test("T3: publish pushes the work commit by oid to <user>/<branch> on the fork, with the token only through the helper", async (t) => {
const w = await world(t);
assert.deepEqual(await w.go(), { ref: DST, commit: w.work, previous: null });
assert.equal(git(w.dir, `--git-dir=${w.forkRepo}`, "rev-parse", DST), w.work);
// Every request went to the fork's path, and each one that carried auth carried the agent's user.
assert.ok(w.gitea.requests.length > 0);
for (const r of w.gitea.requests) assert.match(r.path, /^\/coder\/stack\/(info\/refs|git-receive-pack|git-upload-pack)$/);
assert.ok(w.gitea.requests.some((r) => r.user === USER && r.path.endsWith("git-receive-pack")));
// A second publish of the same commit is a no-op push and names the previous tip.
assert.deepEqual(await w.go(), { ref: DST, commit: w.work, previous: w.work });
// The working repo holds no remote and no credential config.
assert.equal(spawnSync("git", [`--git-dir=${w.repo}`, "config", "--get-regexp", "^(remote|credential)\\."], { env: gitEnv() }).stdout.toString(), "");
});
test("T3: a moved fork branch refuses non-fast-forward; no + and no force", async (t) => {
const w = await world(t);
const other = join(w.dir, "other");
git(w.dir, "clone", "-q", w.root, other);
const elsewhere = commit(other, "elsewhere");
git(other, "push", "-q", w.forkRepo, `${elsewhere}:${DST}`);
await rejects(w.go(), "non-fast-forward");
assert.equal(git(w.dir, `--git-dir=${w.forkRepo}`, "rev-parse", DST), elsewhere);
});
test("T3: D9a: an empty fork and an unrelated fork refuse remote-unrelated; a missing fork refuses fork-unavailable", async (t) => {
const empty = await world(t, { fork: false });
git(empty.dir, "init", "-q", "--bare", empty.forkRepo);
await rejects(empty.go(), "remote-unrelated");
assert.ok(empty.gitea.requests.length > 0);
assert.equal(empty.gitea.requests.some((r) => r.path.endsWith("git-receive-pack")), false);
const unrelated = await world(t, { fork: false });
const alone = join(unrelated.dir, "alone");
mkdirSync(alone);
git(alone, "init", "-q", "-b", "main");
commit(alone, "unrelated");
git(unrelated.dir, "clone", "-q", "--bare", alone, unrelated.forkRepo);
await rejects(unrelated.go(), "remote-unrelated");
assert.equal(unrelated.gitea.requests.some((r) => r.path.endsWith("git-receive-pack")), false);
const missing = await world(t, { fork: false });
await rejects(missing.go(), "fork-unavailable");
assert.ok(missing.gitea.requests.length > 0);
});
test("T3: a fork past main refuses remote-unrelated until root catches up", async (t) => {
const w = await world(t);
const other = join(w.dir, "other");
git(w.dir, "clone", "-q", w.forkRepo, other);
const ahead = commit(other, "ahead");
git(other, "push", "-q", w.forkRepo, `${ahead}:refs/heads/main`);
await rejects(w.go(), "remote-unrelated");
});
test("T3: a wrong token refuses fork-unavailable; git's stderr isn't in the refusal", async (t) => {
const w = await world(t);
await assert.rejects(w.remote({ token: "fake_wrong_token_0123456789" }), (e) => e.code === "fork-unavailable" && e.message === "fork-unavailable");
assert.ok(w.gitea.requests.length > 0);
assert.equal(git(w.dir, `--git-dir=${w.forkRepo}`, "for-each-ref", DST), "");
});
test("T3: a 302 is not followed: fork-unavailable, and the target sees no request", async (t) => {
const w = await world(t);
const target = await fakeGitea({ dir: join(w.dir, "target"), tls: scratchCa(join(w.dir, "ca2")), user: USER, token: TOKEN });
t.after(() => target.close());
const bounce = await fakeGitea({ dir: join(w.dir, "bounce"), tls: scratchCa(join(w.dir, "ca3")), user: USER, token: TOKEN, redirect: target.origin });
t.after(() => bounce.close());
const tls = ["-c", `http.sslCAInfo=${join(w.dir, "ca3", "ca.pem")}`];
await rejects(w.remote({ fork: `${bounce.origin}/${USER}/stack`, tls }), "fork-unavailable");
assert.ok(bounce.requests.length > 0);
assert.equal(target.requests.length, 0);
});
test("T3: the helper answers only the fork's host and port: a fork on another port gets no token", async (t) => {
const w = await world(t);
// The fork names port 1; insteadOf (through the tests' tls parameter) sends git to the fake's
// port. The helper was told port 1, so it answers nothing and no password goes out.
const before = w.gitea.requests.length;
await rejects(w.remote({ fork: `https://127.0.0.1:1/${USER}/stack`, tls: [...w.tls, "-c", `url.https://127.0.0.1:${w.gitea.port}/.insteadOf=https://127.0.0.1:1/`] }), "fork-unavailable");
assert.ok(w.gitea.requests.length > before, "the rewritten URL reached the fake");
assert.equal(w.gitea.requests.slice(before).some((r) => r.user !== null), false, "no credential went to a host the helper wasn't told");
});
test("T3: a fork that echoes the token refuses credential-leak", async (t) => {
const w = await world(t);
const hook = join(w.forkRepo, "hooks", "pre-receive");
writeFileSync(hook, `#!/bin/sh\necho ${TOKEN} >&2\nexit 0\n`);
chmodSync(hook, 0o755);
await rejects(w.go(), "credential-leak");
});
test("T3: no tls parameter, no trust: the scratch CA reaches git only through it", async (t) => {
const w = await world(t);
await rejects(w.remote({ tls: [] }), "fork-unavailable");
});
test("D9 steps 3 and 4: ref-moved and identity-mismatch refuse before any request", async (t) => {
const w = await world(t);
await rejects(w.go({ source: w.first }), "ref-moved");
const other = commit(w.clone, "by someone else", "[email protected]");
const file = join(w.dir, "b2.bundle");
git(w.clone, "bundle", "create", "-q", file, "refs/heads/feat", "--not", "--remotes=origin");
await push({ dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root, taskRef: TASK, branch: "feat", bundle: file });
await rejects(w.go({ source: other }), "identity-mismatch");
assert.equal(w.gitea.requests.length, 0);
});
test("publishRemote refuses a fork, user or destination that isn't shell- and ref-safe before any git", async (t) => {
const w = await world(t);
for (const extra of [
{ fork: `http://127.0.0.1:${w.gitea.port}/${USER}/stack` },
{ fork: `https://127.0.0.1:${w.gitea.port}'/${USER}/stack` },
{ user: "co'der" },
{ dst: "refs/heads/main" },
{ dst: "refs/heads/pm/feat" },
{ source: "HEAD" },
]) await rejects(w.remote(extra), "invalid-request");
assert.equal(w.gitea.requests.length, 0);
});
+2 -6
View File
@@ -80,11 +80,7 @@ export function tasksAdapter({
tree: null,
bots: new Set([businesses[name].tracker.sync.botId, ...Object.values(s.roles).map((r) => r.botId)]),
sync: use('@sync'),
// Row 199 (#1697, design D5): a request's call keys from the broker's session for its cap,
// never from a role name. probe is the host's own start check of each role's token.
as: (session, method, path, opts) =>
credentials.useFor(session, 'vikunja', (token) => call(token, method, path, opts)),
probe: (role, method, path, opts) => use(role)(method, path, opts),
as: (role, method, path, opts) => use(role)(method, path, opts),
state: 'starting',
refused: null,
queue: Promise.resolve(),
@@ -100,7 +96,7 @@ export function tasksAdapter({
timers: [],
};
ctx.syncCall = (_token, method, path, opts) => ctx.sync(method, path, opts);
ctx.asCall = (role) => (_token, method, path, opts) => ctx.probe(role, method, path, opts);
ctx.asCall = (role) => (_token, method, path, opts) => ctx.as(role, method, path, opts);
ctx.handle = verbs(ctx);
contexts.set(name, ctx);
}
+2 -2
View File
@@ -41,7 +41,7 @@ export async function startup(ctx) {
if (probe.status === 404 || probe.status === 403 || outcome(probe) === 'ok') refuse('scope-too-broad');
expect(probe, 401, 'tracker-unexpected');
for (const [role, r] of Object.entries(ctx.roles)) {
const control = await ctx.probe(role, 'GET', `/tasks/${MISSING}`);
const control = await ctx.as(role, 'GET', `/tasks/${MISSING}`);
if (control.status !== 404 || control.json?.code !== 4002) {
expect(control, 404, 'tracker-unexpected');
refuse('tracker-unexpected');
@@ -49,7 +49,7 @@ export async function startup(ctx) {
const probes = [['DELETE', `/tasks/${MISSING}`, undefined]];
if (r.definition !== 'pm') probes.push(['POST', `/tasks/${MISSING}/labels`, { label_id: 1 }]);
for (const [method, path, body] of probes) {
const p = await ctx.probe(role, method, path, { body });
const p = await ctx.as(role, method, path, { body });
if (p.status === 404 || p.status === 403 || outcome(p) === 'ok') refuse('scope-too-broad');
if (p.status !== 401) refuse(p.status === 0 || p.status >= 500 ? 'tracker-unavailable' : 'tracker-unexpected');
}
+8 -9
View File
@@ -122,9 +122,8 @@ export function verbs(ctx) {
const relatedTo = (task, kind, other) =>
Array.isArray(task.related_tasks?.[kind]) && task.related_tasks[kind].some((t) => t?.id === other);
// One write with the acting role's token. An uncertain answer is settled by a re-read.
// `me` is the broker's session for the caller's cap (design D5).
async function step(me, method, path, body, at, done) {
const r = await ctx.as(me, method, path, { body });
async function step(role, method, path, body, at, done) {
const r = await ctx.as(role, method, path, { body });
const o = outcome(r);
if (o === 'ok') return r;
if (o === 'uncertain' && at !== null && done) {
@@ -145,7 +144,7 @@ export function verbs(ctx) {
if (!mine) fail('tracker-role');
if (PM_VERBS.has(verb) && mine.definition !== 'pm') fail('field-writer');
const plan = prepare(verb, args, role, mine);
if (verb === 'task.create') return create(cap, me, plan);
if (verb === 'task.create') return create(cap, role, plan);
const at = target(args.task_ref);
const before = await read(at);
if (args.expect !== undefined && args.expect !== before.digest) {
@@ -166,7 +165,7 @@ export function verbs(ctx) {
let landed = 0;
try {
for (const [method, path, body, done] of work.steps) {
await step(me, method, path, body, at, done);
await step(role, method, path, body, at, done);
landed++;
}
} catch (e) {
@@ -386,12 +385,12 @@ export function verbs(ctx) {
fail('unknown-verb');
}
}
async function create(cap, me, plan) {
async function create(cap, role, plan) {
if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found');
await located(plan.others);
ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null });
// No re-read can find a create whose answer was lost, so an uncertain create is reported as such.
const r = await step(me, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null);
const r = await step(role, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null);
if (r.status !== 201 && r.status !== 200) fail('tracker-unexpected');
checkTask(r.json);
const id = r.json.id;
@@ -400,9 +399,9 @@ export function verbs(ctx) {
let failure = null;
try {
for (const l of plan.labels)
await step(me, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l));
await step(role, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l));
for (const x of plan.relations)
await step(me, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (a) =>
await step(role, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (a) =>
relatedTo(a.task, x.kind, x.other),
);
} catch (e) {
+1 -2
View File
@@ -73,8 +73,7 @@ business's arbiter for its domain. Pass `domain` to `raise_decision`,
gated raise, and every hard wait, goes to the human through the inbox.
The hard waits are `spend`, `data.delete`, `network.expose`,
`account.create`, `credential.mint`, `message.external`,
`git.push.protected`, `git.push.remote` and `deploy`; a citation never
moves them.
`git.push.protected` and `deploy`; a citation never moves them.
A `role.revoke` raise whose target is the assistant is a hard wait too:
it goes to the human, cited or not.
+1 -2
View File
@@ -53,8 +53,7 @@ business's arbiter for its domain. Pass `domain` to `raise_decision`,
gated raise, and every hard wait, goes to the human through the inbox.
The hard waits are `spend`, `data.delete`, `network.expose`,
`account.create`, `credential.mint`, `message.external`,
`git.push.protected`, `git.push.remote` and `deploy`; a citation never
moves them.
`git.push.protected` and `deploy`; a citation never moves them.
A `role.revoke` raise whose target is the assistant is a hard wait too:
it goes to the human, cited or not.
+1 -1
View File
@@ -35,6 +35,6 @@ if [ "${1:-}" = business ]; then
exec node "$REPO/packages/business/src/cli.mjs" "$@"
fi
case "${1:-}" in
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|credentials|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
esac
exec node "$REPO/packages/seat/src/cli.mjs" "$@"