Files
jason.woltje cb5fdb064f credentials: a credential store per agent, its own git identity, pushes to a remote (row 199, #1697)
Squash of Megavolt's 627979a17..86963d281 on origin/megavolt/row199-on-190
(base 00d4cc1f6, row 190's landing), the 37 manifest paths, build.patch
05383075, manifest 35f900fd (Duckworth approve round 1, comment 29779,
rev 2540). Mutant batches 1 and 2 in agents/megavolt/work/queue-199/BUILD.md:
batch 2 26 of 28 killed, M17 and M27 equivalent. docs/TOOLS.md is a 3-way
merge with row 200 (9e1085816): row 199's hunk applies unchanged (patch-id
d88379111e2e) and no test reads the file. Gate on 9e1085816 plus the patch:
15 package suites 2735 pass 0 fail, 10 scripts/test-*.sh suites 429 pass
0 fail (plus 156 node tests in the queue suite). Refactor moved to 76134dfde
since, a brief touching no manifest path. No live change: the stack runs from
copy 4429a104f until the 10-24 re-pin window.
2026-10-20 19:23:12 -05:00

16 KiB

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/.