Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e021dfa260 | ||
|
|
b726e10f44 | ||
|
|
7b10bc0fff | ||
|
|
9ba31b5b3f | ||
|
|
9db578ff54 | ||
|
|
8eb9ddd12c | ||
|
|
ae3c36f248 | ||
|
|
a2af7ab495 |
@@ -23,6 +23,8 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
|
||||
| Herb | Builder, a second gate lane (#1695) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
|
||||
| Bushroot | Builder, retire the control board writer on 7331 (#1696) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1008 | none, T3 only |
|
||||
| Megavolt | Builder, MVP T1 item 6 (agent credentials and git identity, #1697) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Quackerjack | Builder, federation R5 and R2 (#414) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1023 | none, T3 only |
|
||||
| Gizmoduck | Federation design (#414) and builder of FED R1 | T3 session (Claude, Opus 5.5, thread 9312a896) | none, T3 only |
|
||||
| Webby | Reviewer: Case 2 designs, rows 192 and 194 | T3 session (Claude, Opus 5.5, thread 03086006), started by Mos (mos-0981) | none, T3 only |
|
||||
| Duckworth | Reviewer: federation designs, row 193 | T3 session (Claude, Opus 5.5, thread ffceafec), started by Mos (mos-0977) | none, T3 only |
|
||||
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Gizmoduck
|
||||
|
||||
Design and builder seat, Claude Opus 5.5, a T3 thread (9312a896). Author of the federation design
|
||||
(`agents/gizmoduck/work/federation/DESIGN.md` on `gizmoduck/federation-design`, #414). Builds FED R1, the
|
||||
access.json loader and validator, under Mos's order mos-1023 item 1.
|
||||
|
||||
No launcher in this directory. The seat works in its own worktree and has no credentials of its own; until
|
||||
the credentials row settles per-agent identity, it pushes only its own `gizmoduck/*` branches through the
|
||||
repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -1,167 +0,0 @@
|
||||
# Row 199 (#1697): per-agent credential store, each agent's own git identity, publish to its fork
|
||||
|
||||
Builder: Megavolt. Brief: `docs/plans/2026-10-20_agent-credentials.md`. Design: `DESIGN.md` r6 at
|
||||
4d3007ec9, approved by Duckworth in duckworth-0027 (REVIEW-r6.md at 640011518). Authority model ruled
|
||||
by Mos in mos-1027. Branch: megavolt/row199. Reviewer: Duckworth.
|
||||
|
||||
Status: in progress. The full gate has not run; it is Sage's. Only single test files have run on
|
||||
this branch, apart from one slip noted under "Runs".
|
||||
|
||||
## Commits so far
|
||||
|
||||
- 286bf598c business: agent identity, the publish grant, the project upstream with the D9c parse, store
|
||||
credential references; git.push.remote as a hard wait.
|
||||
- a2c387dba bus: broker publish authority (checkPublish, recordPublish, publishRefused, pushCommit on
|
||||
the raise); the D9c parse moves to `business/src/vocabulary.mjs`; pm.md and chief-of-staff.md list
|
||||
git.push.remote among the hard waits.
|
||||
- 609dc9364 bus: the per-agent credential store in `credentials.mjs`.
|
||||
- 504336797 bus: runtime wiring, the store root and the four publish entries in the op table.
|
||||
- a18fc6cf8 tasks: request calls use `useFor` keyed from the session; the start probe keeps `use`. The
|
||||
D5 test in `credential-store.test.mjs` pins the `use(` call sites to `credentials.mjs` (useFor's own
|
||||
call) and `tasks/src/adapter.mjs` (the host's @sync and start probe).
|
||||
- aa740cc71 bus: the lifecycle verbs `credentials.issue`, `credentials.revoke` and `credentials.delete`
|
||||
in `credential-verbs.mjs`, the broker branch, the runtime wiring; `organizationWriter` gets a
|
||||
`detail` on `write` and a receipt-only `record`.
|
||||
- 0cdace415 cli: `mosaic credentials status|issue|rotate|revoke|delete`, the scripts/mosaic route, docs.
|
||||
|
||||
## Deviations from DESIGN.md r6
|
||||
|
||||
1. **Identity is optional on an agent.** D8 reads as identity on every agent. Today's business files
|
||||
carry none, so identity stays optional and `publish` requires it (N10's load refusal). An agent
|
||||
without identity commits as E7 (T6).
|
||||
2. **Revoke of a `{file}` or `{env}` reference.** Section 6 describes revoke for store references.
|
||||
For a reference outside the store, revoke forgets the token in memory and never unlinks the file;
|
||||
the lifecycle verb then turns the business file's reference into `store`, so a restart finds
|
||||
nothing. The test: `credential-store.test.mjs`, "revoke of a {file} reference".
|
||||
3. **Issue order.** Issue writes the token file (tmp, fsync, rename, dir fsync), then runs the
|
||||
caller's commit (the business file's reference becomes `store`), then sets memory. A failed commit
|
||||
leaves the file on disk and the token out of memory, so the agent stays `not-issued` until the
|
||||
issue is run again. The test: "a failed commit leaves the token on disk but not in memory".
|
||||
4. **The D9c parse lives in `business/src/vocabulary.mjs`, not `business.mjs`.** The broker reads
|
||||
`forkProblem` and `forkUrl`, and `project-runtime.test.mjs` pins the broker's import closure to
|
||||
files that import nothing. `business.mjs` wraps `parseGiteaUrl` with the loader's refusal
|
||||
messages. The pin test names the two new imports.
|
||||
5. **`withGitea` takes the publish ticket, not a cap (D9b).** The key is the session checkPublish
|
||||
checked with `#agent` (a run holding its claim; never a tester, reader or human), read through
|
||||
`broker.publishSession(ticket)`. The token is usable only between checkPublish and recordPublish
|
||||
or publishRefused. Its refusals are filed once, by publishRefused on the task's trail, and not
|
||||
also by `refusing()`. The test: `publish.test.mjs`, "runtime: withGitea keys from the open ticket
|
||||
only".
|
||||
6. **Role contracts.** `workflow-roles.test.mjs` requires the hard waits listed in `roles/pm.md` and
|
||||
`roles/chief-of-staff.md` to equal GATED_ONLY. Adding git.push.remote to the vocabulary made it
|
||||
fail, so both lists now name git.push.remote. Prose only; no hash pin reads either file. Duckworth
|
||||
should review this hunk because `roles/` is reviewed authority.
|
||||
|
||||
7. **Delete takes a reason.** Section 6 lists `delete AGENT` with no reason. The broker requires
|
||||
`reason` on delete as on revoke, so every receipt that ends a credential says why.
|
||||
8. **Rotate is `credentials.issue`.** There are three bus verbs, not four. An issue over an entry the
|
||||
bus holds is recorded as `rotate` in the receipt and the answer. The CLI's `rotate` reads the
|
||||
status first and refuses before reading stdin when nothing is issued.
|
||||
9. **Issue covers entries the bus loaded.** The business file must already name the kind for the
|
||||
agent, and the bus must have loaded that reference at start; otherwise `credential-unknown`. An
|
||||
agent added after the bus started needs the restart row 165 already asks for. Row 194's human
|
||||
`create` is where a new agent's `store` references come from.
|
||||
10. **No Console page yet.** Section 6 puts the verbs in the Console's agent page too. This branch has
|
||||
the bus verbs and the CLI. The Console page is a separate piece (packages/webui, its `BUS_VERBS`
|
||||
and `STATUS` pins); I raise the split with Sage before the manifest. Ruled in sage-1421: the
|
||||
Console page is out of row 199 and becomes a follow-up row with Webby as reviewer. The manifest
|
||||
holds bus, business, tasks, cli and seat.
|
||||
11. **The push runs in the bus child, and the git socket polls for it (D9b).** The git socket runs in
|
||||
the host process. The broker, the runtime and the token are in the bus child that
|
||||
`packages/cli/src/host.mjs` forks, and host ops cross that IPC one at a time with a 10 s answer
|
||||
limit. ls-remote and the push can run longer than that, so `withGitea(ticket, fn)` can't wrap a
|
||||
host-side git. The split: the socket runs D9 steps 3 and 4 (tip and identity under the lock,
|
||||
`working.publish`), then op `publishPush`, which starts `publishRemote` in the bus child under
|
||||
`withGitea` and answers `{state: 'running'}`. The socket polls op `publishResult` every 200 ms
|
||||
until it reads `pushed` or `refused`. Fork, user, source and destination come from the ticket
|
||||
(`broker.publishTarget`), never from the host's message. `recordPublish` also refuses `ref-moved`
|
||||
when the pushed ref isn't the ticket's destination. A second `publishPush` on a running ticket
|
||||
refuses `publish-busy`. The token reaches git on fd 3 only (`runGit` option `pass`), read once
|
||||
by a one-shot credential helper that answers `get` for https on the fork's exact host and port.
|
||||
On close, a running publish's git isn't awaited; git's own time limit ends it, and the ticket
|
||||
goes with the process. The 10 s IPC limit stays as it is (sage-1440). The poll has a bound
|
||||
(sage-1440): `publishDeadlineMs`, by default four times git's limit and grace (publishRemote runs
|
||||
at most four git commands) plus 30 s. Past it the socket refuses `publish-timeout`, filed once by
|
||||
publishRefused, and logs that the bus process's push may still reach the fork. A failure that is not a refusal is filed as `git-failed` and logged on
|
||||
the bus's stderr by error name and code only. Tests: `seat/tests/publish.test.mjs` (11),
|
||||
`bus/tests/publish.test.mjs` (the process ops), `cli/tests/publish-socket.test.mjs` (7, end to
|
||||
end through the socket, the in-process runtime, the store and a TLS fake Gitea; one is the
|
||||
publish-timeout outcome).
|
||||
12. **No harness tool for publish.** `packages/harness/src/git-tools.mjs` has push, fetch and land
|
||||
clients. A runner tool that calls the socket's `publish` op is in packages/harness, outside the
|
||||
manifest scope sage-1421 set. Today a publish needs a socket client; the tool is a follow-up.
|
||||
13. **The bind rule's `git.push.remote` clause has no test of its own.** D11 adds it and the
|
||||
launcher has it. A role can publish only a push its own role made (`work-not-yours`), and
|
||||
`recordPush` needs `git.push.working`, which already binds the socket. No role reaches the
|
||||
clause alone, so dropping it changes no outcome today. It matters once a role can publish work
|
||||
it didn't push. Ruled in sage-1440: keep it; its mutant goes in the section 11 list as
|
||||
equivalent, with this reasoning, and Duckworth judges it.
|
||||
14. **A push between checkPublish and the git push refuses; it never lands a different commit.**
|
||||
T3 says such a push "doesn't change the commit that lands". Under the hard wait it refuses:
|
||||
`working.publish` checks the work ref's tip against the ticket's source under the lock
|
||||
(`ref-moved`, `seat/tests/publish.test.mjs`), and `recordPublish` checks `pushCommit` again
|
||||
(`decision-mismatch`, `bus/tests/publish.test.mjs` "r4 B4"). The fork gets only the ticket's
|
||||
oid either way. N9's routing-commit test still applies. Ruled in sage-1440: T3's wording in
|
||||
DESIGN.md now reads "refuses with `ref-moved` or `decision-mismatch`", and an end-to-end test
|
||||
asserts each code mid-flight in `cli/tests/publish-socket.test.mjs`: a push after checkPublish
|
||||
refuses `ref-moved` with no request to the fork; a push recorded while the bus process pushes
|
||||
refuses `decision-mismatch`, filed once, and the fork holds only the ticket's oid.
|
||||
|
||||
15. **Fix: rotate on a due gitea key (gizmoduck-0050, sage-1494).** The CLI's `ISSUED` set
|
||||
(`packages/cli/src/credentials.mjs`) listed valid, expiring and expired, but the bus's `status()`
|
||||
gives a gitea key past its rotate-by date `rotation-due`. So `credentials rotate` refused the one
|
||||
case it exists for. `ISSUED` now names every state status() gives a held token. The test:
|
||||
`cli/tests/credentials.test.mjs`, "rotate goes ahead on each held state". Its mutant (the old set)
|
||||
fails that test and goes in the section 11 list.
|
||||
|
||||
## N9, for the routing commit (sage-1388)
|
||||
|
||||
Today git.push.remote is a hard wait, so every publish carries a decision, and the decision's
|
||||
`pushCommit` fixes the commit. If a later commit routes git.push.remote within role, a publish has
|
||||
no decision and no `pushCommit`. `#publishFacts` then takes the commit from `#latestPush`, and the
|
||||
git socket's tip check under the lock is the only thing that holds it. That commit needs a test
|
||||
that a push between checkPublish and the git push doesn't change what lands.
|
||||
|
||||
## R1, the backup set
|
||||
|
||||
Checked at 504336797. Row 185's `backup_store` (`scripts/stack-release.sh:372-392`) copies only
|
||||
`<dataRoot>/bus/bus.sqlite`, plus its sums, modes, listing and stamps, into
|
||||
`<dataRoot>/backups/stack/<activation>/`. Nothing under `<configDir>` is copied, so
|
||||
`<configDir>/secrets/agents` isn't in the backup set. `rehearse` copies the live business file
|
||||
through `R_BUSINESS_JS` (`:1268-1288`). Its `swap` replaces every credential reference, a `store`
|
||||
one included, with a placeholder file in the rehearsal's own dir. The guard then refuses
|
||||
`credential-ref-live` unless every reference is such a placeholder (`:1329-1337`), so a rehearsal
|
||||
never reads a store token. A backup tool outside this repository that copies `<configDir>` would
|
||||
copy tokens. That is R1's open half, and it is Jason's to check on each VM.
|
||||
|
||||
## T1 limit
|
||||
|
||||
A wrong owner on a store dir or file can't be set up without root, so T1 covers modes, symlinks and
|
||||
dirs. The owner check (`st.uid !== process.getuid()`) is in `privateDir` and `#read`, and is untested.
|
||||
|
||||
## Runs
|
||||
|
||||
Single files, each green on its commit: bus broker, decision-close, decision-route, decisions,
|
||||
hard-waits, land, project-runtime, launch-notice, review, publish, credential-store, credentials,
|
||||
credential-status, limits, tester, plugins-verb, routes-verb, limits-runtime, instances-verb, web,
|
||||
usage-runtime, limit-checks, daily-quota-runtime, chief-of-staff, tasks, tester-writes; every
|
||||
business test file. Since: credential-verbs (2/2), credential-store (11/11, with D5), each tasks file,
|
||||
tester, credential-status, plugins-verb, routes-verb, instances-verb; business launch-settings,
|
||||
plugin-settings, instance-settings, token-limit-settings; cli credentials (3/3), cli, app,
|
||||
tester-reads, tester, other-tree, transport, instances, limits, verbs. Hash pins: none on a changed
|
||||
file (`thread-migration.test.mjs:46` pins DDL only).
|
||||
|
||||
Publish, single files, each green before its commit: seat
|
||||
checkout (77), working (26), publish (11), landing (8); bus process (8), land (17), publish (12),
|
||||
credential-verbs (2); cli publish-socket (4), git-socket (24), launcher (29); harness tools (22).
|
||||
Hash pins: none on a changed file.
|
||||
|
||||
sage-1440 changes (the poll bound, the mid-flight tests), single files, green before the commit: cli
|
||||
publish-socket (7), git-socket (24), launcher (29). Hash pins: none on a changed file.
|
||||
|
||||
The rotation-due fix, single files: cli credentials (4), green; the old-set mutant fails it 3/1.
|
||||
Hash pins: none on a changed file.
|
||||
|
||||
Slip, 2026-10-20 about 19:51Z: I ran `node --test packages/tasks/tests/*.test.mjs` in one command,
|
||||
which is a package suite, without asking Sage for the slot. It passed, 97 pass and 0 fail, in under a
|
||||
minute. Reported to Sage in megavolt-0017.
|
||||
@@ -1,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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -215,13 +215,6 @@ an operator inspect content and delivery evidence after an uncertain result.
|
||||
| `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. |
|
||||
| `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. |
|
||||
| `credentials` | No args; human and reader sessions only. `[{service,account,role,dateKind,date,state}]` for the capability's business, never a token, file path or variable name. |
|
||||
| `credentials.issue` | `{agent,service,token,date}`; human only (row 199, #1697). Writes the token to `<configDir>/secrets/agents/<business>/<agent>/<service>` (0600, dirs 0700: tmp, fsync, rename), rewrites the agent's reference in the business file to `{"store":true,<rotateBy or expires>:date}`, then holds the token in memory, with no restart. Over an issued entry it is a rotate: the old value is gone from disk and memory when it returns. `service` is `gitea` or `vikunja` and the business file must already name it for the agent (`credential-unknown`). A token the bus already holds is `credential-leak`; a bad token `credential-format`; a bad date `credential-date`. Returns `{agent,verb,service,date,state}`, `verb` `issue` or `rotate`. The tester gets `tester-refused`. |
|
||||
| `credentials.revoke` | `{agent,service?,reason}`; human, or the tester (Q173). Drops the entry from memory, then unlinks a store file; a `file` or `env` reference keeps its file and becomes `store`, so a restart loads nothing. Without `service`, every kind the agent has. Returns `{agent,verb,services:[{service,state}]}`. |
|
||||
| `credentials.delete` | `{agent,reason}`; human only. `credentials.revoke` for every kind, then removes the agent's store dir; a file there other than a token is `credential-file`. While the agent or one of its threads holds a claim, `agent-running`. |
|
||||
|
||||
Each of the three leaves one receipt in the organization's `changes.jsonl`: the actor and a
|
||||
`detail` of `{verb,agent,kind,date or reason}`, never a value. A change to the store alone writes
|
||||
a receipt with empty `changes`. A refusal is `action.refused` with its code alone.
|
||||
| `requirements` | No args; role sessions only, as `task.create` is (row 90, #1584). The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. |
|
||||
| `state` | No args; role, human and reader sessions (row 109, #1603). `{launch:{state,at,event},tasks:{open:[{task_ref,title}],total,cap}}` for the capability's business. `launch` is the latest `launch.revoked` or `launch.restored` event: `allowed` with `at` and `event` null when none exists. `open` is the tracker snapshot's open tasks in `task_ref` order, at most `cap` (the `tasks` page size, 50); `total` counts every open task. The harness runner reads it before each turn. Writes nothing. |
|
||||
| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. |
|
||||
|
||||
+6
-153
@@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path';
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl } from '../../business/src/vocabulary.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -18,8 +18,6 @@ export const ACTIONS = Object.freeze([
|
||||
'git.push.working',
|
||||
'git.push.protected',
|
||||
'git.merge.protected',
|
||||
// Row 199 (#1697, mos-1027 Q-M2): publish to the agent's fork. Not in ROUTED, so a hard wait.
|
||||
'git.push.remote',
|
||||
'review.request',
|
||||
'review.verdict',
|
||||
'record.write',
|
||||
@@ -103,11 +101,6 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
|
||||
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
|
||||
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
|
||||
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
|
||||
// Row 199 (#1697, design section 6): the credential lifecycle, human-only. Under Q173 the tester may
|
||||
// revoke; issue (rotate is an issue over an issued entry) and delete refuse it, because a token it
|
||||
// issued would pass through its session (duckworth N1). The status read stays the `credentials` verb.
|
||||
export const CREDENTIAL_VERBS = Object.freeze(['credentials.issue', 'credentials.revoke', 'credentials.delete']);
|
||||
const TESTER_CREDENTIAL_VERBS = Object.freeze(['credentials.revoke']);
|
||||
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
|
||||
const POLL_KINDS = new Set([
|
||||
'task.changed.external',
|
||||
@@ -198,13 +191,10 @@ export class Broker {
|
||||
#received = new Set();
|
||||
// Row 137 (#1631): landings checkLand let start, by ticket, until recordLand or landRefused ends them.
|
||||
#landings = new Map();
|
||||
// Row 199: a publish between checkPublish and recordPublish or publishRefused.
|
||||
#publishes = new Map();
|
||||
#secretCheck;
|
||||
#credentialStatus;
|
||||
#requirements;
|
||||
#organization;
|
||||
#credentialVerbs;
|
||||
#project;
|
||||
#usage;
|
||||
#limits;
|
||||
@@ -213,15 +203,13 @@ export class Broker {
|
||||
#stops = new Map();
|
||||
#clock = 0;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null }) {
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
this.#businesses = structuredClone(businesses);
|
||||
this.#store = store;
|
||||
this.#secretCheck = secretCheck;
|
||||
this.#credentialStatus = credentialStatus;
|
||||
this.#organization = organization;
|
||||
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
|
||||
this.#credentialVerbs = credentialVerbs;
|
||||
this.#project = project;
|
||||
this.#usage = usage;
|
||||
this.#limits = limits;
|
||||
@@ -807,12 +795,7 @@ export class Broker {
|
||||
// records `?? null` and the current holder reads undefined.
|
||||
if (action === 'role.revoke' && (context.holderRun ?? null) !== (this.#holder(s.business, target)?.holder_run ?? null))
|
||||
fail('decision-mismatch');
|
||||
// Row 199 (r4 B4): approve at A, push B, publish refuses.
|
||||
if (action === 'git.push.remote' && (context.pushCommit ?? null) !== (this.#latestPush(s.business, target)?.commit ?? null))
|
||||
fail('decision-mismatch');
|
||||
return action === 'git.push.remote'
|
||||
? { class: cls, decision: d.id, pushCommit: context.pushCommit ?? null }
|
||||
: { class: cls, decision: d.id };
|
||||
return { class: cls, decision: d.id };
|
||||
}
|
||||
// The raise's context: its target and approval option, from the decision.raise action.allowed.
|
||||
#raised(business, decision) {
|
||||
@@ -875,14 +858,13 @@ export class Broker {
|
||||
if (body.commit !== push.commit) fail('verdict-stale');
|
||||
}
|
||||
// A push is the action.allowed recordPush wrote, the only one for git.push.working with a commit.
|
||||
// role is the role that pushed (row 199, work-not-yours).
|
||||
#latestPush(business, task) {
|
||||
const r = this.#store.get(
|
||||
"SELECT body, actor_role FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.action')='git.push.working' AND json_type(body,'$.commit')='text' AND subject=? ORDER BY seq DESC LIMIT 1",
|
||||
"SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.action')='git.push.working' AND json_type(body,'$.commit')='text' AND subject=? ORDER BY seq DESC LIMIT 1",
|
||||
business,
|
||||
task,
|
||||
);
|
||||
return r ? { ...JSON.parse(r.body), role: r.actor_role } : null;
|
||||
return r ? JSON.parse(r.body) : null;
|
||||
}
|
||||
// Row 93: the bus host's git socket asks before it takes a push's bytes. Nothing is recorded
|
||||
// unless it refuses: the push is recorded, and its authority consumed, once the ref moved.
|
||||
@@ -1016,98 +998,6 @@ export class Broker {
|
||||
this.#landings.delete(ticket);
|
||||
return code;
|
||||
}
|
||||
// Row 199 (#1697, design D9 steps 1 to 3, D9b): the bus host's git socket publishes a task's
|
||||
// recorded work commit to the agent's own fork. checkPublish runs before anything touches the
|
||||
// network. The run holds its claim; git.push.remote is a hard wait, so it cites a decision the
|
||||
// human resolved for this task, bound to the task's latest push (#authorityFor). The project must
|
||||
// be in the agent's publish grant with an upstream; the fork is built from that upstream and the
|
||||
// agent's own Gitea user, never from the request. The ticket carries the session and decision to
|
||||
// recordPublish or publishRefused. No token is in the result.
|
||||
checkPublish(cap, args) {
|
||||
const s = this.#session(cap);
|
||||
return this.#store.transaction(() => {
|
||||
keys(args, ['task_ref', 'project', 'branch', 'decision'], ['task_ref', 'project', 'branch']);
|
||||
if (!taskRef(args.task_ref) || (args.decision !== undefined && !id(args.decision))) fail('invalid-request');
|
||||
identifier(args.project);
|
||||
string(args.branch, 200);
|
||||
this.#agent(s);
|
||||
const { task_ref, project, branch } = args;
|
||||
const decision = args.decision ?? null;
|
||||
const result = this.#checkAuthority(s, 'git.push.remote', { decision, target: task_ref });
|
||||
const facts = this.#publishFacts(s, task_ref, project, result);
|
||||
const ticket = randomUUID();
|
||||
const dst = `refs/heads/${facts.user}/${branch}`;
|
||||
this.#publishes.set(ticket, { s, task_ref, project, decision, dst, ...facts });
|
||||
return { ticket, task_ref, project, fork: facts.fork, user: facts.user, email: facts.email, source: facts.source, dst };
|
||||
});
|
||||
}
|
||||
// Steps 2 and 3, checked again at recordPublish. After routing a within-role publish has no
|
||||
// decision, so the commit is the latest push's (N9); the git socket still checks the work ref tip.
|
||||
#publishFacts(s, task_ref, project, result) {
|
||||
if (result.decision && this.#spent(s.business, result.decision)) fail('decision-consumed');
|
||||
const b = this.#business(s.business);
|
||||
const role = b.roles?.[s.role];
|
||||
const upstream = b.projects?.[project]?.remote;
|
||||
if (!role?.identity || !(role.publish ?? []).includes(project) || !upstream) fail('remote-not-granted');
|
||||
const problem = forkProblem(upstream, role.identity);
|
||||
if (problem) fail(problem);
|
||||
const push = this.#latestPush(s.business, task_ref);
|
||||
if (!push) fail('no-push');
|
||||
if (push.role !== s.role) fail('work-not-yours');
|
||||
if (push.project !== project) fail('work-ref');
|
||||
const source = result.pushCommit ?? push.commit;
|
||||
if (source !== push.commit) fail('decision-mismatch');
|
||||
const user = role.identity.gitea.user;
|
||||
return { fork: forkUrl(upstream, user), user, email: role.identity.email, source };
|
||||
}
|
||||
#publish(ticket) {
|
||||
return (typeof ticket === 'string' && this.#publishes.get(ticket)) || fail('publish-unknown');
|
||||
}
|
||||
// Design D5: the only key the git socket's gitea token use gets is the agent checkPublish checked
|
||||
// (#agent: a run holding its claim, never a tester, reader or human). Trusted host only.
|
||||
publishSession(ticket) {
|
||||
const { s } = this.#publish(ticket);
|
||||
return Object.freeze({ business: s.business, role: s.role });
|
||||
}
|
||||
// Where the bus process's git sends the push (D9 step 5): all from the ticket, none from the
|
||||
// host's message. Trusted host only.
|
||||
publishTarget(ticket) {
|
||||
const { s, project, fork, user, source, dst } = this.#publish(ticket);
|
||||
return Object.freeze({ business: s.business, project, fork, user, source, dst });
|
||||
}
|
||||
// Step 6: records the push the git socket made as the action.allowed for git.push.remote, which
|
||||
// spends the decision. The checks run again; the run may have ended while git ran, so the holder
|
||||
// check is not repeated (as recordLand). Trusted host only.
|
||||
recordPublish(ticket, pushed) {
|
||||
const t = this.#publish(ticket);
|
||||
const done = this.#store.transaction(() => {
|
||||
keys(pushed, ['ref', 'commit', 'previous'], ['ref', 'commit', 'previous']);
|
||||
if (!oid(pushed.commit) || (pushed.previous !== null && !oid(pushed.previous))) fail('invalid-request');
|
||||
string(pushed.ref, 260);
|
||||
const result = this.#authorityFor(t.s, 'git.push.remote', { decision: t.decision, target: t.task_ref });
|
||||
const facts = this.#publishFacts(t.s, t.task_ref, t.project, result);
|
||||
if (facts.source !== t.source || pushed.commit !== t.source || facts.fork !== t.fork || pushed.ref !== t.dst) fail('ref-moved');
|
||||
const { ref, commit, previous } = pushed;
|
||||
this.#event(
|
||||
t.s,
|
||||
'action.allowed',
|
||||
{ project: t.project, fork: t.fork, ref, commit, previous, action: 'git.push.remote', ...result, target: t.task_ref },
|
||||
t.task_ref,
|
||||
);
|
||||
return { task_ref: t.task_ref, fork: t.fork, ref, commit, previous };
|
||||
});
|
||||
this.#publishes.delete(ticket);
|
||||
return done;
|
||||
}
|
||||
// Ends a publish that didn't push: action.refused with the code on the task's trail. The git
|
||||
// socket answers the run with the same code. Trusted host only.
|
||||
publishRefused(ticket, { code } = {}) {
|
||||
const t = this.#publish(ticket);
|
||||
if (!/^[a-z-]{1,64}$/.test(code ?? '')) fail('invalid-request');
|
||||
this.#store.transaction(() => this.#event(t.s, 'action.refused', { code }, t.task_ref));
|
||||
this.#publishes.delete(ticket);
|
||||
return code;
|
||||
}
|
||||
// The outcome as a RESULT from the bus, as #notice sends: the runner takes a turn on it and sends
|
||||
// no automatic reply. It goes to the role in the thread the decision was raised in.
|
||||
#landMessage(s, decision, body) {
|
||||
@@ -1355,7 +1245,6 @@ export class Broker {
|
||||
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
|
||||
if (PROJECT_FILE_VERBS.includes(request.verb))
|
||||
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
|
||||
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set"].includes(request.verb)) {
|
||||
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
|
||||
// file lists organization.change. It takes the same keys, runs the same write path and
|
||||
@@ -1427,35 +1316,6 @@ export class Broker {
|
||||
throw this.#refused(s, e, request);
|
||||
}
|
||||
}
|
||||
// Row 199 (#1697, design section 6). The token of an issue comes in args from the human's stdin; the
|
||||
// secret check above has refused one the bus already holds, a refusal records its code alone, and
|
||||
// nothing returned carries it. Outside the store transaction, as the organization branch.
|
||||
#requestCredential(s, verb, a) {
|
||||
if (s.tester ? !TESTER_CREDENTIAL_VERBS.includes(verb) : !s.human) fail(s.tester ? 'tester-refused' : 'human-required');
|
||||
if (!this.#credentialVerbs) fail('not-configured');
|
||||
if (verb === 'credentials.issue') keys(a, ['agent', 'service', 'token', 'date'], ['agent', 'service', 'token', 'date']);
|
||||
else keys(a, verb === 'credentials.revoke' ? ['agent', 'service', 'reason'] : ['agent', 'reason'], ['agent', 'reason']);
|
||||
string(a.agent, 64);
|
||||
if (a.service !== undefined && !['gitea', 'vikunja'].includes(a.service)) fail('invalid-request');
|
||||
if (a.token !== undefined) string(a.token, 16384);
|
||||
if (a.date !== undefined) string(a.date, 10);
|
||||
if (a.reason !== undefined) string(a.reason, 500);
|
||||
if (!Object.hasOwn(this.#business(s.business).roles, a.agent)) fail('credential-unknown');
|
||||
// A running agent keeps its tokens until its runs end: delete waits for every claim to end.
|
||||
if (verb === 'credentials.delete' && (this.#holder(s.business, a.agent)?.op === 'claim' || this.#threadHolders(s.business, a.agent).length))
|
||||
fail('agent-running');
|
||||
let result;
|
||||
try {
|
||||
result = this.#credentialVerbs(s.business, verb, a, s.tester ? 'tester' : s.human);
|
||||
} catch (e) {
|
||||
if (e instanceof BusError && e.code.startsWith('credential-')) throw e;
|
||||
if (e.code === 'revision-conflict' || e.code?.startsWith('organization-')) fail(e.code);
|
||||
if (e.name === 'BusinessError') fail('invalid-request');
|
||||
throw new BusError('outcome-unknown');
|
||||
}
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
}
|
||||
// Only the runner's bound session and delivered message identify a turn; no host path crosses the wire.
|
||||
// Row 156 (#1651, sage-0808): a role run's landing turn is a merge decision id instead. Its begin
|
||||
// needs the decision held for the run's role; its end needs that begin, since the landing may
|
||||
@@ -1838,12 +1698,6 @@ export class Broker {
|
||||
by,
|
||||
);
|
||||
}
|
||||
// Row 199 (#1697, design D9 step 1, r4 B4): a publish decision binds one commit, the task's
|
||||
// latest recorded push now, read here and never taken from the agent. The human sees it.
|
||||
const pushCommit =
|
||||
a.action === 'git.push.remote' ? (this.#latestPush(s.business, a.target)?.commit ?? fail('no-push')) : null;
|
||||
const question = pushCommit ? `${a.question} (commit ${pushCommit})` : a.question;
|
||||
string(question);
|
||||
const did = randomUUID();
|
||||
this.#store.run(
|
||||
'INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,task_ref,requirement_ref,blocking,supersedes,spec_path,spec_commit) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
@@ -1856,7 +1710,7 @@ export class Broker {
|
||||
cls,
|
||||
a.action,
|
||||
route,
|
||||
question,
|
||||
a.question,
|
||||
JSON.stringify(a.options),
|
||||
a.recommendation,
|
||||
a.task_ref ?? null,
|
||||
@@ -1878,7 +1732,6 @@ export class Broker {
|
||||
approvalChoice,
|
||||
holderRun:
|
||||
a.action === 'role.revoke' ? (this.#holder(s.business, a.target)?.holder_run ?? null) : null,
|
||||
...(pushCommit ? { pushCommit } : {}),
|
||||
},
|
||||
a.task_ref ?? (taskRef(a.target) ? a.target : did),
|
||||
);
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { BusError } from './broker.mjs';
|
||||
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete of one agent's credentials, for
|
||||
// the broker's human-only verbs. The broker checks the session, the keys and a running claim first.
|
||||
// Each verb leaves one receipt in the organization's changes.jsonl: the actor and a detail naming the
|
||||
// verb, agent, kinds, date or reason, never a value. Nothing here logs, and a result carries no token.
|
||||
const DATE_KEY = Object.freeze({ gitea: 'rotateBy', vikunja: 'expires' });
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
export function credentialVerbs({ credentials, writer }) {
|
||||
const state = (instance, service) =>
|
||||
credentials.status().find((e) => e.instance === instance && e.service === service)?.state;
|
||||
return (id, verb, args, actor) => {
|
||||
const { agent } = args;
|
||||
const instance = id + '/' + agent;
|
||||
const { document, revision } = writer.read(id);
|
||||
const current = document.roles?.[agent]?.credentials;
|
||||
if (!current) deny('credential-unknown');
|
||||
if (verb === 'credentials.issue') {
|
||||
const { service, token, date } = args;
|
||||
if (!current[service]) deny('credential-unknown');
|
||||
// rotate is an issue over an entry the bus holds now; the old value is gone when this returns.
|
||||
const kind = ['not-issued', 'revoked', undefined].includes(state(instance, service)) ? 'issue' : 'rotate';
|
||||
const next = structuredClone(document);
|
||||
next.roles[agent].credentials[service] = { store: true, [DATE_KEY[service]]: date };
|
||||
const detail = { verb: kind, agent, kind: [service], date };
|
||||
const status = credentials.issue(instance, service, token, date, () =>
|
||||
writer.write(id, next, { revision, actor, detail }),
|
||||
);
|
||||
return { agent, verb: kind, service, date: status.date, state: status.state };
|
||||
}
|
||||
const services = verb === 'credentials.revoke' && args.service ? [args.service] : Object.keys(current);
|
||||
if (services.some((service) => !current[service])) deny('credential-unknown');
|
||||
// Memory first, then the store file (Credentials.revoke), then the business file. A reference
|
||||
// outside the store becomes `store` with its date, so a restart loads nothing for it. When that
|
||||
// write fails, the entry stays revoked in memory and the refusal says to run the verb again.
|
||||
if (verb === 'credentials.delete') credentials.remove(instance);
|
||||
else for (const service of services) credentials.revoke(instance, service);
|
||||
const detail = { verb: verb.slice('credentials.'.length), agent, kind: services, reason: args.reason };
|
||||
const moved = services.filter((service) => current[service].store !== true);
|
||||
if (moved.length) {
|
||||
const next = structuredClone(document);
|
||||
for (const service of moved)
|
||||
next.roles[agent].credentials[service] = { store: true, [DATE_KEY[service]]: current[service][DATE_KEY[service]] };
|
||||
writer.write(id, next, { revision, actor, detail });
|
||||
} else writer.record(id, { actor, detail });
|
||||
return {
|
||||
agent,
|
||||
verb: detail.verb,
|
||||
services: services.map((service) => ({ service, state: state(instance, service) ?? 'not-issued' })),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import {
|
||||
openSync, closeSync, fstatSync, readFileSync, lstatSync, realpathSync, constants, mkdirSync, writeSync, fsyncSync,
|
||||
renameSync, unlinkSync, rmdirSync, readdirSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { openSync, closeSync, fstatSync, readFileSync, lstatSync, realpathSync, constants } from 'node:fs';
|
||||
import { isAbsolute, relative, resolve } from 'node:path';
|
||||
import { BusError } from './broker.mjs';
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
@@ -16,33 +13,9 @@ const day = (value) =>
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
|
||||
!Number.isNaN(Date.parse(value)) &&
|
||||
new Date(value).toISOString().slice(0, 10) === value;
|
||||
const TOKEN = /^[-A-Za-z0-9._~]+\n?$/;
|
||||
// Row 199 (#1697, design D1, D4): a store dir is the stack instance's user's own and nobody else's.
|
||||
const privateDir = (path, { missing = false } = {}) => {
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(path);
|
||||
} catch (e) {
|
||||
if (missing && e.code === 'ENOENT') return false;
|
||||
deny('credential-file');
|
||||
}
|
||||
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077) !== 0) deny('credential-file');
|
||||
return true;
|
||||
};
|
||||
export class Credentials {
|
||||
#tokens = new Map();
|
||||
// Row 199: every reference the bus loaded, so issue, rotate, revoke and status work per entry.
|
||||
#refs = new Map();
|
||||
#revoked = new Set();
|
||||
#storeRoot;
|
||||
#repoRoots;
|
||||
#dataRoot;
|
||||
// storeRoot is <configDir>/secrets/agents (design D1); a reference with store: true must name the
|
||||
// path under it for its own business, agent and service.
|
||||
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null }) {
|
||||
this.#storeRoot = storeRoot;
|
||||
this.#repoRoots = repoRoots;
|
||||
this.#dataRoot = dataRoot;
|
||||
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env }) {
|
||||
try {
|
||||
for (const [instance, services] of Object.entries(references))
|
||||
for (const [service, ref] of Object.entries(services)) {
|
||||
@@ -53,41 +26,12 @@ export class Credentials {
|
||||
if (
|
||||
(ref.service !== undefined && ref.service !== service) ||
|
||||
Object.keys(ref).some(
|
||||
(k) => !['service', 'file', 'env', 'store', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
|
||||
(k) => !['service', 'file', 'env', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
|
||||
) ||
|
||||
Boolean(ref.file) === Boolean(ref.env) ||
|
||||
(ref.store !== undefined && (ref.store !== true || ref.file !== this.#storePath(instance, service)))
|
||||
Boolean(ref.file) === Boolean(ref.env)
|
||||
)
|
||||
deny('credential-reference');
|
||||
this.#refs.set(instance + ':' + service, { ref, date });
|
||||
const token = this.#read(ref, env);
|
||||
if (token !== null) this.#tokens.set(instance + ':' + service, { token, date });
|
||||
}
|
||||
} catch (e) {
|
||||
this.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
#storePath(instance, service) {
|
||||
if (!this.#storeRoot) deny('credential-reference');
|
||||
const [business, agent, ...rest] = instance.split('/');
|
||||
if (!business || !agent || rest.length || agent.startsWith('@')) deny('credential-reference');
|
||||
return join(this.#storeRoot, business, agent, service);
|
||||
}
|
||||
// One token, read and checked, or null for a store reference with no file yet (design D3a).
|
||||
#read(ref, env) {
|
||||
let token;
|
||||
if (ref.store) {
|
||||
const agentDir = dirname(ref.file);
|
||||
privateDir(this.#storeRoot);
|
||||
if (!privateDir(dirname(agentDir), { missing: true }) || !privateDir(agentDir, { missing: true })) return null;
|
||||
try {
|
||||
lstatSync(ref.file);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') return null;
|
||||
deny('credential-file');
|
||||
}
|
||||
}
|
||||
if (ref.file) {
|
||||
if (!isAbsolute(ref.file)) deny('credential-location');
|
||||
let resolved, original;
|
||||
@@ -99,7 +43,7 @@ export class Credentials {
|
||||
}
|
||||
if (original.isSymbolicLink() || !original.isFile()) deny('credential-file');
|
||||
if (
|
||||
[...this.#repoRoots, this.#dataRoot].filter(Boolean).some((root) =>
|
||||
[...repoRoots, dataRoot].filter(Boolean).some((root) =>
|
||||
inside(
|
||||
(() => {
|
||||
try {
|
||||
@@ -137,98 +81,16 @@ export class Credentials {
|
||||
if (!/^[A-Z_][A-Z0-9_]*$/.test(ref.env)) deny('credential-reference');
|
||||
token = env[ref.env];
|
||||
}
|
||||
return Credentials.#format(token);
|
||||
}
|
||||
static #format(token) {
|
||||
if (typeof token !== 'string' || token.length > 16384 || !TOKEN.test(token)) deny('credential-format');
|
||||
token = token.replace(/\n$/, '');
|
||||
if (token.length < 16) deny('credential-format');
|
||||
return token;
|
||||
}
|
||||
#known(instance, service) {
|
||||
const known = this.#refs.get(instance + ':' + service);
|
||||
if (!known) deny('credential-unknown');
|
||||
return known;
|
||||
}
|
||||
// Row 199 (design section 6): write the token to the agent's store path (tmp, fsync, rename), then
|
||||
// `commit` (the business file's reference becomes store), then memory. The token comes from the
|
||||
// caller's stdin and is never logged. Returns the entry's new status.
|
||||
issue(instance, service, token, date, commit = () => {}) {
|
||||
this.#known(instance, service);
|
||||
if (!day(date)) deny('credential-date');
|
||||
token = Credentials.#format(token);
|
||||
const file = this.#storePath(instance, service);
|
||||
const agentDir = dirname(file);
|
||||
mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 });
|
||||
privateDir(this.#storeRoot);
|
||||
for (const dir of [dirname(agentDir), agentDir]) {
|
||||
if (!privateDir(dir, { missing: true })) mkdirSync(dir, { mode: 0o700 });
|
||||
privateDir(dir);
|
||||
}
|
||||
const tmp = file + '.tmp';
|
||||
try {
|
||||
unlinkSync(tmp);
|
||||
if (typeof token !== 'string' || token.length > 16384 || !/^[-A-Za-z0-9._~]+\n?$/.test(token))
|
||||
deny('credential-format');
|
||||
token = token.replace(/\n$/, '');
|
||||
if (token.length < 16) deny('credential-format');
|
||||
this.#tokens.set(instance + ':' + service, { token, date });
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
this.close();
|
||||
throw e;
|
||||
}
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
writeSync(fd, token + '\n');
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
renameSync(tmp, file);
|
||||
const dirFd = openSync(agentDir, constants.O_RDONLY);
|
||||
try {
|
||||
fsyncSync(dirFd);
|
||||
} finally {
|
||||
closeSync(dirFd);
|
||||
}
|
||||
commit();
|
||||
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
|
||||
this.#tokens.set(instance + ':' + service, { token, date });
|
||||
this.#revoked.delete(instance + ':' + service);
|
||||
return this.status().find((e) => e.instance === instance && e.service === service);
|
||||
}
|
||||
// Memory first, then the store file (design section 6, D12). A file or env reference outside the
|
||||
// store is never unlinked; the caller turns its reference into store, so a restart finds nothing.
|
||||
revoke(instance, service) {
|
||||
const { ref } = this.#known(instance, service);
|
||||
this.#tokens.delete(instance + ':' + service);
|
||||
this.#revoked.add(instance + ':' + service);
|
||||
if (ref.store) {
|
||||
try {
|
||||
unlinkSync(ref.file);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
}
|
||||
}
|
||||
return { file: Boolean(ref.store) };
|
||||
}
|
||||
// revoke for every service, then the agent's dir. A file other than a token or its tmp refuses.
|
||||
remove(instance) {
|
||||
const services = [...this.#refs.keys()].filter((k) => k.startsWith(instance + ':')).map((k) => k.slice(instance.length + 1));
|
||||
for (const service of services) this.revoke(instance, service);
|
||||
const agentDir = dirname(this.#storePath(instance, 'gitea'));
|
||||
let names;
|
||||
try {
|
||||
names = readdirSync(agentDir);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') return { services };
|
||||
deny('credential-file');
|
||||
}
|
||||
if (names.some((n) => !/^(gitea|vikunja)(\.tmp)?$/.test(n))) deny('credential-file');
|
||||
for (const n of names) unlinkSync(join(agentDir, n));
|
||||
rmdirSync(agentDir);
|
||||
return { services };
|
||||
}
|
||||
// The agent-facing use (design D5): the key comes only from the broker's verified session.
|
||||
async useFor(session, service, operation) {
|
||||
if (!session || typeof session.business !== 'string' || typeof session.role !== 'string' || session.role.startsWith('@'))
|
||||
deny('credential-not-yours');
|
||||
return this.use(session.business + '/' + session.role, service, operation);
|
||||
}
|
||||
assertClean(value) {
|
||||
let encoded;
|
||||
@@ -247,16 +109,14 @@ export class Credentials {
|
||||
let result;
|
||||
try {
|
||||
result = await operation(entry.token);
|
||||
} catch (e) {
|
||||
// Row 199: a host step's own refusal (publish) keeps its code; anything else is the service's.
|
||||
if (e instanceof BusError) throw e;
|
||||
} catch {
|
||||
deny('service-failed');
|
||||
}
|
||||
this.assertClean(result);
|
||||
return result;
|
||||
}
|
||||
status() {
|
||||
return [...this.#refs.entries()].map(([key, { date }]) => {
|
||||
return [...this.#tokens.entries()].map(([key, { date }]) => {
|
||||
const i = key.lastIndexOf(':'),
|
||||
service = key.slice(i + 1),
|
||||
left = Date.parse(date + 'T00:00:00Z') - Date.now();
|
||||
@@ -264,11 +124,8 @@ export class Credentials {
|
||||
instance: key.slice(0, i),
|
||||
service,
|
||||
date,
|
||||
state: !this.#tokens.has(key)
|
||||
? this.#revoked.has(key)
|
||||
? 'revoked'
|
||||
: 'not-issued'
|
||||
: service === 'gitea'
|
||||
state:
|
||||
service === 'gitea'
|
||||
? left <= 0
|
||||
? 'rotation-due'
|
||||
: 'valid'
|
||||
@@ -282,6 +139,5 @@ export class Credentials {
|
||||
}
|
||||
close() {
|
||||
this.#tokens.clear();
|
||||
this.#refs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,6 @@ const LAUNCH_OPS = new Set([
|
||||
'checkLand',
|
||||
'recordLand',
|
||||
'landRefused',
|
||||
'checkPublish',
|
||||
'publishPush',
|
||||
'publishResult',
|
||||
'recordPublish',
|
||||
'publishRefused',
|
||||
'landHeld',
|
||||
'threadsState',
|
||||
'threadIdle',
|
||||
@@ -39,12 +34,6 @@ function launchOp(m) {
|
||||
if (m.op === 'checkLand') return runtime.checkLand(m.cap, m.args);
|
||||
if (m.op === 'recordLand') return runtime.recordLand(m.ticket, m.land);
|
||||
if (m.op === 'landRefused') return runtime.landRefused(m.ticket, m.args);
|
||||
// Row 199 (#1697, design D9b): the git socket's publish op. No entry returns a token.
|
||||
if (m.op === 'checkPublish') return runtime.checkPublish(m.cap, m.args);
|
||||
if (m.op === 'publishPush') return runtime.publishPush(m.ticket);
|
||||
if (m.op === 'publishResult') return runtime.publishResult(m.ticket);
|
||||
if (m.op === 'recordPublish') return runtime.recordPublish(m.ticket, m.pushed);
|
||||
if (m.op === 'publishRefused') return runtime.publishRefused(m.ticket, m.args);
|
||||
if (m.op === 'landHeld') return runtime.landHeld(m.record);
|
||||
// Row 121 (#1615): the launcher's view of the business's threads, and its idle release.
|
||||
if (m.op === 'threadsState') return runtime.threadsState(m.business);
|
||||
@@ -110,8 +99,7 @@ if (!process.send) {
|
||||
booted = true;
|
||||
clearTimeout(timer);
|
||||
// `trackers` is S3's plain-data boot config; the adapter is loaded only when a business has one.
|
||||
// Row 199: publishTls is the tests' scratch CA; no boot config sets it.
|
||||
const { trackers, publishTls: _tls, ...config } = message.config ?? {};
|
||||
const { trackers, ...config } = message.config ?? {};
|
||||
if (trackers) {
|
||||
const { tasksAdapter } = await import('../../tasks/src/adapter.mjs');
|
||||
config.tasks = tasksAdapter({ trackers });
|
||||
|
||||
@@ -8,12 +8,9 @@ import { Credentials } from './credentials.mjs';
|
||||
import { serve } from './server.mjs';
|
||||
import { launchSettings } from '../../business/src/launch-settings.mjs';
|
||||
import { readSessions } from '../../seat/src/session.mjs';
|
||||
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
|
||||
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
|
||||
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
|
||||
import { instanceSettings } from '../../business/src/instance-settings.mjs';
|
||||
import { organizationWriter } from '../../business/src/organization.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { webService } from './web.mjs';
|
||||
import { verifyHuman } from './human.mjs';
|
||||
import { createUsage } from './usage.mjs';
|
||||
@@ -75,9 +72,6 @@ export async function startBroker({
|
||||
repoRoots = [],
|
||||
tasks = null,
|
||||
organization = null,
|
||||
// Row 199: the tests' scratch CA for publish's git (`-c http.sslCAInfo=...`). process.mjs drops
|
||||
// it from the boot config, so only an in-process caller sets it.
|
||||
publishTls = [],
|
||||
}) {
|
||||
let store, credentials, server, adapter;
|
||||
try {
|
||||
@@ -92,9 +86,7 @@ export async function startBroker({
|
||||
...repoRoots,
|
||||
...Object.values(businesses).flatMap((b) => Object.values(b.projects ?? {}).map((p) => p.root)),
|
||||
];
|
||||
// Row 199 (#1697, design D1): the per-agent store is <configDir>/secrets/agents.
|
||||
const storeRoot = organization ? join(organization.dir, 'secrets', 'agents') : null;
|
||||
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot });
|
||||
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot });
|
||||
store = new Store(dataRoot);
|
||||
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
|
||||
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
|
||||
@@ -141,11 +133,6 @@ export async function startBroker({
|
||||
}:null,
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
credentialStatus: () => credentials.status(),
|
||||
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete, live with no restart; each
|
||||
// rewrites or records through the organization writer, so changes.jsonl holds the receipt.
|
||||
credentialVerbs: organization
|
||||
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) })
|
||||
: null,
|
||||
// Row 65: a new launch binds only on a harness its business switches on, read from the file
|
||||
// as it stands now; an unreadable file switches none on.
|
||||
harnessOn: (id, name) => {
|
||||
@@ -225,42 +212,6 @@ export async function startBroker({
|
||||
// Row 137 (#1631): the git socket's land op. checkLand's refusal is the run's, like checkPush's.
|
||||
// A landing then ends in recordLand or landRefused under its ticket, which outlive the run's cap.
|
||||
const checkLand = (cap, args) => refusing(cap, () => broker.checkLand(cap, args), args?.task_ref);
|
||||
// Row 199 (#1697, design D9b): the git socket's publish op. checkPublish's refusal is the run's.
|
||||
// The gitea token is used only under the open ticket, keyed from the agent checkPublish checked,
|
||||
// and the op table returns no token. A publish then ends in recordPublish or publishRefused,
|
||||
// which files any refusal once, withGitea's included.
|
||||
const checkPublish = (cap, args) => refusing(cap, () => broker.checkPublish(cap, args), args?.task_ref);
|
||||
const withGitea = async (ticket, fn) => credentials.useFor(broker.publishSession(ticket), 'gitea', fn);
|
||||
// The push itself runs here, where the token is (D9 step 5), and the git socket's host polls
|
||||
// for its end: the host's op channel answers one op at a time within 10 s, and ls-remote and
|
||||
// push can take longer. Fork, user, source and dst all come from the ticket. A WorkingError's
|
||||
// code is the refusal; git's stderr stays here.
|
||||
const remotes = new Map();
|
||||
function publishPush(ticket) {
|
||||
if (remotes.has(ticket)) throw new BusError('publish-busy');
|
||||
const { business, project, fork, user, source, dst } = broker.publishTarget(ticket);
|
||||
const entry = { state: 'running' };
|
||||
remotes.set(ticket, entry);
|
||||
withGitea(ticket, (token) =>
|
||||
publishRemote({ repo: workingRepo(dataRoot, business, project), fork, user, source, dst, token, tls: publishTls }).catch((e) => {
|
||||
throw e instanceof WorkingError ? new BusError(e.code) : e;
|
||||
}),
|
||||
).then(
|
||||
(r) => Object.assign(entry, { state: 'pushed', ...r }),
|
||||
(e) => {
|
||||
// Not a refusal: the error's name and code only, never a message that could carry git's output.
|
||||
if (!(e instanceof BusError)) process.stderr.write(`bus: publish git-failed: ${JSON.stringify({ name: e?.name, code: e?.code })}\n`);
|
||||
Object.assign(entry, { state: 'refused', code: e instanceof BusError ? e.code : 'git-failed' });
|
||||
},
|
||||
);
|
||||
return { state: 'running' };
|
||||
}
|
||||
function publishResult(ticket) {
|
||||
const entry = remotes.get(ticket);
|
||||
if (!entry) throw new BusError('publish-unknown');
|
||||
if (entry.state !== 'running') remotes.delete(ticket);
|
||||
return { ...entry };
|
||||
}
|
||||
// Metadata only (service, date, state); the token stays in Credentials.
|
||||
function credentialStatus(business, role) {
|
||||
return credentials
|
||||
@@ -286,12 +237,6 @@ export async function startBroker({
|
||||
checkLand,
|
||||
recordLand: (ticket, land) => broker.recordLand(ticket, land),
|
||||
landRefused: (ticket, args) => broker.landRefused(ticket, args),
|
||||
checkPublish,
|
||||
withGitea,
|
||||
publishPush,
|
||||
publishResult,
|
||||
recordPublish: (ticket, pushed) => broker.recordPublish(ticket, pushed),
|
||||
publishRefused: (ticket, args) => broker.publishRefused(ticket, args),
|
||||
landHeld: (record) => broker.landHeld(record),
|
||||
// Row 156 (#1651): the launcher's landing pass.
|
||||
landHolders: (business) => broker.landHolders(business),
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
// Row 199 (#1697, design D1, D3a, D4, section 6, T1 and T5): the per-agent credential store under
|
||||
// <configDir>/secrets/agents/<business>/<agent>/<service>. Fake tokens in a scratch root only.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
|
||||
const TOKEN = 'fake_store_token_0123456789';
|
||||
const ROTATED = 'fake_rotated_token_9876543210';
|
||||
const AGENT = { business: 'acme', role: 'coder' };
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-store-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const storeRoot = join(root, 'secrets', 'agents');
|
||||
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const file = join(storeRoot, 'acme', 'coder', 'gitea');
|
||||
const references = { 'acme/coder': { gitea: { service: 'gitea', store: true, file, rotateBy: '2099-01-01' } } };
|
||||
const open = (extra = {}) => new Credentials({ references, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data', ...extra });
|
||||
const state = (c) => c.status().find((e) => e.instance === 'acme/coder' && e.service === 'gitea').state;
|
||||
const read = (c, session = AGENT) => c.useFor(session, 'gitea', async (token) => ({ same: token === TOKEN, rotated: token === ROTATED }));
|
||||
return { root, storeRoot, file, references, open, state, read };
|
||||
}
|
||||
|
||||
test('T1: a store reference with no file yet starts, and the agent is not-issued until issue', async (t) => {
|
||||
const s = setup(t);
|
||||
const c = s.open();
|
||||
assert.equal(s.state(c), 'not-issued');
|
||||
await assert.rejects(s.read(c), /credential-unavailable/);
|
||||
const status = c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
|
||||
assert.equal(status.state, 'valid');
|
||||
assert.deepEqual(await s.read(c), { same: true, rotated: false });
|
||||
assert.equal(statSync(s.file).mode & 0o777, 0o600);
|
||||
for (const dir of [join(s.storeRoot, 'acme'), join(s.storeRoot, 'acme', 'coder')]) assert.equal(statSync(dir).mode & 0o777, 0o700);
|
||||
assert.deepEqual(readdirSync(join(s.storeRoot, 'acme', 'coder')), ['gitea']);
|
||||
// A restart reads the issued file.
|
||||
assert.deepEqual(await s.read(s.open()), { same: true, rotated: false });
|
||||
});
|
||||
|
||||
test('T1: a store path the reference does not own, or no store root, refuses the reference', (t) => {
|
||||
const s = setup(t);
|
||||
const other = join(s.storeRoot, 'acme', 'pm', 'gitea');
|
||||
assert.throws(() => s.open({ references: { 'acme/coder': { gitea: { store: true, file: other, rotateBy: '2099-01-01' } } } }), /credential-reference/);
|
||||
assert.throws(() => new Credentials({ references: s.references, repoRoots: [], dataRoot: '/nonexistent-data' }), /credential-reference/);
|
||||
assert.throws(() => s.open({ references: { 'acme/@tester': { gitea: { store: true, file: join(s.storeRoot, 'acme', '@tester', 'gitea'), rotateBy: '2099-01-01' } } } }),
|
||||
/credential-reference/);
|
||||
});
|
||||
|
||||
test('T1: a wrong mode on the store root, a business dir, an agent dir or the file refuses start', (t) => {
|
||||
const s = setup(t);
|
||||
s.open().issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
|
||||
for (const [path, bad, good] of [
|
||||
[s.storeRoot, 0o750, 0o700],
|
||||
[join(s.storeRoot, 'acme'), 0o705, 0o700],
|
||||
[join(s.storeRoot, 'acme', 'coder'), 0o770, 0o700],
|
||||
[s.file, 0o640, 0o600],
|
||||
]) {
|
||||
chmodSync(path, bad);
|
||||
assert.throws(() => s.open(), /credential-file/, path);
|
||||
chmodSync(path, good);
|
||||
}
|
||||
s.open();
|
||||
});
|
||||
|
||||
test('T1: a missing store root refuses start; a missing {file} reference still refuses start', (t) => {
|
||||
const s = setup(t);
|
||||
rmSync(s.storeRoot, { recursive: true });
|
||||
assert.throws(() => s.open(), /credential-file/);
|
||||
assert.throws(() => s.open({ references: { coder: { gitea: { file: join(s.root, 'absent'), rotateBy: '2099-01-01' } } } }), /credential-file/);
|
||||
});
|
||||
|
||||
test('T1: a store file that is a directory or a symlink refuses start', (t) => {
|
||||
const s = setup(t);
|
||||
mkdirSync(join(s.storeRoot, 'acme', 'coder'), { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(s.storeRoot, 'acme'), 0o700);
|
||||
mkdirSync(s.file, { mode: 0o700 });
|
||||
assert.throws(() => s.open(), /credential-file/);
|
||||
rmSync(s.file, { recursive: true });
|
||||
const target = join(s.root, 'target');
|
||||
writeFileSync(target, TOKEN + '\n', { mode: 0o600 });
|
||||
symlinkSync(target, s.file);
|
||||
assert.throws(() => s.open(), /credential-file/);
|
||||
});
|
||||
|
||||
test('T2: useFor keys from the session only', async (t) => {
|
||||
const s = setup(t);
|
||||
const c = s.open();
|
||||
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
|
||||
await assert.rejects(s.read(c, { business: 'acme', role: 'pm' }), /credential-unavailable/);
|
||||
await assert.rejects(s.read(c, { business: 'acme', role: '@tester' }), /credential-not-yours/);
|
||||
await assert.rejects(s.read(c, null), /credential-not-yours/);
|
||||
await assert.rejects(s.read(c, { business: 'acme' }), /credential-not-yours/);
|
||||
});
|
||||
|
||||
test('T5: issue, rotate, revoke and delete are live with no restart, and revoke holds across one', async (t) => {
|
||||
const s = setup(t);
|
||||
const c = s.open();
|
||||
let committed = 0;
|
||||
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01', () => committed++);
|
||||
assert.equal(committed, 1);
|
||||
// Rotate is a second issue: the new token is used at once, with no tmp file left.
|
||||
c.issue('acme/coder', 'gitea', ROTATED + '\n', '2099-06-01');
|
||||
assert.deepEqual(await s.read(c), { same: false, rotated: true });
|
||||
assert.equal(c.status()[0].date, '2099-06-01');
|
||||
assert.deepEqual(readdirSync(join(s.storeRoot, 'acme', 'coder')), ['gitea']);
|
||||
// Revoke: memory first, then the file.
|
||||
assert.deepEqual(c.revoke('acme/coder', 'gitea'), { file: true });
|
||||
assert.equal(s.state(c), 'revoked');
|
||||
assert.equal(existsSync(s.file), false);
|
||||
await assert.rejects(s.read(c), /credential-unavailable/);
|
||||
const restarted = s.open();
|
||||
await assert.rejects(s.read(restarted), /credential-unavailable/);
|
||||
assert.equal(s.state(restarted), 'not-issued');
|
||||
// Delete removes the agent's dir.
|
||||
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
|
||||
assert.deepEqual(c.remove('acme/coder'), { services: ['gitea'] });
|
||||
assert.equal(existsSync(join(s.storeRoot, 'acme', 'coder')), false);
|
||||
await assert.rejects(s.read(c), /credential-unavailable/);
|
||||
});
|
||||
|
||||
test('T5: a failed commit leaves the token on disk but not in memory, and a bad token or date writes nothing', async (t) => {
|
||||
const s = setup(t);
|
||||
const c = s.open();
|
||||
assert.throws(() => c.issue('acme/coder', 'gitea', 'short', '2099-01-01'), /credential-format/);
|
||||
assert.throws(() => c.issue('acme/coder', 'gitea', TOKEN, '2099-13-01'), /credential-date/);
|
||||
assert.throws(() => c.issue('acme/pm', 'gitea', TOKEN, '2099-01-01'), /credential-unknown/);
|
||||
assert.equal(existsSync(s.file), false);
|
||||
assert.throws(() => c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01', () => { throw new Error('commit failed'); }), /commit failed/);
|
||||
assert.equal(existsSync(s.file), true);
|
||||
assert.equal(s.state(c), 'not-issued');
|
||||
await assert.rejects(s.read(c), /credential-unavailable/);
|
||||
});
|
||||
|
||||
test('T5: revoke of a {file} reference forgets the token and never unlinks the file', async (t) => {
|
||||
const s = setup(t);
|
||||
const path = join(s.root, 'outside-token');
|
||||
writeFileSync(path, TOKEN + '\n', { mode: 0o600 });
|
||||
const c = s.open({ references: { 'acme/coder': { gitea: { file: path, rotateBy: '2099-01-01' } } } });
|
||||
assert.deepEqual(await s.read(c), { same: true, rotated: false });
|
||||
assert.deepEqual(c.revoke('acme/coder', 'gitea'), { file: false });
|
||||
assert.equal(existsSync(path), true);
|
||||
await assert.rejects(s.read(c), /credential-unavailable/);
|
||||
});
|
||||
|
||||
test('T5: delete refuses an agent dir holding anything but tokens', (t) => {
|
||||
const s = setup(t);
|
||||
const c = s.open();
|
||||
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
|
||||
writeFileSync(join(s.storeRoot, 'acme', 'coder', 'notes'), 'x', { mode: 0o600 });
|
||||
assert.throws(() => c.remove('acme/coder'), /credential-file/);
|
||||
assert.equal(existsSync(join(s.storeRoot, 'acme', 'coder', 'notes')), true);
|
||||
});
|
||||
|
||||
// Design D5: the name-keyed use() is the host's own, for @sync and the tracker's start check of each
|
||||
// role's token. Every other credential use goes through useFor, keyed from the broker's session. A
|
||||
// new use( call site outside this list fails here.
|
||||
test('D5: every credentials use( call site is on the allowlist', () => {
|
||||
const packages = new URL('../../', import.meta.url);
|
||||
const allowed = new Map([
|
||||
['bus/src/credentials.mjs', 1], // useFor's own call
|
||||
['tasks/src/adapter.mjs', 1], // the host's use(instance) for @sync and probe
|
||||
]);
|
||||
const found = new Map();
|
||||
const walk = (dir, rel) => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === 'node_modules' || name === 'tests') continue;
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) walk(path, `${rel}${name}/`);
|
||||
else if (name.endsWith('.mjs') || name.endsWith('.js')) {
|
||||
const code = readFileSync(path, 'utf8').split('\n').filter((line) => !/^\s*\/\//.test(line)).join('\n');
|
||||
const n = (code.match(/(?:credentials|this)\.use\(/g) ?? []).length;
|
||||
if (n) found.set(`${rel}${name}`, n);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const pkg of readdirSync(packages)) if (statSync(new URL(`${pkg}/src`, packages), { throwIfNoEntry: false })?.isDirectory())
|
||||
walk(new URL(`${pkg}/src`, packages).pathname, `${pkg}/src/`);
|
||||
assert.deepEqual(Object.fromEntries([...found].sort()), Object.fromEntries([...allowed].sort()));
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
// Row 199 (#1697, design section 6, T4, T5): credentials.issue, credentials.revoke and
|
||||
// credentials.delete through the runtime. Human-only; the tester revokes and never issues. Each verb
|
||||
// is live with no restart and leaves one receipt in changes.jsonl with no value in it. Fake tokens in a
|
||||
// scratch root only.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { Broker, CREDENTIAL_VERBS } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||
|
||||
const TOKEN = 'fake_issued_token_0123456789';
|
||||
const ROTATED = 'fake_rotated_token_9876543210';
|
||||
const OTHER = 'fake_other_token_5555555555';
|
||||
const DATE = '2099-01-01';
|
||||
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
|
||||
async function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
|
||||
doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE };
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
|
||||
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
|
||||
// The bus's resolved business carries the same references; a store one names its derived file.
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
|
||||
roles: {
|
||||
coder: { ...role, credentials: { gitea: { store: true, file: store('coder', 'gitea'), rotateBy: DATE }, vikunja: doc.roles.coder.credentials.vikunja } },
|
||||
reviewer: { ...role, credentials: doc.roles.reviewer.credentials },
|
||||
},
|
||||
};
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(() => bus.close());
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const tester = bus.broker.bindTester({ business: 'demo' });
|
||||
const ask = (cap, verb, args) => bus.broker.request(cap, { verb, args });
|
||||
const file = () => JSON.parse(readFileSync(join(root, 'businesses/demo.json'), 'utf8'));
|
||||
const receipts = () => {
|
||||
const log = join(root, 'businesses/.history/demo/changes.jsonl');
|
||||
return existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse) : [];
|
||||
};
|
||||
const committed = () => receipts().filter((r) => r.state === 'committed');
|
||||
const status = (agent) => Object.fromEntries(bus.credentialStatus('demo', agent).map((e) => [e.service, e.state]));
|
||||
return { root, bus, human, tester, ask, file, receipts, committed, status, store };
|
||||
}
|
||||
|
||||
test('row 199: the lifecycle verbs refuse every session but the human, and the tester revokes only', (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-cred-gate-'));
|
||||
const s = new Store(root);
|
||||
t.after(() => { s.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const calls = [];
|
||||
const b = new Broker({
|
||||
store: s,
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, roles: { coder: role } } },
|
||||
credentialVerbs: (id, verb, args, actor) => (calls.push([verb, actor]), { ok: true }),
|
||||
});
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const tester = b.bindTester({ business: 'demo' });
|
||||
const reader = b.bindReader({ business: 'demo' });
|
||||
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' });
|
||||
const args = {
|
||||
'credentials.issue': { agent: 'coder', service: 'gitea', token: TOKEN, date: DATE },
|
||||
'credentials.revoke': { agent: 'coder', reason: 'fired' },
|
||||
'credentials.delete': { agent: 'coder', reason: 'fired' },
|
||||
};
|
||||
assert.deepEqual(CREDENTIAL_VERBS, Object.keys(args));
|
||||
for (const verb of CREDENTIAL_VERBS) {
|
||||
assert.throws(() => b.request(reader, { verb, args: args[verb] }), /human-required/, verb);
|
||||
assert.throws(() => b.request(coder, { verb, args: args[verb] }), /human-required/, verb);
|
||||
}
|
||||
assert.throws(() => b.request(tester, { verb: 'credentials.issue', args: args['credentials.issue'] }), /tester-refused/);
|
||||
assert.throws(() => b.request(tester, { verb: 'credentials.delete', args: args['credentials.delete'] }), /tester-refused/);
|
||||
// Keys: a token on revoke, a missing reason, an unknown service, an unknown agent, an @ agent.
|
||||
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { ...args['credentials.revoke'], token: TOKEN } }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { agent: 'coder' } }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], service: 'model' } }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], file: '/x' } }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], agent: 'pm' } }), /credential-unknown/);
|
||||
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { agent: '@sync', reason: 'x' } }), /credential-unknown/);
|
||||
assert.equal(calls.length, 0);
|
||||
// A refusal records its code alone, never the token.
|
||||
const refused = s.all("SELECT body FROM events WHERE kind='action.refused'");
|
||||
assert.ok(refused.length >= 10);
|
||||
for (const { body } of refused) assert.equal(body.includes(TOKEN), false);
|
||||
// delete waits for the agent's claim to end.
|
||||
b.request(coder, { verb: 'role.claim' });
|
||||
assert.throws(() => b.request(human, { verb: 'credentials.delete', args: args['credentials.delete'] }), /agent-running/);
|
||||
b.request(coder, { verb: 'role.release' });
|
||||
for (const verb of CREDENTIAL_VERBS) b.request(human, { verb, args: args[verb] });
|
||||
b.request(tester, { verb: 'credentials.revoke', args: args['credentials.revoke'] });
|
||||
assert.deepEqual(calls, [['credentials.issue', 'jason'], ['credentials.revoke', 'jason'], ['credentials.delete', 'jason'], ['credentials.revoke', 'tester']]);
|
||||
// No lifecycle without the runtime's store.
|
||||
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } } } });
|
||||
const h2 = bare.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
assert.throws(() => bare.request(h2, { verb: 'credentials.revoke', args: args['credentials.revoke'] }), /not-configured/);
|
||||
});
|
||||
|
||||
test('T5: issue, rotate, revoke and delete are live, rewrite the business file and leave one receipt each, with no value anywhere', async (t) => {
|
||||
const c = await setup(t);
|
||||
const coder = { business: 'demo', role: 'coder' };
|
||||
const read = (service) => c.bus.credentials.useFor(coder, service, async (token) => token === TOKEN ? 'issued' : token === ROTATED ? 'rotated' : 'other');
|
||||
assert.deepEqual(c.status('coder'), { gitea: 'not-issued', vikunja: 'valid' });
|
||||
// issue: the file, then the business file's reference, then memory.
|
||||
const issued = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN + '\n', date: DATE });
|
||||
assert.deepEqual(issued, { agent: 'coder', verb: 'issue', service: 'gitea', date: DATE, state: 'valid' });
|
||||
assert.equal(await read('gitea'), 'issued');
|
||||
assert.equal(statSync(c.store('coder', 'gitea')).mode & 0o777, 0o600);
|
||||
assert.deepEqual(c.file().roles.coder.credentials.gitea, { store: true, rotateBy: DATE });
|
||||
let last = c.committed().at(-1);
|
||||
assert.equal(last.actor, 'jason');
|
||||
assert.deepEqual(last.detail, { verb: 'issue', agent: 'coder', kind: ['gitea'], date: DATE });
|
||||
// The same token again is one the bus holds: the secret check refuses it before any write.
|
||||
const count = c.receipts().length;
|
||||
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN, date: DATE }), /credential-leak/);
|
||||
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: 'short', date: DATE }), /credential-format/);
|
||||
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: OTHER, date: '2099-02-30' }), /credential-date/);
|
||||
assert.equal(c.receipts().length, count);
|
||||
// rotate: an issue over an issued entry, used at once.
|
||||
const rotated = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: ROTATED, date: '2099-06-01' });
|
||||
assert.equal(rotated.verb, 'rotate');
|
||||
assert.equal(await read('gitea'), 'rotated');
|
||||
assert.deepEqual(c.file().roles.coder.credentials.gitea, { store: true, rotateBy: '2099-06-01' });
|
||||
assert.deepEqual(c.committed().at(-1).detail, { verb: 'rotate', agent: 'coder', kind: ['gitea'], date: '2099-06-01' });
|
||||
// revoke of a store entry by the tester: memory and file, no document change, a receipt.
|
||||
const bytes = readFileSync(join(c.root, 'businesses/demo.json'), 'utf8');
|
||||
const revoked = c.ask(c.tester, 'credentials.revoke', { agent: 'coder', service: 'gitea', reason: 'test' });
|
||||
assert.deepEqual(revoked, { agent: 'coder', verb: 'revoke', services: [{ service: 'gitea', state: 'revoked' }] });
|
||||
await assert.rejects(read('gitea'), /credential-unavailable/);
|
||||
assert.equal(existsSync(c.store('coder', 'gitea')), false);
|
||||
assert.equal(readFileSync(join(c.root, 'businesses/demo.json'), 'utf8'), bytes);
|
||||
last = c.committed().at(-1);
|
||||
assert.equal(last.actor, 'tester');
|
||||
assert.deepEqual(last.changes, []);
|
||||
assert.deepEqual(last.detail, { verb: 'revoke', agent: 'coder', kind: ['gitea'], reason: 'test' });
|
||||
// revoke of a {file} reference: memory, the outside file kept, the reference becomes store.
|
||||
const outside = c.file().roles.coder.credentials.vikunja.file;
|
||||
c.ask(c.human, 'credentials.revoke', { agent: 'coder', service: 'vikunja', reason: 'moved' });
|
||||
await assert.rejects(read('vikunja'), /credential-unavailable/);
|
||||
assert.equal(existsSync(outside), true);
|
||||
assert.deepEqual(c.file().roles.coder.credentials.vikunja, { store: true, expires: DATE });
|
||||
assert.ok(c.committed().at(-1).changes.some((e) => e.path === 'roles.coder.credentials.vikunja.store' && e.new === true));
|
||||
// delete: every kind revoked, the agent's dir gone, both references store.
|
||||
c.ask(c.human, 'credentials.issue', { agent: 'reviewer', service: 'gitea', token: OTHER, date: DATE });
|
||||
assert.equal(existsSync(c.store('reviewer', 'gitea')), true);
|
||||
const deleted = c.ask(c.human, 'credentials.delete', { agent: 'reviewer', reason: 'fired' });
|
||||
assert.deepEqual(deleted.services.map((e) => e.state), ['revoked', 'revoked']);
|
||||
assert.equal(existsSync(join(c.root, 'secrets', 'agents', 'demo', 'reviewer')), false);
|
||||
assert.deepEqual(c.file().roles.reviewer.credentials, { gitea: { store: true, rotateBy: DATE }, vikunja: { store: true, expires: DATE } });
|
||||
assert.deepEqual(c.committed().at(-1).detail, { verb: 'delete', agent: 'reviewer', kind: ['gitea', 'vikunja'], reason: 'fired' });
|
||||
// The status read never carries a value.
|
||||
assert.deepEqual(c.ask(c.human, 'credentials', {}).map((e) => e.state).sort(), ['revoked', 'revoked', 'revoked', 'revoked']);
|
||||
// T4: no token in any file under the root (receipts, business file, events, logs).
|
||||
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? walk(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
|
||||
for (const path of walk(c.root))
|
||||
for (const token of [TOKEN, ROTATED, OTHER]) assert.equal(readFileSync(path).includes(token), false, path);
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { fixture } from './spec-fixtures.mjs';
|
||||
|
||||
// PRD 1.5 REQ-WAIT-1, REQ-WAIT-2 and REQ-DEC-2 (row 139, #1633).
|
||||
const WAITS = ['spend', 'data.delete', 'network.expose', 'account.create', 'credential.mint', 'message.external',
|
||||
'git.push.protected', 'git.push.remote', 'deploy'];
|
||||
'git.push.protected', 'deploy'];
|
||||
const LEFT = ['git.merge.protected', 'policy.change', 'prd.approve', 'role.revoke'];
|
||||
const cite = f => ({ project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
|
||||
const routed = (f, d) => ({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) });
|
||||
@@ -36,14 +36,10 @@ test('REQ-WAIT-2: a business granting a hard wait at either class is refused; th
|
||||
});
|
||||
|
||||
test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbiter cannot resolve it', async t => {
|
||||
const f = await fixture(t, { roles: { coder: { definition: 'coder',
|
||||
authority: { withinRole: ['message.send', 'record.write', 'git.push.working'], crossRole: [] } } } });
|
||||
// Row 199: a git.push.remote raise binds the task's latest push, so the task has one.
|
||||
f.broker.recordPush(f.coder, { task_ref: 'vikunja:1/2', project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2',
|
||||
commit: 'a'.repeat(40), previous: null });
|
||||
const f = await fixture(t);
|
||||
for (const action of WAITS) {
|
||||
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }]) {
|
||||
const d = await f.raise({ action, ...(action === 'git.push.remote' ? { target: 'vikunja:1/2' } : {}), ...args });
|
||||
const d = await f.raise({ action, ...args });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, `${action} ${JSON.stringify(args)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
"import { MERGE, LandingRefusal, readLanding } from './landing.mjs';",
|
||||
// Row 141 (#1635): the business ID_PATTERN for instance names. The vocabulary is already in
|
||||
// RUNNER_FILES and imports nothing. Row 155 (#1649) reads THREAD_DEFINITION and END_PROTECTED
|
||||
// from it too. Row 199 (#1697) reads the D9c fork parse, forkProblem and forkUrl, from it.
|
||||
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl } from '../../business/src/vocabulary.mjs';",
|
||||
// from it too.
|
||||
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';",
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
// Row 199 (#1697, design D9 steps 1 to 3 and 6, D9b): the broker side of publish. git.push.remote
|
||||
// is a hard wait; its decision binds the task's latest push (pushCommit). checkPublish builds the
|
||||
// agent's fork from the project's upstream and the agent's own Gitea user; recordPublish spends the
|
||||
// decision; publishRefused files the refusal.
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fork } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
|
||||
const TASK = 'vikunja:1/2';
|
||||
const A = 'a'.repeat(40);
|
||||
const B = 'b'.repeat(40);
|
||||
const C = 'c'.repeat(40);
|
||||
const identity = (user) => ({ name: user, email: `${user}@example.com`, gitea: { url: 'https://git.example', user } });
|
||||
const pusher = { withinRole: ['git.push.working', 'message.send'], crossRole: [] };
|
||||
function businesses(upstream = 'https://git.example/mosaic/stack') {
|
||||
return {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'reviewer', delivery: 'reviewer' },
|
||||
projects: { stack: { root: '/nonexistent', remote: upstream }, books: { root: '/nonexistent-books' } },
|
||||
roles: {
|
||||
coder: { authority: pusher, identity: identity('acme-coder'), publish: ['stack'] },
|
||||
second: { authority: pusher, identity: identity('acme-second'), publish: ['stack'] },
|
||||
plain: { authority: pusher },
|
||||
reviewer: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setup(t, upstream) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-publish-'));
|
||||
const store = new Store(root);
|
||||
const b = new Broker({ store, businesses: businesses(upstream) });
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const caps = {};
|
||||
for (const role of ['coder', 'second', 'plain', 'reviewer']) {
|
||||
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
|
||||
b.request(caps[role], { verb: 'role.claim', args: {} });
|
||||
}
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (cap, verb, args) => b.request(cap, { verb, args });
|
||||
const push = (commit, previous = null, role = 'coder', project = 'stack') => {
|
||||
b.checkPush(caps[role], TASK);
|
||||
return b.recordPush(caps[role], { task_ref: TASK, project, branch: 'feat', ref: 'work/vikunja-stack-2', commit, previous });
|
||||
};
|
||||
const raise = (role = 'coder') =>
|
||||
call(caps[role], 'decision.raise', {
|
||||
action: 'git.push.remote', target: TASK, task_ref: TASK, question: 'Publish?', blocking: false, recommendation: 'yes',
|
||||
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }],
|
||||
});
|
||||
const approve = (role = 'coder') => {
|
||||
const d = raise(role);
|
||||
call(human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
return d;
|
||||
};
|
||||
const publish = (decision, role = 'coder', extra = {}) =>
|
||||
b.checkPublish(caps[role], { task_ref: TASK, project: 'stack', branch: 'feat', ...(decision ? { decision: decision.id } : {}), ...extra });
|
||||
const events = (kind) => store.all('SELECT * FROM events WHERE kind=? ORDER BY seq', kind).map((e) => ({ ...e, body: JSON.parse(e.body) }));
|
||||
return { b, store, caps, human, call, push, raise, approve, publish, events };
|
||||
}
|
||||
|
||||
test('a raise with no recorded push refuses no-push; a raise binds the latest push and shows it', (t) => {
|
||||
const f = setup(t);
|
||||
assert.throws(() => f.raise(), /no-push/);
|
||||
f.push(A);
|
||||
const d = f.raise();
|
||||
assert.match(d.question, new RegExp(`commit ${A}\\)$`));
|
||||
const raised = f.events('action.allowed').find((e) => e.body.operation === 'decision.raise');
|
||||
assert.equal(raised.body.pushCommit, A);
|
||||
assert.deepEqual({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) }, { class: 'gated', route_to: 'human' });
|
||||
});
|
||||
|
||||
test('happy path: the fork is the upstream repo under the agent\'s own user, the destination is host-built', (t) => {
|
||||
const f = setup(t);
|
||||
f.push(A);
|
||||
const d = f.approve();
|
||||
const r = f.publish(d);
|
||||
assert.deepEqual({ ...r, ticket: typeof r.ticket }, {
|
||||
ticket: 'string', task_ref: TASK, project: 'stack', fork: 'https://git.example/acme-coder/stack',
|
||||
user: 'acme-coder', email: '[email protected]', source: A, dst: 'refs/heads/acme-coder/feat',
|
||||
});
|
||||
const done = f.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null });
|
||||
assert.deepEqual(done, { task_ref: TASK, fork: r.fork, ref: r.dst, commit: A, previous: null });
|
||||
const allowed = f.events('action.allowed').filter((e) => e.body.action === 'git.push.remote' && e.body.operation !== 'decision.raise');
|
||||
assert.equal(allowed.length, 1);
|
||||
assert.equal(allowed[0].subject, TASK);
|
||||
assert.equal(allowed[0].body.decision, d.id);
|
||||
assert.equal(allowed[0].body.fork, r.fork);
|
||||
// Spent once.
|
||||
assert.throws(() => f.publish(d), /decision-consumed/);
|
||||
assert.throws(() => f.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null }), /publish-unknown/);
|
||||
});
|
||||
|
||||
test('a hard wait: no decision refuses, and a decision for another task refuses', (t) => {
|
||||
const f = setup(t);
|
||||
f.push(A);
|
||||
assert.throws(() => f.publish(null), /decision-required/);
|
||||
const other = f.call(f.caps.coder, 'decision.raise', {
|
||||
action: 'deploy', target: 'vikunja:1/3', question: 'Ship?', blocking: false, recommendation: 'yes',
|
||||
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }],
|
||||
});
|
||||
f.call(f.human, 'decision.resolve', { id: other.id, choice: 'yes' });
|
||||
assert.throws(() => f.publish(other), /decision-mismatch/);
|
||||
const d = f.raise();
|
||||
assert.throws(() => f.publish(d), /decision-not-approved/);
|
||||
});
|
||||
|
||||
test('r4 B4: approve while the latest push is A, push B, publish refuses decision-mismatch', (t) => {
|
||||
const f = setup(t);
|
||||
f.push(A);
|
||||
const d = f.approve();
|
||||
f.push(B, A);
|
||||
assert.throws(() => f.publish(d), /decision-mismatch/);
|
||||
// And a push between checkPublish and recordPublish refuses the record.
|
||||
const f2 = setup(t);
|
||||
f2.push(A);
|
||||
const d2 = f2.approve();
|
||||
const r = f2.publish(d2);
|
||||
f2.push(C, A);
|
||||
assert.throws(() => f2.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null }), /decision-mismatch/);
|
||||
assert.throws(() => f2.b.recordPublish(r.ticket, { ref: r.dst, commit: C, previous: null }), /decision-mismatch/);
|
||||
});
|
||||
|
||||
test('grant and fork refusals', (t) => {
|
||||
const f = setup(t);
|
||||
f.push(A, null, 'plain');
|
||||
// plain has no identity and no grant; its raise still binds the push, the publish refuses.
|
||||
const d = f.approve('plain');
|
||||
assert.throws(() => f.publish(d, 'plain'), /remote-not-granted/);
|
||||
assert.throws(() => f.publish(d, 'plain', { project: 'books' }), /remote-not-granted|decision-mismatch/);
|
||||
// work-not-yours: second publishes a push coder made.
|
||||
const g = setup(t);
|
||||
g.push(A);
|
||||
const d2 = g.approve('second');
|
||||
assert.throws(() => g.publish(d2, 'second'), /work-not-yours/);
|
||||
});
|
||||
|
||||
test('remote-not-fork, compared without case, and remote-host-mismatch, at push', (t) => {
|
||||
for (const [upstream, code] of [
|
||||
['https://git.example/acme-coder/stack', /remote-not-fork/],
|
||||
['https://git.example/ACME-Coder/stack', /remote-not-fork/],
|
||||
['https://other.example/mosaic/stack', /remote-host-mismatch/],
|
||||
['https://git.example:3000/mosaic/stack', /remote-host-mismatch/],
|
||||
]) {
|
||||
const f = setup(t, upstream);
|
||||
f.push(A);
|
||||
const d = f.approve();
|
||||
assert.throws(() => f.publish(d), code, upstream);
|
||||
}
|
||||
});
|
||||
|
||||
test('two agents granted one project each get their own fork', (t) => {
|
||||
const f = setup(t);
|
||||
f.push(A);
|
||||
const r1 = f.publish(f.approve());
|
||||
assert.equal(r1.fork, 'https://git.example/acme-coder/stack');
|
||||
const g = setup(t);
|
||||
g.push(A, null, 'second');
|
||||
const r2 = g.publish(g.approve('second'), 'second');
|
||||
assert.equal(r2.fork, 'https://git.example/acme-second/stack');
|
||||
assert.equal(r2.dst, 'refs/heads/acme-second/feat');
|
||||
});
|
||||
|
||||
test('the request cannot name the fork, the user or the destination', (t) => {
|
||||
const f = setup(t);
|
||||
f.push(A);
|
||||
const d = f.approve();
|
||||
for (const extra of [{ fork: 'https://git.example/x/stack' }, { dst: 'refs/heads/main' }, { user: 'x' }, { remote: 'https://x' }])
|
||||
assert.throws(() => f.publish(d, 'coder', extra), /invalid-request/);
|
||||
});
|
||||
|
||||
test('publishRefused files the code on the task\'s trail and drops the ticket', (t) => {
|
||||
const f = setup(t);
|
||||
f.push(A);
|
||||
const r = f.publish(f.approve());
|
||||
assert.equal(f.b.publishRefused(r.ticket, { code: 'remote-unrelated' }), 'remote-unrelated');
|
||||
const refused = f.events('action.refused').filter((e) => e.subject === TASK);
|
||||
assert.deepEqual(refused.at(-1).body, { code: 'remote-unrelated' });
|
||||
assert.throws(() => f.b.publishRefused(r.ticket, { code: 'x' }), /publish-unknown/);
|
||||
});
|
||||
|
||||
test('git.push.remote is in neither ROUTED list and no shipped role grants it', async () => {
|
||||
const { ACTIONS, HARD_WAITS } = await import('../src/broker.mjs');
|
||||
const vocabulary = await import('../../business/src/vocabulary.mjs');
|
||||
assert.ok(ACTIONS.includes('git.push.remote') && HARD_WAITS.includes('git.push.remote'));
|
||||
assert.ok(!vocabulary.ROUTED.includes('git.push.remote'));
|
||||
const { readdirSync, readFileSync } = await import('node:fs');
|
||||
const dir = new URL('../../../roles/', import.meta.url);
|
||||
for (const name of readdirSync(dir).filter((n) => n.endsWith('.json')))
|
||||
assert.ok(!readFileSync(new URL(name, dir), 'utf8').includes('git.push.remote'), name);
|
||||
});
|
||||
|
||||
// D1 and D9b through the real startBroker: the store is <configDir>/secrets/agents, and the gitea
|
||||
// token is used only under an open publish ticket, keyed from the agent checkPublish checked.
|
||||
test('runtime: withGitea keys from the open ticket only, and the store root is the organization dir', async (t) => {
|
||||
const { startBroker } = await import('../src/runtime.mjs');
|
||||
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
|
||||
const { chmodSync, mkdirSync } = await import('node:fs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-publish-rt-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
// The runtime reads the harness switch from the organization file as it stands.
|
||||
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const demo = businesses().demo;
|
||||
demo.roles.coder.credentials = { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: '2099-01-01' } };
|
||||
const organization = { dir: root, rolesDir, system: systemFor(root) };
|
||||
// No organization, no store root: a store reference refuses the start.
|
||||
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo } }), /credential-reference/);
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization });
|
||||
t.after(() => bus.close());
|
||||
assert.deepEqual(bus.credentialStatus('demo', 'coder'), [{ service: 'gitea', date: '2099-01-01', state: 'not-issued' }]);
|
||||
const coder = bus.broker.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
bus.broker.request(coder, { verb: 'role.claim', args: {} });
|
||||
bus.checkPush(coder, TASK);
|
||||
bus.recordPush(coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2', commit: A, previous: null });
|
||||
const d = bus.broker.request(coder, { verb: 'decision.raise', args: {
|
||||
action: 'git.push.remote', target: TASK, task_ref: TASK, question: 'Publish?', blocking: false, recommendation: 'yes',
|
||||
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }] } });
|
||||
bus.broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
|
||||
assert.throws(() => bus.checkPublish(coder, { task_ref: TASK, project: 'stack', branch: 'feat' }), /decision-required/);
|
||||
const ticket = bus.checkPublish(coder, { task_ref: TASK, project: 'stack', branch: 'feat', decision: d.id }).ticket;
|
||||
await assert.rejects(bus.withGitea(ticket, async () => 1), /credential-unavailable/);
|
||||
bus.credentials.issue('demo/coder', 'gitea', 'fake_publish_token_0123456789', '2099-01-01');
|
||||
assert.equal(await bus.withGitea(ticket, async (token) => token === 'fake_publish_token_0123456789'), true);
|
||||
await assert.rejects(bus.withGitea(ticket, async (token) => ({ echo: token })), /credential-leak/);
|
||||
await assert.rejects(bus.withGitea('not-a-ticket', async () => 1), /publish-unknown/);
|
||||
await assert.rejects(bus.withGitea(coder, async () => 1), /publish-unknown/);
|
||||
bus.recordPublish(ticket, { ref: 'refs/heads/acme-coder/feat', commit: A, previous: null });
|
||||
await assert.rejects(bus.withGitea(ticket, async () => 1), /publish-unknown/);
|
||||
});
|
||||
|
||||
// Row 199 (D9b, deviation 11): the bus process answers the socket's five publish ops from their own
|
||||
// launchOp lines. A LAUNCH_OPS name without one would get launch-op-refused. A boot config that
|
||||
// carries publishTls still boots; process.mjs drops it before startBroker.
|
||||
test('the bus process answers the publish ops from their own lines, not launch-op-refused', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-publish-ops-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const child = fork(new URL('../src/process.mjs', import.meta.url), [], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
|
||||
t.after(() => {
|
||||
if (child.exitCode === null) child.kill('SIGKILL');
|
||||
});
|
||||
const ask = async (m) => {
|
||||
const reply = once(child, 'message');
|
||||
child.send(m);
|
||||
return (await reply)[0];
|
||||
};
|
||||
const config = { dataRoot: root, businesses: businesses(), launches: [], readers: [], publishTls: ['-c', 'http.sslCAInfo=/nonexistent'] };
|
||||
assert.equal((await ask({ op: 'boot', config })).ok, true);
|
||||
const unknown = 'not-a-ticket';
|
||||
assert.deepEqual(await ask({ op: 'publishPush', ticket: unknown, id: 1 }), { ok: false, error: 'publish-unknown', id: 1 });
|
||||
assert.deepEqual(await ask({ op: 'publishResult', ticket: unknown, id: 2 }), { ok: false, error: 'publish-unknown', id: 2 });
|
||||
assert.deepEqual(await ask({ op: 'recordPublish', ticket: unknown, pushed: { ref: 'refs/heads/x/y', commit: A, previous: null }, id: 3 }), { ok: false, error: 'publish-unknown', id: 3 });
|
||||
assert.deepEqual(await ask({ op: 'publishRefused', ticket: unknown, args: { code: 'x' }, id: 4 }), { ok: false, error: 'publish-unknown', id: 4 });
|
||||
assert.deepEqual(await ask({ op: 'checkPublish', cap: 'not-a-cap', args: { task_ref: TASK, project: 'stack', branch: 'feat' }, id: 5 }), { ok: false, error: 'unauthenticated', id: 5 });
|
||||
const exit = once(child, 'exit');
|
||||
child.send({ op: 'close' });
|
||||
assert.equal((await exit)[0], 0);
|
||||
});
|
||||
@@ -10,7 +10,6 @@ import { checkVars } from "./vars.mjs";
|
||||
import { parseCredentialRef } from "./credentials.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { checkPlugins } from "../../plugins/src/kinds.mjs";
|
||||
import { forkProblem, giteaName, parseGiteaUrl } from "./vocabulary.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
} from "./util.mjs";
|
||||
@@ -41,7 +40,7 @@ function checkBot(tracker, where) {
|
||||
return { bot: tracker.bot, botId: tracker.botId };
|
||||
}
|
||||
|
||||
function checkCredentialMap(map, services, where, store = null) {
|
||||
function checkCredentialMap(map, services, where) {
|
||||
requireObject(map, where);
|
||||
const names = Object.keys(map).sort();
|
||||
const wanted = [...services].sort();
|
||||
@@ -49,37 +48,10 @@ function checkCredentialMap(map, services, where, store = null) {
|
||||
refuse(`${where} must reference exactly the services its role definition needs (${wanted.join(", ") || "none"}; got ${names.join(", ") || "none"})`);
|
||||
}
|
||||
const out = {};
|
||||
for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`, { store });
|
||||
for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Row 199 (#1697, design D8): the agent's own git identity. The email is the one its Gitea user
|
||||
// has, so Gitea links the commits to that user; gitea.url is the instance its pushes go to.
|
||||
const EMAIL = /^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,190}\.[A-Za-z]{2,24}$/;
|
||||
const NAME = /^[^\0\n\r<>]{1,100}$/;
|
||||
|
||||
// Row 199 (design D9c): the parse is vocabulary.mjs's, where the broker reads it; this wraps it with
|
||||
// the loader's refusals. Returns {url, owner, repo}.
|
||||
export function httpsUrl(value, where, { repo = false } = {}) {
|
||||
const parsed = parseGiteaUrl(value, { repo });
|
||||
if (parsed.problem === "form") refuse(`${where} must be a normalized https URL with no user, query or fragment`);
|
||||
if (parsed.problem === "instance") refuse(`${where} must name the Gitea instance only, https://HOST[:PORT]`);
|
||||
if (parsed.problem === "repository") refuse(`${where} must name a repository, https://HOST/OWNER/REPO`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function checkIdentity(identity, where) {
|
||||
requireObject(identity, where);
|
||||
rejectUnknownKeys(identity, ["name", "email", "gitea"], where);
|
||||
if (typeof identity.name !== "string" || !NAME.test(identity.name)) refuse(`${where}.name must be 1 to 100 characters with no <, > or line break`);
|
||||
if (typeof identity.email !== "string" || !EMAIL.test(identity.email)) refuse(`${where}.email must be an email address`);
|
||||
requireObject(identity.gitea, `${where}.gitea`);
|
||||
rejectUnknownKeys(identity.gitea, ["url", "user"], `${where}.gitea`);
|
||||
httpsUrl(identity.gitea.url, `${where}.gitea.url`);
|
||||
if (!giteaName(identity.gitea.user)) refuse(`${where}.gitea.user must be a Gitea user name`);
|
||||
return { name: identity.name, email: identity.email, gitea: { url: identity.gitea.url, user: identity.gitea.user } };
|
||||
}
|
||||
|
||||
function checkTracker(tracker, file) {
|
||||
const where = `${file} tracker`;
|
||||
requireObject(tracker, where);
|
||||
@@ -102,7 +74,7 @@ function checkTracker(tracker, file) {
|
||||
return { sync, labels };
|
||||
}
|
||||
|
||||
function checkRoles(roles, file, rolesDir, business) {
|
||||
function checkRoles(roles, file, rolesDir) {
|
||||
const where = `${file} roles`;
|
||||
requireObject(roles, where);
|
||||
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
|
||||
@@ -114,7 +86,7 @@ function checkRoles(roles, file, rolesDir, business) {
|
||||
requireId(instance, `${where} instance name`);
|
||||
const at = `${where}.${instance}`;
|
||||
requireObject(entry, at);
|
||||
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish"], at);
|
||||
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials"], at);
|
||||
requireId(entry.definition, `${at}.definition`);
|
||||
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
|
||||
definitions[entry.definition] = definition;
|
||||
@@ -136,18 +108,8 @@ function checkRoles(roles, file, rolesDir, business) {
|
||||
holder: entry.holder === undefined ? null : requireId(entry.holder, `${at}.holder`),
|
||||
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
|
||||
tracker,
|
||||
credentials: checkCredentialMap(entry.credentials ?? {}, services, `${at}.credentials`,
|
||||
{ configDir: dirname(dirname(file)), business, agent: instance }),
|
||||
credentials: checkCredentialMap(entry.credentials ?? {}, services, `${at}.credentials`),
|
||||
};
|
||||
// Row 199 (#1697, design D8, D9 step 2): identity and publish are optional, so today's files
|
||||
// load unchanged. publish needs both an identity and a gitea credential; the project ids it
|
||||
// names are checked against projects once those are read.
|
||||
if (entry.identity !== undefined) out[instance].identity = checkIdentity(entry.identity, `${at}.identity`);
|
||||
if (entry.publish !== undefined) {
|
||||
// N10: one message for both, naming the grant.
|
||||
if (!out[instance].identity || !services.includes("gitea")) refuse(`${at}.publish needs identity and a gitea definition`);
|
||||
out[instance].publish = requireDistinctList(entry.publish, `${at}.publish`, (p) => requireId(p, `${at}.publish entry`), { nonEmpty: true });
|
||||
}
|
||||
}
|
||||
return { roles: out, definitions };
|
||||
}
|
||||
@@ -197,7 +159,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
|
||||
}
|
||||
const human = requireId(document.human, "business human");
|
||||
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
|
||||
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id);
|
||||
const { roles, definitions } = checkRoles(document.roles, file, rolesDir);
|
||||
|
||||
requireObject(document.arbiters, `${file} arbiters`);
|
||||
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
|
||||
@@ -221,7 +183,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
|
||||
for (const [id, entry] of Object.entries(document.projects)) {
|
||||
requireId(id, `${file} project id`);
|
||||
requireObject(entry, `${file} projects.${id}`);
|
||||
rejectUnknownKeys(entry, ["root", "repo", "remote"], `${file} projects.${id}`);
|
||||
rejectUnknownKeys(entry, ["root", "repo"], `${file} projects.${id}`);
|
||||
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
|
||||
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
|
||||
}
|
||||
@@ -229,18 +191,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
|
||||
normalize(entry.repo) !== entry.repo || entry.repo.includes("\0"))) {
|
||||
refuse(`${file} projects.${id}.repo must be a normalized absolute path`);
|
||||
}
|
||||
// Row 199 (#1697, design D9 step 2): the upstream; a granted agent's work goes to its fork.
|
||||
if (Object.hasOwn(entry, "remote")) httpsUrl(entry.remote, `${file} projects.${id}.remote`, { repo: true });
|
||||
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
|
||||
...(Object.hasOwn(entry, "remote") ? { remote: entry.remote } : {}) };
|
||||
}
|
||||
for (const [instance, role] of Object.entries(roles)) {
|
||||
for (const project of role.publish ?? []) {
|
||||
if (!Object.hasOwn(projects, project)) refuse(`${file} roles.${instance}.publish names ${project}, which the business file doesn't declare`);
|
||||
if (!projects[project].remote) refuse(`${file} roles.${instance}.publish names ${project}, which has no "remote"`);
|
||||
const problem = forkProblem(projects[project].remote, role.identity);
|
||||
if (problem) refuse(`${file} roles.${instance}.publish names ${project}: ${problem}`);
|
||||
}
|
||||
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}) };
|
||||
}
|
||||
|
||||
const tracker = checkTracker(document.tracker, file);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// lstat and realpath only; nothing in this package opens a token file.
|
||||
|
||||
import { lstatSync, realpathSync } from "node:fs";
|
||||
import { isAbsolute, join, normalize, relative, sep } from "node:path";
|
||||
import { isAbsolute, normalize, relative, sep } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { SERVICES } from "./vocabulary.mjs";
|
||||
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
|
||||
@@ -14,35 +14,21 @@ const WARN_DAYS = 7;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Shape check for one reference. Returns a frozen { service, file | env,
|
||||
// rotateBy | expires }. Row 199 (#1697, design D1 and D2): `store` is the
|
||||
// file at <configDir>/secrets/agents/<business>/<agent>/<service>, derived
|
||||
// here and never written, so an entry can't name another agent's file. A
|
||||
// reference with no agent (the tracker's sync bot) passes store = null and
|
||||
// can't use the form. A `file` reference into the agents' store must be the
|
||||
// agent's own path (`credential-not-yours`).
|
||||
export function parseCredentialRef(ref, service, where, { store = null } = {}) {
|
||||
// rotateBy | expires }.
|
||||
export function parseCredentialRef(ref, service, where) {
|
||||
if (!SERVICES.includes(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
|
||||
requireObject(ref, where);
|
||||
const dateKey = DATE_KEY[service];
|
||||
rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where);
|
||||
rejectUnknownKeys(ref, ["file", "env", dateKey], where);
|
||||
const hasFile = ref.file !== undefined;
|
||||
const hasEnv = ref.env !== undefined;
|
||||
const hasStore = ref.store !== undefined;
|
||||
if (Number(hasFile) + Number(hasEnv) + Number(hasStore) !== 1) refuse(`${where} must name exactly one of "file", "env" or "store"`);
|
||||
if (hasFile === hasEnv) refuse(`${where} must name exactly one of "file" or "env"`);
|
||||
const out = { service };
|
||||
if (hasStore) {
|
||||
if (ref.store !== true) refuse(`${where}.store must be true`);
|
||||
if (!store) refuse(`${where}: a "store" reference needs an agent`);
|
||||
out.store = true;
|
||||
out.file = storePath(store, service);
|
||||
} else if (hasFile) {
|
||||
if (hasFile) {
|
||||
if (typeof ref.file !== "string" || !isAbsolute(ref.file) || normalize(ref.file) !== ref.file || ref.file.includes("\0")) {
|
||||
refuse(`${where}.file must be a normalized absolute path`);
|
||||
}
|
||||
out.file = ref.file;
|
||||
if (store && inside(join(store.configDir, "secrets", "agents"), ref.file) && ref.file !== storePath(store, service)) {
|
||||
refuse(`${where}.file credential-not-yours: ${ref.file} is in the agents' store but not this agent's path`);
|
||||
}
|
||||
} else {
|
||||
if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`);
|
||||
out.env = ref.env;
|
||||
@@ -52,11 +38,6 @@ export function parseCredentialRef(ref, service, where, { store = null } = {}) {
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
// The store path for one agent and service (design D1).
|
||||
export function storePath({ configDir, business, agent }, service) {
|
||||
return join(configDir, "secrets", "agents", business, agent, service);
|
||||
}
|
||||
|
||||
function inside(root, path) {
|
||||
const rel = relative(root, path);
|
||||
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
||||
@@ -74,10 +55,8 @@ export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(),
|
||||
let stat = null;
|
||||
try {
|
||||
stat = lstatSync(ref.file);
|
||||
} catch (e) {
|
||||
// Row 199 (design D3a): a store reference with no file is an agent not issued yet.
|
||||
if (ref.store && e.code === "ENOENT") warnings.push(`${label}: not issued`);
|
||||
else problems.push(`${label}: not found`);
|
||||
} catch {
|
||||
problems.push(`${label}: not found`);
|
||||
}
|
||||
if (stat) {
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`);
|
||||
|
||||
@@ -174,9 +174,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Row 199 (#1697, design section 6): `detail` says what a credential verb did (verb, agent, kind,
|
||||
// date, reason), never a value; the other setters pass none.
|
||||
function write(id, document, { revision, actor, detail }) {
|
||||
function write(id, document, { revision, actor }) {
|
||||
requireId(actor, "authenticated actor");
|
||||
requireId(id, "organization id");
|
||||
return withLock(id, ({ home, parent }) => {
|
||||
@@ -203,7 +201,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
syncDir(tx);
|
||||
const event = {
|
||||
transaction, actor, business: id, before, after,
|
||||
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
|
||||
changes: changes(old, written), at: new Date().toISOString(),
|
||||
};
|
||||
log(id, { ...event, state: "prepared" });
|
||||
syncDir(home);
|
||||
@@ -221,22 +219,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
});
|
||||
}
|
||||
|
||||
// Row 199: a receipt with no document change, for a credential verb that changes only the agents'
|
||||
// store (a revoke or delete of store references). Committed at once; there is nothing to recover.
|
||||
function record(id, { actor, detail }) {
|
||||
requireId(actor, "authenticated actor");
|
||||
requireId(id, "organization id");
|
||||
return withLock(id, () => {
|
||||
recover(id);
|
||||
const hash = sha256(regular(businessFilePath(id, dir)));
|
||||
const transaction = randomUUID();
|
||||
log(id, {
|
||||
transaction, actor, business: id, before: hash, after: hash, changes: [], detail,
|
||||
at: new Date().toISOString(), state: "committed",
|
||||
});
|
||||
return { revision: hash, transaction };
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, write, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, write, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
|
||||
@@ -20,9 +20,6 @@ export const ACTIONS = Object.freeze([
|
||||
"git.push.working",
|
||||
"git.push.protected",
|
||||
"git.merge.protected",
|
||||
// Row 199 (#1697, mos-1027 Q-M2): the host pushes an agent's recorded work commit to the fork its
|
||||
// business grants it. A hard wait under REQ-WAIT-3 until a PRD version routes it.
|
||||
"git.push.remote",
|
||||
"review.request",
|
||||
"review.verdict",
|
||||
"record.write",
|
||||
@@ -86,8 +83,8 @@ export const ROUTED = Object.freeze([
|
||||
// The hard waits (PRD 1.5 REQ-WAIT-1): always gated, always the human's,
|
||||
// cited spec or not. A role file may not list these under withinRole or
|
||||
// crossRole (REQ-WAIT-2). Today this is spend, data.delete, network.expose,
|
||||
// account.create, credential.mint, message.external, git.push.protected,
|
||||
// git.push.remote and deploy; a test pins the list.
|
||||
// account.create, credential.mint, message.external, git.push.protected and
|
||||
// deploy; a test pins the list.
|
||||
export const GATED_ONLY = Object.freeze(ACTIONS.filter((action) => !ROUTED.includes(action)));
|
||||
|
||||
// pi's documented built-in tools; the same list scripts/mosaic-task.mjs
|
||||
@@ -136,50 +133,3 @@ export const THREAD_DEFINITION = "assistant";
|
||||
// the human resolved through the CLI, may end or revoke. Row 155 moves the list here, and no
|
||||
// organization write may give an instance one of them.
|
||||
export const END_PROTECTED = Object.freeze([THREAD_DEFINITION]);
|
||||
|
||||
// Row 199 (#1697, design D9c): the Gitea URL parse. It lives here because the broker reads it and
|
||||
// this file imports nothing. Gitea's name rule for users and repositories: letters, digits, "_", "."
|
||||
// and "-", not "." or "..".
|
||||
export const giteaName = (name) =>
|
||||
typeof name === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(name) && name !== "." && name !== "..";
|
||||
|
||||
// An https URL that is already its own normalized form, so a dot segment, an encoded dot, userinfo, a
|
||||
// query, a fragment, an upper-case host or an explicit :443 each fail, and the path checked is the
|
||||
// path used. With repo, the path is exactly /OWNER/REPO (one ".git" stripped); without, it is "/".
|
||||
// Returns {url, owner, repo}, or {problem} naming the rule that failed: form, instance or repository.
|
||||
export function parseGiteaUrl(value, { repo = false } = {}) {
|
||||
let url = null;
|
||||
try {
|
||||
url = typeof value === "string" ? new URL(value) : null;
|
||||
} catch {
|
||||
url = null;
|
||||
}
|
||||
const normal = url && (repo ? [url.origin + url.pathname] : [url.origin, url.origin + "/"]);
|
||||
if (!url || url.protocol !== "https:" || !normal.includes(value)) return { problem: "form" };
|
||||
if (!repo) return url.pathname === "/" ? { url } : { problem: "instance" };
|
||||
const segments = url.pathname.split("/").slice(1);
|
||||
const name = segments[1]?.endsWith(".git") ? segments[1].slice(0, -4) : segments[1];
|
||||
if (segments.length !== 2 || !giteaName(segments[0]) || !giteaName(name)) return { problem: "repository" };
|
||||
return { url, owner: segments[0], repo: name };
|
||||
}
|
||||
|
||||
// Design D9 step 2: the agent's fork of an upstream, https://HOST[:PORT]/<user>/REPO, or null for an
|
||||
// upstream that doesn't parse. The owner is always the agent's own Gitea user; nothing else names it.
|
||||
// URL.host drops a default port, so 443 counts the same on both sides.
|
||||
export function forkUrl(upstream, user) {
|
||||
const { url, repo } = parseGiteaUrl(upstream, { repo: true });
|
||||
return url ? `https://${url.host}/${user}/${repo}` : null;
|
||||
}
|
||||
|
||||
// The refusal code for publishing `upstream` as an agent with `identity`, or null. Gitea compares
|
||||
// names without case (lower_name), so the owner check does too. An upstream or gitea.url that doesn't
|
||||
// parse is remote-not-granted: the business loader refuses both, so only a hand-built business gets
|
||||
// here.
|
||||
export function forkProblem(upstream, identity) {
|
||||
const { url, owner } = parseGiteaUrl(upstream, { repo: true });
|
||||
const instance = parseGiteaUrl(identity?.gitea?.url).url;
|
||||
if (!url || !instance || typeof identity.gitea.user !== "string") return "remote-not-granted";
|
||||
if (url.host !== instance.host) return "remote-host-mismatch";
|
||||
if (owner.toLowerCase() === identity.gitea.user.toLowerCase()) return "remote-not-fork";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
// Row 199 (#1697): an agent's own git identity, its publish grant, the project's upstream and the
|
||||
// credential store reference. Design D1, D8, D9 step 2 and D9c.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import { BusinessError, parseCredentialRef, validateBusinessDocument } from "../src/index.mjs";
|
||||
import { forkProblem, forkUrl } from "../src/vocabulary.mjs";
|
||||
import { businessDoc, FAR_DATE, REPO_ROLES, tmp } from "./helpers.mjs";
|
||||
|
||||
function refuses(fn, pattern) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
|
||||
assert.equal(error.exitCode, 2);
|
||||
assert.match(error.message, pattern);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const IDENTITY = { name: "Coder", email: "[email protected]", gitea: { url: "https://git.example", user: "acme-coder" } };
|
||||
|
||||
// The fixture with coder granted publish on stack, whose upstream is mosaic/stack.
|
||||
function check(mutate) {
|
||||
const root = tmp();
|
||||
const doc = businessDoc(root);
|
||||
doc.projects.stack.remote = "https://git.example/mosaic/stack";
|
||||
doc.roles.coder.identity = structuredClone(IDENTITY);
|
||||
doc.roles.coder.publish = ["stack"];
|
||||
mutate?.(doc, root);
|
||||
return validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
|
||||
}
|
||||
|
||||
test("today's files load unchanged: identity, publish and remote are optional", () => {
|
||||
const root = tmp();
|
||||
const business = validateBusinessDocument(businessDoc(root), join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
|
||||
assert.equal(business.roles.coder.identity, undefined);
|
||||
assert.equal(business.roles.coder.publish, undefined);
|
||||
assert.equal(business.projects.stack.remote, undefined);
|
||||
});
|
||||
|
||||
test("a granted agent loads with its identity, grant and the project's upstream", () => {
|
||||
const business = check();
|
||||
assert.deepEqual({ ...business.roles.coder.identity, gitea: { ...business.roles.coder.identity.gitea } }, IDENTITY);
|
||||
assert.deepEqual([...business.roles.coder.publish], ["stack"]);
|
||||
assert.equal(business.projects.stack.remote, "https://git.example/mosaic/stack");
|
||||
});
|
||||
|
||||
test("N10: publish needs identity and a gitea definition, and the error names the grant", () => {
|
||||
refuses(() => check((d) => { delete d.roles.coder.identity; }), /roles\.coder\.publish needs identity and a gitea definition/);
|
||||
refuses(() => check((d) => { d.roles.chief.identity = structuredClone(IDENTITY); d.roles.chief.publish = ["stack"]; }),
|
||||
/roles\.chief\.publish needs identity and a gitea definition/);
|
||||
});
|
||||
|
||||
test("publish names declared projects that have a remote, each once", () => {
|
||||
refuses(() => check((d) => { d.roles.coder.publish = ["books"]; }), /publish names books, which the business file doesn't declare/);
|
||||
refuses(() => check((d) => { delete d.projects.stack.remote; }), /publish names stack, which has no "remote"/);
|
||||
refuses(() => check((d) => { d.roles.coder.publish = []; }), /publish/);
|
||||
refuses(() => check((d) => { d.roles.coder.publish = ["stack", "stack"]; }), /publish/);
|
||||
});
|
||||
|
||||
test("identity fields are checked", () => {
|
||||
refuses(() => check((d) => { d.roles.coder.identity.email = "coder"; }), /identity\.email/);
|
||||
refuses(() => check((d) => { d.roles.coder.identity.name = "a\nb"; }), /identity\.name/);
|
||||
refuses(() => check((d) => { d.roles.coder.identity.extra = 1; }), /identity/);
|
||||
refuses(() => check((d) => { d.roles.coder.identity.gitea.user = ".."; }), /gitea\.user/);
|
||||
refuses(() => check((d) => { d.roles.coder.identity.gitea.user = "a/b"; }), /gitea\.user/);
|
||||
});
|
||||
|
||||
test("D9c: the upstream must be its own normalized form, exactly /OWNER/REPO", () => {
|
||||
const bad = {
|
||||
"a dot segment": "https://git.example/acme-coder/../mosaic/stack",
|
||||
"an encoded dot segment": "https://git.example/acme-coder/%2e%2e/mosaic/stack",
|
||||
"an extra segment": "https://git.example/mosaic/stack/more",
|
||||
"a missing segment": "https://git.example/stack",
|
||||
"a trailing slash": "https://git.example/mosaic/stack/",
|
||||
userinfo: "https://u:[email protected]/mosaic/stack",
|
||||
"a query": "https://git.example/mosaic/stack?x=1",
|
||||
"a fragment": "https://git.example/mosaic/stack#f",
|
||||
"an explicit :443": "https://git.example:443/mosaic/stack",
|
||||
"an upper-case host": "https://GIT.example/mosaic/stack",
|
||||
"a backslash": "https://git.example\\mosaic\\stack",
|
||||
"a name outside Gitea's rule": "https://git.example/mosaic/st%41ck",
|
||||
"a dot-dot repo": "https://git.example/mosaic/...git",
|
||||
http: "http://git.example/mosaic/stack",
|
||||
};
|
||||
for (const [what, remote] of Object.entries(bad)) {
|
||||
assert.throws(() => check((d) => { d.projects.stack.remote = remote; }), BusinessError, what);
|
||||
}
|
||||
});
|
||||
|
||||
test("D9c: one .git is stripped, and the fork is built from the stripped name", () => {
|
||||
assert.equal(check((d) => { d.projects.stack.remote = "https://git.example/mosaic/stack.git"; }).projects.stack.remote,
|
||||
"https://git.example/mosaic/stack.git");
|
||||
assert.equal(forkUrl("https://git.example/mosaic/stack.git", "acme-coder"), "https://git.example/acme-coder/stack");
|
||||
assert.equal(forkUrl("https://git.example:3000/mosaic/stack", "acme-coder"), "https://git.example:3000/acme-coder/stack");
|
||||
});
|
||||
|
||||
test("D9c: gitea.url names the instance only", () => {
|
||||
for (const url of ["https://git.example/gitea", "https://git.example/gitea/", "https://git.example/?x", "https://git.example:443", "http://git.example"]) {
|
||||
assert.throws(() => check((d) => { d.roles.coder.identity.gitea.url = url; }), BusinessError, url);
|
||||
}
|
||||
check((d) => { d.roles.coder.identity.gitea.url = "https://git.example/"; });
|
||||
});
|
||||
|
||||
test("the upstream's host and port must be the agent's Gitea's", () => {
|
||||
refuses(() => check((d) => { d.projects.stack.remote = "https://other.example/mosaic/stack"; }), /remote-host-mismatch/);
|
||||
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example:3000/mosaic/stack"; }), /remote-host-mismatch/);
|
||||
assert.equal(forkProblem("https://git.example/mosaic/stack", { gitea: { url: "https://git.example/", user: "u" } }), null);
|
||||
});
|
||||
|
||||
test("remote-not-fork: an upstream the agent's own user owns, compared without case", () => {
|
||||
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example/acme-coder/stack"; }), /remote-not-fork/);
|
||||
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example/ACME-Coder/stack"; }), /remote-not-fork/);
|
||||
});
|
||||
|
||||
test("store references: own path only (design D1, D3a)", () => {
|
||||
const store = { configDir: "/cfg", business: "acme", agent: "coder" };
|
||||
const ref = parseCredentialRef({ store: true, rotateBy: FAR_DATE }, "gitea", "r", { store });
|
||||
assert.equal(ref.file, "/cfg/secrets/agents/acme/coder/gitea");
|
||||
assert.equal(ref.store, true);
|
||||
refuses(() => parseCredentialRef({ store: true, rotateBy: FAR_DATE }, "gitea", "r"), /store/);
|
||||
refuses(() => parseCredentialRef({ store: true, file: "/a", rotateBy: FAR_DATE }, "gitea", "r", { store }), /exactly one/);
|
||||
refuses(() => parseCredentialRef({ file: "/cfg/secrets/agents/acme/pm/gitea", rotateBy: FAR_DATE }, "gitea", "r", { store }),
|
||||
/credential-not-yours/);
|
||||
assert.equal(parseCredentialRef({ file: "/cfg/secrets/agents/acme/coder/gitea", rotateBy: FAR_DATE }, "gitea", "r", { store }).file,
|
||||
"/cfg/secrets/agents/acme/coder/gitea");
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { validateRoleDocument } from "../src/index.mjs";
|
||||
import { tmp } from "./helpers.mjs";
|
||||
|
||||
const WAITS = ["spend", "data.delete", "network.expose", "account.create", "credential.mint", "message.external",
|
||||
"git.push.protected", "deploy", "git.push.remote"];
|
||||
"git.push.protected", "deploy"];
|
||||
const LEFT = ["git.merge.protected", "policy.change", "prd.approve", "role.revoke"];
|
||||
const role = (authority) => {
|
||||
const dir = tmp();
|
||||
|
||||
@@ -1009,19 +1009,6 @@ names no one; the restart itself needs Mos's go or Jason's hand. A vars or launc
|
||||
prints `no restart`, because the launcher reads it at its next launch check.
|
||||
The Console Settings page has the same read and write (`packages/webui`).
|
||||
|
||||
## credentials (row 199)
|
||||
|
||||
`mosaic credentials [status [AGENT]] [--json] [--tester]` lists each agent's
|
||||
credential kinds with date and state, never a value. `issue AGENT gitea
|
||||
--rotate-by DATE` and `issue AGENT vikunja --expires DATE` read the token on
|
||||
stdin (pipe it, or paste it and end input) and send it once to the bus's
|
||||
`credentials.issue`; it is never an argument. `rotate` is the same over an
|
||||
entry the bus holds now, and refuses before reading stdin when there is none.
|
||||
`revoke AGENT [KIND] --reason TEXT` and `delete AGENT --reason TEXT` drop the
|
||||
entries; `delete` refuses `agent-running` while the agent holds a claim. With
|
||||
`--tester` only `status` and `revoke` run. After a rotate or revoke, delete
|
||||
the old token in Gitea or Vikunja yourself; the bus only forgets it.
|
||||
|
||||
## Token usage
|
||||
|
||||
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
|
||||
|
||||
@@ -17,7 +17,6 @@ import { projectBusCommand, projectCommand, PROJECT_BUS_USAGE, PROJECT_USAGE } f
|
||||
import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TALK_USAGE, THREAD_USAGE, THREADS_USAGE } from "./thread.mjs";
|
||||
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
|
||||
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
|
||||
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
|
||||
import { instancesCommand, INSTANCES_USAGE } from "./instances.mjs";
|
||||
import { createChannelCaller } from "../../plugins/src/channel.mjs";
|
||||
import { PluginError } from "../../plugins/src/kinds.mjs";
|
||||
@@ -39,7 +38,6 @@ export const USAGE = `usage:
|
||||
${USAGE_USAGE}
|
||||
${LIMITS_USAGE}
|
||||
${PLUGINS_USAGE}
|
||||
${CREDENTIALS_USAGE}
|
||||
${INSTANCES_USAGE}
|
||||
${PROJECT_USAGE}
|
||||
${PROJECT_BUS_USAGE}
|
||||
@@ -578,8 +576,6 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
|
||||
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
|
||||
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 199 (#1697): issue and rotate read the token on stdin.
|
||||
if (verb === "credentials") return credentialsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 165 (#1662): scripts/mosaic sends only `business instances` and `business instance` here.
|
||||
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
if (verb === "talk") return talk(rest, io, deps);
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
// `mosaic credentials` (row 199, #1697, design section 6): an agent's per-agent credentials through
|
||||
// the broker's credentials, credentials.issue, credentials.revoke and credentials.delete. Every verb
|
||||
// is the human's; with --tester (Q173) only status and revoke. issue and rotate read the token on
|
||||
// stdin, never argv, and nothing here prints it: the broker's answer carries the kind, date and state.
|
||||
import { CliError } from "./errors.mjs";
|
||||
|
||||
export const CREDENTIALS_USAGE = "mosaic credentials [status [AGENT]] [--json] [--tester] | issue|rotate AGENT gitea --rotate-by DATE | " +
|
||||
"issue|rotate AGENT vikunja --expires DATE | revoke AGENT [KIND] --reason TEXT [--tester] | delete AGENT --reason TEXT [--business ID]";
|
||||
const DATE_FLAG = Object.freeze({ gitea: "--rotate-by", vikunja: "--expires" });
|
||||
const MAX_TOKEN = 16384;
|
||||
|
||||
function parse(args) {
|
||||
const out = { positional: [], flags: new Set(), values: {} };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
|
||||
else if (["--business", "--rotate-by", "--expires", "--reason"].includes(arg)) {
|
||||
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(CREDENTIALS_USAGE, 4);
|
||||
out.values[arg] = args[++i];
|
||||
} else if (arg.startsWith("--")) throw new CliError(CREDENTIALS_USAGE, 4);
|
||||
else out.positional.push(arg);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The whole of stdin, one token; a terminal shows what is typed, so a pipe or a paste then EOF.
|
||||
async function readToken(stdin) {
|
||||
let text = "";
|
||||
for await (const chunk of stdin) {
|
||||
text += chunk;
|
||||
if (text.length > MAX_TOKEN + 2) throw new CliError("refused: the token on stdin is too long", 2);
|
||||
}
|
||||
if (!text.trim()) throw new CliError("refused: no token on stdin; pipe or paste it, then end input", 4);
|
||||
return text;
|
||||
}
|
||||
|
||||
// The states the bus's status() gives a held token: gitea is valid or rotation-due, vikunja valid,
|
||||
// expiring or expired. not-issued and revoked hold none.
|
||||
const ISSUED = new Set(["valid", "rotation-due", "expiring", "expired"]);
|
||||
|
||||
export async function credentialsCommand(args, io, callFor) {
|
||||
const parsed = parse(args);
|
||||
const [verb = "status", agent, kind, ...extra] = parsed.positional;
|
||||
const v = parsed.values;
|
||||
const shape = {
|
||||
status: () => kind === undefined && !v["--rotate-by"] && !v["--expires"] && !v["--reason"],
|
||||
issue: () => agent && DATE_FLAG[kind] && v[DATE_FLAG[kind]] && !v[kind === "gitea" ? "--expires" : "--rotate-by"] && !v["--reason"],
|
||||
revoke: () => agent && (kind === undefined || DATE_FLAG[kind]) && v["--reason"] && !v["--rotate-by"] && !v["--expires"],
|
||||
delete: () => agent && kind === undefined && v["--reason"] && !v["--rotate-by"] && !v["--expires"],
|
||||
};
|
||||
shape.rotate = shape.issue;
|
||||
if (extra.length || !shape[verb]?.()) throw new CliError(CREDENTIALS_USAGE, 4);
|
||||
// Q173 (duckworth N1): a token the tester issued would pass through its session.
|
||||
if (parsed.flags.has("--tester") && !["status", "revoke"].includes(verb)) {
|
||||
throw new CliError(`--tester takes only status and revoke\n${CREDENTIALS_USAGE}`, 4);
|
||||
}
|
||||
try {
|
||||
const call = callFor(parsed);
|
||||
let result;
|
||||
if (verb === "status") {
|
||||
const rows = await call("credentials", {});
|
||||
result = agent ? rows.filter((r) => r.role === agent) : rows;
|
||||
if (!parsed.flags.has("--json")) {
|
||||
io.stdout.write(result.map((r) => `${r.role}\t${r.service}\t${r.state}\t${r.dateKind} ${r.date}`).join("\n") + (result.length ? "\n" : ""));
|
||||
return;
|
||||
}
|
||||
} else if (verb === "issue" || verb === "rotate") {
|
||||
// rotate names an entry the bus holds now; checked before stdin is read.
|
||||
if (verb === "rotate") {
|
||||
const held = (await call("credentials", {})).find((r) => r.role === agent && r.service === kind);
|
||||
if (!ISSUED.has(held?.state)) throw new CliError(`refused: ${agent} has no issued ${kind} credential to rotate; use issue`, 2);
|
||||
}
|
||||
const token = await readToken(io.stdin);
|
||||
result = await call("credentials.issue", { agent, service: kind, token, date: v[DATE_FLAG[kind]] });
|
||||
} else if (verb === "revoke") {
|
||||
result = await call("credentials.revoke", { agent, ...(kind ? { service: kind } : {}), reason: v["--reason"] });
|
||||
} else result = await call("credentials.delete", { agent, reason: v["--reason"] });
|
||||
io.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
||||
} catch (error) {
|
||||
if (error instanceof CliError) throw error;
|
||||
throw new CliError(error.message, 2);
|
||||
}
|
||||
}
|
||||
@@ -26,19 +26,6 @@
|
||||
// squash that will land. The gate runs after the answer, and its
|
||||
// outcome is the broker's: recordLand or landRefused, each on the
|
||||
// task's trail and told to the role as a RESULT message.
|
||||
// publish {"cap", "op": "publish", "task_ref", "project", "branch", "decision"}
|
||||
// (row 199, #1697, design D9 to D11). The host answers {"ok": true,
|
||||
// "result": {fork, ref, commit, previous}} or a refusal. The broker's
|
||||
// checkPublish names the fork, the agent's email, the source commit
|
||||
// and the destination; the request names none of them. publish
|
||||
// (working.mjs) checks the work ref and the commits' identity under
|
||||
// the repository's lock, then the bus process runs ls-remote and the
|
||||
// push with the agent's gitea token (publishPush), and the host polls
|
||||
// publishResult every publishPollMs: the host's op channel answers
|
||||
// one op at a time within 10 s. The poll ends at publishDeadlineMs
|
||||
// (publish-timeout), past git's own limit on each of the four git
|
||||
// runs. recordPublish files the push; any refusal after checkPublish
|
||||
// is filed once, by publishRefused.
|
||||
//
|
||||
// A refusal is {"ok": false, "error": "<code>"}, and the connection ends.
|
||||
// The host checks, in order: identity (a role of this business), the request
|
||||
@@ -77,10 +64,9 @@ import { randomBytes } from "node:crypto";
|
||||
import { closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, rmSync, statSync, symlinkSync, writeSync, chmodSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { GIT_GRACE_MS, GIT_TIMEOUT_MS } from "../../seat/src/checkout.mjs";
|
||||
import { gitSocketPath } from "../../seat/src/session.mjs";
|
||||
import { gateEnv, isMosaicStack, rootHits, runGate } from "../../seat/src/landing.mjs";
|
||||
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, publish, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
|
||||
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
|
||||
const HEADER_MAX = 4096;
|
||||
@@ -98,11 +84,6 @@ export const bodyDeadline = (size) => BODY_IDLE_MS + Math.ceil(size / BODY_MIN_R
|
||||
const MAX_ACTIVE = 8;
|
||||
// The longest close() waits for its host-closing answers to leave.
|
||||
export const CLOSE_WAIT_MS = 2000;
|
||||
// How often the host asks the bus process whether a publish's push ended.
|
||||
const PUBLISH_POLL_MS = 200;
|
||||
// The longest the host polls: publishRemote runs at most four git commands
|
||||
// (ls-remote, cat-file, merge-base, push), each ended by git's own limit.
|
||||
const PUBLISH_DEADLINE_MS = 4 * (GIT_TIMEOUT_MS + GIT_GRACE_MS) + 30_000;
|
||||
const CAP = /^[0-9a-f]{64}$/;
|
||||
const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
||||
|
||||
@@ -291,7 +272,7 @@ Applied-under: git socket land (bus host)
|
||||
// host: startHost()'s handle; business: loadBusiness() output. bundleMax,
|
||||
// bodyDeadline, bodyIdle, maxActive, fsck and gate (LAND_GATE's shape) are
|
||||
// for the tests.
|
||||
export function createGitSocket({ host, business: b, dataRoot, env = process.env, log = () => {}, bundleMax = BUNDLE_MAX, bodyDeadline: deadlineOf = bodyDeadline, bodyIdle = BODY_IDLE_MS, maxActive = MAX_ACTIVE, fsck = FSCK, gate = LAND_GATE, publishPollMs = PUBLISH_POLL_MS, publishDeadlineMs = PUBLISH_DEADLINE_MS }) {
|
||||
export function createGitSocket({ host, business: b, dataRoot, env = process.env, log = () => {}, bundleMax = BUNDLE_MAX, bodyDeadline: deadlineOf = bodyDeadline, bodyIdle = BODY_IDLE_MS, maxActive = MAX_ACTIVE, fsck = FSCK, gate = LAND_GATE }) {
|
||||
const path = gitSocketPath(dataRoot);
|
||||
const dir = join(dataRoot, "bus-host");
|
||||
let server = null;
|
||||
@@ -405,55 +386,6 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
|
||||
})();
|
||||
}
|
||||
|
||||
async function doPublish(socket, cap, req) {
|
||||
const { task_ref, branch, decision } = req;
|
||||
const p = project(req.project);
|
||||
if (!p || !validBranch(branch) || (decision !== undefined && typeof decision !== "string")) throw new Refusal("invalid-request");
|
||||
workRef(task_ref);
|
||||
const facts = await op({ op: "checkPublish", cap, args: { task_ref, project: req.project, branch, ...(decision === undefined ? {} : { decision }) } });
|
||||
const { ticket } = facts;
|
||||
// From the ticket on, a refusal is the publish's own, filed once by publishRefused.
|
||||
const refused = async (e) => {
|
||||
const code = e instanceof Refusal || e instanceof Recorded || e instanceof WorkingError ? e.code : "git-failed";
|
||||
if (code === "git-failed") log(`git socket: publish ${task_ref}: ${code}: ${e.message}`);
|
||||
await host.op({ op: "publishRefused", ticket, args: { code } }).catch((x) => log(`git socket: publish ${task_ref}: refusal ${code} not recorded: ${x.code ?? x.message}`));
|
||||
return new Recorded(code);
|
||||
};
|
||||
let pushed;
|
||||
try {
|
||||
pushed = await publish({
|
||||
dataRoot, business: b.id, project: req.project, root: p.root, taskRef: task_ref, source: facts.source, email: facts.email, env,
|
||||
remote: async () => {
|
||||
await op({ op: "publishPush", ticket });
|
||||
const until = Date.now() + publishDeadlineMs;
|
||||
for (;;) {
|
||||
// The bus's git may still finish after this; the fork could then hold the commit with
|
||||
// no record, as when recordPublish refuses after a push.
|
||||
if (Date.now() >= until) {
|
||||
log(`git socket: publish ${task_ref}: publish-timeout; the bus process's push may still reach the fork`);
|
||||
throw new Refusal("publish-timeout");
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, publishPollMs));
|
||||
const r = await op({ op: "publishResult", ticket });
|
||||
if (r.state === "pushed") return { ref: r.ref, commit: r.commit, previous: r.previous };
|
||||
if (r.state === "refused") throw new Recorded(r.code);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
throw await refused(e);
|
||||
}
|
||||
// The commit is on the fork now; a record the broker refuses is filed as that refusal.
|
||||
let result;
|
||||
try {
|
||||
result = await op({ op: "recordPublish", ticket, pushed });
|
||||
} catch (e) {
|
||||
log(`git socket: publish ${task_ref}: ${pushed.commit} is on ${facts.fork} but wasn't recorded: ${e.code}`);
|
||||
throw await refused(e);
|
||||
}
|
||||
socket.end(`${JSON.stringify({ ok: true, result: { fork: result.fork, ref: result.ref, commit: result.commit, previous: result.previous } })}\n`);
|
||||
}
|
||||
|
||||
async function serve(socket) {
|
||||
let cap = null;
|
||||
let subject = null;
|
||||
@@ -478,8 +410,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
|
||||
if (req.op === "push") await doPush(socket, cap, req, rest, file);
|
||||
else if (req.op === "fetch") await doFetch(socket, cap, req, file);
|
||||
else if (req.op === "land") await doLand(socket, cap, req);
|
||||
else if (req.op === "publish") await doPublish(socket, cap, req);
|
||||
else throw new Refusal("invalid-request", "op is push, fetch, land or publish");
|
||||
else throw new Refusal("invalid-request", "op is push, fetch or land");
|
||||
} catch (e) {
|
||||
if (e instanceof Recorded) return fail(e.code);
|
||||
const code = e instanceof Refusal || e instanceof WorkingError ? e.code : "git-failed";
|
||||
|
||||
@@ -47,9 +47,8 @@
|
||||
// launchedBy = the business's human and no caller capability.
|
||||
//
|
||||
// listen() also serves the git socket (src/git-socket.mjs, row 93), which
|
||||
// the sandbox binds for a role that holds git.push.working,
|
||||
// review.verdict or git.push.remote (row 199), or a merge decision to land
|
||||
// (landHeld, row 137);
|
||||
// the sandbox binds for a role that holds git.push.working or
|
||||
// review.verdict, or a merge decision to land (landHeld, row 137);
|
||||
// session.json names it as gitSocket.
|
||||
//
|
||||
// Thread sessions (row 121, #1615). A thread instance (an instance whose
|
||||
@@ -398,9 +397,8 @@ export function createLauncher({
|
||||
const transcript = call(model.harness, "transcript", () => harness.transcript({ runDir, store }));
|
||||
const harnessMounts = call(model.harness, "mounts", () => harness.mounts({ env }));
|
||||
// Row 93: the git socket, for a role that pushes work or reviews it. Row 137: or lands it.
|
||||
// Row 199 (D11): or publishes it, once a reviewed commit routes git.push.remote within-role.
|
||||
const held = [...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole];
|
||||
const git = land || ["git.push.working", "review.verdict", "git.push.remote"].some((a) => held.includes(a)) ? gitSocketPath(dataRoot) : null;
|
||||
const git = land || held.includes("git.push.working") || held.includes("review.verdict") ? gitSocketPath(dataRoot) : null;
|
||||
writeSessionFile(runDir, {
|
||||
...sessionDefaults,
|
||||
business: b.id,
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
// Row 199 (#1697, design section 6, T4, T5): `mosaic credentials`. issue and rotate read the token on
|
||||
// stdin and pass it once to credentials.issue; nothing printed carries it. --tester takes status and
|
||||
// revoke only. A fake transport records the calls; no bus runs here.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { main } from "../src/cli.mjs";
|
||||
import { io, tmp } from "./helpers.mjs";
|
||||
|
||||
const TOKEN = "fake_cli_token_0123456789";
|
||||
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
|
||||
const ROWS = [
|
||||
{ service: "gitea", account: null, role: "coder", dateKind: "rotateBy", date: "2099-01-01", state: "valid" },
|
||||
{ service: "vikunja", account: "bot", role: "coder", dateKind: "expires", date: "2099-01-01", state: "not-issued" },
|
||||
{ service: "gitea", account: null, role: "reviewer", dateKind: "rotateBy", date: "2099-01-01", state: "revoked" },
|
||||
];
|
||||
|
||||
function deps(calls, rows = ROWS) {
|
||||
const answer = (who) => (business) => async (verb, args) => {
|
||||
calls.push({ who, business, verb, args });
|
||||
if (verb === "credentials") return rows;
|
||||
if (verb === "credentials.issue") return { agent: args.agent, verb: "issue", service: args.service, date: args.date, state: "valid" };
|
||||
return { agent: args.agent, verb: verb.slice(12), services: [] };
|
||||
};
|
||||
return { system: { dataRoot: "/nonexistent" }, transport: answer("human"), testerTransport: answer("tester") };
|
||||
}
|
||||
const withStdin = (x, text) => {
|
||||
x.stdin.end(text);
|
||||
return x;
|
||||
};
|
||||
|
||||
test("row 199: issue and rotate send the stdin token once to credentials.issue and print no value", async () => {
|
||||
const calls = [];
|
||||
const d = deps(calls);
|
||||
const x = withStdin(io(), TOKEN + "\n");
|
||||
await main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], x, d);
|
||||
assert.deepEqual(calls, [{ who: "human", business: "acme", verb: "credentials.issue", args: { agent: "coder", service: "gitea", token: TOKEN + "\n", date: "2099-01-01" } }]);
|
||||
assert.equal(x.out.text.includes(TOKEN), false);
|
||||
assert.equal(JSON.parse(x.out.text).state, "valid");
|
||||
// rotate checks the entry is issued before it reads stdin.
|
||||
calls.length = 0;
|
||||
await main(["credentials", "rotate", "coder", "gitea", "--rotate-by", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), d);
|
||||
assert.deepEqual(calls.map((c) => c.verb), ["credentials", "credentials.issue"]);
|
||||
calls.length = 0;
|
||||
await assert.rejects(main(["credentials", "rotate", "coder", "vikunja", "--expires", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), d), /no issued vikunja credential to rotate; use issue/);
|
||||
assert.deepEqual(calls.map((c) => c.verb), ["credentials"]);
|
||||
// No token on stdin, the date flag of the other kind, a token in argv: refused before any write.
|
||||
calls.length = 0;
|
||||
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io(), "\n"), d), /no token on stdin/);
|
||||
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--expires", "2099-01-01", "--business", "acme"], withStdin(io(), TOKEN), d), /mosaic credentials/);
|
||||
await assert.rejects(main(["credentials", "issue", "coder", "gitea", TOKEN, "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io(), TOKEN), d), /mosaic credentials/);
|
||||
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io(), "x".repeat(17000)), d), /too long/);
|
||||
assert.equal(calls.filter((c) => c.verb === "credentials.issue").length, 0);
|
||||
// From inside an agent run, the human's verbs refuse before any call.
|
||||
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io({ CLAUDECODE: "1" }), TOKEN), d), /outside any agent run/);
|
||||
});
|
||||
|
||||
// gizmoduck-0050: a gitea key past its rotate-by date is rotation-due, and rotate is how a human
|
||||
// clears it. Every state the bus's status() gives a held token rotates; the other two refuse.
|
||||
test("row 199: rotate goes ahead on each held state, rotation-due included, and refuses not-issued, revoked and an unknown entry", async () => {
|
||||
const states = { valid: true, "rotation-due": true, expiring: true, expired: true, "not-issued": false, revoked: false };
|
||||
for (const [state, held] of Object.entries(states)) {
|
||||
const calls = [];
|
||||
const d = deps(calls, [{ service: "gitea", account: null, role: "coder", dateKind: "rotateBy", date: "2020-01-01", state }]);
|
||||
const run = main(["credentials", "rotate", "coder", "gitea", "--rotate-by", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), d);
|
||||
if (held) await run;
|
||||
else await assert.rejects(run, /no issued gitea credential to rotate; use issue/);
|
||||
assert.deepEqual(calls.map((c) => c.verb), held ? ["credentials", "credentials.issue"] : ["credentials"], state);
|
||||
}
|
||||
const calls = [];
|
||||
await assert.rejects(main(["credentials", "rotate", "tester", "gitea", "--rotate-by", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), deps(calls)), /no issued gitea credential/);
|
||||
assert.deepEqual(calls.map((c) => c.verb), ["credentials"]);
|
||||
});
|
||||
|
||||
test("row 199: status, revoke and delete; the tester runs status and revoke only", async () => {
|
||||
const calls = [];
|
||||
const d = deps(calls);
|
||||
const x = io();
|
||||
await main(["credentials", "status", "coder", "--business", "acme"], x, d);
|
||||
assert.equal(x.out.text, "coder\tgitea\tvalid\trotateBy 2099-01-01\ncoder\tvikunja\tnot-issued\texpires 2099-01-01\n");
|
||||
const j = io();
|
||||
await main(["credentials", "--json", "--business", "acme", "--tester"], j, d);
|
||||
assert.equal(JSON.parse(j.out.text).length, 3);
|
||||
await main(["credentials", "revoke", "coder", "gitea", "--reason", "fired", "--business", "acme", "--tester"], io(), d);
|
||||
await main(["credentials", "revoke", "coder", "--reason", "fired", "--business", "acme"], io(), d);
|
||||
await main(["credentials", "delete", "coder", "--reason", "fired", "--business", "acme"], io(), d);
|
||||
assert.deepEqual(calls.map((c) => [c.who, c.verb, c.args]), [
|
||||
["human", "credentials", {}],
|
||||
["tester", "credentials", {}],
|
||||
["tester", "credentials.revoke", { agent: "coder", service: "gitea", reason: "fired" }],
|
||||
["human", "credentials.revoke", { agent: "coder", reason: "fired" }],
|
||||
["human", "credentials.delete", { agent: "coder", reason: "fired" }],
|
||||
]);
|
||||
calls.length = 0;
|
||||
for (const args of [
|
||||
["issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--tester"],
|
||||
["rotate", "coder", "gitea", "--rotate-by", "2099-01-01", "--tester"],
|
||||
["delete", "coder", "--reason", "x", "--tester"],
|
||||
]) await assert.rejects(main(["credentials", ...args, "--business", "acme"], withStdin(io(), TOKEN), d), /--tester takes only status and revoke/);
|
||||
for (const args of [["revoke", "coder"], ["delete", "coder"], ["delete", "coder", "gitea", "--reason", "x"], ["revoke", "coder", "model", "--reason", "x"], ["purge", "coder"]])
|
||||
await assert.rejects(main(["credentials", ...args, "--business", "acme"], io(), d), /mosaic credentials/, args.join(" "));
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("row 199: scripts/mosaic sends credentials to packages/cli, not the seat CLI", (t) => {
|
||||
const root = tmp(t, "mosaic-cli-cred-route-");
|
||||
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
|
||||
const r = spawnSync(MOSAIC, ["credentials", "purge"], { env, encoding: "utf8" });
|
||||
assert.equal(r.status, 4);
|
||||
assert.match(r.stderr, /^mosaic: mosaic credentials \[status \[AGENT\]\]/);
|
||||
assert.doesNotMatch(r.stderr, /mosaic launch </);
|
||||
});
|
||||
@@ -1,281 +0,0 @@
|
||||
// Row 199 (#1697, design D9, D9b, D11, T3): the git socket's publish op end to end. The socket,
|
||||
// working.mjs, the bus runtime (in this process, so its publishTls can name the scratch CA), the
|
||||
// per-agent store and a TLS fake Gitea on 127.0.0.1. The host stand-in answers the socket's ops
|
||||
// the way packages/bus/src/process.mjs does. Fake tokens in a scratch root only.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { connect } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from "../../business/tests/helpers.mjs";
|
||||
import { pushWorking } from "../../harness/src/git-tools.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { workRef } from "../../seat/src/working.mjs";
|
||||
import { fakeGitea, scratchCa } from "../../seat/tests/fake-gitea.mjs";
|
||||
import { createGitSocket } from "../src/git-socket.mjs";
|
||||
|
||||
const TASK = "vikunja:1/2";
|
||||
const TASK2 = "vikunja:1/3";
|
||||
const TOKEN = "fake_socket_token_0123456789";
|
||||
const TOKEN2 = "fake_second_token_0123456789";
|
||||
|
||||
function git(cwd, ...args) {
|
||||
const r = spawnSync("git", args, { cwd, encoding: "utf8", env: gitEnv() });
|
||||
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
function commit(dir, text, email) {
|
||||
writeFileSync(join(dir, "README"), `${text}\n`);
|
||||
git(dir, "add", "README");
|
||||
git(dir, "-c", `user.name=${text}`, "-c", `user.email=${email}`, "commit", "-q", "-m", text);
|
||||
return git(dir, "rev-parse", "HEAD");
|
||||
}
|
||||
function raw(path, req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = connect(path);
|
||||
let buf = "";
|
||||
s.on("connect", () => s.write(`${JSON.stringify(req)}\n`));
|
||||
s.on("data", (b) => (buf += b));
|
||||
s.on("end", () => resolve(JSON.parse(buf.split("\n")[0])));
|
||||
s.on("error", reject);
|
||||
});
|
||||
}
|
||||
// The socket's ops, answered as process.mjs's launchOp answers them.
|
||||
const OPS = {
|
||||
identity: (r, m) => r.identity(m.cap),
|
||||
refuse: (r, m) => r.refuse(m.cap, m.code, m.subject),
|
||||
checkPush: (r, m) => r.checkPush(m.cap, m.task_ref),
|
||||
recordPush: (r, m) => r.recordPush(m.cap, m.push),
|
||||
checkPublish: (r, m) => r.checkPublish(m.cap, m.args),
|
||||
publishPush: (r, m) => r.publishPush(m.ticket),
|
||||
publishResult: (r, m) => r.publishResult(m.ticket),
|
||||
recordPublish: (r, m) => r.recordPublish(m.ticket, m.pushed),
|
||||
publishRefused: (r, m) => r.publishRefused(m.ticket, m.args),
|
||||
};
|
||||
|
||||
async function setup(t, { publishDeadlineMs } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), "cli-publish-"));
|
||||
const down = [];
|
||||
t.after(async () => {
|
||||
for (const fn of down) await fn();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const ca = scratchCa(join(root, "ca"));
|
||||
const gitea = await fakeGitea({ dir: join(root, "gitea"), tls: ca, users: { coder: TOKEN, second: TOKEN2 } });
|
||||
down.push(() => gitea.close());
|
||||
const project = join(root, "project");
|
||||
mkdirSync(project);
|
||||
git(project, "init", "-q", "-b", "main");
|
||||
commit(project, "one", "[email protected]");
|
||||
// Each agent's fork and the upstream, mosaic/stack, all clones of the root.
|
||||
for (const owner of ["coder", "second", "mosaic"]) {
|
||||
mkdirSync(join(gitea.repo(owner, "stack"), ".."), { recursive: true });
|
||||
git(root, "clone", "-q", "--bare", project, gitea.repo(owner, "stack"));
|
||||
}
|
||||
const forkRepo = gitea.repo("coder", "stack");
|
||||
|
||||
const rolesDir = rolesCopy(root);
|
||||
writeJson(join(root, "businesses/demo.json"), { ...businessDoc(root, "demo"), plugins: { harness: ["pi"] } });
|
||||
mkdirSync(join(root, "data"), { mode: 0o700 });
|
||||
mkdirSync(join(root, "secrets", "agents"), { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, "secrets"), 0o700);
|
||||
const agent = (user) => ({
|
||||
authority: { withinRole: ["git.push.working", "message.send"], crossRole: [] },
|
||||
identity: { name: user, email: `${user}@example.com`, gitea: { url: gitea.origin, user } },
|
||||
publish: ["stack"],
|
||||
credentials: { gitea: { store: true, file: join(root, "secrets", "agents", "demo", user, "gitea"), rotateBy: "2099-01-01" } },
|
||||
});
|
||||
const demo = {
|
||||
id: "demo", human: "jason", arbiters: { technical: "reviewer", delivery: "reviewer" },
|
||||
projects: { stack: { root: project, remote: `${gitea.origin}/mosaic/stack` } },
|
||||
roles: {
|
||||
coder: agent("coder"),
|
||||
second: agent("second"),
|
||||
reviewer: { authority: { withinRole: ["message.send"], crossRole: [] } },
|
||||
},
|
||||
};
|
||||
const dataRoot = join(root, "data");
|
||||
const bus = await startBroker({ dataRoot, businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) }, publishTls: ["-c", `http.sslCAInfo=${ca.ca}`] });
|
||||
down.unshift(() => bus.close());
|
||||
// A test sets hooks[op] to act around one op: hook(message, next), where next() answers it.
|
||||
const hooks = {};
|
||||
const host = { op: async (m) => (hooks[m.op] ? hooks[m.op](m, () => OPS[m.op](bus, m)) : OPS[m.op](bus, m)) };
|
||||
const logs = [];
|
||||
mkdirSync(join(dataRoot, "bus-host"), { mode: 0o700 });
|
||||
const sock = createGitSocket({ host, business: demo, dataRoot, env: process.env, log: (l) => logs.push(l), publishPollMs: 20, ...(publishDeadlineMs ? { publishDeadlineMs } : {}) });
|
||||
down.unshift(() => sock.close());
|
||||
await sock.listen();
|
||||
|
||||
const caps = {};
|
||||
for (const role of ["coder", "second"]) {
|
||||
caps[role] = bus.broker.bindLaunch({ business: "demo", role, run: `${role}-run`, harness: "pi" });
|
||||
bus.broker.request(caps[role], { verb: "role.claim", args: {} });
|
||||
}
|
||||
const human = bus.broker.bindHuman({ business: "demo", human: "jason", via: "cli", outsideAgent: true });
|
||||
// Each agent's workspace: a clone of the root with one commit of its own on feat, pushed to the
|
||||
// working repo for its task.
|
||||
const work = async (role, task) => {
|
||||
const workspace = join(root, `ws-${role}`);
|
||||
const tmp = join(root, `tmp-${role}`);
|
||||
mkdirSync(workspace);
|
||||
mkdirSync(tmp);
|
||||
git(workspace, "clone", "-q", project, "stack");
|
||||
const clone = join(workspace, "stack");
|
||||
git(clone, "checkout", "-q", "-b", "feat");
|
||||
const commitId = commit(clone, `work by ${role}`, `${role}@example.com`);
|
||||
await pushWorking({ path: sock.path, cap: caps[role], workspace, tmp, task_ref: task, project: "stack", branch: "feat" });
|
||||
return commitId;
|
||||
};
|
||||
// A second commit on the coder's feat, pushed to the working repo through the socket.
|
||||
const again = async () => {
|
||||
const workspace = join(root, "ws-coder");
|
||||
const commitId = commit(join(workspace, "stack"), "more work", "[email protected]");
|
||||
await pushWorking({ path: sock.path, cap: caps.coder, workspace, tmp: join(root, "tmp-coder"), task_ref: TASK, project: "stack", branch: "feat" });
|
||||
return commitId;
|
||||
};
|
||||
const approve = (role = "coder", task = TASK) => {
|
||||
const d = bus.broker.request(caps[role], { verb: "decision.raise", args: {
|
||||
action: "git.push.remote", target: task, task_ref: task, question: "Publish?", blocking: false, recommendation: "yes",
|
||||
options: [{ key: "yes", text: "Yes" }, { key: "no", text: "No" }] } });
|
||||
bus.broker.request(human, { verb: "decision.resolve", args: { id: d.id, choice: "yes" } });
|
||||
return d.id;
|
||||
};
|
||||
const publish = (decision, extra = {}, role = "coder", task = TASK) =>
|
||||
raw(sock.path, { cap: caps[role], op: "publish", task_ref: task, project: "stack", branch: "feat", decision, ...extra });
|
||||
// The bus holds the writer lock; the test reads its events through a read-only handle.
|
||||
const events = (kind, task = TASK) => {
|
||||
const db = new DatabaseSync(join(dataRoot, "bus", "bus.sqlite"), { readOnly: true });
|
||||
try {
|
||||
return db.prepare("SELECT * FROM events WHERE kind=? AND subject=? ORDER BY seq").all(kind, task).map((e) => ({ ...e, body: JSON.parse(e.body) }));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
return { root, bus, gitea, forkRepo, work: await work("coder", TASK), more: work, again, hooks, caps, approve, publish, logs, events };
|
||||
}
|
||||
|
||||
test("T3 end to end: publish under an approved decision sends the work commit to the agent's own fork and records it once", async (t) => {
|
||||
const w = await setup(t);
|
||||
// Nothing issued: the store has no token, and the refusal is filed once.
|
||||
const first = w.approve();
|
||||
assert.deepEqual(await w.publish(first), { ok: false, error: "credential-unavailable" });
|
||||
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["credential-unavailable"]);
|
||||
assert.equal(w.gitea.requests.length, 0);
|
||||
|
||||
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
|
||||
const reply = await w.publish(first);
|
||||
assert.deepEqual(reply, { ok: true, result: { fork: `${w.gitea.origin}/coder/stack`, ref: "refs/heads/coder/feat", commit: w.work, previous: null } });
|
||||
assert.equal(git(w.root, `--git-dir=${w.forkRepo}`, "rev-parse", "refs/heads/coder/feat"), w.work);
|
||||
// Every request went to the agent's fork; the upstream, mosaic/stack, received none.
|
||||
assert.ok(w.gitea.requests.some((r) => r.path === "/coder/stack/git-receive-pack" && r.user === "coder"));
|
||||
for (const r of w.gitea.requests) assert.match(r.path, /^\/coder\/stack\//);
|
||||
const allowed = w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise");
|
||||
assert.equal(allowed.length, 1);
|
||||
assert.equal(allowed[0].body.fork, `${w.gitea.origin}/coder/stack`);
|
||||
assert.equal(allowed[0].body.commit, w.work);
|
||||
// The decision is spent.
|
||||
assert.deepEqual(await w.publish(first), { ok: false, error: "decision-consumed" });
|
||||
// T4: no token in any answer, event or log line, nor in any file under the scratch root but the
|
||||
// agent's own store file.
|
||||
assert.equal(JSON.stringify([reply, w.events("action.allowed"), w.events("action.refused"), w.logs]).includes(TOKEN), false);
|
||||
const store = join(w.root, "secrets", "agents", "demo", "coder", "gitea");
|
||||
const holding = readdirSync(w.root, { recursive: true })
|
||||
.map((name) => join(w.root, name))
|
||||
.filter((file) => lstatSync(file).isFile() && readFileSync(file).includes(TOKEN));
|
||||
assert.deepEqual(holding, [store]);
|
||||
});
|
||||
|
||||
test("T3 end to end: a fork the token can't reach is fork-unavailable, filed once, and nothing is recorded as allowed", async (t) => {
|
||||
const w = await setup(t);
|
||||
w.bus.credentials.issue("demo/coder", "gitea", "fake_wrong_token_0123456789", "2099-01-01");
|
||||
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "fork-unavailable" });
|
||||
assert.ok(w.gitea.requests.length > 0);
|
||||
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["fork-unavailable"]);
|
||||
assert.equal(w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise").length, 0);
|
||||
});
|
||||
|
||||
test("a bad branch or project refuses before the broker; a fork, user or ref in the request is ignored", async (t) => {
|
||||
const w = await setup(t);
|
||||
const d = w.approve();
|
||||
assert.deepEqual(await w.publish(d, { branch: "../main" }), { ok: false, error: "invalid-request" });
|
||||
assert.deepEqual(await w.publish(d, { project: "nope" }), { ok: false, error: "invalid-request" });
|
||||
// Filed through the socket's refuse op, as a bad push request is; no publish ticket opened.
|
||||
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["invalid-request", "invalid-request"]);
|
||||
assert.equal(w.gitea.requests.length, 0);
|
||||
// The broker builds the fork and the destination; the request's own are never read.
|
||||
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
|
||||
const reply = await w.publish(d, { fork: "https://127.0.0.1:1/other/stack", user: "other", dst: "refs/heads/main", ref: "refs/heads/main" });
|
||||
assert.deepEqual(reply.result, { fork: `${w.gitea.origin}/coder/stack`, ref: "refs/heads/coder/feat", commit: w.work, previous: null });
|
||||
for (const r of w.gitea.requests) assert.match(r.path, /^\/coder\/stack\//);
|
||||
assert.equal(spawnSync("git", [`--git-dir=${w.forkRepo}`, "rev-parse", "-q", "--verify", "refs/heads/other/feat"], { env: gitEnv() }).status, 1);
|
||||
});
|
||||
|
||||
test("T3 end to end: two agents, one project, each lands on its own fork only; the upstream receives nothing", async (t) => {
|
||||
const w = await setup(t);
|
||||
const second = await w.more("second", TASK2);
|
||||
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
|
||||
w.bus.credentials.issue("demo/second", "gitea", TOKEN2, "2099-01-01");
|
||||
assert.equal((await w.publish(w.approve())).ok, true);
|
||||
const coderRequests = w.gitea.requests.length;
|
||||
for (const r of w.gitea.requests) assert.deepEqual([r.path.split("/")[1], r.user ?? "coder"], ["coder", "coder"]);
|
||||
const reply = await w.publish(w.approve("second", TASK2), {}, "second", TASK2);
|
||||
assert.deepEqual(reply.result, { fork: `${w.gitea.origin}/second/stack`, ref: "refs/heads/second/feat", commit: second, previous: null });
|
||||
for (const r of w.gitea.requests.slice(coderRequests)) assert.deepEqual([r.path.split("/")[1], r.user ?? "second"], ["second", "second"]);
|
||||
const refs = (owner) => git(w.root, `--git-dir=${w.gitea.repo(owner, "stack")}`, "for-each-ref", "--format=%(refname) %(objectname)");
|
||||
assert.equal(refs("coder").includes(`refs/heads/coder/feat ${w.work}`), true);
|
||||
assert.equal(refs("coder").includes("refs/heads/second/"), false);
|
||||
assert.equal(refs("second").includes(`refs/heads/second/feat ${second}`), true);
|
||||
assert.equal(refs("second").includes("refs/heads/coder/"), false);
|
||||
assert.equal(refs("mosaic").split("\n").length, 1, "the upstream holds only main");
|
||||
});
|
||||
|
||||
// sage-1440 on BUILD.md 14: a push between checkPublish and the git push refuses, with one code
|
||||
// for each place it can come in, and the fork never receives a commit the decision didn't bind.
|
||||
test("T3: a push after checkPublish and before the lock refuses ref-moved; the fork receives nothing", async (t) => {
|
||||
const w = await setup(t);
|
||||
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
|
||||
w.hooks.checkPublish = async (m, next) => {
|
||||
const facts = await next();
|
||||
await w.again();
|
||||
return facts;
|
||||
};
|
||||
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "ref-moved" });
|
||||
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["ref-moved"]);
|
||||
assert.equal(w.gitea.requests.length, 0);
|
||||
assert.equal(git(w.root, `--git-dir=${w.forkRepo}`, "for-each-ref", "refs/heads/coder/"), "");
|
||||
});
|
||||
|
||||
test("T3: a push recorded while the bus process pushes refuses decision-mismatch; only the decision's commit reached the fork", async (t) => {
|
||||
const w = await setup(t);
|
||||
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
|
||||
// The working repo's lock is held through the git push, so a socket push would wait for it; the
|
||||
// broker's record of a newer push is what a push that won the lock leaves.
|
||||
w.hooks.publishPush = async (m, next) => {
|
||||
w.bus.checkPush(w.caps.coder, TASK);
|
||||
w.bus.recordPush(w.caps.coder, { task_ref: TASK, project: "stack", branch: "feat", ref: workRef(TASK), commit: "c".repeat(40), previous: w.work });
|
||||
return next();
|
||||
};
|
||||
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "decision-mismatch" });
|
||||
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["decision-mismatch"]);
|
||||
assert.equal(w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise").length, 0);
|
||||
assert.equal(git(w.root, `--git-dir=${w.forkRepo}`, "rev-parse", "refs/heads/coder/feat"), w.work);
|
||||
assert.ok(w.logs.some((l) => l.includes("wasn't recorded")));
|
||||
});
|
||||
|
||||
test("sage-1440: the publishResult poll is bounded: publish-timeout, filed once, and nothing recorded", async (t) => {
|
||||
const w = await setup(t, { publishDeadlineMs: 150 });
|
||||
let polls = 0;
|
||||
w.hooks.publishResult = async () => {
|
||||
polls++;
|
||||
return { state: "running" };
|
||||
};
|
||||
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "publish-timeout" });
|
||||
assert.ok(polls > 0);
|
||||
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["publish-timeout"]);
|
||||
assert.equal(w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise").length, 0);
|
||||
assert.ok(w.logs.some((l) => l.includes("publish-timeout")));
|
||||
});
|
||||
@@ -101,10 +101,8 @@ export function gitEnv(env = process.env) {
|
||||
// consequence: a Ctrl-C at the terminal of a host run by hand no longer
|
||||
// reaches its git, though a systemd stop still does (it kills the cgroup).
|
||||
// `stdin` names a file git reads as its input (row 137: patch-id); without
|
||||
// it git gets none. `pass` (row 199, publish) is written once to a pipe on
|
||||
// fd 3, which git and what it starts inherit; publish's credential helper
|
||||
// reads its token there, so the token is in no argv, env, URL or file.
|
||||
export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null, pass = null } = {}) {
|
||||
// it git gets none.
|
||||
export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
let input = "ignore";
|
||||
try {
|
||||
@@ -112,10 +110,8 @@ export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null, pass = nu
|
||||
} catch (e) {
|
||||
return resolve({ ok: false, out: "", err: "", why: e.code ?? e.message });
|
||||
}
|
||||
const child = spawn("git", argv, { env, detached: true, stdio: [input, "pipe", "pipe", ...(pass === null ? [] : ["pipe"])] });
|
||||
const child = spawn("git", argv, { env, detached: true, stdio: [input, "pipe", "pipe"] });
|
||||
if (typeof input === "number") closeSync(input);
|
||||
// A git that never asked for it closes the pipe unread: EPIPE is no fault.
|
||||
if (pass !== null) child.stdio[3].on("error", () => {}).end(pass);
|
||||
let out = "";
|
||||
let err = "";
|
||||
let signalled = false;
|
||||
|
||||
@@ -63,22 +63,6 @@
|
||||
// compare and swap (base-moved), then record() runs; when it throws, main
|
||||
// goes back, and a main that can't go back refuses rollback-failed.
|
||||
// dropLand removes the worktree; the caller runs it on every path.
|
||||
//
|
||||
// publish (row 199, #1697, design D9 to D9b) sends a recorded work commit to
|
||||
// the agent's own fork. It runs in two processes, because the gitea token
|
||||
// lives in the bus process and the lock in the host's:
|
||||
// publish, in the host, under the lock: the seed; the work ref is at the
|
||||
// source commit (ref-moved); every commit in main..source has author and
|
||||
// committer email equal to the agent's (identity-mismatch); then
|
||||
// remote(), which the caller wires to publishRemote in the bus process.
|
||||
// publishRemote, in the bus process, with the token on fd 3 only:
|
||||
// `ls-remote --symref <fork> HEAD <dst>`; a non-zero exit refuses
|
||||
// fork-unavailable. The fork's HEAD must be a commit this repository has
|
||||
// and an ancestor of main (remote-unrelated, an empty fork included).
|
||||
// Then `push <fork> <source>:<dst>` by oid, with no `+`. Only https
|
||||
// crosses (protocol.https.allow), redirects are refused, and output
|
||||
// holding the token refuses credential-leak. Git's stderr is never
|
||||
// returned: it can carry the URL.
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { closeSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
@@ -117,7 +101,7 @@ export const FSCK = ["-c", "transfer.fsckObjects=true"];
|
||||
|
||||
// `limits` is { timeoutMs, graceMs }; the tests shorten them. runGit
|
||||
// (checkout.mjs) runs git in its own process group and signals the group.
|
||||
const git = (args, env, { pre = [], limits = GIT_LIMITS, stdin = null, pass = null } = {}) => runGit([...SAFE, ...pre, ...args], gitEnv(env), limits, { stdin, pass });
|
||||
const git = (args, env, { pre = [], limits = GIT_LIMITS, stdin = null } = {}) => runGit([...SAFE, ...pre, ...args], gitEnv(env), limits, { stdin });
|
||||
// git with this call's env and limits, for the helpers below.
|
||||
const bound = (env, limits) => (args, opts = {}) => git(args, env, { ...opts, limits });
|
||||
|
||||
@@ -418,75 +402,3 @@ async function removeWorktree(repo, worktree, g) {
|
||||
if (!r.ok) rmSync(worktree, { recursive: true, force: true });
|
||||
await g([`--git-dir=${repo}`, "worktree", "prune"]);
|
||||
}
|
||||
|
||||
// await publish({ dataRoot, business, project, root, taskRef, source, email, remote, env, limits })
|
||||
// Resolves what remote() resolves; rejects with WorkingError.
|
||||
export function publish({ dataRoot, business, project, root, taskRef, source, email, remote, env = process.env, limits = GIT_LIMITS }) {
|
||||
if (!PROJECT.test(project ?? "") || !OID.test(source ?? "") || typeof email !== "string" || !email) refuse("invalid-request");
|
||||
const ref = `refs/heads/${workRef(taskRef)}`;
|
||||
const repo = workingRepo(dataRoot, business, project);
|
||||
const g = bound(env, limits);
|
||||
return locked(repo, async () => {
|
||||
try {
|
||||
if (!lstatSync(repo).isDirectory()) refuse("working-repo-invalid");
|
||||
} catch (e) {
|
||||
if (e instanceof WorkingError) throw e;
|
||||
refuse("working-repo-invalid", `${repo} can't be read: ${e.code}`);
|
||||
}
|
||||
await seed(repo, root, g);
|
||||
const at = `--git-dir=${repo}`;
|
||||
if ((await tip(repo, ref, g)) !== source) refuse("ref-moved", `${ref} isn't at ${source}`);
|
||||
const range = await g([at, "log", "--format=%ae%x00%ce", `refs/heads/main..${source}`]);
|
||||
if (!range.ok) refuse("working-repo-invalid", range.why);
|
||||
for (const line of range.out.split("\n").filter(Boolean)) {
|
||||
if (line !== `${email}\0${email}`) refuse("identity-mismatch", `a commit in main..${source.slice(0, 12)} isn't by ${email}`);
|
||||
}
|
||||
return remote();
|
||||
});
|
||||
}
|
||||
|
||||
const HOST = /^[a-z0-9.-]{1,253}(:[0-9]{1,5})?$/;
|
||||
const NAME = /^[A-Za-z0-9_.-]{1,100}$/;
|
||||
// The one-shot helper: for a `get` on https at exactly the fork's host and
|
||||
// port, it answers the user and the line it reads from fd 3. Anything else,
|
||||
// and `store` and `erase`, gets no answer. HOST and NAME keep both shell-safe.
|
||||
const helper = (host, user) =>
|
||||
`!f() { test "$1" = get || exit 0; p=; h=; while IFS= read -r l; do case "$l" in protocol=*) p=\${l#protocol=};; host=*) h=\${l#host=};; "") break;; esac; done; ` +
|
||||
`test "$p" = https && test "$h" = '${host}' || exit 0; IFS= read -r t <&3 || exit 0; printf 'username=%s\\npassword=%s\\n' '${user}' "$t"; }; f`;
|
||||
|
||||
// await publishRemote({ repo, fork, user, source, dst, token, tls, env, limits })
|
||||
// Resolves { ref, commit, previous }, previous being dst's tip on the fork
|
||||
// before the push, or null. `tls` is for the tests' scratch CA only.
|
||||
export async function publishRemote({ repo, fork, user, source, dst, token, tls = [], env = process.env, limits = GIT_LIMITS }) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(fork);
|
||||
} catch {
|
||||
refuse("invalid-request");
|
||||
}
|
||||
if (url.protocol !== "https:" || !HOST.test(url.host) || !NAME.test(user ?? "") || !OID.test(source ?? "") || !dst?.startsWith(`refs/heads/${user}/`)) refuse("invalid-request");
|
||||
const secret = String(token).trim();
|
||||
const net = async (args) => {
|
||||
const pre = [...tls, "-c", "protocol.https.allow=always", "-c", "http.followRedirects=false", "-c", "credential.helper=", "-c", `credential.helper=${helper(url.host, user)}`];
|
||||
const r = await git([`--git-dir=${repo}`, ...args], env, { pre, limits, pass: `${secret}\n` });
|
||||
if (r.out.includes(secret) || r.err.includes(secret)) refuse("credential-leak");
|
||||
return r;
|
||||
};
|
||||
const g = bound(env, limits);
|
||||
const listed = await net(["ls-remote", "--symref", fork, "HEAD", dst]);
|
||||
if (!listed.ok) refuse("fork-unavailable");
|
||||
let head = null;
|
||||
let previous = null;
|
||||
for (const line of listed.out.split("\n")) {
|
||||
const [oid, name] = line.split("\t");
|
||||
if (!OID.test(oid ?? "")) continue;
|
||||
if (name === "HEAD") head = oid;
|
||||
else if (name === dst) previous = oid;
|
||||
}
|
||||
if (!head) refuse("remote-unrelated", "the fork is empty");
|
||||
const known = await g([`--git-dir=${repo}`, "cat-file", "-e", `${head}^{commit}`]);
|
||||
if (!known.ok || !(await g([`--git-dir=${repo}`, "merge-base", "--is-ancestor", head, "refs/heads/main"])).ok) refuse("remote-unrelated", "the fork's HEAD isn't in main's history");
|
||||
const pushed = await net(["push", "--porcelain", "--no-follow-tags", fork, `${source}:${dst}`]);
|
||||
if (!pushed.ok) refuse(/\((non-fast-forward|fetch first)\)/.test(pushed.out) ? "non-fast-forward" : "push-failed");
|
||||
return { ref: dst, commit: source, previous };
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
// Row 199 (#1697, design T3): a fake Gitea for publish, on 127.0.0.1 over TLS with a scratch CA,
|
||||
// serving bare repositories under `<dir>/repos/<user>/<repo>.git` through git http-backend. Every
|
||||
// request needs Basic auth with the user and token it was made with, or with any pair in `users`,
|
||||
// as a private repository on Gitea does. It records each request's method, path and user, never the password. `redirect`
|
||||
// makes every request a 302 to `redirect`. A repository's own hooks run on a push, so a test can
|
||||
// make the fork echo something back as `remote:` lines. Nothing here leaves 127.0.0.1.
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:https";
|
||||
import { join } from "node:path";
|
||||
|
||||
// A CA and a server certificate for IP 127.0.0.1, made once per directory with openssl.
|
||||
export function scratchCa(dir) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const ssl = (...args) => {
|
||||
const r = spawnSync("openssl", args, { cwd: dir, encoding: "utf8" });
|
||||
if (r.status !== 0) throw new Error(`openssl ${args[0]}: ${r.stderr}`);
|
||||
};
|
||||
ssl("req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes", "-keyout", "ca.key", "-out", "ca.pem", "-days", "2", "-subj", "/CN=mosaic test CA");
|
||||
ssl("req", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes", "-keyout", "server.key", "-out", "server.csr", "-subj", "/CN=127.0.0.1");
|
||||
writeFileSync(join(dir, "san.ext"), "subjectAltName=IP:127.0.0.1\n");
|
||||
ssl("x509", "-req", "-in", "server.csr", "-CA", "ca.pem", "-CAkey", "ca.key", "-CAcreateserial", "-out", "server.pem", "-days", "2", "-extfile", "san.ext");
|
||||
return { ca: join(dir, "ca.pem"), key: readFileSync(join(dir, "server.key")), cert: readFileSync(join(dir, "server.pem")) };
|
||||
}
|
||||
|
||||
export async function fakeGitea({ dir, tls, user, token, users = { [user]: token }, redirect = null }) {
|
||||
const root = join(dir, "repos");
|
||||
mkdirSync(root, { recursive: true });
|
||||
const requests = [];
|
||||
const server = createServer({ key: tls.key, cert: tls.cert }, (req, res) => {
|
||||
const url = new URL(req.url, "https://127.0.0.1");
|
||||
const auth = /^Basic (.+)$/.exec(req.headers.authorization ?? "");
|
||||
const [who, pass] = auth ? Buffer.from(auth[1], "base64").toString().split(":") : [null, null];
|
||||
requests.push({ method: req.method, path: url.pathname, user: who });
|
||||
if (redirect) {
|
||||
res.writeHead(302, { location: redirect + req.url });
|
||||
return res.end();
|
||||
}
|
||||
if (!who || !Object.hasOwn(users, who) || pass !== users[who]) {
|
||||
res.writeHead(401, { "www-authenticate": 'Basic realm="Gitea"' });
|
||||
return res.end();
|
||||
}
|
||||
// /<owner>/<repo>/info/refs and the like, on <root>/<owner>/<repo>.git.
|
||||
const m = /^\/([^/]+)\/([^/]+?)(?:\.git)?(\/.*)$/.exec(url.pathname);
|
||||
if (!m) {
|
||||
res.writeHead(404);
|
||||
return res.end();
|
||||
}
|
||||
const cgi = spawn("git", ["http-backend"], {
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
GIT_PROJECT_ROOT: root,
|
||||
GIT_HTTP_EXPORT_ALL: "1",
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_CONFIG_GLOBAL: "/dev/null",
|
||||
HOME: "/nonexistent",
|
||||
PATH_INFO: `/${m[1]}/${m[2]}.git${m[3]}`,
|
||||
REQUEST_METHOD: req.method,
|
||||
QUERY_STRING: url.search.slice(1),
|
||||
CONTENT_TYPE: req.headers["content-type"] ?? "",
|
||||
HTTP_CONTENT_ENCODING: req.headers["content-encoding"] ?? "",
|
||||
GIT_PROTOCOL: req.headers["git-protocol"] ?? "",
|
||||
REMOTE_USER: who,
|
||||
REMOTE_ADDR: "127.0.0.1",
|
||||
},
|
||||
stdio: ["pipe", "pipe", "ignore"],
|
||||
});
|
||||
req.pipe(cgi.stdin);
|
||||
const chunks = [];
|
||||
cgi.stdout.on("data", (c) => chunks.push(c));
|
||||
cgi.on("close", () => {
|
||||
const out = Buffer.concat(chunks);
|
||||
const split = out.indexOf("\r\n\r\n");
|
||||
const head = out.subarray(0, split).toString().split("\r\n");
|
||||
const headers = {};
|
||||
let status = 200;
|
||||
for (const line of head) {
|
||||
const [k, ...v] = line.split(":");
|
||||
if (k.toLowerCase() === "status") status = Number.parseInt(v.join(":").trim(), 10);
|
||||
else headers[k] = v.join(":").trim();
|
||||
}
|
||||
res.writeHead(status, headers);
|
||||
res.end(out.subarray(split + 4));
|
||||
});
|
||||
});
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const port = server.address().port;
|
||||
return {
|
||||
port,
|
||||
origin: `https://127.0.0.1:${port}`,
|
||||
requests,
|
||||
repo: (owner, name) => join(root, owner, `${name}.git`),
|
||||
close: () => new Promise((r) => server.close(r)),
|
||||
};
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
// Row 199 (#1697, design D9 step 3 to 5, D9a, T3): publish and publishRemote (src/working.mjs)
|
||||
// against a TLS fake Gitea on 127.0.0.1 (fake-gitea.mjs) with a scratch CA, passed only through
|
||||
// the `tls` parameter. Fake tokens in a scratch root. Each refusal that should reach the network
|
||||
// checks the fake saw the request, so no case passes because git refused https first.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { gitEnv } from "../src/checkout.mjs";
|
||||
import { publish, publishRemote, push, workingRepo } from "../src/working.mjs";
|
||||
import { fakeGitea, scratchCa } from "./fake-gitea.mjs";
|
||||
|
||||
const TASK = "vikunja:1/2";
|
||||
const TOKEN = "fake_publish_token_0123456789";
|
||||
const USER = "coder";
|
||||
const EMAIL = "[email protected]";
|
||||
const DST = "refs/heads/coder/feat";
|
||||
|
||||
function git(cwd, ...args) {
|
||||
const r = spawnSync("git", args, { cwd, encoding: "utf8", env: gitEnv() });
|
||||
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
function commit(dir, text, email = EMAIL) {
|
||||
writeFileSync(join(dir, "README"), `${text}\n`);
|
||||
git(dir, "add", "README");
|
||||
git(dir, "-c", `user.name=${text}`, "-c", `user.email=${email}`, "commit", "-q", "-m", text);
|
||||
return git(dir, "rev-parse", "HEAD");
|
||||
}
|
||||
const rejects = (p, code) => assert.rejects(p, (e) => e.code === code || assert.fail(`${e.code}: ${e.message}, wanted ${code}`));
|
||||
|
||||
// A root, a coder's clone with work on feat pushed to the working repo, and a fake Gitea whose
|
||||
// coder/stack fork is a bare clone of the root.
|
||||
async function world(t, { fork = true, redirect = null } = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "mosaic-publish-"));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const root = join(dir, "root");
|
||||
mkdirSync(root);
|
||||
git(root, "init", "-q", "-b", "main");
|
||||
const first = commit(root, "one", "[email protected]");
|
||||
const clone = join(dir, "clone");
|
||||
git(dir, "clone", "-q", root, clone);
|
||||
git(clone, "checkout", "-q", "-b", "feat");
|
||||
const work = commit(clone, "work");
|
||||
const dataRoot = join(dir, "data");
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const file = join(dir, "b.bundle");
|
||||
git(clone, "bundle", "create", "-q", file, "refs/heads/feat", "--not", "--remotes=origin");
|
||||
await push({ dataRoot, business: "acme", project: "stack", root, taskRef: TASK, branch: "feat", bundle: file });
|
||||
const ca = scratchCa(join(dir, "ca"));
|
||||
const gitea = await fakeGitea({ dir: join(dir, "gitea"), tls: ca, user: USER, token: TOKEN, redirect });
|
||||
t.after(() => gitea.close());
|
||||
const forkRepo = gitea.repo(USER, "stack");
|
||||
if (fork) {
|
||||
mkdirSync(join(forkRepo, ".."), { recursive: true });
|
||||
git(dir, "clone", "-q", "--bare", root, forkRepo);
|
||||
}
|
||||
const tls = ["-c", `http.sslCAInfo=${ca.ca}`];
|
||||
const repo = workingRepo(dataRoot, "acme", "stack");
|
||||
const remote = (extra = {}) => publishRemote({ repo, fork: `${gitea.origin}/${USER}/stack`, user: USER, source: work, dst: DST, token: TOKEN + "\n", tls, ...extra });
|
||||
const go = (extra = {}) => publish({ dataRoot, business: "acme", project: "stack", root, taskRef: TASK, source: work, email: EMAIL, remote: () => remote(), ...extra });
|
||||
return { dir, root, first, clone, work, dataRoot, repo, gitea, forkRepo, tls, remote, go };
|
||||
}
|
||||
|
||||
test("T3: publish pushes the work commit by oid to <user>/<branch> on the fork, with the token only through the helper", async (t) => {
|
||||
const w = await world(t);
|
||||
assert.deepEqual(await w.go(), { ref: DST, commit: w.work, previous: null });
|
||||
assert.equal(git(w.dir, `--git-dir=${w.forkRepo}`, "rev-parse", DST), w.work);
|
||||
// Every request went to the fork's path, and each one that carried auth carried the agent's user.
|
||||
assert.ok(w.gitea.requests.length > 0);
|
||||
for (const r of w.gitea.requests) assert.match(r.path, /^\/coder\/stack\/(info\/refs|git-receive-pack|git-upload-pack)$/);
|
||||
assert.ok(w.gitea.requests.some((r) => r.user === USER && r.path.endsWith("git-receive-pack")));
|
||||
// A second publish of the same commit is a no-op push and names the previous tip.
|
||||
assert.deepEqual(await w.go(), { ref: DST, commit: w.work, previous: w.work });
|
||||
// The working repo holds no remote and no credential config.
|
||||
assert.equal(spawnSync("git", [`--git-dir=${w.repo}`, "config", "--get-regexp", "^(remote|credential)\\."], { env: gitEnv() }).stdout.toString(), "");
|
||||
});
|
||||
|
||||
test("T3: a moved fork branch refuses non-fast-forward; no + and no force", async (t) => {
|
||||
const w = await world(t);
|
||||
const other = join(w.dir, "other");
|
||||
git(w.dir, "clone", "-q", w.root, other);
|
||||
const elsewhere = commit(other, "elsewhere");
|
||||
git(other, "push", "-q", w.forkRepo, `${elsewhere}:${DST}`);
|
||||
await rejects(w.go(), "non-fast-forward");
|
||||
assert.equal(git(w.dir, `--git-dir=${w.forkRepo}`, "rev-parse", DST), elsewhere);
|
||||
});
|
||||
|
||||
test("T3: D9a: an empty fork and an unrelated fork refuse remote-unrelated; a missing fork refuses fork-unavailable", async (t) => {
|
||||
const empty = await world(t, { fork: false });
|
||||
git(empty.dir, "init", "-q", "--bare", empty.forkRepo);
|
||||
await rejects(empty.go(), "remote-unrelated");
|
||||
assert.ok(empty.gitea.requests.length > 0);
|
||||
assert.equal(empty.gitea.requests.some((r) => r.path.endsWith("git-receive-pack")), false);
|
||||
|
||||
const unrelated = await world(t, { fork: false });
|
||||
const alone = join(unrelated.dir, "alone");
|
||||
mkdirSync(alone);
|
||||
git(alone, "init", "-q", "-b", "main");
|
||||
commit(alone, "unrelated");
|
||||
git(unrelated.dir, "clone", "-q", "--bare", alone, unrelated.forkRepo);
|
||||
await rejects(unrelated.go(), "remote-unrelated");
|
||||
assert.equal(unrelated.gitea.requests.some((r) => r.path.endsWith("git-receive-pack")), false);
|
||||
|
||||
const missing = await world(t, { fork: false });
|
||||
await rejects(missing.go(), "fork-unavailable");
|
||||
assert.ok(missing.gitea.requests.length > 0);
|
||||
});
|
||||
|
||||
test("T3: a fork past main refuses remote-unrelated until root catches up", async (t) => {
|
||||
const w = await world(t);
|
||||
const other = join(w.dir, "other");
|
||||
git(w.dir, "clone", "-q", w.forkRepo, other);
|
||||
const ahead = commit(other, "ahead");
|
||||
git(other, "push", "-q", w.forkRepo, `${ahead}:refs/heads/main`);
|
||||
await rejects(w.go(), "remote-unrelated");
|
||||
});
|
||||
|
||||
test("T3: a wrong token refuses fork-unavailable; git's stderr isn't in the refusal", async (t) => {
|
||||
const w = await world(t);
|
||||
await assert.rejects(w.remote({ token: "fake_wrong_token_0123456789" }), (e) => e.code === "fork-unavailable" && e.message === "fork-unavailable");
|
||||
assert.ok(w.gitea.requests.length > 0);
|
||||
assert.equal(git(w.dir, `--git-dir=${w.forkRepo}`, "for-each-ref", DST), "");
|
||||
});
|
||||
|
||||
test("T3: a 302 is not followed: fork-unavailable, and the target sees no request", async (t) => {
|
||||
const w = await world(t);
|
||||
const target = await fakeGitea({ dir: join(w.dir, "target"), tls: scratchCa(join(w.dir, "ca2")), user: USER, token: TOKEN });
|
||||
t.after(() => target.close());
|
||||
const bounce = await fakeGitea({ dir: join(w.dir, "bounce"), tls: scratchCa(join(w.dir, "ca3")), user: USER, token: TOKEN, redirect: target.origin });
|
||||
t.after(() => bounce.close());
|
||||
const tls = ["-c", `http.sslCAInfo=${join(w.dir, "ca3", "ca.pem")}`];
|
||||
await rejects(w.remote({ fork: `${bounce.origin}/${USER}/stack`, tls }), "fork-unavailable");
|
||||
assert.ok(bounce.requests.length > 0);
|
||||
assert.equal(target.requests.length, 0);
|
||||
});
|
||||
|
||||
test("T3: the helper answers only the fork's host and port: a fork on another port gets no token", async (t) => {
|
||||
const w = await world(t);
|
||||
// The fork names port 1; insteadOf (through the tests' tls parameter) sends git to the fake's
|
||||
// port. The helper was told port 1, so it answers nothing and no password goes out.
|
||||
const before = w.gitea.requests.length;
|
||||
await rejects(w.remote({ fork: `https://127.0.0.1:1/${USER}/stack`, tls: [...w.tls, "-c", `url.https://127.0.0.1:${w.gitea.port}/.insteadOf=https://127.0.0.1:1/`] }), "fork-unavailable");
|
||||
assert.ok(w.gitea.requests.length > before, "the rewritten URL reached the fake");
|
||||
assert.equal(w.gitea.requests.slice(before).some((r) => r.user !== null), false, "no credential went to a host the helper wasn't told");
|
||||
});
|
||||
|
||||
test("T3: a fork that echoes the token refuses credential-leak", async (t) => {
|
||||
const w = await world(t);
|
||||
const hook = join(w.forkRepo, "hooks", "pre-receive");
|
||||
writeFileSync(hook, `#!/bin/sh\necho ${TOKEN} >&2\nexit 0\n`);
|
||||
chmodSync(hook, 0o755);
|
||||
await rejects(w.go(), "credential-leak");
|
||||
});
|
||||
|
||||
test("T3: no tls parameter, no trust: the scratch CA reaches git only through it", async (t) => {
|
||||
const w = await world(t);
|
||||
await rejects(w.remote({ tls: [] }), "fork-unavailable");
|
||||
});
|
||||
|
||||
test("D9 steps 3 and 4: ref-moved and identity-mismatch refuse before any request", async (t) => {
|
||||
const w = await world(t);
|
||||
await rejects(w.go({ source: w.first }), "ref-moved");
|
||||
const other = commit(w.clone, "by someone else", "[email protected]");
|
||||
const file = join(w.dir, "b2.bundle");
|
||||
git(w.clone, "bundle", "create", "-q", file, "refs/heads/feat", "--not", "--remotes=origin");
|
||||
await push({ dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root, taskRef: TASK, branch: "feat", bundle: file });
|
||||
await rejects(w.go({ source: other }), "identity-mismatch");
|
||||
assert.equal(w.gitea.requests.length, 0);
|
||||
});
|
||||
|
||||
test("publishRemote refuses a fork, user or destination that isn't shell- and ref-safe before any git", async (t) => {
|
||||
const w = await world(t);
|
||||
for (const extra of [
|
||||
{ fork: `http://127.0.0.1:${w.gitea.port}/${USER}/stack` },
|
||||
{ fork: `https://127.0.0.1:${w.gitea.port}'/${USER}/stack` },
|
||||
{ user: "co'der" },
|
||||
{ dst: "refs/heads/main" },
|
||||
{ dst: "refs/heads/pm/feat" },
|
||||
{ source: "HEAD" },
|
||||
]) await rejects(w.remote(extra), "invalid-request");
|
||||
assert.equal(w.gitea.requests.length, 0);
|
||||
});
|
||||
@@ -80,11 +80,7 @@ export function tasksAdapter({
|
||||
tree: null,
|
||||
bots: new Set([businesses[name].tracker.sync.botId, ...Object.values(s.roles).map((r) => r.botId)]),
|
||||
sync: use('@sync'),
|
||||
// Row 199 (#1697, design D5): a request's call keys from the broker's session for its cap,
|
||||
// never from a role name. probe is the host's own start check of each role's token.
|
||||
as: (session, method, path, opts) =>
|
||||
credentials.useFor(session, 'vikunja', (token) => call(token, method, path, opts)),
|
||||
probe: (role, method, path, opts) => use(role)(method, path, opts),
|
||||
as: (role, method, path, opts) => use(role)(method, path, opts),
|
||||
state: 'starting',
|
||||
refused: null,
|
||||
queue: Promise.resolve(),
|
||||
@@ -100,7 +96,7 @@ export function tasksAdapter({
|
||||
timers: [],
|
||||
};
|
||||
ctx.syncCall = (_token, method, path, opts) => ctx.sync(method, path, opts);
|
||||
ctx.asCall = (role) => (_token, method, path, opts) => ctx.probe(role, method, path, opts);
|
||||
ctx.asCall = (role) => (_token, method, path, opts) => ctx.as(role, method, path, opts);
|
||||
ctx.handle = verbs(ctx);
|
||||
contexts.set(name, ctx);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function startup(ctx) {
|
||||
if (probe.status === 404 || probe.status === 403 || outcome(probe) === 'ok') refuse('scope-too-broad');
|
||||
expect(probe, 401, 'tracker-unexpected');
|
||||
for (const [role, r] of Object.entries(ctx.roles)) {
|
||||
const control = await ctx.probe(role, 'GET', `/tasks/${MISSING}`);
|
||||
const control = await ctx.as(role, 'GET', `/tasks/${MISSING}`);
|
||||
if (control.status !== 404 || control.json?.code !== 4002) {
|
||||
expect(control, 404, 'tracker-unexpected');
|
||||
refuse('tracker-unexpected');
|
||||
@@ -49,7 +49,7 @@ export async function startup(ctx) {
|
||||
const probes = [['DELETE', `/tasks/${MISSING}`, undefined]];
|
||||
if (r.definition !== 'pm') probes.push(['POST', `/tasks/${MISSING}/labels`, { label_id: 1 }]);
|
||||
for (const [method, path, body] of probes) {
|
||||
const p = await ctx.probe(role, method, path, { body });
|
||||
const p = await ctx.as(role, method, path, { body });
|
||||
if (p.status === 404 || p.status === 403 || outcome(p) === 'ok') refuse('scope-too-broad');
|
||||
if (p.status !== 401) refuse(p.status === 0 || p.status >= 500 ? 'tracker-unavailable' : 'tracker-unexpected');
|
||||
}
|
||||
|
||||
@@ -122,9 +122,8 @@ export function verbs(ctx) {
|
||||
const relatedTo = (task, kind, other) =>
|
||||
Array.isArray(task.related_tasks?.[kind]) && task.related_tasks[kind].some((t) => t?.id === other);
|
||||
// One write with the acting role's token. An uncertain answer is settled by a re-read.
|
||||
// `me` is the broker's session for the caller's cap (design D5).
|
||||
async function step(me, method, path, body, at, done) {
|
||||
const r = await ctx.as(me, method, path, { body });
|
||||
async function step(role, method, path, body, at, done) {
|
||||
const r = await ctx.as(role, method, path, { body });
|
||||
const o = outcome(r);
|
||||
if (o === 'ok') return r;
|
||||
if (o === 'uncertain' && at !== null && done) {
|
||||
@@ -145,7 +144,7 @@ export function verbs(ctx) {
|
||||
if (!mine) fail('tracker-role');
|
||||
if (PM_VERBS.has(verb) && mine.definition !== 'pm') fail('field-writer');
|
||||
const plan = prepare(verb, args, role, mine);
|
||||
if (verb === 'task.create') return create(cap, me, plan);
|
||||
if (verb === 'task.create') return create(cap, role, plan);
|
||||
const at = target(args.task_ref);
|
||||
const before = await read(at);
|
||||
if (args.expect !== undefined && args.expect !== before.digest) {
|
||||
@@ -166,7 +165,7 @@ export function verbs(ctx) {
|
||||
let landed = 0;
|
||||
try {
|
||||
for (const [method, path, body, done] of work.steps) {
|
||||
await step(me, method, path, body, at, done);
|
||||
await step(role, method, path, body, at, done);
|
||||
landed++;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -386,12 +385,12 @@ export function verbs(ctx) {
|
||||
fail('unknown-verb');
|
||||
}
|
||||
}
|
||||
async function create(cap, me, plan) {
|
||||
async function create(cap, role, plan) {
|
||||
if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found');
|
||||
await located(plan.others);
|
||||
ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null });
|
||||
// No re-read can find a create whose answer was lost, so an uncertain create is reported as such.
|
||||
const r = await step(me, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null);
|
||||
const r = await step(role, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null);
|
||||
if (r.status !== 201 && r.status !== 200) fail('tracker-unexpected');
|
||||
checkTask(r.json);
|
||||
const id = r.json.id;
|
||||
@@ -400,9 +399,9 @@ export function verbs(ctx) {
|
||||
let failure = null;
|
||||
try {
|
||||
for (const l of plan.labels)
|
||||
await step(me, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l));
|
||||
await step(role, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l));
|
||||
for (const x of plan.relations)
|
||||
await step(me, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (a) =>
|
||||
await step(role, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (a) =>
|
||||
relatedTo(a.task, x.kind, x.other),
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -73,8 +73,7 @@ business's arbiter for its domain. Pass `domain` to `raise_decision`,
|
||||
gated raise, and every hard wait, goes to the human through the inbox.
|
||||
The hard waits are `spend`, `data.delete`, `network.expose`,
|
||||
`account.create`, `credential.mint`, `message.external`,
|
||||
`git.push.protected`, `git.push.remote` and `deploy`; a citation never
|
||||
moves them.
|
||||
`git.push.protected` and `deploy`; a citation never moves them.
|
||||
A `role.revoke` raise whose target is the assistant is a hard wait too:
|
||||
it goes to the human, cited or not.
|
||||
|
||||
|
||||
+1
-2
@@ -53,8 +53,7 @@ business's arbiter for its domain. Pass `domain` to `raise_decision`,
|
||||
gated raise, and every hard wait, goes to the human through the inbox.
|
||||
The hard waits are `spend`, `data.delete`, `network.expose`,
|
||||
`account.create`, `credential.mint`, `message.external`,
|
||||
`git.push.protected`, `git.push.remote` and `deploy`; a citation never
|
||||
moves them.
|
||||
`git.push.protected` and `deploy`; a citation never moves them.
|
||||
A `role.revoke` raise whose target is the assistant is a hard wait too:
|
||||
it goes to the human, cited or not.
|
||||
|
||||
|
||||
+1
-1
@@ -35,6 +35,6 @@ if [ "${1:-}" = business ]; then
|
||||
exec node "$REPO/packages/business/src/cli.mjs" "$@"
|
||||
fi
|
||||
case "${1:-}" in
|
||||
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|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" "$@"
|
||||
|
||||
Reference in New Issue
Block a user