Squash of Megavolt's 627979a17..86963d281 on origin/megavolt/row199-on-190 (base00d4cc1f6, 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 on9e1085816plus 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 to76134dfdesince, a brief touching no manifest path. No live change: the stack runs from copy4429a104funtil the 10-24 re-pin window.
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():
-
Identity from the cap (E5), then the broker's
#checkAuthority(s, "git.push.remote", {decision, target: task_ref}), ascheckLanddoes for a merge (broker.mjs:907-913).git.push.remoteis 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 classifiesgatedfor 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.
#authorityForbinds 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 recordsholderRunforrole.revoke(broker.mjs:1733). When the action isgit.push.remote, thedecision.raiseevent body getspushCommit. 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 refusesno-push. The decision's question shown to the human ends with that commit.#authorityForthen refusesdecision-mismatchwhencontext.pushCommitdiffers from the commit of#latestPushnow, which is the same checkholderRungets atbroker.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'swithinRole. From then on a coder publishes without a decision, anddecisionis optional for a within-role class only. -
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 equalidentity.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 owngitea.user.forkis the only URL the rest ofpublishuses: D9a'sls-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'sgitea.user, compared without case, refusesremote-not-fork, at load and at push.remoteis a new key besiderootandrepo(business.mjs:186), andidentityandpublishare new keys on a role entry (business.mjs:89). All three are optional, so today's business files load unchanged. -
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 refusesref-moved. The work ref is per task, not per agent. Two checks make it the caller's:#latestPushwas recorded by the caller's role (work-not-yours), and step 4. The destination is alwaysrefs/heads/<gitea.user>/<branch>, built by the host.validBranch(branch)holds (invalid-request). The agent never names the destination ref. -
Every commit in
main..<work ref>has author and committer email equal toidentity.email(identity-mismatch). -
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 aftercheckPublishcan't change what goes out (non-fast-forwardfrom git stays a refusal). Git runs with E8's SAFE flags (working.mjs:92-98), which setprotocol.allow=never.publishadds exactly two flags,protocol.https.allow=alwaysandhttp.followRedirects=false, plusGIT_TERMINAL_PROMPT=0and 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. SeatgitEnv(checkout.mjs:79-89) takes only PATH from the host and sets the rest itself, so no proxy orGIT_SSL_*variable reaches git.publishtakes atlsparameter that defaults to[], the same waypushtakesfsck = 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 answersgetonly whenprotocolishttpsandhost, port included, equalsfork's. It writes nothing for any othergetand forstoreanderase.assertCleanchecks git's output, so a fork that echoes the token getscredential-leak. -
The bus records
action.allowedforgit.push.remotewith 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; forgitea.url,url.origin + "/"orurl.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, becausenew 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.giton REPO is allowed and stripped.forkis built from the stripped REPO. OWNER,REPOandgitea.usereach 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), soremote-not-forkcompares the upstream's OWNER withgitea.userwithout case. An upstream owned byCoderand agitea.userofcoderwould 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 untilrecordPublish, the same waycheckLandholds a landing.withGitea(cap, fn):credentials.useFor(broker.session(cap), "gitea", fn).fngets 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 outsideA/*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.issueis never called by the chief. Row 194's hire stays uncredentialed (its D1).- A human
createof a credentialed definition: row 194 lifts E9's refusal for the human path only. The agent is created withstorereferences and no files.launchrefusescredential-unavailableuntil Jason runsissue. fireanddelete: row 194 callscredentials.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'sidentity.email. - A2. Create its token: sign in as that user, Settings, Applications, Generate New Token, scopes
write:repositoryandread: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>/REPOfails rather than creating a plain repository (N11).publishrefusesfork-unavailablebefore 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 withissue, which rewrites that agent's reference tostore. - M2. Nothing here touches the dev repository's helper, its
.git/config,~/.mosaicor~/.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
storeuntil 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.remoteand 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 withrole.hireandrole.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
statusandrevokeonly. - 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
giteaandvikunja. 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
publishonce 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 refusescredential-not-yours. A bus restart after a create withoutissuestarts, and the agent's status isnot-issued. A missing{file}still refuses start. - T2.
useForkeys from the cap only. A request naming another agent is refused and leaves oneaction.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
tlsparameter (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 landsrefs/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, includingwork-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 refusesdecision-consumed. Approve while the latest push is A, push B, publish: refusesdecision-mismatch, and the fork receives nothing. A push to the working repo betweencheckPublishand the git push refuses withref-movedordecision-mismatch(sage-1440, BUILD.md deviation 14). A raise with no recorded push refusesno-push. A test pins thatgit.push.remoteis in neither ROUTED list and that noroles/*.jsongrants 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>/REPOonly and B's reaches/<B's user>/REPOonly, and the fake's upstream repository receives nothing. An upstream owned by the agent's own user refusesremote-not-fork, and so does one whose owner differs fromgitea.useronly in case. With an upstream that shares root's history, a first publish to an empty fork and to an unrelated fork each refuseremote-unrelated, and neither fork nor upstream receives objects. A fork that doesn't exist refusesfork-unavailable, and the fake records no push.ls-remoteand 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 agitea.urlwith a sub-path. A.gitsuffix loads and builds the fork without it. Apublishgrant on an agent withoutidentity, and on one whose definition lacksgitea, 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,
publishand the Vikunja path refusecredential-unavailable, and so they do after a bus restart that follows the revoke. The tester can runstatusandrevoke, andissueandrotaterefuse it. - T6. Default: an agent without
identitycommits 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.