Files
stack/packages/mosaic/framework/guides/SEAT-IDENTITY.md
T
fred efb3c3a10c
ci/woodpecker/pr/ci Pipeline failed
guides: add SEAT-IDENTITY and FLEET-COMMS; harden CODE-REVIEW evidence rules
Three guides that existed only as one host's working copy, promoted to framework
templates so every estate gets them. A working copy under ~/.mosaic binds one
host; only a template here binds all of them.

SEAT-IDENTITY.md (new) documents how a seat's git credential is actually
resolved after #1311: identity from MOSAIC_GIT_IDENTITY, then
mosaic.gitIdentity, then the stdin username; host mapped to a store prefix; then
ONE of two stores chosen by whether the seat directory exists, with no
precedence and no fallback between them. A seat with a directory and an empty
slot fails closed rather than reaching the service store, and that is the point.

It also corrects how to find the helper. credential.helper commonly names an
absolute path, so `command -v git-credential-mosaic` answers a different question
than the one git asks, and the two stop agreeing the moment the PATH copy is
removed. Git also tries EVERY configured helper in order, so a fail-closed helper
in front silently hands the request to whatever is configured behind it. The
guide says to read the whole list.

FLEET-COMMS.md (new) documents agent-send.sh: the class table, the addressing
preamble, and the exit codes — including that rc=2 means the text reached the
pane as an unsubmitted draft, so retrying double-sends it. Confirm with
capture-pane instead. It also says to measure the fleet rather than trust
roster.yaml, which on a live host was simultaneously naming a socket that did not
exist, listing seats that were not running, and omitting seats that were.

CODE-REVIEW.md gains an Evidence Discipline section: a green is not a result
until you have shown it could go red, measurement and explanation are separate
sentences, verify by content on the ref that ships rather than by ancestry of a
local sha, and confidence is part of a finding. Plus four shell-measurement rules
earned on #1311, each of which produced a wrong conclusion first — `cmd | tail;
echo rc=$?` reports tail's status, a missed glob under pipefail exits 2 and kills
the run under set -e, nonzero-with-no-output is an environment question before it
is a code question, and `git -C` in a non-repo directory answers from the
enclosing repo.

The estate-specific repository exception that lived in the working copy is not
carried here. The template says an estate may document one, scoped to a named
repository and never precedent for a second.

Both new guides are added to the two routing tables that agents read.
2026-08-18 18:15:50 -05:00

5.7 KiB

Seat Identity & Credentials Guide

Every agent that touches a Mosaic-managed git host acts as a named seat with its own credential. This guide is how that works on a host, and what an agent must never do with it.

The mechanism below is the framework's. The specific paths, seats and stores are per-host: measure yours before trusting any of them.

The rule

One seat, one identity, one token file. A seat never borrows another seat's credential, never falls back to a shared owner account, and never carries a second copy of its own token. A second copy is drift, and drift surfaces as the stale copy returning 401 — which reads as a revoked token and sends whoever debugs it somewhere else entirely.

A credential refusal is correct behavior, not a bug to route around. If git refuses with a fail-closed diagnostic, the fix is to provision or correct your identity. Escalate; do not substitute.

How a credential is resolved

Find the helper the way git does, not with command -v. Git runs whatever credential.helper names, and on a Mosaic host that is an absolute path — so a PATH lookup answers a different question and the two disagree the moment the PATH copy is removed. It was removed here on 2026-08-18.

git config --get-all credential.helper     # every helper, in the order git tries them

Git tries each configured helper in turn until one supplies a credential. A fail-closed helper supplies nothing, so a second helper configured behind it silently becomes the one that answers. When you care which binary serves a credential, read the whole list. ~/.mosaic/fleet/bin/lib-credential-helper.sh does this correctly and handles all three forms git accepts (absolute path, !command, bare name resolved on PATH).

The helper resolves the identity in this order:

  1. $MOSAIC_GIT_IDENTITY
  2. git config --get mosaic.gitIdentity
  3. the username git supplied on stdin

It maps the host to a store prefix — git.mosaicstack.dev to gitea-mosaicstack, git.uscllc.com to gitea-usc. Any other host is declined quietly with rc=0, which is not an error and raises no escalation.

Then it chooses one of two stores, and reads exactly one file:

brain_home = ${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}

seat     — when $brain_home/fleet/agents/<identity>/ EXISTS
           $brain_home/fleet/agents/<identity>/secrets/<prefix>-<identity>.token
service  — otherwise
           ~/.config/mosaic/secrets/gitea-tokens/<prefix>-<identity>.token

There is no precedence between the two and no fallback from one to the other. The existence of the seat directory decides it. A seat that has a directory and an empty slot fails closed; it does not reach the service store. That is the intended behavior — the alternative is an agent silently acting as somebody else.

If the file is unreadable the helper fails closed: it refuses, spools a record, and attempts a fleet notification. It does not fall back to a shared account. That fallback is what made usc/uconnect#3084 unattributable, and it was removed deliberately.

Verify the helper you actually have:

h=$(git config --get credential.helper)
grep -c 'FAIL CLOSED'  "$h"    # expect >= 1
grep -c 'fleet/agents' "$h"    # expect >= 1; 0 means it predates mosaicstack#1311

Where a seat's token lives

The seat slot is the only copy:

~/.mosaic/fleet/agents/<seat>/secrets/<prefix>-<seat>.token      real file, mode 600

The framework store at ~/.config/mosaic/secrets/gitea-tokens/ holds tokens for service identities only — identities with no seat directory. A seat's token does not belong there.

Before mosaicstack#1311 the deployed helper knew only the service store, and seats were bridged with a symlink from the store into the slot. Those bridges are gone (removed 2026-08-18 by ~/.mosaic/fleet/bin/migrate-credentials-to-seat-slots.sh) and must not be recreated. A symlink is not how a system finds a credential; the helper resolving the right store is.

.principal and .scopes beside the token are grant records, not secrets. They are tracked. The .token never is.

Provisioning a new seat

  1. Create ~/.mosaic/fleet/agents/<seat>/secrets/ mode 700.
  2. Write .principal (the Gitea login) and .scopes (the granted scopes), mode 600.
  3. Jason mints the token into the seat slot, mode 600. Agents do not mint their own.
  4. Symlink the framework store entry to the seat slot.
  5. Verify with an authenticated GET /user and confirm the returned login is the seat, not the minting account. Record the date in ENTITY.md. Never record the value.

Until step 3, the seat is unminted and its git writes fail closed. That is the designed state and is safe to launch in — the seat is told at launch so it does not discover it mid-task.

Acting as yourself

Name the identity on every invocation:

MOSAIC_GIT_IDENTITY=<seat> git push
git -c user.name=<seat> -c user.email=<seat>@mosaicstack.dev commit -m "..."

Never persist git config mosaic.gitIdentity inside a ~/src/stack worktree. Every worktree of that clone shares one .git/config, so a persisted identity there silently rewrites the identity of every other seat working in that clone. The per-invocation form has no exception.

Handling

  1. Never print a token value. Compare by SHA-256 digest, or write <REDACTED>.
  2. Never stage a .token, secrets.json, or ENTITY.md. Stage explicit paths and never git add -Asecrets/*.principal and secrets/*.scopes are covered by no ignore rule.
  3. Never place a token in an environment variable in an interactive session. A declare -x dump has leaked the whole environment to a terminal before.
  4. No real credential or operator data on a sandbox VM, ever.