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.
417 lines
32 KiB
Markdown
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.
|