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

417 lines
32 KiB
Markdown

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