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

32 KiB

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.