Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe4fa20309 | ||
|
|
5e93ef70bd | ||
|
|
3884f2de4d | ||
|
|
a3c50d91ca | ||
|
|
2fd102e6af | ||
|
|
efb3c3a10c | ||
|
|
d4d32a80b2 | ||
|
|
c703cc50eb | ||
|
|
3d2b712355 | ||
|
|
245e0c427d |
@@ -43,6 +43,8 @@ overwritten on upgrade. (Layer model: `constitution/LAYER-MODEL.md`.)
|
|||||||
| Secrets / vault usage | `guides/VAULT-SECRETS.md` |
|
| Secrets / vault usage | `guides/VAULT-SECRETS.md` |
|
||||||
| Tool/credential reference (service CLIs, wrappers) | `guides/TOOLS-REFERENCE.md` |
|
| Tool/credential reference (service CLIs, wrappers) | `guides/TOOLS-REFERENCE.md` |
|
||||||
| Memory protocol (OpenBrain capture/recall) | `guides/MEMORY.md` |
|
| Memory protocol (OpenBrain capture/recall) | `guides/MEMORY.md` |
|
||||||
|
| Seat identity, git credentials, token slots | `guides/SEAT-IDENTITY.md` |
|
||||||
|
| Reaching another agent (fleet comms) | `guides/FLEET-COMMS.md` |
|
||||||
|
|
||||||
## Subagent Model Selection (Cost — Hard Rule)
|
## Subagent Model Selection (Cost — Hard Rule)
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,78 @@ Merge strategy enforcement (HARD RULE):
|
|||||||
- Merge to `main` MUST be squash-only.
|
- Merge to `main` MUST be squash-only.
|
||||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
|
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
|
||||||
|
|
||||||
|
An estate MAY carry a documented exception for a repository whose gates are commit hooks rather
|
||||||
|
than review. Such an exception belongs in that estate's own working copy of this guide, is
|
||||||
|
scoped to the named repository, and is never precedent for a second one.
|
||||||
|
|
||||||
|
**Do not use `pr-review.sh` or `issue-comment.sh` to post a verdict** (mosaicstack#1280). Post
|
||||||
|
through a direct authenticated API call as your own seat, or hand the verdict to the requesting
|
||||||
|
seat. Handing it over is a legitimate delivery path, not a fallback.
|
||||||
|
|
||||||
|
## Evidence Discipline (applies to every finding)
|
||||||
|
|
||||||
|
The checklist below says what to look at. This section says when you are allowed to believe what
|
||||||
|
you saw. Every rule here was earned by a wrong conclusion that reached a report.
|
||||||
|
|
||||||
|
1. **A finding is a claim about behavior.** State the failing input, the path taken, and the
|
||||||
|
wrong result. "This looks fragile" is not a finding.
|
||||||
|
2. **A green check is not a result until you have shown it could go red.** Run the control. A
|
||||||
|
`0`, an empty result, or a column of identical values with no failing counterpart is a
|
||||||
|
non-result.
|
||||||
|
3. **Measurement and explanation are separate sentences.** Report the command and its output,
|
||||||
|
then, as its own sentence, what you think it means.
|
||||||
|
4. **Never widen the case you measured.** If you checked one path, the finding covers one path.
|
||||||
|
5. **Reproduce a reported failure before recording it, and say which tree you measured.** Two
|
||||||
|
correct measurements of two different trees disagree without either being wrong.
|
||||||
|
6. **Verify by content on the ref that ships**, never by ancestry of a local sha. A rebase mints
|
||||||
|
new shas; a commit being an ancestor of something local proves nothing about the remote.
|
||||||
|
Compare by digest against `origin/<branch>`.
|
||||||
|
7. **Confidence is part of the finding.** "I could not reproduce this" is a usable review
|
||||||
|
comment. A confident guess is not.
|
||||||
|
8. **Author is not reviewer** (Gate-16). Do not review your own work, or work you shaped closely
|
||||||
|
enough to be a co-author of. Say so and hand it back.
|
||||||
|
|
||||||
|
### Measuring a shell suite
|
||||||
|
|
||||||
|
Each of these produced a wrong conclusion before it was written down.
|
||||||
|
|
||||||
|
9. **`cmd | tail; echo rc=$?` reports `tail`'s exit code, not `cmd`'s.** It reads as a pass when
|
||||||
|
the command failed. Redirect to a file and check `rc` directly, or use `${PIPESTATUS[0]}`.
|
||||||
|
10. **Under `set -o pipefail`, a missed glob makes `ls` exit 2**, the pipeline inherits it, and
|
||||||
|
`set -e` kills the run. Iterate a glob with a `for` loop and an `-e` test instead of piping
|
||||||
|
`ls`.
|
||||||
|
11. **A suite that exits nonzero with ZERO output is an environment question, not a defect in
|
||||||
|
the code under review.** The usual cause is a sourced dependency that is absent, so `set -e`
|
||||||
|
kills the first case before anything prints. Extract whole tool trees — `tools/git` alone is
|
||||||
|
missing `tools/_lib/credentials.sh`. Isolate the variable and prove it by adding only that
|
||||||
|
back.
|
||||||
|
12. **`git -C <dir>` in a directory that is not itself a repo answers from the enclosing repo.**
|
||||||
|
A scratch tree under `~/.mosaic` reports `~/.mosaic`'s HEAD, not the PR's, and every
|
||||||
|
conclusion drawn from it describes the wrong tree. Confirm `git rev-parse --show-toplevel`
|
||||||
|
is the tree you think it is before trusting any git output.
|
||||||
|
|
||||||
|
### Feedback Categories
|
||||||
|
|
||||||
|
- **Blocker**: must fix before merge (security, bugs, test failures)
|
||||||
|
- **Should Fix**: important but not blocking (code quality, minor issues)
|
||||||
|
- **Suggestion**: optional improvement (style preference, nice-to-have)
|
||||||
|
- **Question**: seeking clarification
|
||||||
|
|
||||||
## Review Checklist
|
## Review Checklist
|
||||||
|
|
||||||
|
Reviewer seats split this checklist by class rather than duplicating it. A seat reviews its own
|
||||||
|
sections in full and may raise anything it notices outside them as a Suggestion, never as a
|
||||||
|
Blocker on someone else's ground.
|
||||||
|
|
||||||
|
| Reviewer class | Owns |
|
||||||
|
| ---------------- | ------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `rev-code-*` | 1 Correctness, 3 Testing, 4 Code Quality, 4a TypeScript, 5 Documentation, 6 Performance, 7 Dependencies |
|
||||||
|
| `rev-security-*` | 2 Security, 2a OWASP |
|
||||||
|
|
||||||
|
Where two seats of the same class review the same change, they review independently and compare
|
||||||
|
after. A second seat that reads the first seat's findings before measuring is a proofreader, not
|
||||||
|
a second opinion.
|
||||||
|
|
||||||
### 1. Correctness
|
### 1. Correctness
|
||||||
|
|
||||||
- [ ] Code does what the issue/PR description says
|
- [ ] Code does what the issue/PR description says
|
||||||
@@ -53,7 +123,7 @@ Merge strategy enforcement (HARD RULE):
|
|||||||
- [ ] Tests cover happy path AND error cases
|
- [ ] Tests cover happy path AND error cases
|
||||||
- [ ] Situational tests cover all impacted change surfaces (primary gate)
|
- [ ] Situational tests cover all impacted change surfaces (primary gate)
|
||||||
- [ ] Tests validate required behavior/outcomes, not only internal implementation details
|
- [ ] Tests validate required behavior/outcomes, not only internal implementation details
|
||||||
- [ ] TDD was applied when required by `~/.config/mosaic/guides/QA-TESTING.md`
|
- [ ] TDD was applied when required by `guides/QA-TESTING.md`
|
||||||
- [ ] Coverage meets 85% minimum
|
- [ ] Coverage meets 85% minimum
|
||||||
- [ ] Tests are readable and maintainable
|
- [ ] Tests are readable and maintainable
|
||||||
- [ ] No flaky tests introduced
|
- [ ] No flaky tests introduced
|
||||||
@@ -82,7 +152,7 @@ Merge strategy enforcement (HARD RULE):
|
|||||||
### 5. Documentation
|
### 5. Documentation
|
||||||
|
|
||||||
- [ ] Complex logic has explanatory comments
|
- [ ] Complex logic has explanatory comments
|
||||||
- [ ] Required docs updated per `~/.config/mosaic/guides/DOCUMENTATION.md`
|
- [ ] Required docs updated per `guides/DOCUMENTATION.md`
|
||||||
- [ ] Public APIs are documented
|
- [ ] Public APIs are documented
|
||||||
- [ ] Private/internal APIs are documented
|
- [ ] Private/internal APIs are documented
|
||||||
- [ ] API input/output schemas are documented
|
- [ ] API input/output schemas are documented
|
||||||
@@ -126,13 +196,6 @@ git diff main...HEAD
|
|||||||
- Distinguish between blocking issues and suggestions
|
- Distinguish between blocking issues and suggestions
|
||||||
- Be constructive, not critical of the person
|
- Be constructive, not critical of the person
|
||||||
|
|
||||||
### Feedback Categories
|
|
||||||
|
|
||||||
- **Blocker**: Must fix before merge (security, bugs, test failures)
|
|
||||||
- **Should Fix**: Important but not blocking (code quality, minor issues)
|
|
||||||
- **Suggestion**: Optional improvements (style preferences, nice-to-haves)
|
|
||||||
- **Question**: Seeking clarification
|
|
||||||
|
|
||||||
### Review Comment Format
|
### Review Comment Format
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Fleet Comms Guide
|
||||||
|
|
||||||
|
How one seat reaches another on a host. The mechanism is the framework's; the sessions and
|
||||||
|
sockets are per-host, so measure yours rather than trusting an example.
|
||||||
|
|
||||||
|
`mosaic <runtime>` would normally inject the addressing block from the roster. Where the composer
|
||||||
|
is unavailable, or where the roster is stale, this guide is the substitute.
|
||||||
|
|
||||||
|
## Measure the fleet; do not trust the roster
|
||||||
|
|
||||||
|
`fleet/roster.yaml` is a declaration of intent, not an observation. It routinely names a socket
|
||||||
|
that was never created, lists seats that are not running, and omits seats that are — this was
|
||||||
|
all three have been observed true at once on a live host. Find out what is actually
|
||||||
|
up before addressing anyone:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tmux list-sessions
|
||||||
|
tmux list-panes -a -F '#{session_name} #{pane_current_command} #{pane_current_path}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The pane command tells you the runtime. A pane showing `bash` is an idle shell with no agent
|
||||||
|
attached — a send there lands in a shell prompt and is not read by anyone.
|
||||||
|
|
||||||
|
Use the **default socket**. Do not pass `-L mosaic-fleet` on the strength of the roster.
|
||||||
|
|
||||||
|
## Sending
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.config/mosaic/tools/tmux/agent-send.sh -s <dst_session> -C <class> -m "<message>"
|
||||||
|
```
|
||||||
|
|
||||||
|
`-s` also accepts `session:window.pane`. `-f <file>` sends a file body; stdin works too.
|
||||||
|
|
||||||
|
### Classes
|
||||||
|
|
||||||
|
`-C` takes exactly one of these. Anything else exits 3.
|
||||||
|
|
||||||
|
| Class | Use for |
|
||||||
|
| -------------- | -------------------------------------------------------- |
|
||||||
|
| `terminal-log` | log only; never needs the agent's attention |
|
||||||
|
| `actionable` | a decision, blocker, gate, or question needing an answer |
|
||||||
|
| `human` | relayed from a human operator |
|
||||||
|
| `reaction` | an ack or acknowledgement token |
|
||||||
|
| `digest` | machine wake, coalescible |
|
||||||
|
|
||||||
|
An absent class is treated as `actionable` by consumers, which is the fail-safe direction. Prefer
|
||||||
|
naming it anyway.
|
||||||
|
|
||||||
|
### Addressing preamble
|
||||||
|
|
||||||
|
The wire format is `[<src> -> <dst> class=<class>] <body>`. Flip it when you reply — the tool
|
||||||
|
sends, it does not auto-reply.
|
||||||
|
|
||||||
|
### Exit codes
|
||||||
|
|
||||||
|
| rc | Meaning |
|
||||||
|
| --- | ---------------------------------------------- |
|
||||||
|
| 0 | delivered or queued |
|
||||||
|
| 1 | target session not found |
|
||||||
|
| 2 | text reached the pane but is **still a draft** |
|
||||||
|
| 3 | usage error (bad class, missing `-s`) |
|
||||||
|
|
||||||
|
**Never retry on rc=2.** The message is in the target pane; retrying double-sends it. Confirm
|
||||||
|
instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tmux capture-pane -p -t <session>:0.0 | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
rc=2 is the normal result when the target is an idle pi seat.
|
||||||
|
|
||||||
|
## Durable comms
|
||||||
|
|
||||||
|
tmux delivery is host-local and does not survive a pane. Anything that must outlive the session
|
||||||
|
goes through the estate's durable comms protocol — a committed `comms/` tree in an estate repo,
|
||||||
|
with its own README. Use it for cross-host messages, verdicts, and anything a later session needs
|
||||||
|
to find.
|
||||||
|
|
||||||
|
## Handing work across seats
|
||||||
|
|
||||||
|
1. **A verdict handed to the requesting seat is a legitimate delivery path**, and the required one
|
||||||
|
for anything `pr-review.sh` would otherwise post (see `guides/CODE-REVIEW.md`).
|
||||||
|
2. **Address the seat, not the runtime.** A seat name is a session name; whether it runs claude,
|
||||||
|
pi or codex is not the sender's business.
|
||||||
|
3. **Say what you measured, not just what you concluded** — the receiving seat cannot see your
|
||||||
|
terminal.
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# 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 on hosts that have completed that migration.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
|
Resolve all three forms git accepts — absolute path, `!command`, and a bare name looked up on
|
||||||
|
PATH — not just the one your host happens to use.
|
||||||
|
|
||||||
|
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 and writes a durable record to
|
||||||
|
the escalation spool. It does not fall back to a shared account. The record is what exists — any
|
||||||
|
alerting built on top of it is a separate, best-effort concern and is not performed by the helper,
|
||||||
|
so do not wait for a notification that nothing sends. That fallback is what made
|
||||||
|
`usc/uconnect#3084` unattributable, and it was removed deliberately.
|
||||||
|
|
||||||
|
Verify the helper you actually have:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 must be removed once a seat-aware helper is deployed, and must not be
|
||||||
|
recreated.** Remove them only after the helper can reach the slot without them; the reverse order
|
||||||
|
takes every seat offline. 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. The estate operator mints the token into the seat slot, mode 600. Agents do not mint their
|
||||||
|
own, and do not ask another agent to mint one for them.
|
||||||
|
4. 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.
|
||||||
|
|
||||||
|
There is no step that links the framework store to the slot. A seat-aware helper reads the slot
|
||||||
|
directly; a store entry pointing at a slot is the bridge described in **Where a seat's token lives** above,
|
||||||
|
and it is not part of provisioning.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 -A`** — `secrets/*.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.**
|
||||||
@@ -15,3 +15,5 @@
|
|||||||
| Infrastructure/DevOps | `~/.config/mosaic/guides/INFRASTRUCTURE.md` |
|
| Infrastructure/DevOps | `~/.config/mosaic/guides/INFRASTRUCTURE.md` |
|
||||||
| QA/Testing | `~/.config/mosaic/guides/QA-TESTING.md` |
|
| QA/Testing | `~/.config/mosaic/guides/QA-TESTING.md` |
|
||||||
| Secrets management (Vault) | `~/.config/mosaic/guides/VAULT-SECRETS.md` |
|
| Secrets management (Vault) | `~/.config/mosaic/guides/VAULT-SECRETS.md` |
|
||||||
|
| Seat identity / git credentials | `~/.config/mosaic/guides/SEAT-IDENTITY.md` |
|
||||||
|
| Reaching another agent (fleet comms) | `~/.config/mosaic/guides/FLEET-COMMS.md` |
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ fleet commits, pushes, and opens PRs under one identity — with no cryptographi
|
|||||||
separation between an author and a reviewer.
|
separation between an author and a reviewer.
|
||||||
|
|
||||||
Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent
|
Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent
|
||||||
identity** before falling back to the shared account:
|
identity**:
|
||||||
|
|
||||||
1. `MOSAIC_GIT_IDENTITY` environment variable, or
|
1. `MOSAIC_GIT_IDENTITY` environment variable, or
|
||||||
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
|
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
|
||||||
@@ -51,12 +51,54 @@ identity** before falling back to the shared account:
|
|||||||
3. (git-credential-mosaic only) the username git itself supplies for the credential
|
3. (git-credential-mosaic only) the username git itself supplies for the credential
|
||||||
request.
|
request.
|
||||||
|
|
||||||
If the resolved identity has a token file at
|
### Which store a credential is read from
|
||||||
`~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-<agent-id>.token`, that
|
|
||||||
identity + token is used. **Nothing configured → nothing changes**: with no per-slot
|
The store is chosen by what the identity **is**, not by which file happens to exist first:
|
||||||
token file present, both tools fall through to the existing shared-account path
|
|
||||||
unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
|
| The identity | Its credential is read from |
|
||||||
tokens.
|
| ------------------------------------------------------------------ | -------------------------------------------------------------------------- |
|
||||||
|
| has a directory at `<brain>/fleet/agents/<id>/` — it is a **seat** | `<brain>/fleet/agents/<id>/secrets/gitea-{usc,mosaicstack}-<id>.token` |
|
||||||
|
| does not — it is a **service identity** | `~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-<id>.token` |
|
||||||
|
|
||||||
|
`<brain>` is `MOSAIC_BRAIN_HOME` if set, else `~/.mosaic` — the same resolution
|
||||||
|
`packages/mosaic/src/fleet/brain-home.ts` performs.
|
||||||
|
|
||||||
|
**There is no precedence between the two stores and no fallback from one to the other.**
|
||||||
|
A seat whose slot is empty is refused even when a same-named token sits in the framework
|
||||||
|
store. One credential lives in exactly one location: a second copy is drift rather than
|
||||||
|
redundancy, and the way drift surfaces is a stale copy returning 401, which reads as a
|
||||||
|
revoked token and sends whoever debugs it to the wrong place.
|
||||||
|
|
||||||
|
### What happens when nothing resolves
|
||||||
|
|
||||||
|
| identity resolves | token in its store | host runs a fleet | result |
|
||||||
|
| ----------------- | ------------------ | ----------------- | ------------------------- |
|
||||||
|
| yes | yes | — | that identity + token |
|
||||||
|
| yes | no | — | **fail closed** |
|
||||||
|
| no | — | yes | **fail closed** |
|
||||||
|
| no | — | no | shared account, unchanged |
|
||||||
|
|
||||||
|
A host "runs a fleet" when `<brain>/fleet/agents` exists — the same signal `brain-home.ts`
|
||||||
|
uses to decide a brain is active.
|
||||||
|
|
||||||
|
Failing closed means: nothing is emitted, the exit status is nonzero, a stderr diagnostic
|
||||||
|
names the identity, its source, the store it resolved to and the path that was expected,
|
||||||
|
and `git-credential-mosaic` additionally appends a record (identity, host, reason, cwd —
|
||||||
|
never a token value) to `${MOSAIC_CREDENTIAL_SPOOL:-~/.local/state/mosaic-credential-escalations}`.
|
||||||
|
The git operation fails; nothing is attributed to anyone.
|
||||||
|
|
||||||
|
The shared-account fallback that used to cover these two cases is why a PR could be
|
||||||
|
authored, commented and merged under an account whose owner did not open it — every seat
|
||||||
|
shared one identity, so the record could not be traced back afterwards. An
|
||||||
|
under-provisioned agent is refused rather than handed the most privileged account
|
||||||
|
available.
|
||||||
|
|
||||||
|
**On a host with no fleet, nothing changes**: no `fleet/agents` directory means the shared
|
||||||
|
account still answers, so this is a no-op for an operator who has not provisioned per-slot
|
||||||
|
tokens. On a host that does run a fleet, a human doing manual git work needs an identity
|
||||||
|
of their own — `MOSAIC_GIT_IDENTITY=<id>` with a provisioned slot. There is deliberately no
|
||||||
|
environment variable that restores the fallback; one would reintroduce exactly the
|
||||||
|
substitution this removes.
|
||||||
|
|
||||||
### Enabling it for a clone
|
### Enabling it for a clone
|
||||||
|
|
||||||
|
|||||||
@@ -507,37 +507,69 @@ get_gitea_token() {
|
|||||||
|
|
||||||
# 0. Per-agent identity (Gate-16 author≠reviewer). If MOSAIC_GIT_IDENTITY, or the
|
# 0. Per-agent identity (Gate-16 author≠reviewer). If MOSAIC_GIT_IDENTITY, or the
|
||||||
# per-worktree `git config mosaic.gitIdentity`, resolves to an agent that has a
|
# per-worktree `git config mosaic.gitIdentity`, resolves to an agent that has a
|
||||||
# stored per-slot token for this host, act AS that agent so API tooling
|
# stored credential for this host, act AS that agent so API tooling
|
||||||
# (pr-create, issue-create, …) authors under the right identity — matching the
|
# (pr-create, issue-create, …) authors under the right identity — matching the
|
||||||
# git credential helper. Backward-compatible: nothing resolvable → shared logic below.
|
# git credential helper, which this block deliberately mirrors.
|
||||||
local _ident="${MOSAIC_GIT_IDENTITY:-}"
|
local _ident="${MOSAIC_GIT_IDENTITY:-}"
|
||||||
local _ident_src="MOSAIC_GIT_IDENTITY"
|
local _ident_src="MOSAIC_GIT_IDENTITY"
|
||||||
if [[ -z "$_ident" ]]; then
|
if [[ -z "$_ident" ]]; then
|
||||||
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
||||||
_ident_src="git config mosaic.gitIdentity"
|
_ident_src="git config mosaic.gitIdentity"
|
||||||
fi
|
fi
|
||||||
if [[ -n "$_ident" ]]; then
|
|
||||||
local _idpfx=""
|
# Recognized Gitea hosts are the ones carrying the per-identity token scheme.
|
||||||
case "$host" in
|
local _idpfx=""
|
||||||
git.uscllc.com) _idpfx=gitea-usc ;;
|
case "$host" in
|
||||||
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
|
git.uscllc.com) _idpfx=gitea-usc ;;
|
||||||
esac
|
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
|
||||||
if [[ -n "$_idpfx" ]]; then
|
esac
|
||||||
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
|
||||||
if [[ -r "$_idtok" ]]; then
|
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
|
||||||
cat "$_idtok"
|
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
|
||||||
return 0
|
local _brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
||||||
fi
|
|
||||||
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
|
if [[ -n "$_ident" && -n "$_idpfx" ]]; then
|
||||||
# but no per-slot token exists for THAT identity. Refuse to fall through to the
|
# Credential store selection: an identity is a SEAT or a SERVICE, and which
|
||||||
# shared/default credential loader below — silently borrowing another slot's token
|
# one it is decides where its credential lives. No precedence between the
|
||||||
# would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review attributed
|
# two stores and no fallback from one to the other — a seat with an empty
|
||||||
# to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the
|
# slot fails loud rather than reading a service credential of the same name.
|
||||||
# caller aborts loudly rather than acting as the wrong identity.
|
# One credential, one location: two copies diverge, and the stale copy fails
|
||||||
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
|
# in a way that reads as a revoked token rather than as drift.
|
||||||
echo " Refusing to borrow another slot's token. Provision the per-slot token, or unset the identity to use shared credentials." >&2
|
local _idtok _ident_kind
|
||||||
return 1
|
if [[ -d "$_brain_home/fleet/agents/$_ident" ]]; then
|
||||||
|
_ident_kind="seat"
|
||||||
|
_idtok="$_brain_home/fleet/agents/$_ident/secrets/${_idpfx}-${_ident}.token"
|
||||||
|
else
|
||||||
|
_ident_kind="service identity"
|
||||||
|
_idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
||||||
fi
|
fi
|
||||||
|
if [[ -r "$_idtok" ]]; then
|
||||||
|
cat "$_idtok"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
|
||||||
|
# but no credential exists for THAT identity. Refuse to fall through to the
|
||||||
|
# shared/default credential loader below — silently borrowing another identity's
|
||||||
|
# token would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review
|
||||||
|
# attributed to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop
|
||||||
|
# instead so the caller aborts loudly rather than acting as the wrong identity.
|
||||||
|
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
|
||||||
|
echo " Resolved as a ${_ident_kind}; there is no fallback between the seat and service stores." >&2
|
||||||
|
echo " Refusing to borrow another slot's token. Provision the credential at that path, or unset the identity to use shared credentials." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# FAIL LOUD: no identity resolved, on a host that HAS a fleet. Where seats exist,
|
||||||
|
# every record must name the agent that made it, so an unattributable request is
|
||||||
|
# refused rather than handed the shared account. `fleet/agents` existing is the
|
||||||
|
# same signal brain-home.ts uses to decide a brain is active. A host with no fleet
|
||||||
|
# keeps the shared path below unchanged: there the shared account is the operator's
|
||||||
|
# own and there is no attribution to lose.
|
||||||
|
if [[ -z "$_ident" && -n "$_idpfx" && -d "$_brain_home/fleet/agents" ]]; then
|
||||||
|
echo "Error: no git identity resolved for host '$host', but this host runs a fleet ($_brain_home/fleet/agents)." >&2
|
||||||
|
echo " Refusing to fall back to the shared account: records it creates cannot be attributed to the agent that made them." >&2
|
||||||
|
echo " Set MOSAIC_GIT_IDENTITY=<agent-id> or 'git config mosaic.gitIdentity <agent-id>'." >&2
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
|
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
|
||||||
|
|||||||
@@ -1,21 +1,48 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# git-credential-mosaic — git credential helper — resolves Gitea tokens from
|
# git-credential-mosaic — git credential helper. Resolves a Gitea token from the
|
||||||
# the Mosaic credential store at runtime so remote URLs never embed secrets.
|
# Mosaic credential store at runtime so remote URLs never embed secrets.
|
||||||
#
|
#
|
||||||
# Install (one-time, per clone or globally):
|
# Install (one-time, per clone or globally):
|
||||||
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||||
# # or, fleet-wide: git config --global credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
|
||||||
#
|
#
|
||||||
# Per-agent Gate-16 identity (author != reviewer separation):
|
# Per-agent identity (Gate-16 author != reviewer separation):
|
||||||
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
|
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
|
||||||
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
|
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
|
||||||
#
|
#
|
||||||
# Resolution priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity
|
# ── WHY THIS FAILS CLOSED ──────────────────────────────────────────────────────
|
||||||
# (per-worktree, survives across non-persistent shells) > git-supplied username
|
# This helper used to end by emitting the shared account's token for any request
|
||||||
# (credential.username / URL). When the resolved identity has a matching
|
# it could not resolve to an identity. A seat with no identity, or with an
|
||||||
# per-agent token file, use it instead of the shared account. Backward
|
# identity whose token was never provisioned, therefore received the most
|
||||||
# compatible: nothing resolvable -> shared token (unchanged behavior).
|
# privileged credential configured on the host — silently, and indistinguishably
|
||||||
|
# from correct operation. Every record it then created (commit, push, PR, review)
|
||||||
|
# was attributed to that shared account, so author != reviewer separation was
|
||||||
|
# unenforceable and the true actor was unrecoverable after the fact.
|
||||||
|
#
|
||||||
|
# Under-provisioning must fail loudly, not impersonate. A refused git operation
|
||||||
|
# is recoverable in one command; a merged pull request attributed to the wrong
|
||||||
|
# principal is not.
|
||||||
|
#
|
||||||
|
# ── CONTRACT ───────────────────────────────────────────────────────────────────
|
||||||
|
# identity : MOSAIC_GIT_IDENTITY > git config mosaic.gitIdentity > the
|
||||||
|
# username git supplies on stdin
|
||||||
|
# store : chosen by what the identity IS, with no precedence and no
|
||||||
|
# cross-store fallback (see "Credential store selection" below)
|
||||||
|
# hit : emit username + password, exit 0
|
||||||
|
# miss : emit NOTHING, spool a durable escalation record, explain on
|
||||||
|
# stderr, exit 1 — git surfaces the failure and nothing is attributed
|
||||||
|
# unknown host : exit 0 with no output, no record (passthrough for non-Mosaic
|
||||||
|
# remotes handled by another helper)
|
||||||
|
#
|
||||||
|
# Backward compatibility is preserved for exactly one case: a host with no fleet
|
||||||
|
# and no identity requested still gets the shared account, because on such a host
|
||||||
|
# the shared account is the operator's own and there is no attribution to lose.
|
||||||
|
# A host that HAS a fleet has agents whose records must be distinguishable, so
|
||||||
|
# the shared fallback is refused there.
|
||||||
|
#
|
||||||
|
# A token is never written to stderr, to the escalation record, or to any log.
|
||||||
|
|
||||||
[ "$1" = "get" ] || exit 0
|
[ "$1" = "get" ] || exit 0
|
||||||
|
|
||||||
host=""; username_in=""
|
host=""; username_in=""
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
[ -z "$line" ] && break
|
[ -z "$line" ] && break
|
||||||
@@ -24,46 +51,170 @@ while IFS= read -r line; do
|
|||||||
username=*) username_in=${line#username=};;
|
username=*) username_in=${line#username=};;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
# Per-agent identity resolution (Gate-16 author≠reviewer separation).
|
|
||||||
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
|
# Recognized Gitea hosts carry the per-identity token scheme. Anything else is
|
||||||
# survives across non-persistent shells) > git-supplied username (credential.username
|
# declined quietly — another helper owns it, and refusing would break it.
|
||||||
# / URL). When the resolved identity has a matching per-agent token, use it instead of
|
|
||||||
# the shared account. Backward-compatible: nothing resolvable → shared token.
|
|
||||||
ident="$MOSAIC_GIT_IDENTITY"
|
|
||||||
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
|
||||||
[ -z "$ident" ] && ident="$username_in"
|
|
||||||
if [ -n "$ident" ]; then
|
|
||||||
case "$host" in
|
|
||||||
git.uscllc.com) idpfx=gitea-usc;;
|
|
||||||
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
|
||||||
*) idpfx="";;
|
|
||||||
esac
|
|
||||||
if [ -n "$idpfx" ]; then
|
|
||||||
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
|
|
||||||
if [ -r "$idtok" ]; then
|
|
||||||
echo "username=${ident}"
|
|
||||||
echo "password=$(cat "$idtok")"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
case "$host" in
|
case "$host" in
|
||||||
git.uscllc.com) svc=gitea-usc;;
|
git.uscllc.com) idpfx=gitea-usc;;
|
||||||
git.mosaicstack.dev) svc=gitea-mosaicstack;;
|
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||||
*) exit 0;;
|
*) exit 0;;
|
||||||
esac
|
esac
|
||||||
# Script-relative (not $HOME-absolute) so this resolves correctly regardless
|
|
||||||
# of where the framework installer places tools/ under $HOME — mirrors
|
ident="$MOSAIC_GIT_IDENTITY"; ident_src="MOSAIC_GIT_IDENTITY"
|
||||||
# detect-platform.sh's own cred_loader resolution in this same directory.
|
if [ -z "$ident" ]; then
|
||||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||||
# shellcheck source=../_lib/credentials.sh
|
ident_src="git config mosaic.gitIdentity"
|
||||||
source "$script_dir/../_lib/credentials.sh"
|
fi
|
||||||
load_credentials "$svc" >/dev/null 2>&1 || exit 0
|
if [ -z "$ident" ]; then
|
||||||
# GITEA_USER is not populated by load_credentials (it only exports
|
ident="$username_in"
|
||||||
# GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's
|
ident_src="the username git supplied"
|
||||||
# git-over-HTTP auth authenticates from the token itself (the password field),
|
fi
|
||||||
# not from the username string, so any non-empty placeholder works here — this
|
|
||||||
# is deliberately NOT a real account name (framework files must stay
|
# ── Credential store selection ────────────────────────────────────────────────
|
||||||
# operator-agnostic; see tools/quality/scripts/verify-sanitized.sh).
|
# An identity is a SEAT or it is a SERVICE, and which one it is determines where
|
||||||
echo "username=${GITEA_USER:-git}"
|
# its credential lives. There is no precedence rule between the two stores and no
|
||||||
echo "password=$GITEA_TOKEN"
|
# fallback from one to the other: a seat whose slot is empty fails closed rather
|
||||||
|
# than reading a service credential that happens to share its name.
|
||||||
|
#
|
||||||
|
# seat — <brain>/fleet/agents/<ident>/ exists
|
||||||
|
# credential at <brain>/fleet/agents/<ident>/secrets/<idpfx>-<ident>.token
|
||||||
|
# service — it does not
|
||||||
|
# credential at ~/.config/mosaic/secrets/gitea-tokens/<idpfx>-<ident>.token
|
||||||
|
#
|
||||||
|
# One credential, one location. Two copies of one credential diverge, and the
|
||||||
|
# stale copy fails in a way that reads as a revoked token rather than as drift.
|
||||||
|
#
|
||||||
|
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
|
||||||
|
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
|
||||||
|
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
||||||
|
svc_store="$HOME/.config/mosaic/secrets/gitea-tokens"
|
||||||
|
|
||||||
|
idtok=""; ident_kind=""
|
||||||
|
if [ -n "$ident" ]; then
|
||||||
|
if [ -d "$brain_home/fleet/agents/$ident" ]; then
|
||||||
|
ident_kind="seat"
|
||||||
|
idtok="$brain_home/fleet/agents/$ident/secrets/${idpfx}-${ident}.token"
|
||||||
|
else
|
||||||
|
ident_kind="service identity"
|
||||||
|
idtok="$svc_store/${idpfx}-${ident}.token"
|
||||||
|
fi
|
||||||
|
if [ -r "$idtok" ]; then
|
||||||
|
echo "username=${ident}"
|
||||||
|
echo "password=$(cat "$idtok")"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Shared-account fallback: ONLY on a host with no fleet and no identity ──────
|
||||||
|
# `fleet/agents` existing is the same signal brain-home.ts uses to decide a brain
|
||||||
|
# is active. Where there are seats, records must be attributable, so an
|
||||||
|
# unresolvable request is refused instead of borrowing the shared account.
|
||||||
|
fleet_present=0
|
||||||
|
[ -d "$brain_home/fleet/agents" ] && fleet_present=1
|
||||||
|
|
||||||
|
if [ -z "$ident" ] && [ "$fleet_present" -eq 0 ]; then
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=../_lib/credentials.sh
|
||||||
|
source "$script_dir/../_lib/credentials.sh"
|
||||||
|
load_credentials "$idpfx" >/dev/null 2>&1 || exit 0
|
||||||
|
# GITEA_USER is not populated by load_credentials (it exports GITEA_URL and
|
||||||
|
# GITEA_TOKEN only). Gitea's git-over-HTTP auth authenticates from the token in
|
||||||
|
# the password field, not from the username string, so any non-empty
|
||||||
|
# placeholder works — deliberately NOT a real account name, since framework
|
||||||
|
# files stay operator-agnostic (tools/quality/scripts/verify-sanitized.sh).
|
||||||
|
echo "username=${GITEA_USER:-git}"
|
||||||
|
echo "password=$GITEA_TOKEN"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── FAIL CLOSED ───────────────────────────────────────────────────────────────
|
||||||
|
if [ -z "$ident" ]; then
|
||||||
|
reason="no-identity"
|
||||||
|
else
|
||||||
|
reason="no-token-for-identity"
|
||||||
|
fi
|
||||||
|
|
||||||
|
seat="${MOSAIC_AGENT_NAME:-unknown}"
|
||||||
|
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
|
# The escalation RECORD is durable and unconditional; any notification built on
|
||||||
|
# top of it is best-effort. Record and alert are deduplicated separately — a cap
|
||||||
|
# on the alert alone lets the spool grow without bound exactly while the operator
|
||||||
|
# is being told nothing, so the louder the failure the quieter it gets.
|
||||||
|
#
|
||||||
|
# A record field is arbitrary operator-supplied text: an identity comes from git
|
||||||
|
# config or the environment, and cwd is whatever directory git ran in. Either can
|
||||||
|
# contain a quote or a backslash, which would make the line unparseable JSON --
|
||||||
|
# and a spool that silently stops parsing is worse than no spool, because the
|
||||||
|
# operator only discovers it while reading the record that explains an outage.
|
||||||
|
json_escape() {
|
||||||
|
local s=$1
|
||||||
|
s=${s//\\/\\\\}
|
||||||
|
s=${s//\"/\\\"}
|
||||||
|
s=${s//$'\t'/\\t}
|
||||||
|
s=${s//$'\r'/\\r}
|
||||||
|
s=${s//$'\n'/\\n}
|
||||||
|
printf '%s' "$s"
|
||||||
|
}
|
||||||
|
|
||||||
|
spool="${MOSAIC_CREDENTIAL_SPOOL:-$HOME/.local/state/mosaic-credential-escalations}"
|
||||||
|
spool_record=""
|
||||||
|
if mkdir -p "$spool" 2>/dev/null; then
|
||||||
|
chmod 700 "$spool" 2>/dev/null
|
||||||
|
spoolfile="$spool/$(date -u +%Y%m%d).jsonl"
|
||||||
|
dedupe="$spool/.spooled-${seat}-${ident:-none}-${reason}-$(date -u +%Y%m%d%H%M)"
|
||||||
|
if [ ! -e "$dedupe" ]; then
|
||||||
|
: > "$dedupe" 2>/dev/null
|
||||||
|
printf '{"ts":"%s","reason":"%s","identity":"%s","identity_source":"%s","kind":"%s","seat":"%s","host":"%s","cwd":"%s"}\n' \
|
||||||
|
"$(json_escape "$ts")" "$(json_escape "$reason")" \
|
||||||
|
"$(json_escape "${ident:-<unset>}")" "$(json_escape "$ident_src")" \
|
||||||
|
"$(json_escape "${ident_kind:-none}")" "$(json_escape "$seat")" \
|
||||||
|
"$(json_escape "$host")" "$(json_escape "$PWD")" \
|
||||||
|
>> "$spoolfile" 2>/dev/null
|
||||||
|
chmod 600 "$spoolfile" 2>/dev/null
|
||||||
|
fi
|
||||||
|
# Name the record only if one is actually on disk. Printing the path
|
||||||
|
# unconditionally sends the operator to a file that does not exist on exactly
|
||||||
|
# the hosts where the spool could not be created.
|
||||||
|
[ -s "$spoolfile" ] && spool_record="$spoolfile"
|
||||||
|
find "$spool" -maxdepth 1 -name '.spooled-*' -mmin +120 -delete 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >&2 <<EOF
|
||||||
|
git-credential-mosaic: REFUSED (fail-closed).
|
||||||
|
host : ${host}
|
||||||
|
identity : ${ident:-<unset>}${ident:+ (from ${ident_src}; resolved as a ${ident_kind})}
|
||||||
|
reason : ${reason}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ -n "$ident" ]; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
expected : ${idtok}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >&2 <<EOF
|
||||||
|
|
||||||
|
No per-identity credential resolved. This helper does NOT fall back to the shared
|
||||||
|
account: that fallback makes every record it creates attributable to one
|
||||||
|
principal, which is unrecoverable once a pull request has merged under it.
|
||||||
|
|
||||||
|
Fix (pick one):
|
||||||
|
export MOSAIC_GIT_IDENTITY=<agent-id> # process-scoped
|
||||||
|
git config mosaic.gitIdentity <agent-id> # per-repo/worktree, persists
|
||||||
|
Then provision that identity's credential at the path named above. An identity
|
||||||
|
with a directory under \${MOSAIC_BRAIN_HOME:-\$HOME/.mosaic}/fleet/agents/ is a
|
||||||
|
seat and is read ONLY from its own secrets/ slot; any other identity is read from
|
||||||
|
~/.config/mosaic/secrets/gitea-tokens/. There is no fallback between the two.
|
||||||
|
|
||||||
|
If this identity legitimately needs git access and has none, ask the orchestrator
|
||||||
|
to provision one.
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ -n "$spool_record" ]; then
|
||||||
|
echo " record: ${spool_record}" >&2
|
||||||
|
else
|
||||||
|
echo " record: NOT WRITTEN — spool unavailable at ${spool}" >&2
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Regression harness for `git-credential-mosaic` — per-agent Gitea identity
|
# Regression harness for `git-credential-mosaic` — per-agent Gitea identity
|
||||||
# resolution (Gate-16 author≠reviewer separation).
|
# resolution (Gate-16 author≠reviewer separation) and fail-closed refusal.
|
||||||
#
|
#
|
||||||
# Covers:
|
# Covers:
|
||||||
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
|
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
|
||||||
# mosaic.gitIdentity (per-worktree) > git-supplied username.
|
# mosaic.gitIdentity (per-worktree) > git-supplied username.
|
||||||
# 2. Correct per-slot token file path chosen per host
|
# 2. Correct token file path chosen per host
|
||||||
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
||||||
# 3. Per-slot token present -> emits that identity + token.
|
# 3. Credential store selection: an identity with a directory under
|
||||||
# 4. Per-slot token absent -> falls back to the shared account
|
# <brain>/fleet/agents/ is a SEAT and is read ONLY from its own secrets/
|
||||||
# (backward-compat / no-op for hosts without per-slot tokens).
|
# slot; any other identity is a SERVICE and is read from the framework
|
||||||
# 5. Unknown/unrelated host -> exits 0 with no output (passthrough).
|
# store. No precedence between them and NO fallback from one to the other.
|
||||||
|
# 4. Fail-closed: an identity that resolves but has no credential is REFUSED —
|
||||||
|
# no output, nonzero exit, a stderr diagnostic, and a durable spool record.
|
||||||
|
# The shared account is never emitted in its place.
|
||||||
|
# 5. Fail-closed: no identity resolvable on a host that runs a fleet is also
|
||||||
|
# REFUSED, because records made there must name the agent that made them.
|
||||||
|
# 6. Backward compatibility, the one surviving fallback: no identity AND no
|
||||||
|
# fleet -> shared account, unchanged. On such a host the shared account is
|
||||||
|
# the operator's own and there is no attribution to lose.
|
||||||
|
# 7. Unknown/unrelated host -> exits 0 with no output (passthrough).
|
||||||
|
# 8. Non-"get" verb -> exits 0 with no output.
|
||||||
#
|
#
|
||||||
# Uses stubbed token files under a fake HOME + a real (throwaway) git repo.
|
# Uses stubbed token files under a fake HOME + a real (throwaway) git repo.
|
||||||
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||||
@@ -21,6 +31,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
|
||||||
FAKE_HOME="$WORK_DIR/home"
|
FAKE_HOME="$WORK_DIR/home"
|
||||||
REPO_DIR="$WORK_DIR/repo"
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
BRAIN_DIR="$WORK_DIR/brain"
|
||||||
|
SPOOL_DIR="$WORK_DIR/spool"
|
||||||
|
SVC_STORE="$FAKE_HOME/.config/mosaic/secrets/gitea-tokens"
|
||||||
# Mirror the real deployed layout (~/.config/mosaic/tools/{git,_lib}/) under the
|
# Mirror the real deployed layout (~/.config/mosaic/tools/{git,_lib}/) under the
|
||||||
# fake HOME: git-credential-mosaic resolves its credentials.sh sibling via a
|
# fake HOME: git-credential-mosaic resolves its credentials.sh sibling via a
|
||||||
# script-relative path (BASH_SOURCE), so the copy must live next to a stubbed
|
# script-relative path (BASH_SOURCE), so the copy must live next to a stubbed
|
||||||
@@ -28,10 +41,10 @@ REPO_DIR="$WORK_DIR/repo"
|
|||||||
HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \
|
mkdir -p "$SVC_STORE" \
|
||||||
"$FAKE_HOME/.config/mosaic/tools/git" \
|
"$FAKE_HOME/.config/mosaic/tools/git" \
|
||||||
"$FAKE_HOME/.config/mosaic/tools/_lib" \
|
"$FAKE_HOME/.config/mosaic/tools/_lib" \
|
||||||
"$REPO_DIR"
|
"$REPO_DIR" "$BRAIN_DIR"
|
||||||
|
|
||||||
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
||||||
chmod +x "$HELPER"
|
chmod +x "$HELPER"
|
||||||
@@ -68,7 +81,8 @@ run_helper() {
|
|||||||
local host="$1" username_in="$2"; shift 2
|
local host="$1" username_in="$2"; shift 2
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
env -i HOME="$FAKE_HOME" PATH="$PATH" "$@" bash "$HELPER" get <<EOF
|
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" "$@" \
|
||||||
|
bash "$HELPER" get <<EOF
|
||||||
host=$host
|
host=$host
|
||||||
username=$username_in
|
username=$username_in
|
||||||
|
|
||||||
@@ -76,20 +90,61 @@ EOF
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A refusal must be observable in four independent ways: nonzero exit, EMPTY
|
||||||
|
# stdout, a stderr diagnostic naming the identity and host, and — the assertion
|
||||||
|
# that actually catches a regression to the old behavior — NO shared token value
|
||||||
|
# anywhere in the output. Checking only the exit code would pass against a helper
|
||||||
|
# that emitted the shared credential and then exited 1.
|
||||||
|
assert_fail_closed() {
|
||||||
|
local desc="$1" host="$2" username_in="$3" want_in_stderr="$4"; shift 4
|
||||||
|
local stderr_file="$WORK_DIR/stderr.tmp"
|
||||||
|
: > "$stderr_file"
|
||||||
|
set +e
|
||||||
|
local stdout
|
||||||
|
stdout=$(run_helper "$host" "$username_in" "$@" 2>"$stderr_file")
|
||||||
|
local rc=$?
|
||||||
|
set -e
|
||||||
|
local stderr
|
||||||
|
stderr=$(cat "$stderr_file")
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL: $desc — expected nonzero exit, got 0 (stdout='$stdout')" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ -n "$stdout" ]]; then
|
||||||
|
echo "FAIL: $desc — expected empty stdout (nothing emitted), got '$stdout'" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ "$stdout$stderr" == *"shared-mosaicstack-token"* || "$stdout$stderr" == *"shared-usc-token"* ]]; then
|
||||||
|
echo "FAIL: $desc — a SHARED token value appeared in the output. The shared-account fallback must be gone:" >&2
|
||||||
|
echo "$stdout$stderr" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ -n "$want_in_stderr" && "$stderr" != *"$want_in_stderr"* ]]; then
|
||||||
|
echo "FAIL: $desc — stderr does not contain '$want_in_stderr':" >&2
|
||||||
|
echo "$stderr" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ "$stderr" != *"$host"* ]]; then
|
||||||
|
echo "FAIL: $desc — stderr does not name the host '$host':" >&2
|
||||||
|
echo "$stderr" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 1. No identity resolvable anywhere, no per-slot token -> shared fallback
|
# 1. Backward compatibility: nothing resolvable, and NO fleet on this host ->
|
||||||
# (backward-compat: unchanged behavior when nothing is configured).
|
# shared account, unchanged. This is the only surviving fallback.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||||
out=$(run_helper "git.mosaicstack.dev" "")
|
out=$(run_helper "git.mosaicstack.dev" "")
|
||||||
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
|
assert_eq "no identity + no fleet: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||||
assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
assert_eq "no identity + no fleet: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 2. git-supplied username resolves to an identity WITH a per-slot token ->
|
# 2. git-supplied username resolves to a SERVICE identity WITH a token in the
|
||||||
# that identity + token wins over the shared account.
|
# framework store -> that identity + token wins over the shared account.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
|
echo -n "agentA-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentA.token"
|
||||||
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
||||||
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
|
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
|
||||||
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
@@ -97,7 +152,7 @@ assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-to
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
|
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
|
echo -n "agentB-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentB.token"
|
||||||
git -C "$REPO_DIR" config mosaic.gitIdentity agentB
|
git -C "$REPO_DIR" config mosaic.gitIdentity agentB
|
||||||
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
||||||
assert_eq "git-config beats username: username" "username=agentB" "$(echo "$out" | grep '^username=')"
|
assert_eq "git-config beats username: username" "username=agentB" "$(echo "$out" | grep '^username=')"
|
||||||
@@ -106,54 +161,210 @@ assert_eq "git-config beats username: password" "password=agentB-mosaicstack-tok
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 4. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
# 4. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo -n "agentC-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentC.token"
|
echo -n "agentC-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentC.token"
|
||||||
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_GIT_IDENTITY=agentC)
|
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_GIT_IDENTITY=agentC)
|
||||||
assert_eq "env beats git-config: username" "username=agentC" "$(echo "$out" | grep '^username=')"
|
assert_eq "env beats git-config: username" "username=agentC" "$(echo "$out" | grep '^username=')"
|
||||||
assert_eq "env beats git-config: password" "password=agentC-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
assert_eq "env beats git-config: password" "password=agentC-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 5. Identity resolves, but no matching per-slot token file -> falls back to
|
# 5. Correct token PATH is chosen per host: same agent id, different host
|
||||||
# the shared account (per-agent identity is opt-in, not a hard requirement).
|
# prefix (gitea-usc- vs gitea-mosaicstack-).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
out=$(run_helper "git.mosaicstack.dev" "no-such-agent")
|
echo -n "agentD-usc-token" > "$SVC_STORE/gitea-usc-agentD.token"
|
||||||
assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')"
|
|
||||||
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 6. Correct per-slot token PATH is chosen per host: same agent id, different
|
|
||||||
# host prefix (gitea-usc- vs gitea-mosaicstack-).
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
|
|
||||||
out=$(run_helper "git.uscllc.com" "agentD")
|
out=$(run_helper "git.uscllc.com" "agentD")
|
||||||
assert_eq "host-scoped token path (usc): username" "username=agentD" "$(echo "$out" | grep '^username=')"
|
assert_eq "host-scoped token path (usc): username" "username=agentD" "$(echo "$out" | grep '^username=')"
|
||||||
assert_eq "host-scoped token path (usc): password" "password=agentD-usc-token" "$(echo "$out" | grep '^password=')"
|
assert_eq "host-scoped token path (usc): password" "password=agentD-usc-token" "$(echo "$out" | grep '^password=')"
|
||||||
# agentD has NO mosaicstack token -> must fall back to shared mosaicstack, not
|
|
||||||
# leak the usc token across hosts.
|
|
||||||
out=$(run_helper "git.mosaicstack.dev" "agentD")
|
|
||||||
assert_eq "host-scoped token path (cross-host must not leak): username" "username=git" "$(echo "$out" | grep '^username=')"
|
|
||||||
assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 7. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
|
# 6. FAIL CLOSED — identity resolves, no credential for it on this host. Must
|
||||||
# remotes, e.g. github.com via a different credential helper).
|
# NOT borrow the shared account, and must NOT leak the same agent's token
|
||||||
|
# for a DIFFERENT host (agentD holds a usc token and no mosaicstack one).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
assert_fail_closed "cross-host absence refuses (no shared fallback, no cross-host leak)" \
|
||||||
|
"git.mosaicstack.dev" "agentD" "gitea-mosaicstack-agentD.token"
|
||||||
|
# The agent's own usc token must not appear either.
|
||||||
|
: > "$WORK_DIR/stderr.tmp"
|
||||||
|
set +e
|
||||||
|
leak_out=$(run_helper "git.mosaicstack.dev" "agentD" 2>"$WORK_DIR/stderr.tmp")
|
||||||
|
set -e
|
||||||
|
if [[ "$leak_out$(cat "$WORK_DIR/stderr.tmp")" == *"agentD-usc-token"* ]]; then
|
||||||
|
echo "FAIL: cross-host leak — the usc token value appeared on a mosaicstack request" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
assert_fail_closed "unknown identity refuses (shared account never substituted)" \
|
||||||
|
"git.mosaicstack.dev" "no-such-agent" "no-token-for-identity"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. A refusal leaves a durable spool record, and that record contains no token.
|
||||||
|
# The stderr diagnostic is transient; the record is what an operator reads
|
||||||
|
# afterwards, so it must exist independently of anyone watching the terminal.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
spool_file=$(find "$SPOOL_DIR" -maxdepth 1 -name '*.jsonl' | head -n 1)
|
||||||
|
if [[ -z "$spool_file" ]]; then
|
||||||
|
echo "FAIL: fail-closed left no spool record under $SPOOL_DIR" >&2
|
||||||
|
fail=1
|
||||||
|
else
|
||||||
|
spool_body=$(cat "$spool_file")
|
||||||
|
assert_eq "spool record names the refused identity" "1" \
|
||||||
|
"$(grep -c '"identity":"no-such-agent"' "$spool_file" | head -n 1)"
|
||||||
|
if [[ "$spool_body" == *"shared-"*"-token"* || "$spool_body" == *"agentD-usc-token"* ]]; then
|
||||||
|
echo "FAIL: spool record contains a token value:" >&2
|
||||||
|
echo "$spool_body" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. SEAT identity: an id with a directory under <brain>/fleet/agents/ is read
|
||||||
|
# from its OWN secrets/ slot, not from the framework store.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
|
||||||
|
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
|
||||||
|
out=$(run_helper "git.mosaicstack.dev" "seatE" MOSAIC_BRAIN_HOME="$BRAIN_DIR")
|
||||||
|
assert_eq "seat reads its own slot: username" "username=seatE" "$(echo "$out" | grep '^username=')"
|
||||||
|
assert_eq "seat reads its own slot: password" "password=seatE-slot-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 9. NO CROSS-STORE FALLBACK — the assertion this whole store-selection design
|
||||||
|
# exists for. seatF is a seat (it has a directory) with an EMPTY slot, while
|
||||||
|
# a framework-store token of the identical name is present and readable.
|
||||||
|
# The helper must refuse rather than read it: one credential, one location,
|
||||||
|
# and a seat that reads a same-named service credential is exactly the
|
||||||
|
# silent-substitution failure the fail-closed rule removes.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets"
|
||||||
|
echo -n "seatF-SERVICE-STORE-token" > "$SVC_STORE/gitea-mosaicstack-seatF.token"
|
||||||
|
assert_fail_closed "seat with empty slot does NOT fall back to the framework store" \
|
||||||
|
"git.mosaicstack.dev" "seatF" "fleet/agents/seatF/secrets" MOSAIC_BRAIN_HOME="$BRAIN_DIR"
|
||||||
|
: > "$WORK_DIR/stderr.tmp"
|
||||||
|
set +e
|
||||||
|
xstore_out=$(run_helper "git.mosaicstack.dev" "seatF" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr.tmp")
|
||||||
|
set -e
|
||||||
|
if [[ "$xstore_out$(cat "$WORK_DIR/stderr.tmp")" == *"seatF-SERVICE-STORE-token"* ]]; then
|
||||||
|
echo "FAIL: cross-store fallback — a seat read the framework store's same-named token" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
# Control: that framework-store token IS readable, so the refusal above is the
|
||||||
|
# store rule firing and not an unreadable file. A non-seat identity pointed at
|
||||||
|
# the same file gets it.
|
||||||
|
out=$(run_helper "git.mosaicstack.dev" "seatF" MOSAIC_BRAIN_HOME="$WORK_DIR/no-such-brain")
|
||||||
|
assert_eq "control — same file IS readable for a non-seat identity" \
|
||||||
|
"password=seatF-SERVICE-STORE-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 10. FAIL CLOSED — no identity resolvable, but this host runs a fleet. Where
|
||||||
|
# seats exist, an unattributable request is refused instead of receiving
|
||||||
|
# the shared account. Contrast with case 1, which is the same request on a
|
||||||
|
# host with no fleet and still returns the shared account.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||||
|
assert_fail_closed "no identity on a fleet host refuses" \
|
||||||
|
"git.mosaicstack.dev" "" "no-identity" MOSAIC_BRAIN_HOME="$BRAIN_DIR"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 11. The brain home defaults to ~/.mosaic when MOSAIC_BRAIN_HOME is unset —
|
||||||
|
# the fleet gate must fire on the default path too, not only on an
|
||||||
|
# explicitly injected one. Case 1 ran before this directory existed; the
|
||||||
|
# same call now refuses, which also proves case 1 was measuring the
|
||||||
|
# no-fleet branch rather than passing for an unrelated reason.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mkdir -p "$FAKE_HOME/.mosaic/fleet/agents"
|
||||||
|
assert_fail_closed "fleet gate fires on the default ~/.mosaic brain home" \
|
||||||
|
"git.mosaicstack.dev" "" "no-identity"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 12. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
|
||||||
|
# remotes, e.g. github.com via a different credential helper). A fleet host
|
||||||
|
# must not refuse a host this helper does not own.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
out=$(run_helper "github.com" "agentA")
|
out=$(run_helper "github.com" "agentA")
|
||||||
assert_eq "unknown host: no output" "" "$out"
|
assert_eq "unknown host: no output" "" "$out"
|
||||||
|
out=$(run_helper "github.com" "" MOSAIC_BRAIN_HOME="$BRAIN_DIR")
|
||||||
|
assert_eq "unknown host on a fleet host: still passthrough, not a refusal" "" "$out"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
# 13. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
||||||
# protocol: this helper only implements get).
|
# protocol: this helper only implements get).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
|
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
|
||||||
host=git.mosaicstack.dev
|
host=git.mosaicstack.dev
|
||||||
username=agentA
|
username=no-such-agent
|
||||||
password=whatever
|
password=whatever
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
)
|
)
|
||||||
assert_eq "store verb: no output" "" "$store_out"
|
assert_eq "store verb: no output" "" "$store_out"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 14. The escalation record is machine-readable even when a field carries a
|
||||||
|
# quote or a backslash. A cwd is arbitrary operator text; an unescaped one
|
||||||
|
# silently turns the spool into unparseable JSONL, and the operator only
|
||||||
|
# finds out while reading the record that explains an outage.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
hostile_dir="$WORK_DIR/we\"ird\\dir"
|
||||||
|
mkdir -p "$hostile_dir"
|
||||||
|
hostile_spool="$WORK_DIR/spool-hostile"
|
||||||
|
(
|
||||||
|
cd "$hostile_dir"
|
||||||
|
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$hostile_spool" \
|
||||||
|
MOSAIC_GIT_IDENTITY=no-such-agent \
|
||||||
|
bash "$HELPER" get <<EOF >/dev/null 2>&1
|
||||||
|
host=git.mosaicstack.dev
|
||||||
|
username=no-such-agent
|
||||||
|
|
||||||
|
EOF
|
||||||
|
) || true
|
||||||
|
# Deliberately not `ls ... | head -1`: under `set -o pipefail` a missed glob
|
||||||
|
# makes ls exit 2, the pipeline inherits it, and `set -e` kills this suite with
|
||||||
|
# zero output — the same silent-nonzero failure this file exists to catch.
|
||||||
|
record_file=""
|
||||||
|
for candidate in "$hostile_spool"/*.jsonl; do
|
||||||
|
if [[ -e "$candidate" ]]; then
|
||||||
|
record_file="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ -z "$record_file" ]]; then
|
||||||
|
echo "FAIL: hostile cwd — no escalation record was written at all" >&2
|
||||||
|
fail=1
|
||||||
|
elif ! python3 -c 'import json,sys
|
||||||
|
for line in open(sys.argv[1]):
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
json.loads(line)' "$record_file" 2>/dev/null; then
|
||||||
|
echo "FAIL: hostile cwd — escalation record is not parseable JSONL:" >&2
|
||||||
|
cat "$record_file" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 15. When the spool cannot be created, the diagnostic must NOT name a record
|
||||||
|
# path. Naming a file that was never written sends the operator to an
|
||||||
|
# empty path on exactly the hosts where the escalation was lost.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
unwritable_spool="/proc/mosaic-credential-spool-cannot-exist"
|
||||||
|
nospool_err=$(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$unwritable_spool" \
|
||||||
|
MOSAIC_GIT_IDENTITY=no-such-agent \
|
||||||
|
bash "$HELPER" get <<EOF 2>&1 >/dev/null
|
||||||
|
host=git.mosaicstack.dev
|
||||||
|
username=no-such-agent
|
||||||
|
|
||||||
|
EOF
|
||||||
|
) || true
|
||||||
|
if [[ "$nospool_err" == *"record: $unwritable_spool/"* ]]; then
|
||||||
|
echo "FAIL: unwritable spool — diagnostic names a record file that was never written" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ "$nospool_err" != *"NOT WRITTEN"* ]]; then
|
||||||
|
echo "FAIL: unwritable spool — diagnostic does not say the record was not written" >&2
|
||||||
|
echo "$nospool_err" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$fail" -eq 0 ]]; then
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
echo "git-credential-mosaic identity resolution regression passed"
|
echo "git-credential-mosaic identity resolution regression passed"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -23,6 +23,17 @@
|
|||||||
# 6. Scope containment: identity requested + an UNRECOGNIZED Gitea host (no
|
# 6. Scope containment: identity requested + an UNRECOGNIZED Gitea host (no
|
||||||
# per-slot token scheme) -> Patch 2b does not apply; existing
|
# per-slot token scheme) -> Patch 2b does not apply; existing
|
||||||
# fall-through behavior is unchanged.
|
# fall-through behavior is unchanged.
|
||||||
|
# 7. Credential store selection: an identity with a directory under
|
||||||
|
# <brain>/fleet/agents/ is a SEAT and is read ONLY from its own secrets/
|
||||||
|
# slot; any other identity is a SERVICE and is read from the framework
|
||||||
|
# store. No precedence between them and NO fallback from one to the
|
||||||
|
# other — a seat with an empty slot is REFUSED even when a same-named
|
||||||
|
# token sits in the framework store.
|
||||||
|
# 8. Fail loud when NO identity resolves on a host that runs a fleet: where
|
||||||
|
# seats exist, an unattributable API call is refused rather than made
|
||||||
|
# under the shared account. On a host with no fleet the same call still
|
||||||
|
# returns the shared token (case 1), which is what keeps this change a
|
||||||
|
# no-op for non-fleet operators of the framework.
|
||||||
#
|
#
|
||||||
# Uses a stubbed credentials.json + stubbed per-slot token files under a fake
|
# Uses a stubbed credentials.json + stubbed per-slot token files under a fake
|
||||||
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||||
@@ -210,6 +221,94 @@ if [[ "$err" == *"no per-slot token at"* ]]; then
|
|||||||
fi
|
fi
|
||||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. SEAT identity: an id with a directory under <brain>/fleet/agents/ is read
|
||||||
|
# from its OWN secrets/ slot, not from the framework store. The brain home
|
||||||
|
# is resolved exactly as packages/mosaic/src/fleet/brain-home.ts does it:
|
||||||
|
# MOSAIC_BRAIN_HOME, else ~/.mosaic.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
BRAIN_DIR="$WORK_DIR/brain"
|
||||||
|
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
|
||||||
|
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
|
||||||
|
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatE MOSAIC_BRAIN_HOME="$BRAIN_DIR")
|
||||||
|
assert_eq "seat reads its own slot" "seatE-slot-token" "$out"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. NO CROSS-STORE FALLBACK. seatF is a seat (it has a directory) with an
|
||||||
|
# EMPTY slot, while a framework-store token of the identical name is
|
||||||
|
# present and readable. It must be REFUSED rather than served that token:
|
||||||
|
# one credential, one location. A seat that silently reads a same-named
|
||||||
|
# service credential is the same substitution failure as the shared-account
|
||||||
|
# fallback, one store further down.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets"
|
||||||
|
echo -n "seatF-SERVICE-STORE-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-seatF.token"
|
||||||
|
assert_failloud "seat with empty slot does NOT fall back to the framework store" \
|
||||||
|
"git.mosaicstack.dev" "seatF" \
|
||||||
|
"$BRAIN_DIR/fleet/agents/seatF/secrets/gitea-mosaicstack-seatF.token" \
|
||||||
|
MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR"
|
||||||
|
# assert_failloud only screens stderr for the word "shared"; this store's token
|
||||||
|
# is not named that, so check for its value explicitly.
|
||||||
|
set +e
|
||||||
|
xstore_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-xstore.tmp")
|
||||||
|
set -e
|
||||||
|
if [[ "$xstore_out$(cat "$WORK_DIR/stderr-xstore.tmp")" == *"seatF-SERVICE-STORE-token"* ]]; then
|
||||||
|
echo "FAIL: cross-store fallback — a seat was served the framework store's same-named token" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
# Control: that framework-store token IS readable, so the refusal above is the
|
||||||
|
# store rule firing and not an unreadable file. The same id, resolved against a
|
||||||
|
# brain home where it is not a seat, gets it.
|
||||||
|
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=seatF MOSAIC_BRAIN_HOME="$WORK_DIR/no-such-brain")
|
||||||
|
assert_eq "control — same file IS readable for a non-seat identity" "seatF-SERVICE-STORE-token" "$out"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 9. FAIL LOUD — no identity resolvable, but this host runs a fleet. Contrast
|
||||||
|
# with case 1: the identical call on a host with no fleet still returns the
|
||||||
|
# shared token.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||||
|
set +e
|
||||||
|
noid_out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr-noid.tmp")
|
||||||
|
noid_rc=$?
|
||||||
|
set -e
|
||||||
|
noid_err=$(cat "$WORK_DIR/stderr-noid.tmp")
|
||||||
|
if [[ "$noid_rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL: no identity on a fleet host — expected nonzero return, got 0 (stdout='$noid_out')" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ -n "$noid_out" ]]; then
|
||||||
|
echo "FAIL: no identity on a fleet host — expected empty stdout, got '$noid_out'" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ "$noid_out" == *"shared-mosaicstack-token"* || "$noid_err" == *"shared-mosaicstack-token"* ]]; then
|
||||||
|
echo "FAIL: no identity on a fleet host — the shared token was served anyway:" >&2
|
||||||
|
echo "$noid_out$noid_err" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ "$noid_err" != *"MOSAIC_GIT_IDENTITY"* ]]; then
|
||||||
|
echo "FAIL: no identity on a fleet host — stderr does not say how to set an identity:" >&2
|
||||||
|
echo "$noid_err" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 10. The brain home defaults to ~/.mosaic when MOSAIC_BRAIN_HOME is unset —
|
||||||
|
# the fleet gate must fire on the default path too, not only on an
|
||||||
|
# explicitly injected one. Case 1 ran before this directory existed; the
|
||||||
|
# same call now refuses, which also proves case 1 was measuring the
|
||||||
|
# no-fleet branch rather than passing for an unrelated reason.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mkdir -p "$FAKE_HOME/.mosaic/fleet/agents"
|
||||||
|
set +e
|
||||||
|
dflt_out=$(call_get_gitea_token "git.mosaicstack.dev" 2>"$WORK_DIR/stderr-dflt.tmp")
|
||||||
|
dflt_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$dflt_rc" -eq 0 || -n "$dflt_out" ]]; then
|
||||||
|
echo "FAIL: fleet gate did not fire on the default ~/.mosaic brain home (rc=$dflt_rc stdout='$dflt_out')" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$fail" -eq 0 ]]; then
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
echo "get_gitea_token identity resolution regression passed"
|
echo "get_gitea_token identity resolution regression passed"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user