Compare commits

...
Author SHA1 Message Date
jason.woltje 86963d2816 plans: row 199 BUILD.md, mutant batch 2 on 7c3cdd940: 26 killed, M17 and M27 equivalent; pkg-bus 477, pkg-seat 164, pkg-cli 522 green (sage-1565, #1697) 2026-10-20 18:27:06 -05:00
jason.woltje 7c3cdd9406 plans: row 199 BUILD.md, the gate on 00d4cc1f6, mutant batch 1 and its five survivors, the fixes, deviations 16 (publishTls refused) and 17 (M17 equivalent) (sage-1563, #1697) 2026-10-20 18:15:17 -05:00
jason.woltje 116eeec48c bus: row 199 mutants M06 and M26 now die; a test makes revoke's unlink fail and finds the token already gone from memory; the bus process refuses a boot config carrying publishTls (invalid-host-request) instead of dropping it, and the process test asserts the refusal (sage-1563, #1697) 2026-10-20 18:14:43 -05:00
jason.woltje 5fa7b25532 seat: row 199 mutants M05 and M12 now die; a test reads git's argv through a PATH shim and finds no token and no userinfo; the 302 test's target shares the CA git trusts, so only http.followRedirects=false keeps git off it (sage-1563, #1697) 2026-10-20 18:12:23 -05:00
jason.woltje c6868ba413 cli: row 199 rotate on a due gitea key; ISSUED names rotation-due, the state the bus's status() gives a gitea key past its rotate-by date, so credentials rotate no longer refuses it; a test over every status state; BUILD.md deviation 15 (gizmoduck-0050, sage-1494, #1697) 2026-10-20 17:55:12 -05:00
jason.woltje ff22fadebf cli: row 199 sage-1440: publishResult's poll is bounded by publishDeadlineMs (four times git's limit and grace plus 30 s) and refuses publish-timeout, filed once; end-to-end tests for ref-moved and decision-mismatch mid-flight and for the timeout; DESIGN.md T3 now says such a push refuses; BUILD.md deviations 11, 13 and 14 ruled (#1697) 2026-10-20 17:55:12 -05:00
jason.woltje 424bc67160 plans: row 199 BUILD.md, deviation 10 ruled (sage-1421), deviations 11 to 14 for publish, the publish runs (#1697) 2026-10-20 17:55:12 -05:00
jason.woltje 148044ef3f cli: row 199 the git socket's publish op; checkPublish, the seat's checks under the lock, then publishPush and a poll of publishResult, then recordPublish; a refusal after the ticket is filed once by publishRefused; the bind rule adds git.push.remote; an end-to-end test through the in-process runtime, the store and a TLS fake Gitea (#1697) 2026-10-20 17:55:12 -05:00
jason.woltje 8d0f913a82 bus: row 199 publishPush and publishResult; the push runs in the bus child under withGitea with fork, user, source and dst from the ticket (publishTarget), and the host polls; recordPublish refuses a ref that isn't the ticket's dst; the bus process answers the five publish ops; publishTls is in-process only (#1697) 2026-10-20 17:55:12 -05:00
jason.woltje b415d4e765 seat: row 199 publish and publishRemote; the tip and identity checks under the working repo's lock, then ls-remote and a push by oid to refs/heads/<user>/<branch> on the agent's fork; the token reaches git on fd 3 through a one-shot helper for the fork's exact host and port; no redirects, no stored remote or credential; a TLS fake Gitea for the tests (#1697) 2026-10-20 17:55:12 -05:00
jason.woltje c80a238534 plans: row 199 BUILD.md, the lifecycle and CLI commits, deviations 7 to 10 (#1697) 2026-10-20 17:55:12 -05:00
jason.woltje 48c2757f36 cli: row 199 mosaic credentials status, issue, rotate, revoke and delete; issue and rotate read the token on stdin, never argv; --tester takes status and revoke; scripts/mosaic routes credentials to packages/cli (#1697) 2026-10-20 17:55:12 -05:00
jason.woltje 0103d08fbd bus: row 199 credential lifecycle verbs, credentials.issue (rotate over an issued entry), credentials.revoke and credentials.delete; human-only, the tester revokes; delete waits for the agent's claims; each verb leaves one changes.jsonl receipt with no value, record() for a store-only change (#1697) 2026-10-20 17:55:12 -05:00
jason.woltje 4996380126 tasks: row 199 request calls use useFor keyed from the session; the host's start probe keeps use; a D5 test pins the use( call sites (#1697) 2026-10-20 17:55:12 -05:00
jason.woltje a9c59ee5ac plans: row 199 BUILD.md, deviations 1 to 6, N9 for the routing commit, R1 backup set checked (#1697) 2026-10-20 17:55:12 -05:00
jason.woltje 12aea3416a bus: row 199 runtime wiring, the store root is <configDir>/secrets/agents, and the op table gets checkPublish, withGitea, recordPublish and publishRefused; withGitea keys from the open publish ticket (#1697)
Deviation from D9b, recorded for BUILD.md: withGitea takes the ticket, not a cap. The key is the
session checkPublish checked as an agent holding its claim (broker.publishSession), so a tester,
reader or human cap never reaches a token, and the token is usable only between checkPublish and
recordPublish or publishRefused. Its refusals are filed once, by publishRefused.
2026-10-20 17:55:12 -05:00
jason.woltje e2f3208258 bus: row 199 per-agent credential store, store references, issue (tmp, fsync, rename, commit, memory), revoke (memory first, store file only), remove, useFor keyed from the session, not-issued and revoked states (#1697)
Design D1, D3a, D4 and section 6. Tests T1, T2 and the store half of T5 use fake tokens in a scratch
root. A wrong owner can't be set without root, so T1 covers modes, symlinks and dirs only.
2026-10-20 17:55:12 -05:00
jason.woltje b8e10315d9 bus: row 199 broker publish authority, the decision binds the latest push (pushCommit), checkPublish builds the agent's fork, recordPublish spends the decision; the D9c parse moves to vocabulary.mjs to keep the broker's import closure (#1697)
git.push.remote is a hard wait. decision.raise records the task's latest push commit and refuses
no-push without one; the authority check refuses decision-mismatch when the latest push moved.
checkPublish refuses decision-consumed, remote-not-granted, remote-host-mismatch, remote-not-fork,
no-push, work-not-yours and work-ref, and returns the fork, user, email, source and host-built
destination. pm.md and chief-of-staff.md list git.push.remote among the hard waits.
2026-10-20 17:55:12 -05:00
jason.woltje 1cec552b8b business: row 199 agent identity, publish grant, project upstream with the D9c parse, store credential references; git.push.remote as a hard wait (#1697) 2026-10-20 17:55:12 -05:00
jason.woltje 297259d3e5 plans: row 199 design r6, answers duckworth-0026 B5 (ls-remote, push and helper use the derived fork) and B6 (URL parse in D9c), N10, N11 (#1697) 2026-10-20 17:55:11 -05:00
jason.woltje c33bc66802 plans: row 199 design r5, the host derives each agent's fork from the upstream and its own Gitea user (webby-0011 N1), identity optional and required by publish (#1697) 2026-10-20 17:55:11 -05:00
jason.woltje f04bb00b40 plans: row 199 design r4, answers duckworth-0017 B3 (TLS fake through a test seam), B4 (decision binds the commit, push by oid), N6 to N8 (#1697) 2026-10-20 17:55:11 -05:00
jason.woltje 20fa2595ea plans: row 199 design r3, Mos rulings mos-1027 (forks, git.push.remote as a hard wait, tester status and revoke only) (#1697) 2026-10-20 17:55:11 -05:00
jason.woltje 090cbb9629 plans: row 199 design r2, answers duckworth-0013 B1, B2 and N1 to N5 (#1697) 2026-10-20 17:55:11 -05:00
jason.woltje 627979a173 plans: row 199 design, per-agent credential store, own git identity, publish to a granted remote (#1697) 2026-10-20 17:55:11 -05:00
37 changed files with 3016 additions and 78 deletions
+216
View File
@@ -0,0 +1,216 @@
# 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: candidate on megavolt/row199-on-190, rebased on origin/refactor 00d4cc1f6 (sage-1555). The
full gate and the section 11 mutants ran in Sage's slot; results 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.
16. **The bus process refuses a boot config carrying `publishTls` (M26, sage-1563).** process.mjs
used to drop `publishTls` from the boot config before `startBroker`, and no test could see the
drop. The host's boot config (`packages/cli/src/config.mjs`) never carries it; it is the tests'
scratch CA for an in-process runtime only. A boot that carries it now refuses
`invalid-host-request` and the process exits 2, rather than trusting it or dropping it quietly.
The test: `bus/tests/publish.test.mjs`, "the bus process answers the publish ops from their own
lines". Every test file that reads process.mjs ran singly, green (see "Runs").
17. **M17 is equivalent: `#authorityFor`'s pushCommit comparison is also made by `#publishFacts`.**
A git.push.remote raise with no recorded push refuses `no-push` (`broker.mjs` raise,
`pushCommit = ... ?? fail('no-push')`), so every publish decision carries a pushCommit.
`#publishFacts` takes `source = result.pushCommit` and refuses `decision-mismatch` when it isn't
`#latestPush(task_ref).commit`, the same two values `#authorityFor` compares. The check stays as
defense in depth, for a decision whose context lacks pushCommit, which no code path writes today.
Duckworth judges it, as with M27.
## 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.
Rebase (sage-1555): the 21 commits from megavolt/row199 sit on origin/refactor 00d4cc1f6 as the new
branch megavolt/row199-on-190 (no force-push). `git range-diff` shows all 21 patches as `=`; the
merge of 12c85ada1 flattened with an empty remerge-diff.
Full gate, Sage's slot, on c6868ba41 (base 00d4cc1f6), logs started 2026-10-20T22:55:44Z: bus 476
pass 0 fail; business 191/0; tasks 97/0; cli 522/0 (472 s); seat 163/0; scripts/test-queue.sh
156/0; test-auth, test-conductor, test-config, test-discord, test-extension-package,
test-foundation, test-land-work, test-release and test-task each exit 0. The test-task compose run
left the network megavolt-row199_default with 0 containers; I removed it.
Section 11 mutants, batch 1, on c6868ba41, 2026-10-20T23:09:03Z to 23:09:56Z, restores verified and
packages/ clean: 22 of 28 killed; M27 survived, expected equivalent (deviation 13). Five expected
kills survived, each a test gap: M05 (the token in the push URL), M06 (revoke unlinks before it drops
memory), M12 (http.followRedirects=false dropped), M17 (#authorityFor's pushCommit check dropped)
and M26 (publishTls reaches startBroker). Launchpad's stack-release single file ran alongside from
23:09:28Z, so batch 2 reruns all 28 (sage-1563).
Fixes, each checked by its mutant alone: 5fa7b2553 seat publish, a test reads git's argv through a
PATH shim and finds no token and no userinfo (M05 killed); the 302 test's target shares the CA git
trusts, so only http.followRedirects=false keeps git off it (M12 killed). 116eeec48 bus,
credential-store: revoke's unlink fails (dir 0500) and the token is already out of memory (M06
killed); process.mjs refuses publishTls, deviation 16 (M26 killed, its anchor is now the refusal
line). M17 is equivalent, deviation 17. Single files after the fixes, each green: seat publish (12),
bus credential-store (12), bus publish (12), bus process (8), and every other file that reads
process.mjs: bus credential-status (4), limit-checks (16), land (17), tasks (9), end-launch (7); cli
trackers-boot (1), thread-launch (11), publish-socket (7). Hash pins: none on a changed file.
Section 11 mutants, batch 2, all 28, on 7c3cdd940 in Sage's slot (sage-1565), 2026-10-20T23:17:59Z to
23:18:48Z, restores verified and packages/ clean: 26 killed; M17 and M27 survived, both expected
equivalent (deviations 17 and 13). Each of the five batch 1 survivors but M17 now fails its test. Then
pkg-bus 477 pass 0 fail, pkg-seat 164/0, pkg-cli 522/0 (461 s), ending 23:26:40Z. No other seat's run
was active: the busy check said CLEAR before the batch started. Logs:
/mnt/storage/scratch/megavolt-r199/batch2-20261020T231650Z/.
+416
View File
@@ -0,0 +1,416 @@
# 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.
+6
View File
@@ -238,6 +238,12 @@ 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,
+7
View File
@@ -215,6 +215,13 @@ 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. |
+153 -6
View File
@@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path';
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -18,6 +18,8 @@ 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',
@@ -101,6 +103,11 @@ 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',
@@ -191,10 +198,13 @@ 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;
@@ -203,13 +213,15 @@ 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 }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, credentialVerbs = 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;
@@ -795,7 +807,12 @@ 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');
return { class: cls, decision: d.id };
// 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 };
}
// The raise's context: its target and approval option, from the decision.raise action.allowed.
#raised(business, decision) {
@@ -858,13 +875,14 @@ 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 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, 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",
business,
task,
);
return r ? JSON.parse(r.body) : null;
return r ? { ...JSON.parse(r.body), role: r.actor_role } : 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.
@@ -998,6 +1016,98 @@ 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) {
@@ -1245,6 +1355,7 @@ 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
@@ -1316,6 +1427,35 @@ 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
@@ -1698,6 +1838,12 @@ 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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
@@ -1710,7 +1856,7 @@ export class Broker {
cls,
a.action,
route,
a.question,
question,
JSON.stringify(a.options),
a.recommendation,
a.task_ref ?? null,
@@ -1732,6 +1878,7 @@ export class Broker {
approvalChoice,
holderRun:
a.action === 'role.revoke' ? (this.#holder(s.business, a.target)?.holder_run ?? null) : null,
...(pushCommit ? { pushCommit } : {}),
},
a.task_ref ?? (taskRef(a.target) ? a.target : did),
);
+53
View File
@@ -0,0 +1,53 @@
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' })),
};
};
}
+163 -19
View File
@@ -1,5 +1,8 @@
import { openSync, closeSync, fstatSync, readFileSync, lstatSync, realpathSync, constants } from 'node:fs';
import { isAbsolute, relative, resolve } from 'node:path';
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 { BusError } from './broker.mjs';
const deny = (code) => {
throw new BusError(code);
@@ -13,9 +16,33 @@ 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();
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env }) {
// 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;
try {
for (const [instance, services] of Object.entries(references))
for (const [service, ref] of Object.entries(services)) {
@@ -26,12 +53,41 @@ export class Credentials {
if (
(ref.service !== undefined && ref.service !== service) ||
Object.keys(ref).some(
(k) => !['service', 'file', 'env', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
(k) => !['service', 'file', 'env', 'store', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
) ||
Boolean(ref.file) === Boolean(ref.env)
Boolean(ref.file) === Boolean(ref.env) ||
(ref.store !== undefined && (ref.store !== true || ref.file !== this.#storePath(instance, service)))
)
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;
@@ -43,7 +99,7 @@ export class Credentials {
}
if (original.isSymbolicLink() || !original.isFile()) deny('credential-file');
if (
[...repoRoots, dataRoot].filter(Boolean).some((root) =>
[...this.#repoRoots, this.#dataRoot].filter(Boolean).some((root) =>
inside(
(() => {
try {
@@ -81,16 +137,98 @@ export class Credentials {
if (!/^[A-Z_][A-Z0-9_]*$/.test(ref.env)) deny('credential-reference');
token = env[ref.env];
}
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) {
this.close();
throw e;
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);
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
}
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;
@@ -109,14 +247,16 @@ export class Credentials {
let result;
try {
result = await operation(entry.token);
} catch {
} 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;
deny('service-failed');
}
this.assertClean(result);
return result;
}
status() {
return [...this.#tokens.entries()].map(([key, { date }]) => {
return [...this.#refs.entries()].map(([key, { date }]) => {
const i = key.lastIndexOf(':'),
service = key.slice(i + 1),
left = Date.parse(date + 'T00:00:00Z') - Date.now();
@@ -124,8 +264,11 @@ export class Credentials {
instance: key.slice(0, i),
service,
date,
state:
service === 'gitea'
state: !this.#tokens.has(key)
? this.#revoked.has(key)
? 'revoked'
: 'not-issued'
: service === 'gitea'
? left <= 0
? 'rotation-due'
: 'valid'
@@ -139,5 +282,6 @@ export class Credentials {
}
close() {
this.#tokens.clear();
this.#refs.clear();
}
}
+14
View File
@@ -14,6 +14,11 @@ const LAUNCH_OPS = new Set([
'checkLand',
'recordLand',
'landRefused',
'checkPublish',
'publishPush',
'publishResult',
'recordPublish',
'publishRefused',
'landHeld',
'threadsState',
'threadIdle',
@@ -34,6 +39,12 @@ 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);
@@ -99,7 +110,10 @@ 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, for an in-process runtime only. The host's boot
// config never carries it, so a boot that does is refused rather than trusted.
const { trackers, ...config } = message.config ?? {};
if (Object.hasOwn(config, 'publishTls')) throw new BusError('invalid-host-request');
if (trackers) {
const { tasksAdapter } = await import('../../tasks/src/adapter.mjs');
config.tasks = tasksAdapter({ trackers });
+56 -1
View File
@@ -8,9 +8,12 @@ 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';
@@ -72,6 +75,9 @@ 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 {
@@ -86,7 +92,9 @@ export async function startBroker({
...repoRoots,
...Object.values(businesses).flatMap((b) => Object.values(b.projects ?? {}).map((p) => p.root)),
];
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot });
// 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 });
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.
@@ -133,6 +141,11 @@ 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) => {
@@ -212,6 +225,42 @@ 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
@@ -237,6 +286,12 @@ 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),
@@ -0,0 +1,200 @@
// 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 drops the token from memory before the unlink, so a failed unlink still leaves it revoked', async (t) => {
const s = setup(t);
const c = s.open();
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
assert.deepEqual(await s.read(c), { same: true, rotated: false });
// An agent dir the bus can't write: the unlink fails with EACCES.
const agentDir = join(s.storeRoot, 'acme', 'coder');
chmodSync(agentDir, 0o500);
try {
assert.throws(() => c.revoke('acme/coder', 'gitea'), /credential-file/);
} finally {
chmodSync(agentDir, 0o700);
}
assert.equal(existsSync(s.file), true);
assert.equal(s.state(c), 'revoked');
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()));
});
@@ -0,0 +1,163 @@
// 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);
});
+7 -3
View File
@@ -10,7 +10,7 @@ import { fixture } from './spec-fixtures.mjs';
// PRD 1.5 REQ-WAIT-1, REQ-WAIT-2 and REQ-DEC-2 (row 139, #1633).
const WAITS = ['spend', 'data.delete', 'network.expose', 'account.create', 'credential.mint', 'message.external',
'git.push.protected', 'deploy'];
'git.push.protected', 'git.push.remote', '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,10 +36,14 @@ 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);
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 });
for (const action of WAITS) {
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }]) {
const d = await f.raise({ action, ...args });
const d = await f.raise({ action, ...(action === 'git.push.remote' ? { target: 'vikunja:1/2' } : {}), ...args });
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, `${action} ${JSON.stringify(args)}`);
}
}
+2 -2
View File
@@ -22,8 +22,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
"import { MERGE, LandingRefusal, readLanding } from './landing.mjs';",
// Row 141 (#1635): the business ID_PATTERN for instance names. The vocabulary is already in
// RUNNER_FILES and imports nothing. Row 155 (#1649) reads THREAD_DEFINITION and END_PROTECTED
// from it too.
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';",
// 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';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
+283
View File
@@ -0,0 +1,283 @@
// 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 is refused: the scratch CA is for an in-process runtime only.
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: [] };
const tlsChild = fork(new URL('../src/process.mjs', import.meta.url), [], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
t.after(() => {
if (tlsChild.exitCode === null) tlsChild.kill('SIGKILL');
});
const refused = once(tlsChild, 'message');
const gone = once(tlsChild, 'exit');
tlsChild.send({ op: 'boot', config: { ...config, publishTls: ['-c', 'http.sslCAInfo=/nonexistent'] } });
assert.deepEqual((await refused)[0], { ok: false, error: 'invalid-host-request' });
assert.equal((await gone)[0], 2);
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);
});
+57 -8
View File
@@ -10,6 +10,7 @@ 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";
@@ -40,7 +41,7 @@ function checkBot(tracker, where) {
return { bot: tracker.bot, botId: tracker.botId };
}
function checkCredentialMap(map, services, where) {
function checkCredentialMap(map, services, where, store = null) {
requireObject(map, where);
const names = Object.keys(map).sort();
const wanted = [...services].sort();
@@ -48,10 +49,37 @@ function checkCredentialMap(map, services, where) {
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}`);
for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`, { store });
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);
@@ -74,7 +102,7 @@ function checkTracker(tracker, file) {
return { sync, labels };
}
function checkRoles(roles, file, rolesDir) {
function checkRoles(roles, file, rolesDir, business) {
const where = `${file} roles`;
requireObject(roles, where);
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
@@ -86,7 +114,7 @@ function checkRoles(roles, file, rolesDir) {
requireId(instance, `${where} instance name`);
const at = `${where}.${instance}`;
requireObject(entry, at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials"], at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish"], at);
requireId(entry.definition, `${at}.definition`);
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
definitions[entry.definition] = definition;
@@ -108,8 +136,18 @@ function checkRoles(roles, file, rolesDir) {
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`),
credentials: checkCredentialMap(entry.credentials ?? {}, services, `${at}.credentials`,
{ configDir: dirname(dirname(file)), business, agent: instance }),
};
// 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 };
}
@@ -159,7 +197,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);
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id);
requireObject(document.arbiters, `${file} arbiters`);
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
@@ -183,7 +221,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"], `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root", "repo", "remote"], `${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`);
}
@@ -191,7 +229,18 @@ 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`);
}
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}) };
// 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}`);
}
}
const tracker = checkTracker(document.tracker, file);
+29 -8
View File
@@ -3,7 +3,7 @@
// lstat and realpath only; nothing in this package opens a token file.
import { lstatSync, realpathSync } from "node:fs";
import { isAbsolute, normalize, relative, sep } from "node:path";
import { isAbsolute, join, normalize, relative, sep } from "node:path";
import { refuse } from "./errors.mjs";
import { SERVICES } from "./vocabulary.mjs";
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
@@ -14,21 +14,35 @@ const WARN_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
// Shape check for one reference. Returns a frozen { service, file | env,
// rotateBy | expires }.
export function parseCredentialRef(ref, service, where) {
// 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 } = {}) {
if (!SERVICES.includes(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
requireObject(ref, where);
const dateKey = DATE_KEY[service];
rejectUnknownKeys(ref, ["file", "env", dateKey], where);
rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where);
const hasFile = ref.file !== undefined;
const hasEnv = ref.env !== undefined;
if (hasFile === hasEnv) refuse(`${where} must name exactly one of "file" or "env"`);
const hasStore = ref.store !== undefined;
if (Number(hasFile) + Number(hasEnv) + Number(hasStore) !== 1) refuse(`${where} must name exactly one of "file", "env" or "store"`);
const out = { service };
if (hasFile) {
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 (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;
@@ -38,6 +52,11 @@ export function parseCredentialRef(ref, service, where) {
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));
@@ -55,8 +74,10 @@ export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(),
let stat = null;
try {
stat = lstatSync(ref.file);
} catch {
problems.push(`${label}: not found`);
} 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`);
}
if (stat) {
if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`);
+22 -3
View File
@@ -174,7 +174,9 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
}
}
function write(id, document, { revision, actor }) {
// 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 }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
return withLock(id, ({ home, parent }) => {
@@ -201,7 +203,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
syncDir(tx);
const event = {
transaction, actor, business: id, before, after,
changes: changes(old, written), at: new Date().toISOString(),
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
syncDir(home);
@@ -219,5 +221,22 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
});
}
return { logFile, read, write, recover: id => withLock(id, () => recover(id)) };
// 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)) };
}
+52 -2
View File
@@ -20,6 +20,9 @@ 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",
@@ -83,8 +86,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 and
// deploy; a test pins the list.
// account.create, credential.mint, message.external, git.push.protected,
// git.push.remote 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
@@ -133,3 +136,50 @@ 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;
}
@@ -0,0 +1,126 @@
// Row 199 (#1697): an agent's own git identity, its publish grant, the project's upstream and the
// credential store reference. Design D1, D8, D9 step 2 and D9c.
import { test } from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import { BusinessError, parseCredentialRef, validateBusinessDocument } from "../src/index.mjs";
import { forkProblem, forkUrl } from "../src/vocabulary.mjs";
import { businessDoc, FAR_DATE, REPO_ROLES, tmp } from "./helpers.mjs";
function refuses(fn, pattern) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
assert.equal(error.exitCode, 2);
assert.match(error.message, pattern);
return true;
});
}
const IDENTITY = { name: "Coder", email: "[email protected]", gitea: { url: "https://git.example", user: "acme-coder" } };
// The fixture with coder granted publish on stack, whose upstream is mosaic/stack.
function check(mutate) {
const root = tmp();
const doc = businessDoc(root);
doc.projects.stack.remote = "https://git.example/mosaic/stack";
doc.roles.coder.identity = structuredClone(IDENTITY);
doc.roles.coder.publish = ["stack"];
mutate?.(doc, root);
return validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
}
test("today's files load unchanged: identity, publish and remote are optional", () => {
const root = tmp();
const business = validateBusinessDocument(businessDoc(root), join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
assert.equal(business.roles.coder.identity, undefined);
assert.equal(business.roles.coder.publish, undefined);
assert.equal(business.projects.stack.remote, undefined);
});
test("a granted agent loads with its identity, grant and the project's upstream", () => {
const business = check();
assert.deepEqual({ ...business.roles.coder.identity, gitea: { ...business.roles.coder.identity.gitea } }, IDENTITY);
assert.deepEqual([...business.roles.coder.publish], ["stack"]);
assert.equal(business.projects.stack.remote, "https://git.example/mosaic/stack");
});
test("N10: publish needs identity and a gitea definition, and the error names the grant", () => {
refuses(() => check((d) => { delete d.roles.coder.identity; }), /roles\.coder\.publish needs identity and a gitea definition/);
refuses(() => check((d) => { d.roles.chief.identity = structuredClone(IDENTITY); d.roles.chief.publish = ["stack"]; }),
/roles\.chief\.publish needs identity and a gitea definition/);
});
test("publish names declared projects that have a remote, each once", () => {
refuses(() => check((d) => { d.roles.coder.publish = ["books"]; }), /publish names books, which the business file doesn't declare/);
refuses(() => check((d) => { delete d.projects.stack.remote; }), /publish names stack, which has no "remote"/);
refuses(() => check((d) => { d.roles.coder.publish = []; }), /publish/);
refuses(() => check((d) => { d.roles.coder.publish = ["stack", "stack"]; }), /publish/);
});
test("identity fields are checked", () => {
refuses(() => check((d) => { d.roles.coder.identity.email = "coder"; }), /identity\.email/);
refuses(() => check((d) => { d.roles.coder.identity.name = "a\nb"; }), /identity\.name/);
refuses(() => check((d) => { d.roles.coder.identity.extra = 1; }), /identity/);
refuses(() => check((d) => { d.roles.coder.identity.gitea.user = ".."; }), /gitea\.user/);
refuses(() => check((d) => { d.roles.coder.identity.gitea.user = "a/b"; }), /gitea\.user/);
});
test("D9c: the upstream must be its own normalized form, exactly /OWNER/REPO", () => {
const bad = {
"a dot segment": "https://git.example/acme-coder/../mosaic/stack",
"an encoded dot segment": "https://git.example/acme-coder/%2e%2e/mosaic/stack",
"an extra segment": "https://git.example/mosaic/stack/more",
"a missing segment": "https://git.example/stack",
"a trailing slash": "https://git.example/mosaic/stack/",
userinfo: "https://u:[email protected]/mosaic/stack",
"a query": "https://git.example/mosaic/stack?x=1",
"a fragment": "https://git.example/mosaic/stack#f",
"an explicit :443": "https://git.example:443/mosaic/stack",
"an upper-case host": "https://GIT.example/mosaic/stack",
"a backslash": "https://git.example\\mosaic\\stack",
"a name outside Gitea's rule": "https://git.example/mosaic/st%41ck",
"a dot-dot repo": "https://git.example/mosaic/...git",
http: "http://git.example/mosaic/stack",
};
for (const [what, remote] of Object.entries(bad)) {
assert.throws(() => check((d) => { d.projects.stack.remote = remote; }), BusinessError, what);
}
});
test("D9c: one .git is stripped, and the fork is built from the stripped name", () => {
assert.equal(check((d) => { d.projects.stack.remote = "https://git.example/mosaic/stack.git"; }).projects.stack.remote,
"https://git.example/mosaic/stack.git");
assert.equal(forkUrl("https://git.example/mosaic/stack.git", "acme-coder"), "https://git.example/acme-coder/stack");
assert.equal(forkUrl("https://git.example:3000/mosaic/stack", "acme-coder"), "https://git.example:3000/acme-coder/stack");
});
test("D9c: gitea.url names the instance only", () => {
for (const url of ["https://git.example/gitea", "https://git.example/gitea/", "https://git.example/?x", "https://git.example:443", "http://git.example"]) {
assert.throws(() => check((d) => { d.roles.coder.identity.gitea.url = url; }), BusinessError, url);
}
check((d) => { d.roles.coder.identity.gitea.url = "https://git.example/"; });
});
test("the upstream's host and port must be the agent's Gitea's", () => {
refuses(() => check((d) => { d.projects.stack.remote = "https://other.example/mosaic/stack"; }), /remote-host-mismatch/);
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example:3000/mosaic/stack"; }), /remote-host-mismatch/);
assert.equal(forkProblem("https://git.example/mosaic/stack", { gitea: { url: "https://git.example/", user: "u" } }), null);
});
test("remote-not-fork: an upstream the agent's own user owns, compared without case", () => {
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example/acme-coder/stack"; }), /remote-not-fork/);
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example/ACME-Coder/stack"; }), /remote-not-fork/);
});
test("store references: own path only (design D1, D3a)", () => {
const store = { configDir: "/cfg", business: "acme", agent: "coder" };
const ref = parseCredentialRef({ store: true, rotateBy: FAR_DATE }, "gitea", "r", { store });
assert.equal(ref.file, "/cfg/secrets/agents/acme/coder/gitea");
assert.equal(ref.store, true);
refuses(() => parseCredentialRef({ store: true, rotateBy: FAR_DATE }, "gitea", "r"), /store/);
refuses(() => parseCredentialRef({ store: true, file: "/a", rotateBy: FAR_DATE }, "gitea", "r", { store }), /exactly one/);
refuses(() => parseCredentialRef({ file: "/cfg/secrets/agents/acme/pm/gitea", rotateBy: FAR_DATE }, "gitea", "r", { store }),
/credential-not-yours/);
assert.equal(parseCredentialRef({ file: "/cfg/secrets/agents/acme/coder/gitea", rotateBy: FAR_DATE }, "gitea", "r", { store }).file,
"/cfg/secrets/agents/acme/coder/gitea");
});
+1 -1
View File
@@ -9,7 +9,7 @@ import { validateRoleDocument } from "../src/index.mjs";
import { tmp } from "./helpers.mjs";
const WAITS = ["spend", "data.delete", "network.expose", "account.create", "credential.mint", "message.external",
"git.push.protected", "deploy"];
"git.push.protected", "deploy", "git.push.remote"];
const LEFT = ["git.merge.protected", "policy.change", "prd.approve", "role.revoke"];
const role = (authority) => {
const dir = tmp();
+13
View File
@@ -1022,6 +1022,19 @@ names no one; the restart itself needs Mos's go or Jason's hand. A vars or launc
prints `no restart`, because the launcher reads it at its next launch check.
The Console Settings page has the same read and write (`packages/webui`).
## credentials (row 199)
`mosaic credentials [status [AGENT]] [--json] [--tester]` lists each agent's
credential kinds with date and state, never a value. `issue AGENT gitea
--rotate-by DATE` and `issue AGENT vikunja --expires DATE` read the token on
stdin (pipe it, or paste it and end input) and send it once to the bus's
`credentials.issue`; it is never an argument. `rotate` is the same over an
entry the bus holds now, and refuses before reading stdin when there is none.
`revoke AGENT [KIND] --reason TEXT` and `delete AGENT --reason TEXT` drop the
entries; `delete` refuses `agent-running` while the agent holds a claim. With
`--tester` only `status` and `revoke` run. After a rotate or revoke, delete
the old token in Gitea or Vikunja yourself; the bus only forgets it.
## Token usage
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
+4
View File
@@ -17,6 +17,7 @@ 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";
@@ -38,6 +39,7 @@ export const USAGE = `usage:
${USAGE_USAGE}
${LIMITS_USAGE}
${PLUGINS_USAGE}
${CREDENTIALS_USAGE}
${INSTANCES_USAGE}
${PROJECT_USAGE}
${PROJECT_BUS_USAGE}
@@ -576,6 +578,8 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 199 (#1697): issue and rotate read the token on stdin.
if (verb === "credentials") return credentialsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 165 (#1662): scripts/mosaic sends only `business instances` and `business instance` here.
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
if (verb === "talk") return talk(rest, io, deps);
+83
View File
@@ -0,0 +1,83 @@
// `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);
}
}
+72 -3
View File
@@ -26,6 +26,19 @@
// 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
@@ -64,9 +77,10 @@ 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, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, publish, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
import { CliError } from "./errors.mjs";
const HEADER_MAX = 4096;
@@ -84,6 +98,11 @@ 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})?$/;
@@ -272,7 +291,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 }) {
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 }) {
const path = gitSocketPath(dataRoot);
const dir = join(dataRoot, "bus-host");
let server = null;
@@ -386,6 +405,55 @@ 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;
@@ -410,7 +478,8 @@ 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 throw new Refusal("invalid-request", "op is push, fetch or land");
else if (req.op === "publish") await doPublish(socket, cap, req);
else throw new Refusal("invalid-request", "op is push, fetch, land or publish");
} catch (e) {
if (e instanceof Recorded) return fail(e.code);
const code = e instanceof Refusal || e instanceof WorkingError ? e.code : "git-failed";
+5 -3
View File
@@ -47,8 +47,9 @@
// 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 or
// review.verdict, or a merge decision to land (landHeld, row 137);
// 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);
// session.json names it as gitSocket.
//
// Thread sessions (row 121, #1615). A thread instance (an instance whose
@@ -397,8 +398,9 @@ 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 || held.includes("git.push.working") || held.includes("review.verdict") ? gitSocketPath(dataRoot) : null;
const git = land || ["git.push.working", "review.verdict", "git.push.remote"].some((a) => held.includes(a)) ? gitSocketPath(dataRoot) : null;
writeSessionFile(runDir, {
...sessionDefaults,
business: b.id,
+114
View File
@@ -0,0 +1,114 @@
// Row 199 (#1697, design section 6, T4, T5): `mosaic credentials`. issue and rotate read the token on
// stdin and pass it once to credentials.issue; nothing printed carries it. --tester takes status and
// revoke only. A fake transport records the calls; no bus runs here.
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { main } from "../src/cli.mjs";
import { io, tmp } from "./helpers.mjs";
const TOKEN = "fake_cli_token_0123456789";
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
const ROWS = [
{ service: "gitea", account: null, role: "coder", dateKind: "rotateBy", date: "2099-01-01", state: "valid" },
{ service: "vikunja", account: "bot", role: "coder", dateKind: "expires", date: "2099-01-01", state: "not-issued" },
{ service: "gitea", account: null, role: "reviewer", dateKind: "rotateBy", date: "2099-01-01", state: "revoked" },
];
function deps(calls, rows = ROWS) {
const answer = (who) => (business) => async (verb, args) => {
calls.push({ who, business, verb, args });
if (verb === "credentials") return rows;
if (verb === "credentials.issue") return { agent: args.agent, verb: "issue", service: args.service, date: args.date, state: "valid" };
return { agent: args.agent, verb: verb.slice(12), services: [] };
};
return { system: { dataRoot: "/nonexistent" }, transport: answer("human"), testerTransport: answer("tester") };
}
const withStdin = (x, text) => {
x.stdin.end(text);
return x;
};
test("row 199: issue and rotate send the stdin token once to credentials.issue and print no value", async () => {
const calls = [];
const d = deps(calls);
const x = withStdin(io(), TOKEN + "\n");
await main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], x, d);
assert.deepEqual(calls, [{ who: "human", business: "acme", verb: "credentials.issue", args: { agent: "coder", service: "gitea", token: TOKEN + "\n", date: "2099-01-01" } }]);
assert.equal(x.out.text.includes(TOKEN), false);
assert.equal(JSON.parse(x.out.text).state, "valid");
// rotate checks the entry is issued before it reads stdin.
calls.length = 0;
await main(["credentials", "rotate", "coder", "gitea", "--rotate-by", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), d);
assert.deepEqual(calls.map((c) => c.verb), ["credentials", "credentials.issue"]);
calls.length = 0;
await assert.rejects(main(["credentials", "rotate", "coder", "vikunja", "--expires", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), d), /no issued vikunja credential to rotate; use issue/);
assert.deepEqual(calls.map((c) => c.verb), ["credentials"]);
// No token on stdin, the date flag of the other kind, a token in argv: refused before any write.
calls.length = 0;
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io(), "\n"), d), /no token on stdin/);
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--expires", "2099-01-01", "--business", "acme"], withStdin(io(), TOKEN), d), /mosaic credentials/);
await assert.rejects(main(["credentials", "issue", "coder", "gitea", TOKEN, "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io(), TOKEN), d), /mosaic credentials/);
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io(), "x".repeat(17000)), d), /too long/);
assert.equal(calls.filter((c) => c.verb === "credentials.issue").length, 0);
// From inside an agent run, the human's verbs refuse before any call.
await assert.rejects(main(["credentials", "issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--business", "acme"], withStdin(io({ CLAUDECODE: "1" }), TOKEN), d), /outside any agent run/);
});
// gizmoduck-0050: a gitea key past its rotate-by date is rotation-due, and rotate is how a human
// clears it. Every state the bus's status() gives a held token rotates; the other two refuse.
test("row 199: rotate goes ahead on each held state, rotation-due included, and refuses not-issued, revoked and an unknown entry", async () => {
const states = { valid: true, "rotation-due": true, expiring: true, expired: true, "not-issued": false, revoked: false };
for (const [state, held] of Object.entries(states)) {
const calls = [];
const d = deps(calls, [{ service: "gitea", account: null, role: "coder", dateKind: "rotateBy", date: "2020-01-01", state }]);
const run = main(["credentials", "rotate", "coder", "gitea", "--rotate-by", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), d);
if (held) await run;
else await assert.rejects(run, /no issued gitea credential to rotate; use issue/);
assert.deepEqual(calls.map((c) => c.verb), held ? ["credentials", "credentials.issue"] : ["credentials"], state);
}
const calls = [];
await assert.rejects(main(["credentials", "rotate", "tester", "gitea", "--rotate-by", "2099-06-01", "--business", "acme"], withStdin(io(), TOKEN), deps(calls)), /no issued gitea credential/);
assert.deepEqual(calls.map((c) => c.verb), ["credentials"]);
});
test("row 199: status, revoke and delete; the tester runs status and revoke only", async () => {
const calls = [];
const d = deps(calls);
const x = io();
await main(["credentials", "status", "coder", "--business", "acme"], x, d);
assert.equal(x.out.text, "coder\tgitea\tvalid\trotateBy 2099-01-01\ncoder\tvikunja\tnot-issued\texpires 2099-01-01\n");
const j = io();
await main(["credentials", "--json", "--business", "acme", "--tester"], j, d);
assert.equal(JSON.parse(j.out.text).length, 3);
await main(["credentials", "revoke", "coder", "gitea", "--reason", "fired", "--business", "acme", "--tester"], io(), d);
await main(["credentials", "revoke", "coder", "--reason", "fired", "--business", "acme"], io(), d);
await main(["credentials", "delete", "coder", "--reason", "fired", "--business", "acme"], io(), d);
assert.deepEqual(calls.map((c) => [c.who, c.verb, c.args]), [
["human", "credentials", {}],
["tester", "credentials", {}],
["tester", "credentials.revoke", { agent: "coder", service: "gitea", reason: "fired" }],
["human", "credentials.revoke", { agent: "coder", reason: "fired" }],
["human", "credentials.delete", { agent: "coder", reason: "fired" }],
]);
calls.length = 0;
for (const args of [
["issue", "coder", "gitea", "--rotate-by", "2099-01-01", "--tester"],
["rotate", "coder", "gitea", "--rotate-by", "2099-01-01", "--tester"],
["delete", "coder", "--reason", "x", "--tester"],
]) await assert.rejects(main(["credentials", ...args, "--business", "acme"], withStdin(io(), TOKEN), d), /--tester takes only status and revoke/);
for (const args of [["revoke", "coder"], ["delete", "coder"], ["delete", "coder", "gitea", "--reason", "x"], ["revoke", "coder", "model", "--reason", "x"], ["purge", "coder"]])
await assert.rejects(main(["credentials", ...args, "--business", "acme"], io(), d), /mosaic credentials/, args.join(" "));
assert.equal(calls.length, 0);
});
test("row 199: scripts/mosaic sends credentials to packages/cli, not the seat CLI", (t) => {
const root = tmp(t, "mosaic-cli-cred-route-");
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
const r = spawnSync(MOSAIC, ["credentials", "purge"], { env, encoding: "utf8" });
assert.equal(r.status, 4);
assert.match(r.stderr, /^mosaic: mosaic credentials \[status \[AGENT\]\]/);
assert.doesNotMatch(r.stderr, /mosaic launch </);
});
+281
View File
@@ -0,0 +1,281 @@
// 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")));
});
+7 -3
View File
@@ -101,8 +101,10 @@ 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.
export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null } = {}) {
// 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 } = {}) {
return new Promise((resolve) => {
let input = "ignore";
try {
@@ -110,8 +112,10 @@ export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null } = {}) {
} 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"] });
const child = spawn("git", argv, { env, detached: true, stdio: [input, "pipe", "pipe", ...(pass === null ? [] : ["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;
+89 -1
View File
@@ -63,6 +63,22 @@
// 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";
@@ -101,7 +117,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 } = {}) => runGit([...SAFE, ...pre, ...args], gitEnv(env), limits, { stdin });
const git = (args, env, { pre = [], limits = GIT_LIMITS, stdin = null, pass = null } = {}) => runGit([...SAFE, ...pre, ...args], gitEnv(env), limits, { stdin, pass });
// git with this call's env and limits, for the helpers below.
const bound = (env, limits) => (args, opts = {}) => git(args, env, { ...opts, limits });
@@ -402,3 +418,75 @@ async function removeWorktree(repo, worktree, g) {
if (!r.ok) rmSync(worktree, { recursive: true, force: true });
await g([`--git-dir=${repo}`, "worktree", "prune"]);
}
// await publish({ dataRoot, business, project, root, taskRef, source, email, remote, env, limits })
// Resolves what remote() resolves; rejects with WorkingError.
export function publish({ dataRoot, business, project, root, taskRef, source, email, remote, env = process.env, limits = GIT_LIMITS }) {
if (!PROJECT.test(project ?? "") || !OID.test(source ?? "") || typeof email !== "string" || !email) refuse("invalid-request");
const ref = `refs/heads/${workRef(taskRef)}`;
const repo = workingRepo(dataRoot, business, project);
const g = bound(env, limits);
return locked(repo, async () => {
try {
if (!lstatSync(repo).isDirectory()) refuse("working-repo-invalid");
} catch (e) {
if (e instanceof WorkingError) throw e;
refuse("working-repo-invalid", `${repo} can't be read: ${e.code}`);
}
await seed(repo, root, g);
const at = `--git-dir=${repo}`;
if ((await tip(repo, ref, g)) !== source) refuse("ref-moved", `${ref} isn't at ${source}`);
const range = await g([at, "log", "--format=%ae%x00%ce", `refs/heads/main..${source}`]);
if (!range.ok) refuse("working-repo-invalid", range.why);
for (const line of range.out.split("\n").filter(Boolean)) {
if (line !== `${email}\0${email}`) refuse("identity-mismatch", `a commit in main..${source.slice(0, 12)} isn't by ${email}`);
}
return remote();
});
}
const HOST = /^[a-z0-9.-]{1,253}(:[0-9]{1,5})?$/;
const NAME = /^[A-Za-z0-9_.-]{1,100}$/;
// The one-shot helper: for a `get` on https at exactly the fork's host and
// port, it answers the user and the line it reads from fd 3. Anything else,
// and `store` and `erase`, gets no answer. HOST and NAME keep both shell-safe.
const helper = (host, user) =>
`!f() { test "$1" = get || exit 0; p=; h=; while IFS= read -r l; do case "$l" in protocol=*) p=\${l#protocol=};; host=*) h=\${l#host=};; "") break;; esac; done; ` +
`test "$p" = https && test "$h" = '${host}' || exit 0; IFS= read -r t <&3 || exit 0; printf 'username=%s\\npassword=%s\\n' '${user}' "$t"; }; f`;
// await publishRemote({ repo, fork, user, source, dst, token, tls, env, limits })
// Resolves { ref, commit, previous }, previous being dst's tip on the fork
// before the push, or null. `tls` is for the tests' scratch CA only.
export async function publishRemote({ repo, fork, user, source, dst, token, tls = [], env = process.env, limits = GIT_LIMITS }) {
let url;
try {
url = new URL(fork);
} catch {
refuse("invalid-request");
}
if (url.protocol !== "https:" || !HOST.test(url.host) || !NAME.test(user ?? "") || !OID.test(source ?? "") || !dst?.startsWith(`refs/heads/${user}/`)) refuse("invalid-request");
const secret = String(token).trim();
const net = async (args) => {
const pre = [...tls, "-c", "protocol.https.allow=always", "-c", "http.followRedirects=false", "-c", "credential.helper=", "-c", `credential.helper=${helper(url.host, user)}`];
const r = await git([`--git-dir=${repo}`, ...args], env, { pre, limits, pass: `${secret}\n` });
if (r.out.includes(secret) || r.err.includes(secret)) refuse("credential-leak");
return r;
};
const g = bound(env, limits);
const listed = await net(["ls-remote", "--symref", fork, "HEAD", dst]);
if (!listed.ok) refuse("fork-unavailable");
let head = null;
let previous = null;
for (const line of listed.out.split("\n")) {
const [oid, name] = line.split("\t");
if (!OID.test(oid ?? "")) continue;
if (name === "HEAD") head = oid;
else if (name === dst) previous = oid;
}
if (!head) refuse("remote-unrelated", "the fork is empty");
const known = await g([`--git-dir=${repo}`, "cat-file", "-e", `${head}^{commit}`]);
if (!known.ok || !(await g([`--git-dir=${repo}`, "merge-base", "--is-ancestor", head, "refs/heads/main"])).ok) refuse("remote-unrelated", "the fork's HEAD isn't in main's history");
const pushed = await net(["push", "--porcelain", "--no-follow-tags", fork, `${source}:${dst}`]);
if (!pushed.ok) refuse(/\((non-fast-forward|fetch first)\)/.test(pushed.out) ? "non-fast-forward" : "push-failed");
return { ref: dst, commit: source, previous };
}
+95
View File
@@ -0,0 +1,95 @@
// 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)),
};
}
+205
View File
@@ -0,0 +1,205 @@
// 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, readFileSync, 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: the token is never in git's argv, the fork URL included; it reaches git on fd 3 only", async (t) => {
const w = await world(t);
// A git first on PATH that logs its argv and execs the real one; exec keeps fd 3.
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
const shim = join(w.dir, "shim");
mkdirSync(shim);
const log = join(w.dir, "argv.log");
writeFileSync(join(shim, "git"), `#!/bin/sh\nprintf '%s\\n' "$@" >> '${log}'\nexec '${real}' "$@"\n`);
chmodSync(join(shim, "git"), 0o755);
assert.deepEqual(await w.remote({ env: { PATH: `${shim}:${process.env.PATH}` } }), { ref: DST, commit: w.work, previous: null });
const argv = readFileSync(log, "utf8");
assert.match(argv, /^ls-remote$/m);
assert.match(argv, /^push$/m);
assert.equal(argv.includes(TOKEN), false);
assert.equal(argv.includes(`${USER}:`), false, "no userinfo in a URL");
});
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);
// One CA for both, which git trusts, so only http.followRedirects=false keeps git off the target;
// with a CA git doesn't trust, a followed redirect would fail at TLS and the target would see nothing anyway.
const ca = scratchCa(join(w.dir, "ca2"));
const target = await fakeGitea({ dir: join(w.dir, "target"), tls: ca, user: USER, token: TOKEN });
t.after(() => target.close());
const bounce = await fakeGitea({ dir: join(w.dir, "bounce"), tls: ca, user: USER, token: TOKEN, redirect: target.origin });
t.after(() => bounce.close());
const tls = ["-c", `http.sslCAInfo=${ca.ca}`];
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);
});
+6 -2
View File
@@ -80,7 +80,11 @@ export function tasksAdapter({
tree: null,
bots: new Set([businesses[name].tracker.sync.botId, ...Object.values(s.roles).map((r) => r.botId)]),
sync: use('@sync'),
as: (role, method, path, opts) => use(role)(method, path, opts),
// 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),
state: 'starting',
refused: null,
queue: Promise.resolve(),
@@ -96,7 +100,7 @@ export function tasksAdapter({
timers: [],
};
ctx.syncCall = (_token, method, path, opts) => ctx.sync(method, path, opts);
ctx.asCall = (role) => (_token, method, path, opts) => ctx.as(role, method, path, opts);
ctx.asCall = (role) => (_token, method, path, opts) => ctx.probe(role, method, path, opts);
ctx.handle = verbs(ctx);
contexts.set(name, ctx);
}
+2 -2
View File
@@ -41,7 +41,7 @@ export async function startup(ctx) {
if (probe.status === 404 || probe.status === 403 || outcome(probe) === 'ok') refuse('scope-too-broad');
expect(probe, 401, 'tracker-unexpected');
for (const [role, r] of Object.entries(ctx.roles)) {
const control = await ctx.as(role, 'GET', `/tasks/${MISSING}`);
const control = await ctx.probe(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.as(role, method, path, { body });
const p = await ctx.probe(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');
}
+9 -8
View File
@@ -122,8 +122,9 @@ 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.
async function step(role, method, path, body, at, done) {
const r = await ctx.as(role, method, path, { body });
// `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 });
const o = outcome(r);
if (o === 'ok') return r;
if (o === 'uncertain' && at !== null && done) {
@@ -144,7 +145,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, role, plan);
if (verb === 'task.create') return create(cap, me, plan);
const at = target(args.task_ref);
const before = await read(at);
if (args.expect !== undefined && args.expect !== before.digest) {
@@ -165,7 +166,7 @@ export function verbs(ctx) {
let landed = 0;
try {
for (const [method, path, body, done] of work.steps) {
await step(role, method, path, body, at, done);
await step(me, method, path, body, at, done);
landed++;
}
} catch (e) {
@@ -385,12 +386,12 @@ export function verbs(ctx) {
fail('unknown-verb');
}
}
async function create(cap, role, plan) {
async function create(cap, me, 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(role, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null);
const r = await step(me, '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;
@@ -399,9 +400,9 @@ export function verbs(ctx) {
let failure = null;
try {
for (const l of plan.labels)
await step(role, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l));
await step(me, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l));
for (const x of plan.relations)
await step(role, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (a) =>
await step(me, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (a) =>
relatedTo(a.task, x.kind, x.other),
);
} catch (e) {
+2 -1
View File
@@ -73,7 +73,8 @@ 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` and `deploy`; a citation never moves them.
`git.push.protected`, `git.push.remote` 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.
+2 -1
View File
@@ -53,7 +53,8 @@ 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` and `deploy`; a citation never moves them.
`git.push.protected`, `git.push.remote` and `deploy`; a citation never
moves them.
A `role.revoke` raise whose target is the assistant is a hard wait too:
it goes to the human, cited or not.
+1 -1
View File
@@ -35,6 +35,6 @@ if [ "${1:-}" = business ]; then
exec node "$REPO/packages/business/src/cli.mjs" "$@"
fi
case "${1:-}" in
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|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|credentials|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
esac
exec node "$REPO/packages/seat/src/cli.mjs" "$@"