Compare commits
8 Commits
fix/875-be
...
docs/rfc-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5555cc7595 | ||
|
|
01dd2fadb3 | ||
| 529c177830 | |||
| a32ce4c8f9 | |||
| d351caad36 | |||
| 76b86a246e | |||
| 4422231bdb | |||
| 8504216964 |
@@ -96,3 +96,8 @@
|
||||
- [Logical identity and connector lease/fencing implementation](https://git.mosaicstack.dev/mosaicstack/stack/issues/755)
|
||||
- [M1 logical identity and fencing architecture](architecture/mos-runtime-portability-m1.md)
|
||||
- [M1 connector lease operations](guides/mos-connector-lease-operations.md)
|
||||
|
||||
## Comms evolution — Matrix-native MACP (design, draft)
|
||||
|
||||
- [RFC-001 — MACP: a Mosaic-native, Matrix-native comms layer](rfcs/RFC-001-MACP-MATRIX-NATIVE.md) — Synapse + Mosaic appservice backbone, MACP v1 protocol, presence/escalation, federation, strangler migration off the Hermes MCP bridge.
|
||||
- [RFC-002 — Install, configuration & topology for the Matrix/MACP comms system](rfcs/RFC-002-INSTALL-CONFIG-TOPOLOGY.md) — open-source install topology modes, ACME cert provisioning, pluggable secret backend, and config precedence.
|
||||
|
||||
449
docs/rfcs/RFC-001-MACP-MATRIX-NATIVE.md
Normal file
449
docs/rfcs/RFC-001-MACP-MATRIX-NATIVE.md
Normal file
@@ -0,0 +1,449 @@
|
||||
# RFC-001 — MACP: A Mosaic-Native, Matrix-Native Comms Layer
|
||||
|
||||
- **Status:** DRAFT — for Team Lead → Orchestrator staffing
|
||||
- **Author:** MS-LEAD (reviewer identity `ms-lead-reviewer`)
|
||||
- **Sponsor / veto:** Jason (human lead)
|
||||
- **Date:** 2026-07-24
|
||||
- **Program:** Mosaic Stack comms-evolution
|
||||
- **Supersedes backbone:** the Hermes MCP chat bridge (strangler-retired, see §9)
|
||||
- **Audience:** Team Leads, the Mosaic orchestrator, infra, and any harness maintainer (Claude Code / Codex / Pi / Goose)
|
||||
|
||||
> This is a **design document**. No code ships from this RFC. It exists to be decomposed into missions (P1→P5, §10) with per-phase acceptance criteria. Where a claim is uncertain or needs live validation, it is flagged **[VERIFY]**.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
We are building a **Mosaic-native comms layer on Matrix**. We self-host a **Synapse** homeserver and register a privileged **Mosaic Application Service** (the "appservice") that the orchestrator controls. The appservice bulk-provisions one Matrix identity per agent-spin, creates and manages rooms, posts agent introductions, and tracks presence/liveness. Agents talk to it through a thin `packages/comms` client SDK. **tmux stays the P0 same-host fast path**; Matrix is the durable, presence-aware, federated layer above it, and **MACP** (the Mosaic Agent Comms Protocol) is the standard that says which path to use when. Federation is **per-site Synapse homeservers federated over TLS we control** — a direct answer to "the homelab agent went dark and took comms with it." We adopt **Buzz's patterns** (auto-detect/enroll, signed identity, unified event log, humans-and-agents on one surface) without adopting Buzz/Nostr as transport. We migrate off Hermes by the **strangler** pattern: stand native alongside, move channels as proven, retire at parity.
|
||||
|
||||
The **first standalone shippable slice is presence** (P1).
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals / Non-Goals
|
||||
|
||||
### 1.1 Goals
|
||||
|
||||
- **G1 — Presence & liveness first.** A Team Lead must be able to answer "is my coordinator online, away, or dead?" in seconds, not by polling for 13 hours. Presence is the P1 slice and ships before anything else.
|
||||
- **G2 — A native backbone we own.** Replace _Hermes-as-backbone_ with a self-hosted Synapse + a Mosaic-controlled appservice. External chat bridging becomes an optional edge, not the spine.
|
||||
- **G3 — Turnkey harness enrollment.** `mosaic enroll` auto-detects the harness and self-registers the agent via the appservice on spin. No hand-rolled per-bot identity juggling.
|
||||
- **G4 — A real protocol (MACP v1).** Structured, versioned event schema over Matrix custom event types; a documented routing contract for tmux vs Matrix; a documented escalation policy.
|
||||
- **G5 — No central SPOF.** Per-site homeservers federated over TLS/DNS we already control, so one site going dark cannot take the fleet's comms with it.
|
||||
- **G6 — Gate-action integrity.** Reviews / merges / approvals carry **signed authorship** (Buzz pattern) so a gate-critical action is cryptographically attributable, retiring the fragile "distinct bot identity" juggling.
|
||||
|
||||
### 1.2 Non-Goals
|
||||
|
||||
- **NG1 — Do NOT rip out working comms mid-MVP.** tmux fast-path and the existing `mos-comms` git-branch channel keep working until their replacement is proven at parity. This RFC is strangler, not big-bang.
|
||||
- **NG2 — tmux is NOT being replaced.** tmux inter-agent comms remains **P0**. Matrix is _above_ it, not instead of it. MACP defines the boundary; it does not move it.
|
||||
- **NG3 — Not adopting Buzz/Nostr as transport.** We adopt Buzz's _patterns_; the wire is Matrix.
|
||||
- **NG4 — Not building a new chat client in P1–P4.** HIL uses an existing Matrix client (Element or equivalent) until/unless a custom client is justified (open question, §11).
|
||||
- **NG5 — Not federating to the public Matrix network.** Federation is Mosaic-site-to-Mosaic-site over infrastructure we control. Public `matrix.org` federation is out of scope (and should likely be firewalled off).
|
||||
- **NG6 — Not a Hermes feature-clone.** We reach _parity on the channels that matter_ (§9 checklist), not bug-for-bug Hermes compatibility.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
### 2.1 Layer diagram
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ HUMAN (Jason / HIL) │
|
||||
│ Element (or custom client) — §11 │
|
||||
└───────────────────────┬───────────────────────┘
|
||||
│ (same Matrix surface as agents)
|
||||
│
|
||||
┌──────────────────────────────────────────────▼──────────────────────────────────────────────┐
|
||||
│ SYNAPSE HOMESERVER (self-hosted, ours) │
|
||||
│ - Client-Server API (agents + humans send/receive events) │
|
||||
│ - Application Service API (privileged AS hooks: transactions, user/room namespaces) │
|
||||
│ - Presence EDUs, receipts, typing │
|
||||
│ - Federation API (S2S) over TLS ── to peer site homeservers (§6, P4) │
|
||||
└───────▲───────────────────────────────────▲──────────────────────────────────────▲───────────┘
|
||||
│ AS API (hs_token / as_token) │ C-S API (per-agent access_token) │ S2S
|
||||
│ │ │
|
||||
┌───────┴───────────────────────┐ ┌────────┴─────────────────┐ ┌─────────┴──────────┐
|
||||
│ MOSAIC APPSERVICE │ │ packages/comms (SDK) │ │ PEER SITE Synapse │
|
||||
│ (apps/matrix-appservice) │ │ used by every harness │ │ (site-B, site-C…) │
|
||||
│ THE "native layer" │ │ - login/whoami │ │ own appservice │
|
||||
│ replacing Hermes-backbone │ │ - send MACP events │ │ own agents │
|
||||
│ │ │ - subscribe/sync │ └────────────────────┘
|
||||
│ - bulk-provision MXIDs │◄───┤ - presence heartbeat │
|
||||
│ (@mosaic_<agent>:site) │ │ - signed-authorship │
|
||||
│ - create/manage rooms │ │ envelope (gate acts) │
|
||||
│ - post introductions │ └────────────┬─────────────┘
|
||||
│ - track presence/liveness │ │ in-process / IPC
|
||||
│ - enforce room taxonomy │ ┌─────────▼──────────────────────────────────────────┐
|
||||
│ - escalation watchdog (§5) │ │ AGENT HARNESS │
|
||||
│ - controlled by ORCHESTRATOR │ │ Claude Code / Codex / Pi / Goose │
|
||||
└───────▲───────────────────────┘ │ `mosaic enroll` runs on spin (§4.1) │
|
||||
│ orchestrator drives AS └─────────┬───────────────────────────────────────────┘
|
||||
┌───────┴───────────────────────┐ │
|
||||
│ MOSAIC ORCHESTRATOR │ │ P0 FAST PATH (same host, low-latency)
|
||||
│ (~/.config/mosaic) │ ┌─────────▼──────────┐ tmux send-keys / pane I/O
|
||||
│ spins agents, owns rooms, │◄──────►│ tmux (P0) │◄─►│ peer agent on same host │
|
||||
│ sets escalation policy │ MACP └────────────────────┘ └─────────────────────────┘
|
||||
└───────────────────────────────┘ routing rules decide tmux vs Matrix per message (§4.6)
|
||||
```
|
||||
|
||||
Key idea: **the appservice is the backbone.** It is a long-lived privileged process registered with Synapse via an appservice registration file (`hs_token`/`as_token`, namespaces). It is the thing that used to be "Hermes-as-backbone," except we own it, it is inside the orchestrator's control plane, and it speaks native Matrix.
|
||||
|
||||
### 2.2 Message flow: agent spin-up → auto-enroll → room join → introduction → presence-online
|
||||
|
||||
```
|
||||
Orchestrator Harness (mosaic enroll) Mosaic Appservice Synapse
|
||||
│ │ │ │
|
||||
1. spin agent ─────────────────► │ │ │
|
||||
│ │ 2. auto-detect harness │ │
|
||||
│ │ (Claude/Codex/Pi/Goose) │ │
|
||||
│ │ 3. POST /enroll {agent meta} ─► │
|
||||
│ │ │ 4. provision MXID │
|
||||
│ │ │ @mosaic_<agent>:site │
|
||||
│ │ │ via AS API register ─► (201, in namespace)
|
||||
│ │ │ 5. mint access_token │
|
||||
│ │ 6. ◄── {mxid, token, rooms}──┤ (or as_token masq) │
|
||||
│ │ │ 7. invite+join rooms ─► (mission/team/fleet)
|
||||
│ │ 8. /sync (via packages/comms)─────────────────────────► (joined state)
|
||||
│ │ │ 9. post introduction ─► m.room.message +
|
||||
│ │ │ (mosaic.introduction) custom event → rooms
|
||||
│ │ 10. set presence ONLINE ─────────────────────────────► presence EDU
|
||||
│ │ 11. start heartbeat loop │ │
|
||||
│ │ (mosaic.presence ping) │ │
|
||||
│ 12. appservice reports agent │ │ │
|
||||
│ ◄──── live in fleet room ────┤ (watchdog now tracks liveness) │
|
||||
```
|
||||
|
||||
Notes on the steps that matter:
|
||||
|
||||
- **Step 4/5** use the **Application Service API**: the appservice can register users inside its namespace (`@mosaic_*:site`) and act on their behalf. Two viable modes: (a) mint a real per-agent `access_token` via appservice login, or (b) have the appservice **masquerade** using `user_id` query param on C-S calls with the `as_token`. **Recommendation: mint per-agent tokens** for P2 so the agent process holds only its own credential (blast-radius containment, §8); reserve masquerade for bulk/bootstrap operations the appservice itself performs. **[VERIFY]** exact token-lifetime and refresh behavior against the running Synapse version.
|
||||
- **Step 9** — the introduction is both a human-readable `m.room.message` _and_ a structured `mosaic.introduction` custom event (so other agents can machine-parse capabilities without scraping prose).
|
||||
- **Step 10/11** — presence goes online immediately, then a **heartbeat** keeps liveness fresh. Native Matrix presence auto-decays to `unavailable`/`offline`, but we do **not** rely solely on it (Synapse presence timeouts are coarse and federation presence is lossy **[VERIFY]**); MACP adds an explicit `mosaic.presence` heartbeat event for deterministic liveness (§4.5, §5).
|
||||
|
||||
---
|
||||
|
||||
## 3. Repo-home decision (RESOLVED — recommendation)
|
||||
|
||||
The core tension: **product monorepo** (`mosaicstack/stack`, this checkout `/src/mosaic-stack`) vs **framework** (`~/.config/mosaic`, the agent/harness runtime that every agent shares regardless of product). The boundary rule I am ratifying:
|
||||
|
||||
> **Product-monorepo owns the deployed _services and libraries_. Framework owns the _agent/harness contract_ — anything an agent needs the moment it spins, before any product code is checked out.**
|
||||
|
||||
Applying that rule:
|
||||
|
||||
| Piece | Home | Rationale |
|
||||
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Synapse deployment** (compose/helm, config, TLS, `.well-known`, pipelines) | **Product monorepo** → `infra/matrix/` + CI in the monorepo's pipeline dir | It is deployed infrastructure with the same lifecycle/observability as Gateway/Web/DB. Lives beside other `infra/` and Woodpecker pipelines. |
|
||||
| **Mosaic Appservice** | **Product monorepo** → `apps/matrix-appservice` | It is a first-class deployed service (NestJS-style app, same conventions: ESM, `@Inject()`, DTOs, OTEL-before-bootstrap). It talks to Synapse, holds `hs_token`/`as_token`, and is operated like any other app. It is **controlled by** the orchestrator but **is not** the orchestrator. |
|
||||
| **Client SDK** | **Product monorepo** → `packages/comms` | A workspace library consumed by product apps _and_ by harnesses. Published/linked like `packages/queue`, `packages/db`. Versioned with the product. |
|
||||
| **MACP spec** (the standard doc + JSON schemas) | **Framework** → `~/.config/mosaic/spec/macp/` (mirrored/vendored into `packages/comms` at build) | The protocol is an agent-level contract that must exist independent of any one product checkout. Framework is the source of truth; `packages/comms` vendors a pinned copy so the SDK and spec cannot drift silently. |
|
||||
| **`mosaic enroll` harness glue** (auto-detect, spin hook) | **Framework** → `~/.config/mosaic/tools/enroll/` | Agents/harnesses are framework-level. Enrollment must run _before_ the agent has product context; it cannot depend on `/src/mosaic-stack` being present. This is exactly parallel to the existing `~/.config/mosaic/tools/*` wrappers. |
|
||||
|
||||
**Boundary summary:** _the wire and the services are product; the contract and the spin-time glue are framework._ The one deliberate coupling is **MACP**: framework is authoritative, but `packages/comms` pins a vendored copy and CI fails if they diverge, so an agent enrolling via framework and a service validating via `packages/comms` agree on the schema by construction.
|
||||
|
||||
**Rejected alternative:** putting the appservice in the framework. Rejected because the appservice is a stateful, deployed, secret-holding network service that needs the product's CI/observability/secret plumbing; burying it in `~/.config/mosaic` would split its operational story from every other Mosaic service.
|
||||
|
||||
---
|
||||
|
||||
## 4. MACP v1 — the standard
|
||||
|
||||
MACP (Mosaic Agent Comms Protocol) v1 is a **profile of Matrix**: it does not invent a transport, it constrains how Mosaic agents use Matrix so that behavior is uniform across harnesses. Versioned via a `macp_version` field on every custom event; v1 is frozen at ratification (P3).
|
||||
|
||||
### 4.1 Enrollment contract
|
||||
|
||||
`mosaic enroll` MUST, on agent spin, in order:
|
||||
|
||||
1. **Auto-detect harness.** Detection order + signal:
|
||||
- Claude Code — presence of the Claude Code runtime/env (e.g. `CLAUDE_CODE_*` env, `~/.claude`) **[VERIFY exact signal per harness]**
|
||||
- Codex — Codex runtime markers
|
||||
- Pi — Pi SDK runtime (`packages/agent` / `packages/mosaic` context)
|
||||
- Goose — Goose runtime markers
|
||||
- Fallback: explicit `--harness` flag; if undetectable, enroll as `generic` and warn.
|
||||
2. **Provision identity** — call appservice `POST /enroll` with `{agent_slug, harness, host, mission_id?, team_id?, capabilities[]}`. Appservice returns `{mxid, access_token, homeserver, rooms[]}` (§2.2 step 4–6).
|
||||
3. **Join rooms** — accept invites / join the returned room set per taxonomy (§4.6).
|
||||
4. **Introduce** — post `mosaic.introduction` (+ human-readable `m.room.message`) to each joined room.
|
||||
5. **Go present** — set Matrix presence `online` and start the `mosaic.presence` heartbeat loop.
|
||||
|
||||
Enrollment is **idempotent**: re-running `mosaic enroll` for an existing agent slug rebinds to the same MXID (re-mints token if needed) rather than creating a duplicate identity. This is what retires the "distinct bot identity juggling."
|
||||
|
||||
### 4.2 Structured event schema (Matrix custom event types)
|
||||
|
||||
All MACP events carry a common envelope in `content`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"macp_version": "1.0",
|
||||
"macp_type": "<see below>",
|
||||
"agent": { "mxid": "@mosaic_teamlead-3:site-a", "slug": "teamlead-3", "harness": "claude-code" },
|
||||
"ts": 1753300000000,
|
||||
"mission_id": "KBN-101", // optional
|
||||
"signature": { ... } // present ONLY for gate actions, §4.4
|
||||
// ...type-specific fields...
|
||||
}
|
||||
```
|
||||
|
||||
Event types (Matrix `type` shown; timeline events use `m.room.message` with a custom `msgtype` where a human-visible fallback is desirable, state events use a dotted custom `type`):
|
||||
|
||||
| MACP type | Matrix carrier | Purpose | Notable fields |
|
||||
| --------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| **message** | `m.room.message`, `msgtype: "mosaic.message"` (+ `body` fallback) | ordinary agent/human chat | `body`, `thread?` |
|
||||
| **presence** | `m.room.message` msgtype `mosaic.presence` in fleet room **or** custom EDU-backed state | heartbeat/liveness ping | `status: online\|away\|offline`, `seq`, `interval_ms` |
|
||||
| **workflow-step** | state event `mosaic.workflow.step` (state_key = step id) | durable record of a workflow/mission step | `step`, `status: started\|blocked\|done\|failed`, `detail` |
|
||||
| **review / approval** | `m.room.message` msgtype `mosaic.review` | a review verdict or merge/approval | `subject` (PR/commit ref), `verdict: approve\|reject\|request-changes`, `signature` (REQUIRED) |
|
||||
| **escalation** | `m.room.message` msgtype `mosaic.escalation` | raise a stuck/dark condition to HIL/fallback | `reason`, `target`, `severity`, `since_ts` |
|
||||
|
||||
Rationale for the carrier split: **timeline events** (`m.room.message` variants) are the durable, receipted, replayable log (this _is_ Buzz's "unified event log," §7). **State events** (`mosaic.workflow.step`, presence-as-state) give last-writer-wins current status that a newly-joined agent reads instantly from room state without replaying history.
|
||||
|
||||
Every custom event is validated against a JSON Schema shipped in the MACP spec (§3). Unknown/newer `macp_version` → consumers MUST degrade gracefully (render `body`, ignore unknown fields).
|
||||
|
||||
### 4.3 Agent identity model
|
||||
|
||||
- **MXID:** appservice-namespaced `@mosaic_<slug>:<site-domain>`. The `mosaic_` prefix is the AS **user namespace** declared in the registration file (`namespaces.users` regex `@mosaic_.*`), so Synapse routes those users to our appservice and no human can squat the namespace.
|
||||
- **Provisioning:** exclusively via the appservice (AS API register). Never hand-created. One MXID per agent-spin; idempotent rebind on re-enroll (§4.1).
|
||||
- **Signed authorship** overlays identity for gate actions only (§4.4). MXID answers "who is this account"; signature answers "did the real key-holder authorize this gate action."
|
||||
|
||||
### 4.4 Signed-authorship for gate-critical actions (Buzz pattern, scoped)
|
||||
|
||||
Gate-critical = **reviews, merges, approvals** — anything that can move code to `main` or unblock a mission gate.
|
||||
|
||||
- Each enrolled agent is issued (or generates) an **Ed25519 keypair**; the **public** key is registered with the appservice at enrollment and published as agent profile state (`mosaic.identity.pubkey`). Private key custody per §8.
|
||||
- A gate action event carries `content.signature = { alg: "ed25519", key_id, sig }` over a canonical serialization of the envelope (canonical-JSON of `{macp_type, agent.mxid, subject, verdict, ts, mission_id}`).
|
||||
- Verifiers (the appservice gate-watcher, and any agent acting on a verdict) MUST reject an unsigned or bad-signature gate event. Non-gate events are unsigned (keeps the hot path cheap).
|
||||
|
||||
This is deliberately **narrow**: we do not sign every chat line (Buzz signs everything; we take the pattern only where forgery has teeth). Scope may widen post-P5 if warranted.
|
||||
|
||||
### 4.5 Presence & liveness model
|
||||
|
||||
Three visible states plus an explicit heartbeat:
|
||||
|
||||
- **online** — agent set presence online AND last `mosaic.presence` heartbeat within `heartbeat_interval` (default **30s [VERIFY tuning]**) × miss-tolerance (default 2).
|
||||
- **away** — presence `unavailable`, or heartbeats late but < dark threshold.
|
||||
- **offline / dark** — no heartbeat for `dark_threshold` (default **N minutes**, policy value, §5/§11) OR presence `offline`.
|
||||
|
||||
Why not rely on native Matrix presence alone: Synapse presence is (a) coarse-grained, (b) can be disabled for load reasons, and (c) **degrades across federation** **[VERIFY]**. So MACP layers an explicit heartbeat carried as a lightweight timeline/state event in the **fleet presence room**, giving a deterministic, federation-safe liveness signal the escalation watchdog (§5) can reason about. Native presence EDUs are still emitted (they make Element show the right dot for humans) but the _authoritative_ liveness source is the heartbeat.
|
||||
|
||||
### 4.6 Room / channel taxonomy (orchestrator-owned)
|
||||
|
||||
The **orchestrator** (via the appservice) owns room lifecycle. Agents never create backbone rooms ad hoc.
|
||||
|
||||
| Room | Scope | Membership | Purpose |
|
||||
| ----------------------- | ------------------------------------------ | ---------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| **Fleet presence room** | one per site (federated view across sites) | every enrolled agent + HIL | heartbeats, the single "who's alive" board. This is the P1 deliverable. |
|
||||
| **Per-mission room** | one per mission (e.g. `#mission-KBN-101`) | agents on that mission + Team Lead + HIL | workflow-steps, mission chat, reviews for that mission |
|
||||
| **Per-team room** | one per team | team members + Team Lead | intra-team coordination |
|
||||
| **HIL room** | one (or one per site) | humans + escalation-privileged agents | where escalations land; Jason's pane on the fleet |
|
||||
|
||||
Rooms are created with orchestrator-controlled power levels: appservice = admin (PL100), Team Leads elevated, worker agents default. Room aliases (`#mission-KBN-101:site-a`) are stable handles.
|
||||
|
||||
### 4.7 tmux ↔ Matrix routing rules (the fast-path/durable boundary)
|
||||
|
||||
MACP mandates this decision per message. **Default bias: if it must survive the agent, be seen by an offline party, cross a host, or be audited — Matrix. If it is same-host, synchronous, and ephemeral — tmux.**
|
||||
|
||||
| Signal | Route | Why |
|
||||
| ---------------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| Same-host, live pane-to-pane prompt/nudge, sub-second need | **tmux (P0)** | lowest latency, no server round-trip; this is the working fast path we keep |
|
||||
| Recipient may be offline / on another host | **Matrix** | durability + store-and-forward; tmux can't reach a dark or remote pane |
|
||||
| Presence / heartbeat / liveness | **Matrix** | must be observable fleet-wide, including by the watchdog and HIL |
|
||||
| Workflow-step, review/approval, escalation | **Matrix** | must be durable, receipted, auditable, signed (gate acts) |
|
||||
| Cross-site anything | **Matrix (federated)** | tmux is same-host only |
|
||||
| Bulk log spew / high-frequency scratch between co-located agents | **tmux**, with periodic **Matrix** checkpoints | avoid flooding the durable log; keep an audit checkpoint |
|
||||
|
||||
Rule of thumb encoded in the SDK: `comms.send()` takes a `durability` hint; `ephemeral+same-host` short-circuits to tmux, everything else goes Matrix. A message can be **dual-routed** (tmux for immediacy + a Matrix checkpoint) when both speed and durability matter.
|
||||
|
||||
---
|
||||
|
||||
## 5. Coordinator-availability + HIL escalation (the 13h-stall / homelab-dark fix)
|
||||
|
||||
**The failure we are killing:** a Team Lead blocked ~13h, polling every 15 min, unable to distinguish "coordinator offline" from "coordinator busy"; and a homelab agent that goes dark taking comms with it.
|
||||
|
||||
**The fix — presence-driven, policy-encoded escalation:**
|
||||
|
||||
1. **Deterministic liveness (§4.5).** Every agent heartbeats into the fleet presence room. The appservice **escalation watchdog** subscribes and maintains `last_seen` per agent. No polling by the Team Lead — it _subscribes_ (Matrix `/sync` long-poll) and is pushed state changes.
|
||||
|
||||
2. **Encoded policy in MACP:** a machine-readable escalation policy attached to each agent/role, e.g.:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"role": "team-lead",
|
||||
"coordinator": "@mosaic_coordinator-1:site-a",
|
||||
"dark_threshold_min": 10, // OPEN QUESTION §11 — Jason/Mos to set N
|
||||
"on_coordinator_dark": {
|
||||
"action": "escalate",
|
||||
"fallback": "@mosaic_coordinator-2:site-b", // cross-site fallback
|
||||
"then": "notify-HIL",
|
||||
"hil_room": "#hil:site-a",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
3. **Detection & action by the Team Lead:** when the watchdog (or the Team Lead's own SDK) observes `coordinator.last_seen > dark_threshold`, it:
|
||||
- emits a `mosaic.escalation` event into the mission room and the HIL room (`reason: "coordinator dark", since_ts, severity`),
|
||||
- **re-routes** to the declared fallback coordinator (possibly on another site — this is why federation matters),
|
||||
- if no fallback answers within a second threshold, pages **HIL** (Jason) in the HIL room.
|
||||
The Team Lead **never sits blocked polling**; a dark coordinator is a _pushed event_, and the fallback/HIL path is automatic.
|
||||
|
||||
4. **Homelab-dark specifically:** because heartbeats are federated into a cross-site fleet room, a whole _site_ going dark is visible from other sites — the watchdog on site-B sees site-A's coordinator stop heartbeating and triggers the same escalation. A dark homelab can no longer silently strand its agents, because the liveness signal and the fallback live _off that host_.
|
||||
|
||||
**Design invariant:** liveness authority and fallback targets must never be co-located with the thing they monitor. The watchdog for site-A's coordinator should also run (or be mirrored) on site-B.
|
||||
|
||||
---
|
||||
|
||||
## 6. Federation (P4)
|
||||
|
||||
**Model:** each site runs its **own Synapse homeserver** with its **own Mosaic appservice** and its own agents. Sites **federate** with each other over the standard Matrix server-to-server (S2S) API, restricted to Mosaic sites.
|
||||
|
||||
**Why per-site, not one central server:**
|
||||
|
||||
- **No SPOF.** The homelab going dark is the founding trauma of this program. A single central homeserver would recreate exactly that risk at fleet scale. Per-site means a site outage is contained: its agents drop, but every other site's comms and the cross-site fleet room survive.
|
||||
- **Locality.** Same-site agents get low-latency local homeserver traffic; only cross-site events pay the federation cost.
|
||||
- **Blast radius.** A compromised or misbehaving site can be defederated without touching the rest.
|
||||
|
||||
**How federation is wired (real Matrix mechanics):**
|
||||
|
||||
- **Server discovery** via `https://<domain>/.well-known/matrix/server` returning `{"m.server": "matrix.<domain>:443"}`, and/or an `_matrix._tcp` **SRV** record. We control the DNS/domains, so we control the federation graph. **[VERIFY]** current `.well-known` vs SRV precedence for the deployed Synapse version.
|
||||
- **TLS:** federation requires valid TLS on the federation endpoint; we terminate with certs from our own CA/ACME on domains we own.
|
||||
- **Allowlist:** use Synapse `federation_domain_whitelist` to restrict federation to the set of Mosaic site domains — **no public-network federation** (NG5). This is a hard security boundary.
|
||||
- **Cross-site rooms:** the fleet presence room and any cross-mission rooms are federated rooms whose membership spans site homeservers. Room state replicates via S2S; presence heartbeats propagate as events (not relying on lossy presence EDUs across federation, §4.5).
|
||||
|
||||
**Cross-site identity:** an agent on site-B is `@mosaic_<slug>:site-b`. The signed-authorship pubkey travels in profile state, so a site-A verifier can validate a site-B agent's gate action without trusting site-B's homeserver blindly (signature ≠ homeserver trust).
|
||||
|
||||
---
|
||||
|
||||
## 7. Buzz-pattern adoption map
|
||||
|
||||
We adopt Buzz's **ideas**, on Matrix rails, phased:
|
||||
|
||||
| Buzz idea | Adopt? | How, on Matrix | Phase |
|
||||
| --------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| **Harness auto-detect / enroll** | **Yes** | `mosaic enroll` detects Claude/Codex/Pi/Goose and self-registers via the appservice (§4.1) | P2 (enroll v1); scan-machine-and-offer-integrate is **v2** |
|
||||
| **Keypair signed identity** | **Yes, scoped** | Ed25519 signed-authorship on **gate actions only** (reviews/merges/approvals), pubkey in profile state (§4.4) | P5 (hardening); keys issued at enroll from P2 |
|
||||
| **Unified event log (humans + agents, one log)** | **Yes** | Matrix room timeline _is_ the unified, receipted, replayable event log; MACP custom events are first-class entries (§4.2) | P2→P3 |
|
||||
| **Humans and agents on the same surface** | **Yes** | HIL uses the same Matrix rooms via Element/custom client; escalations land where Jason already is (§4.6, §5) | P1 (fleet room) → P2 |
|
||||
| **Scan machine, offer to integrate existing tools** | **Deferred (v2)** | enroll v1 auto-detects the _harness_; scanning a host for other integratable tools is explicitly **enroll v2**, post-P5 | v2 |
|
||||
| **Buzz/Nostr as transport** | **No** | transport is Matrix; only patterns are borrowed (NG3) | — |
|
||||
|
||||
---
|
||||
|
||||
## 8. Security
|
||||
|
||||
- **Homeserver hardening.** Disable open registration (`enable_registration: false`); agents come only via the appservice. `federation_domain_whitelist` to Mosaic sites only (§6). Rate-limiting on. Media repo locked down or disabled if unused. Admin API bound to localhost / behind auth. Run Synapse behind our reverse proxy with TLS termination we control. **[VERIFY]** current recommended hardening flags for the deployed Synapse version.
|
||||
- **Appservice token custody (`hs_token` / `as_token`).** These are the crown jewels — `as_token` lets the holder act as any user in the namespace; `hs_token` authenticates the homeserver → appservice callbacks. They live **only** in the appservice's secret store (see Vault interaction below), never in an agent process, never in the SDK, never in git. Registration file (which contains them) is a secret artifact, mounted at deploy, not committed. Rotate on suspected exposure; rotation requires updating both Synapse's registration and the appservice.
|
||||
- **Per-agent credentials (the hard part — fleet key management).**
|
||||
- Agents hold **only their own** per-agent `access_token` (minted by the appservice at enroll), not the `as_token`. Compromise of one agent ≠ compromise of the namespace.
|
||||
- **Issuance:** at `mosaic enroll`, the appservice mints the token and returns it over the enroll channel (which must itself be authenticated — enroll requests carry a spin-time bootstrap secret / orchestrator-signed nonce **[VERIFY design in P2]**, so a random process can't enroll a rogue agent).
|
||||
- **Signed-authorship keys:** Ed25519 private keys should be generated agent-side and only the public key leaves the agent (best custody: private key never transits the network). Where agents are ephemeral, keys are minted per-spin and discarded on teardown; the pubkey-in-profile record remains for audit.
|
||||
- **Rotation:** tokens are short-lived where the Synapse version supports refresh **[VERIFY]**; otherwise the appservice supports explicit re-issue on re-enroll. A rotation runbook is a P2 deliverable.
|
||||
- **Federation TLS.** Valid certs on federation endpoints; whitelist-only federation; monitor for cert expiry (a cert lapse silently defederates a site — add to observability).
|
||||
- **Interaction with existing Vault/secrets.** The appservice's `hs_token`/`as_token` and the enroll bootstrap secret are stored in the existing secret manager (Vault or the project's chosen store — **open question §11**) and injected at deploy, consistent with how Gateway/DB secrets are handled today. No new bespoke secret store. Per-agent tokens are _transient runtime_ secrets, not persisted to Vault (they're re-mintable). **[VERIFY]** current Mosaic secret-management choice — the CLAUDE.md notes secrets/KBN work in flight, so align with whatever KBN-101 lands.
|
||||
|
||||
---
|
||||
|
||||
## 9. Migration — strangler off Hermes
|
||||
|
||||
**Principle:** stand the native layer up _alongside_ Hermes, move channels over **as each is proven at parity**, retire Hermes only when the parity checklist is green. Never a flag-day cutover (NG1).
|
||||
|
||||
**Sequence:**
|
||||
|
||||
1. **Stand alongside.** Native Synapse + appservice + fleet presence room live in parallel; Hermes still carries everything it carries today. Presence (P1) is _additive_ — it gives us something Hermes never had, at zero risk to existing flows.
|
||||
2. **Move channels as proven.** Per channel (fleet presence → mission coordination → reviews/approvals → external-chat edge), cut traffic to Matrix, keep Hermes as hot fallback until the channel meets parity for a soak period.
|
||||
3. **Retire at parity.** When every checklist item is green and soaked, decommission the Hermes MCP bridge as backbone. (External chat platforms, if still needed, can be re-attached as a _bridge at the edge_ of Matrix rather than the spine.)
|
||||
|
||||
**Parity checklist (must all be green before Hermes retires):**
|
||||
|
||||
- [ ] Every message class Hermes carries today has a MACP equivalent (message, review/approval, escalation, workflow-step).
|
||||
- [ ] Presence/liveness is strictly better than today (it is: today = none).
|
||||
- [ ] Cross-site delivery works over federation with no central SPOF.
|
||||
- [ ] HIL (Jason) can see and act on escalations on the Matrix surface.
|
||||
- [ ] Signed-authorship enforced on gate actions (no unsigned merge/approve accepted).
|
||||
- [ ] `mosaic enroll` auto-onboards all four harnesses (Claude/Codex/Pi/Goose) with zero manual identity setup.
|
||||
- [ ] Delivery receipts / durability demonstrably ≥ Hermes (no lost messages over a soak window).
|
||||
- [ ] Runbooks exist: appservice token rotation, site defederation, dark-site escalation, homeserver restore.
|
||||
- [ ] Observability: appservice + Synapse traced into OTEL/Jaeger like the rest of the stack.
|
||||
- [ ] Rollback path documented (re-enable Hermes channel) for the soak period.
|
||||
|
||||
---
|
||||
|
||||
## 10. Phased delivery plan (P1 → P5) with acceptance criteria
|
||||
|
||||
> This section is the decomposition surface: each phase → one or more missions for the orchestrator.
|
||||
|
||||
### P1 — Presence / availability on a minimal single-site Synapse _(the first standalone shippable slice)_
|
||||
|
||||
**Scope:** one Synapse homeserver, a **minimal** appservice (or even a scripted provisioner) whose only job is: register a handful of agent MXIDs, create the **fleet presence room**, and carry heartbeats; a minimal `packages/comms` slice that sets presence and heartbeats; native presence surfaced to a human via Element.
|
||||
**Acceptance criteria:**
|
||||
|
||||
- A1. Single-site Synapse deployed (`infra/matrix/`), reachable over TLS, open registration OFF.
|
||||
- A2. ≥3 agents enroll (even if semi-manually) and appear in a **fleet presence room** with a live online/away/offline indicator.
|
||||
- A3. `mosaic.presence` heartbeat implemented; an agent killed hard flips to **offline/dark** within `dark_threshold` deterministically (not dependent on native presence timeout alone).
|
||||
- A4. A human (Jason) can open Element, join the fleet room, and see fleet liveness at a glance.
|
||||
- A5. Zero impact to existing tmux + `mos-comms` flows (they still work untouched).
|
||||
|
||||
### P2 — Native appservice + orchestrator auto-enroll / room-provisioning
|
||||
|
||||
**Scope:** full **`apps/matrix-appservice`** registered with Synapse (`hs_token`/`as_token`, namespaces); `mosaic enroll` harness auto-detect; orchestrator-owned room taxonomy; per-agent token minting; introductions.
|
||||
**Acceptance criteria:**
|
||||
|
||||
- B1. Appservice registered with Synapse via registration file; owns `@mosaic_*` user namespace and room-alias namespace.
|
||||
- B2. `mosaic enroll` auto-detects all four harnesses (Claude/Codex/Pi/Goose) and self-registers on spin, idempotently.
|
||||
- B3. On spin, an agent is provisioned an MXID, minted its **own** access token, joined to the correct mission/team/fleet rooms, and posts a `mosaic.introduction`.
|
||||
- B4. Orchestrator can create/destroy mission & team rooms with correct power levels via the appservice.
|
||||
- B5. Enroll bootstrap is authenticated (a rogue local process cannot enroll a rogue agent).
|
||||
- B6. Appservice + Synapse traced into OTEL/Jaeger.
|
||||
|
||||
### P3 — MACP v1 spec ratified
|
||||
|
||||
**Scope:** freeze the standard (§4): envelope, event types + JSON Schemas, identity model, presence model, room taxonomy, tmux↔Matrix routing rules. Spec lives in framework (`~/.config/mosaic/spec/macp`), vendored+pinned into `packages/comms` with CI drift-check.
|
||||
**Acceptance criteria:**
|
||||
|
||||
- C1. MACP v1 document ratified (MS-LEAD sign-off, Jason veto window closed).
|
||||
- C2. JSON Schemas for all five event types published; `packages/comms` validates outbound/inbound against them.
|
||||
- C3. CI fails if framework spec and vendored `packages/comms` copy diverge.
|
||||
- C4. Routing-rule conformance test: SDK provably sends ephemeral+same-host over tmux, everything else over Matrix.
|
||||
- C5. Unknown-`macp_version` graceful-degrade behavior tested.
|
||||
|
||||
### P4 — Federation
|
||||
|
||||
**Scope:** a second site homeserver + appservice; S2S federation over our DNS/TLS; cross-site fleet room; whitelist-only federation; cross-site escalation.
|
||||
**Acceptance criteria:**
|
||||
|
||||
- D1. Two sites federate via `.well-known`/SRV over TLS we control; `federation_domain_whitelist` restricts to Mosaic sites (no public federation).
|
||||
- D2. A cross-site fleet presence room shows agents from both sites; heartbeats propagate as events across federation.
|
||||
- D3. **Homelab-dark test:** killing site-A's coordinator is observed from site-B within `dark_threshold`, and the escalation/fallback fires cross-site (§5).
|
||||
- D4. Cross-site gate action: a site-B agent's signed review is verified by a site-A verifier.
|
||||
- D5. Defederation runbook proven (a site can be cut off cleanly).
|
||||
|
||||
### P5 — Buzz-hardening + signed-authorship + Hermes retired
|
||||
|
||||
**Scope:** Ed25519 signed-authorship enforced on gate actions; security hardening pass; complete the strangler and retire Hermes at parity.
|
||||
**Acceptance criteria:**
|
||||
|
||||
- E1. Every merge/approve/review gate action is signed; unsigned or bad-sig gate events are rejected by the appservice watcher and by consuming agents.
|
||||
- E2. Token/key rotation runbooks executed at least once in anger (rotate `as_token`, rotate a per-agent key).
|
||||
- E3. Security review complete: homeserver hardening flags, token custody, federation TLS/whitelist all verified.
|
||||
- E4. **Parity checklist (§9) fully green + soaked.**
|
||||
- E5. Hermes MCP bridge retired as backbone (optionally re-attached as an edge bridge only).
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions for Jason / Mos (need a human/coordinator ruling)
|
||||
|
||||
1. **DNS / domains per site.** What domain(s) do we own and want to use per site for homeserver names and `.well-known` (e.g. `site-a.mosaicstack.dev`)? Federation identity is permanent-ish once agents mint MXIDs against it — this needs a ruling **before P1 hardens** because MXIDs bake in the domain.
|
||||
2. **Secret-management choice.** Is it Vault, or whatever KBN-101 lands? The appservice `hs_token`/`as_token` and enroll bootstrap secret custody depend on this (§8). CLAUDE.md signals secrets work is in flight — need the authoritative target.
|
||||
3. **N-minute escalation threshold.** What is `dark_threshold_min` for a coordinator, and the second threshold before HIL is paged (§5)? Default proposed: 10 min → fallback, +5 min → HIL. Jason/Mos to confirm per role.
|
||||
4. **HIL client: Element vs custom.** Do humans use off-the-shelf **Element** (fast, free, P1-ready) or do we invest in a custom HIL client? Proposed: **Element for P1–P4**, revisit custom only if HIL ergonomics demand it.
|
||||
5. **Ephemeral vs persistent agent keys.** For signed-authorship, do we mint Ed25519 keys per-spin (simplest custody, no long-term private key at rest) or issue durable per-agent keys (stable identity across spins, but key-at-rest custody problem)? Proposed: **per-spin**, pubkey retained for audit.
|
||||
6. **Federation topology / trust.** Full mesh between all sites, or hub-and-spoke-with-redundancy? Full mesh maximizes no-SPOF but grows O(n²); needs a call once site count is known.
|
||||
7. **Fallback-coordinator assignment authority.** Who assigns each Team Lead's fallback coordinator, and is it always cross-site? (Design invariant §5 wants the fallback off the monitored host — confirm this is acceptable operationally.)
|
||||
8. **Retention / compliance.** How long do we retain the Matrix event log (the unified audit trail)? Affects Synapse storage sizing and any purge policy.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Real Matrix concepts this RFC leans on (quick reference)
|
||||
|
||||
- **Application Service (AS) API** — a privileged service registered with the homeserver via a registration YAML declaring `id`, `url`, `as_token`, `hs_token`, and `namespaces` (users/aliases/rooms regexes). The homeserver pushes events to the AS in transactions; the AS can register/act-as users in its namespace. _(This is our appservice backbone.)_
|
||||
- **`hs_token` / `as_token`** — `hs_token`: homeserver→AS authentication on pushed transactions; `as_token`: AS→homeserver authentication, grants acting-as any namespaced user. Both are high-value secrets (§8).
|
||||
- **Masquerade (`user_id` query param)** — the AS may act as a namespaced user on C-S calls using `as_token` + `?user_id=`. We prefer per-agent tokens for blast-radius; masquerade for AS-internal bulk ops.
|
||||
- **Custom event types** — timeline events via `m.room.message` with a custom `msgtype` (keeps a human-visible `body` fallback) and/or fully custom `type` (dotted, e.g. `mosaic.workflow.step`); **state events** for last-writer-wins current status readable from room state without history replay.
|
||||
- **Presence EDUs** — native online/unavailable/offline signals; coarse and lossy over federation, so MACP adds an explicit heartbeat event as the authoritative liveness source (§4.5).
|
||||
- **Federation (S2S API)** — server-to-server over TLS; discovery via `/.well-known/matrix/server` and/or `_matrix._tcp` SRV; restrictable with `federation_domain_whitelist`.
|
||||
- **Synapse config knobs cited** — `enable_registration`, `federation_domain_whitelist`, appservice registration file, rate-limiting, admin API binding. **[VERIFY]** exact flags/paths against the deployed Synapse version at implementation time.
|
||||
|
||||
_All Matrix mechanics above are cited from architecture knowledge and MUST be re-verified against the actual deployed Synapse version during P1 — every **[VERIFY]** in this document is a checkpoint, not an assumption._
|
||||
622
docs/rfcs/RFC-002-INSTALL-CONFIG-TOPOLOGY.md
Normal file
622
docs/rfcs/RFC-002-INSTALL-CONFIG-TOPOLOGY.md
Normal file
@@ -0,0 +1,622 @@
|
||||
# RFC-002 — Install, Configuration & Topology for the Mosaic Matrix/MACP Comms System
|
||||
|
||||
- **Status:** DRAFT — for Team Lead → Orchestrator staffing
|
||||
- **Author:** MS-LEAD (reviewer identity `ms-lead-reviewer`)
|
||||
- **Sponsor / veto:** Jason (human lead)
|
||||
- **Date:** 2026-07-24
|
||||
- **Program:** Mosaic Stack comms-evolution
|
||||
- **Companion to:** RFC-001 — _MACP: A Mosaic-Native, Matrix-Native Comms Layer_. RFC-001 is the architecture (self-hosted Synapse + Mosaic appservice backbone + `packages/comms` SDK + MACP standard + per-site federation). **RFC-002 is the config substrate the whole thing installs and runs on.**
|
||||
- **Audience:** Team Leads, the Mosaic orchestrator, infra, harness maintainers, and — critically — **strangers who install this open-source product on hardware we will never see.**
|
||||
|
||||
> This is a **design document**. No code ships from this RFC. It is written to be decomposed into missions with per-phase acceptance criteria, and it slots under RFC-001's P1→P5. Every uncertain or must-live-validate claim is flagged **[VERIFY]**.
|
||||
|
||||
> **The one framing that governs every decision below:** this is an **open-source product**. Someone we have never met will `git clone` it and run it on their own domains, their own DNS, their own certs, their own hardware. **NOTHING may hardcode our fleet's topology.** There is no `woltje.com` in the code, no assumption that DNS exists, no assumption that a second site exists. Every topology fact is **user-supplied config**. Where this doc uses `mosaic.woltje.com` / `mosaic.uscllc.com`, those are **illustrative operator values** (Jason's real installs), never defaults and never literals in the product.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
The comms system installs against a **user-supplied topology**, never a baked-in one. At install the operator declares exactly one of **three topology modes**: **(A) split-domain** (identity `server_name` ≠ homeserver host, wired via Matrix delegation — this is Jason's `mosaic.woltje.com` identity + `matrix.woltje.com` host setup), **(B) single-domain** (`server_name` == homeserver host), or **(C) IP-only standalone** (no DNS, no federation, fully supported for local/airgapped). **The PRIMARY/home instance is ALWAYS configured; federation is OPTIONAL.** A single standalone instance MUST work with zero federation.
|
||||
|
||||
**Federation is a hard-gated capability: it REQUIRES DNS + valid certificates. IP-only federation is not possible and is not supported.** IP-only means standalone-only, forever, until the operator acquires DNS + certs.
|
||||
|
||||
Certificates are **one ACME integration** with a user-chosen **directory URL**: either **step-ca** (self-hosted private ACME CA, for total control and private/internal domains public CAs can't issue for) or **Let's Encrypt** (public ACME, ease-of-use). The operator also picks a challenge type (HTTP-01 / DNS-01 / TLS-ALPN-01); **DNS-01 is the answer for private/split-horizon domains.**
|
||||
|
||||
Secrets go through a **pluggable `SecretBackend` interface** — no forced paid dependency. Ships with a **Vault** implementation and a **Vaultwarden** implementation; the operator picks at install. The Vaultwarden model (org + orchestrator enrolled as authority + per-agent scoped access) is designed-for, with an honest **[VERIFY]** on how far Vaultwarden's machine-account coverage has matured.
|
||||
|
||||
Config is **DB-backed with sane defaults and install-time overrides**. Precedence: **install-time → DB override → default.** Config is split into **install-time-immutable** (e.g. `server_name`, which is baked into every MXID and cannot change without re-homing every identity) and **runtime-tunable** (e.g. dark-threshold).
|
||||
|
||||
RFC-002 is the substrate; **RFC-001's P1 (presence) needs only Mode A/B single-instance clean-domain and does NOT require federation, IP-only, or the secret-backend rotation story resolved.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals / Non-Goals
|
||||
|
||||
### 1.1 Goals
|
||||
|
||||
- **G1 — Installable by a stranger.** A person with no relationship to our fleet can install, configure, and run the comms system from published artifacts and a guided installer, on their own hardware and domains, with no edits to product code.
|
||||
- **G2 — Zero hardcoded topology.** Every topology fact — `server_name`, homeserver host/IP, delegation method, federation peers, cert mode, secret backend — is **user-supplied config**, validated at install, stored in the product DB. No fleet-specific literal ships in the product.
|
||||
- **G3 — Standalone MUST work.** The PRIMARY instance is always fully functional with **zero federation**, including with **no DNS at all** (Mode C, IP-only). Presence, rooms, MACP, HIL-via-Element all work single-instance.
|
||||
- **G4 — Federation is optional but honestly gated.** Federation is opt-in and, when opted into, **requires DNS + valid certificates as a hard precondition.** The installer must refuse to _claim_ federation is working when the DNS/cert preconditions aren't met.
|
||||
- **G5 — One ACME integration, two CA choices.** Build a single ACME cert-provisioning path; the operator selects step-ca or Let's Encrypt by supplying an **ACME directory URL** plus a challenge type. No second, bespoke cert path.
|
||||
- **G6 — No forced paid dependency for secrets.** A pluggable `SecretBackend` with at least Vault and Vaultwarden implementations, chosen at install. Open-source ethos: the free/self-hostable path must be first-class.
|
||||
- **G7 — Defaults that just work, overrides where they matter.** DB-backed config with sane defaults so most operators change little; install-time overrides for the topology-critical values; a clear immutable-vs-tunable boundary so operators can't foot-gun `server_name`.
|
||||
- **G8 — A clean upgrade path.** An operator who starts standalone can later turn on federation with a documented, honest procedure (including the real cost if they started IP-only and must now acquire a stable `server_name`).
|
||||
|
||||
### 1.2 Non-Goals
|
||||
|
||||
- **NG1 — Not hosting a managed service.** This RFC is about _self-install_. We are not building multi-tenant SaaS provisioning; each operator runs their own instance(s).
|
||||
- **NG2 — Not a new cert stack.** We do not write our own CA, our own ACME client protocol, or a non-ACME cert path. We integrate ACME and let the operator point it at step-ca or Let's Encrypt. (We _may_ bundle/recommend step-ca as the self-hosted CA, but via its standard ACME provisioner, not a fork.)
|
||||
- **NG3 — Not a new secret manager.** We define an interface and ship adapters. We do not build a secret store; we do not force one.
|
||||
- **NG4 — Not public-network Matrix federation.** Consistent with RFC-001 NG5: federation is Mosaic-site-to-Mosaic-site over infrastructure the operator controls, allowlisted. No `matrix.org` federation.
|
||||
- **NG5 — Not making IP-only federate.** We will not ship a hack (self-signed S2S trust bundles, `/etc/hosts` federation) that pretends IP-only can federate. IP-only is standalone. This is a deliberate, honest boundary (§2.4, §7).
|
||||
- **NG6 — Not re-homing identities silently.** We will not offer a "just change your `server_name`" button that quietly orphans every MXID. Any path that changes `server_name` is a flagged, gated, documented identity re-home (§5.3, §7).
|
||||
|
||||
---
|
||||
|
||||
## 2. The topology model
|
||||
|
||||
### 2.1 The core split: `server_name` vs homeserver host
|
||||
|
||||
Matrix has exactly the split Jason described, natively:
|
||||
|
||||
- **`server_name`** — the Synapse config value that is the server's **identity domain**. It is the part after the colon in every MXID (`@mosaic_agent:mosaic.woltje.com`) and every room alias (`#mission-KBN-101:mosaic.woltje.com`). It is **baked into every identity the moment that identity is minted.** Changing it re-homes everything. This is `server_name` in Synapse's `homeserver.yaml`.
|
||||
- **Homeserver host** — the actual network location (hostname:port or IP:port) where the Synapse process answers federation and (optionally proxied) client traffic. It **can differ** from `server_name`. Matrix reconciles the difference through **delegation**: `https://<server_name>/.well-known/matrix/server` returning `{"m.server": "matrix.woltje.com:443"}`, and/or a `_matrix._tcp.<server_name>` **SRV** record. **[VERIFY]** `.well-known` vs SRV precedence on the deployed Synapse version (RFC-001 §6 flags the same).
|
||||
|
||||
So Jason's "mosaic._ app-domain + matrix._ homeserver-domain" split maps precisely: **`server_name = mosaic.woltje.com` (identity, in MXIDs), homeserver runs at `matrix.woltje.com` (discovered via delegation).** That is **Mode A**.
|
||||
|
||||
### 2.2 The topology config schema
|
||||
|
||||
One canonical config object, stored in the product DB (§5), populated at install (§6). Illustrative shape (field names decomposition-ready, not frozen):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"topology": {
|
||||
"mode": "split-domain | single-domain | ip-only-standalone", // A | B | C — install-time-immutable
|
||||
|
||||
"identity": {
|
||||
"server_name": "mosaic.woltje.com", // INSTALL-TIME-IMMUTABLE. In MXIDs. Never change without re-home (§5.3).
|
||||
"server_name_kind": "domain | ip", // "ip" only legal in Mode C
|
||||
},
|
||||
|
||||
"homeserver": {
|
||||
"host": "matrix.woltje.com", // where Synapse actually listens (Mode A: differs from server_name)
|
||||
"port": 8448, // federation port (default 8448) or 443 if proxied — [VERIFY] per deploy
|
||||
"client_bind": "https://matrix.woltje.com", // C-S API public URL (proxied)
|
||||
"bind_ip": null, // Mode C: e.g. "192.168.1.50" ; Modes A/B: null (DNS-resolved)
|
||||
},
|
||||
|
||||
"delegation": {
|
||||
"method": "well-known | srv | none", // Mode A: well-known or srv ; Mode B/C: none
|
||||
"well_known_server": { "m.server": "matrix.woltje.com:443" }, // if method=well-known
|
||||
"srv_record": "_matrix._tcp.mosaic.woltje.com. 3600 IN SRV 10 0 443 matrix.woltje.com.", // if method=srv (documented, operator provisions)
|
||||
},
|
||||
|
||||
"federation": {
|
||||
"enabled": true, // OPTIONAL. Mode C forces false.
|
||||
"domain_whitelist": [
|
||||
// Synapse federation_domain_whitelist — allowlist ONLY
|
||||
"mosaic.woltje.com",
|
||||
"mosaic.uscllc.com",
|
||||
],
|
||||
"peers": [
|
||||
// operator-declared peer sites (for room/fleet wiring)
|
||||
{ "server_name": "mosaic.uscllc.com", "role": "secondary", "fleet_room": true },
|
||||
],
|
||||
},
|
||||
|
||||
"tls": {
|
||||
"acme": {
|
||||
"directory_url": "https://acme.mosaic.woltje.com/acme/acme/directory", // step-ca OR https://acme-v02.api.letsencrypt.org/directory
|
||||
"ca_kind": "step-ca | letsencrypt", // informational label; the directory_url is the real switch
|
||||
"challenge": "dns-01 | http-01 | tls-alpn-01",
|
||||
"account_email": "ops@woltje.com", // ACME account contact
|
||||
"eab": { "kid": null, "hmac_key_ref": null }, // External Account Binding if the CA requires it (some step-ca provisioners) — secret via SecretBackend
|
||||
},
|
||||
"client_tls_mode": "acme | self-signed", // Mode C may use self-signed for local C-S TLS (weaker trust, §8)
|
||||
},
|
||||
|
||||
"secrets": {
|
||||
"backend": "vault | vaultwarden", // pluggable, install-time choice (§4)
|
||||
"connection": {
|
||||
"address": "https://vault.woltje.com:8200", // or Vaultwarden/Bitwarden server URL
|
||||
"auth_ref": "…", // how the appservice authenticates to the backend (bootstrap, §4/§8)
|
||||
"namespace_or_org": "mosaic-fleet", // Vault namespace / mount, OR Vaultwarden org id
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 The three modes, concretely
|
||||
|
||||
Exactly three supported modes (Jason's ruling — no others):
|
||||
|
||||
**Mode A — split-domain (identity ≠ host, delegated).** _Jason's PRIMARY._ Federation-capable. This is the recommended production shape because it lets identity live on a clean app-domain while the homeserver runs on a separate operational host.
|
||||
|
||||
```jsonc
|
||||
// Mode A — mosaic.woltje.com identity, matrix.woltje.com host, federated with a second site
|
||||
{
|
||||
"topology": {
|
||||
"mode": "split-domain",
|
||||
"identity": { "server_name": "mosaic.woltje.com", "server_name_kind": "domain" },
|
||||
"homeserver": {
|
||||
"host": "matrix.woltje.com",
|
||||
"port": 443,
|
||||
"client_bind": "https://matrix.woltje.com",
|
||||
"bind_ip": null,
|
||||
},
|
||||
"delegation": {
|
||||
"method": "well-known",
|
||||
"well_known_server": { "m.server": "matrix.woltje.com:443" },
|
||||
},
|
||||
"federation": {
|
||||
"enabled": true,
|
||||
"domain_whitelist": ["mosaic.woltje.com", "mosaic.uscllc.com"],
|
||||
"peers": [{ "server_name": "mosaic.uscllc.com", "role": "secondary", "fleet_room": true }],
|
||||
},
|
||||
"tls": {
|
||||
"acme": {
|
||||
"directory_url": "https://acme-v02.api.letsencrypt.org/directory", // public LE, or a step-ca directory
|
||||
"ca_kind": "letsencrypt",
|
||||
"challenge": "dns-01",
|
||||
"account_email": "ops@woltje.com",
|
||||
},
|
||||
"client_tls_mode": "acme",
|
||||
},
|
||||
"secrets": {
|
||||
"backend": "vaultwarden",
|
||||
"connection": { "address": "https://vw.woltje.com", "namespace_or_org": "mosaic-fleet" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
MXIDs on this instance: `@mosaic_coordinator-1:mosaic.woltje.com`. A human/agent's homeserver is discovered by resolving `.well-known/matrix/server` on `mosaic.woltje.com` → `matrix.woltje.com:443`.
|
||||
|
||||
**Mode B — single-domain (identity == host).** Simpler; the `server_name` _is_ the host. No delegation needed. Federation-capable (still needs DNS + cert on that one domain).
|
||||
|
||||
```jsonc
|
||||
// Mode B — one domain does everything
|
||||
{
|
||||
"topology": {
|
||||
"mode": "single-domain",
|
||||
"identity": { "server_name": "matrix.example.org", "server_name_kind": "domain" },
|
||||
"homeserver": {
|
||||
"host": "matrix.example.org",
|
||||
"port": 8448,
|
||||
"client_bind": "https://matrix.example.org",
|
||||
"bind_ip": null,
|
||||
},
|
||||
"delegation": { "method": "none" },
|
||||
"federation": { "enabled": false, "domain_whitelist": [], "peers": [] }, // optional — off here
|
||||
"tls": {
|
||||
"acme": {
|
||||
"directory_url": "https://acme-v02.api.letsencrypt.org/directory",
|
||||
"ca_kind": "letsencrypt",
|
||||
"challenge": "http-01",
|
||||
"account_email": "admin@example.org",
|
||||
},
|
||||
"client_tls_mode": "acme",
|
||||
},
|
||||
"secrets": {
|
||||
"backend": "vault",
|
||||
"connection": { "address": "https://vault.example.org:8200", "namespace_or_org": "mosaic" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
MXIDs: `@mosaic_coordinator-1:matrix.example.org`. Here `server_name == host`, so `@:matrix.example.org` is both the identity domain and where Synapse actually answers.
|
||||
|
||||
**Mode C — IP-only standalone (no DNS, no federation).** Fully supported for local/airgapped/homelab-without-DNS. `server_name` is an IP:port literal. **Cannot federate — ever — in this mode** (federation requires a real domain + valid CA cert; §2.4). Client TLS may be self-signed (weaker trust, §8).
|
||||
|
||||
```jsonc
|
||||
// Mode C — airgapped / local, no DNS, standalone only
|
||||
{
|
||||
"topology": {
|
||||
"mode": "ip-only-standalone",
|
||||
"identity": { "server_name": "192.168.1.50:8448", "server_name_kind": "ip" },
|
||||
"homeserver": {
|
||||
"host": "192.168.1.50",
|
||||
"port": 8448,
|
||||
"client_bind": "https://192.168.1.50:8448",
|
||||
"bind_ip": "192.168.1.50",
|
||||
},
|
||||
"delegation": { "method": "none" },
|
||||
"federation": { "enabled": false, "domain_whitelist": [], "peers": [] }, // FORCED false in Mode C
|
||||
"tls": { "acme": null, "client_tls_mode": "self-signed" }, // may use a private step-ca or self-signed for C-S TLS
|
||||
"secrets": {
|
||||
"backend": "vaultwarden",
|
||||
"connection": { "address": "http://192.168.1.51:8080", "namespace_or_org": "mosaic-local" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
MXIDs: `@mosaic_coordinator-1:192.168.1.50:8448`. **Warning surfaced at install:** this `server_name` is an IP literal; if the operator ever wants federation they must move to a domain, which is an **identity re-home** (§5.3, §7).
|
||||
|
||||
> **[VERIFY]** Synapse accepts an `ip:port` `server_name` and mints usable MXIDs against it for local/standalone use. This is believed workable for non-federated operation but must be validated against the deployed Synapse version; some Synapse versions/tools assume a DNS-resolvable `server_name`. If an IP literal is rejected, Mode C falls back to a **fabricated local domain** (e.g. `mosaic.local`) resolved via `/etc/hosts` or a local resolver — still standalone-only, same re-home caveat.
|
||||
|
||||
### 2.4 The hard federation gate (Jason's HARD STOP)
|
||||
|
||||
**Federation REQUIRES DNS + valid certificates. This is a hard stop, enforced by the installer and by the config validator, not a suggestion.**
|
||||
|
||||
| Precondition | Why | Enforced where |
|
||||
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `server_name` resolves in public/peer DNS (or delegated target does) | S2S discovery uses `.well-known`/SRV over DNS; peers must resolve you | installer reachability check (§6); config validator rejects `federation.enabled=true` with `server_name_kind=ip` |
|
||||
| Valid TLS cert on the federation endpoint, chained to a CA the peer trusts | S2S is TLS; a peer validates your cert. Self-signed/untrusted ⇒ peer refuses ⇒ you are defederated | installer cert probe (§6); cert monitor (§3.4) |
|
||||
| Federation `domain_whitelist` non-empty and mutually consistent with peers | allowlist-only federation (RFC-001 NG5/§6) | config validator |
|
||||
|
||||
**IP-only ⇒ federation is impossible.** There is no valid public/peer CA cert for a bare IP in our trust model (and we will not ship a self-signed S2S trust hack — NG5). Therefore **Mode C is standalone-only by construction**, and the config validator makes `mode=ip-only-standalone ∧ federation.enabled=true` an **illegal state that cannot be persisted.**
|
||||
|
||||
This is the honest, load-bearing boundary of the whole topology model:
|
||||
|
||||
```
|
||||
DNS + valid cert?
|
||||
┌─────────────┴─────────────┐
|
||||
YES NO
|
||||
│ │
|
||||
Mode A or B Mode C (IP-only)
|
||||
federation OPTIONAL STANDALONE ONLY
|
||||
(opt-in, allowlisted) (federation impossible)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Certificate provisioning — one ACME integration
|
||||
|
||||
### 3.1 Single integration, directory-URL as the switch
|
||||
|
||||
We build **exactly one** cert-provisioning integration: an **ACME client integration**. Both supported CAs are ACME CAs. The operator does **not** choose between two code paths; they choose an **ACME directory URL** and a **challenge type**. That is the entire surface.
|
||||
|
||||
| CA choice | What it is | ACME directory URL (illustrative) | Why an operator picks it |
|
||||
| ----------------------- | ------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **step-ca (Smallstep)** | self-hosted **private** ACME CA | `https://acme.<internal-domain>/acme/<provisioner>/directory` | **Total control**; issues for **private/internal/split-horizon domains** a public CA can't (e.g. `mosaic.internal`, RFC-1918 split-horizon); airgap-friendly; you own the root |
|
||||
| **Let's Encrypt** | public ACME CA | `https://acme-v02.api.letsencrypt.org/directory` (staging: `.../acme-staging-v02...`) | **Ease of use**; universally trusted chain (ISRG Root X1); zero CA to operate; ideal for public domains |
|
||||
|
||||
Because both speak ACME, the same client (account key, order, authorization, challenge, finalize, cert-fetch, renew) drives either. The `ca_kind` label in config is informational for UX; the **`directory_url` is the real determinant**. **[VERIFY]** whether the chosen ACME library requires per-CA quirks (LE rate limits, staging switch; step-ca **External Account Binding** on some provisioners — if EAB is required the `kid`/`hmac_key` come from the SecretBackend, §4).
|
||||
|
||||
### 3.2 Challenge-type matrix (which challenge for which topology)
|
||||
|
||||
The operator picks one challenge type per the domains they're covering. This is the crux for **public vs private/split-horizon**:
|
||||
|
||||
| Challenge | How it proves control | Best for | Cannot / caveat |
|
||||
| --------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **HTTP-01** | CA GETs `http://<domain>/.well-known/acme-challenge/<token>` on port 80 | **Public, single hostname**, port 80 reachable from CA (Mode B, or Mode A's homeserver host) | Needs inbound :80 from the CA; **cannot** do wildcards; **useless for private domains** a public CA can't reach |
|
||||
| **DNS-01** | CA checks a `_acme-challenge.<domain>` **TXT** record you publish | **Private / split-horizon / internal domains**, **wildcards**, and any domain where inbound HTTP from the CA is impossible | Requires **programmatic DNS API** access to publish TXT (or manual for step-ca where you own the resolver). **This is the answer for step-ca on private domains** and for Mode A when the homeserver host isn't publicly HTTP-reachable |
|
||||
| **TLS-ALPN-01** | CA connects TLS on :443 with ALPN `acme-tls/1` | Public host where **:443 is free** but :80 is blocked | Needs the ACME client to own the :443 TLS handshake briefly; awkward behind some reverse proxies — **[VERIFY]** against our proxy (RFC-001 terminates TLS at a reverse proxy) |
|
||||
|
||||
**Guidance baked into the installer:**
|
||||
|
||||
- **Let's Encrypt + public domain, port 80 open →** HTTP-01 (simplest). Wildcard or no inbound :80 → DNS-01.
|
||||
- **step-ca + private/internal/split-horizon domain →** **DNS-01** (the private CA can validate against a resolver you control; public HTTP reachability is irrelevant). This is the combination that lets a private homelab domain get real certs.
|
||||
- **:443-only public host →** TLS-ALPN-01.
|
||||
|
||||
### 3.3 Delegation setup for split-domain (Mode A)
|
||||
|
||||
For Mode A, the cert and the delegation must agree. Concretely, at install for `server_name=mosaic.woltje.com`, host `matrix.woltje.com`:
|
||||
|
||||
1. **Cert(s):** obtain a valid cert for **`matrix.woltje.com`** (the federation/host endpoint — this is where the TLS handshake actually lands). If serving `.well-known` over HTTPS on `mosaic.woltje.com`, that origin **also** needs a valid cert for `mosaic.woltje.com`. So Mode A typically provisions **two** SANs/certs: identity-domain (`mosaic.woltje.com`, serves `.well-known`) and host (`matrix.woltje.com`, serves S2S+C-S). **[VERIFY]** whether a single multi-SAN cert is preferable operationally.
|
||||
2. **Delegation record**, one of:
|
||||
- **`.well-known`:** serve `https://mosaic.woltje.com/.well-known/matrix/server` → `{"m.server":"matrix.woltje.com:443"}` (and `.well-known/matrix/client` for C-S discovery so agents/Element find the host).
|
||||
- **SRV:** `_matrix._tcp.mosaic.woltje.com. IN SRV 10 0 443 matrix.woltje.com.` The installer **documents and validates** the record but the operator provisions it in their DNS (we don't run their DNS). **[VERIFY]** `.well-known` vs SRV precedence on the deployed Synapse.
|
||||
3. **Validate:** installer fetches the operator's own `.well-known`/SRV and confirms it points at the configured host, and that the host presents a valid cert (§6). Only then does it declare Mode A "federation-ready."
|
||||
|
||||
### 3.4 Renewal & monitoring — a lapsed federation cert silently defederates
|
||||
|
||||
**This is the operational trap and it must alarm.** ACME certs are short-lived (LE = 90 days; step-ca often shorter by policy). A federation cert that lapses does **not** throw a loud error — peers simply **stop trusting the S2S handshake and the site silently drops out of federation.** From inside, everything looks fine; from peers, the site went dark. That is exactly the "homelab went dark and took comms with it" trauma (RFC-001 §5), but caused by a cert, not a host.
|
||||
|
||||
Requirements:
|
||||
|
||||
- **Auto-renew** on the standard ACME schedule (renew at ~⅓ lifetime remaining; LE guidance ~30 days before expiry). The ACME integration owns this loop.
|
||||
- **Expiry monitoring as a first-class alarm.** Emit cert-days-remaining into OTEL/Jaeger metrics (consistent with RFC-001 §8's "monitor for cert expiry — a cert lapse silently defederates"). Alarm thresholds (e.g. warn <14d, critical <3d) are **runtime-tunable** config (§5).
|
||||
- **Federation-health probe:** periodically resolve our own delegation and validate our own cert _as a peer would_ (external vantage where possible), so a broken renewal is caught as "we would fail a peer's validation" before a peer notices.
|
||||
- **Escalation tie-in:** a critical cert-expiry or federation-health failure raises a `mosaic.escalation` (RFC-001 §4.2/§5) into the HIL room. A cert lapse is a fleet-visibility incident, not a silent config drift.
|
||||
|
||||
---
|
||||
|
||||
## 4. Secret backend interface
|
||||
|
||||
### 4.1 The `SecretBackend` contract
|
||||
|
||||
A single pluggable interface. The appservice and orchestrator depend on the **interface**, never on Vault or Vaultwarden directly. Chosen at install; swappable without touching callers. Illustrative contract (decomposition-ready, not frozen):
|
||||
|
||||
```ts
|
||||
interface SecretBackend {
|
||||
// --- static secret CRUD (appservice tokens, ACME EAB, DB creds) ---
|
||||
get(ref: SecretRef): Promise<SecretValue>;
|
||||
put(ref: SecretRef, value: SecretValue, opts?: { immutable?: boolean }): Promise<void>;
|
||||
rotate(
|
||||
ref: SecretRef,
|
||||
next: SecretValue,
|
||||
): Promise<{ previous: SecretVersion; current: SecretVersion }>;
|
||||
list(prefix: SecretRef): Promise<SecretRef[]>;
|
||||
delete(ref: SecretRef): Promise<void>;
|
||||
|
||||
// --- agent-credential lifecycle (the fleet-identity part) ---
|
||||
enrollAgent(input: {
|
||||
agentSlug: string;
|
||||
scope: CredentialScope; // which rooms/secrets this agent may read
|
||||
ttl?: Duration; // ephemeral-by-default per RFC-001 §8
|
||||
}): Promise<AgentCredentialHandle>; // wraps the per-agent access_token + optional pubkey record
|
||||
|
||||
revokeAgent(agentSlug: string): Promise<void>; // must be authoritative & immediate
|
||||
|
||||
// --- health / bootstrap ---
|
||||
health(): Promise<BackendHealth>;
|
||||
authenticateSelf(bootstrap: BootstrapAuth): Promise<void>; // how the appservice/orchestrator logs into the backend
|
||||
}
|
||||
```
|
||||
|
||||
Design intent: **`get/put/rotate`** cover the static crown-jewel secrets (appservice `hs_token`/`as_token`, ACME account/EAB keys, DB DSN). **`enrollAgent/revokeAgent`** cover the _fleet-identity_ lifecycle — this is where RFC-001's "mint per-agent token at enroll, discard on teardown" (RFC-001 §4.1, §8) actually lands.
|
||||
|
||||
### 4.2 How appservice / agent tokens map onto it
|
||||
|
||||
RFC-001 defines three tiers of Matrix secret. They map cleanly:
|
||||
|
||||
| RFC-001 secret | Sensitivity | `SecretBackend` treatment |
|
||||
| ------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`as_token`** (AS→HS, acts as any namespaced user) | crown jewel | `put(immutable-ish)` + `rotate`; **only** the appservice may `get` it; stored under a fleet-admin scope; never handed to an agent |
|
||||
| **`hs_token`** (HS→AS callback auth) | crown jewel | same as `as_token`; both live only in appservice scope (RFC-001 §8) |
|
||||
| **enroll bootstrap secret / orchestrator-signed nonce** | high | `get` by orchestrator + appservice only; used to authenticate `POST /enroll` so a rogue process can't enroll a rogue agent (RFC-001 §8, B5) |
|
||||
| **per-agent `access_token`** | transient runtime | minted via `enrollAgent`, scoped to that agent, **short-TTL / re-mintable**, discarded on teardown; **not** durably persisted (RFC-001 §8 says per-agent tokens are transient) — the backend may hold a short-lived handle or a personal-vault entry for the agent's own lifetime |
|
||||
| **Ed25519 signed-authorship keypair** | high (private key) | private key generated **agent-side**, only pubkey leaves the agent (RFC-001 §4.4/§8); the SecretBackend stores the **pubkey record** for audit; per-spin keys need no at-rest custody |
|
||||
|
||||
The key blast-radius property (RFC-001 §8) is preserved: agents receive **only their own** credential via `enrollAgent`; the `as_token` never leaves appservice scope.
|
||||
|
||||
### 4.3 Vault implementation
|
||||
|
||||
Vault maps naturally:
|
||||
|
||||
- Static secrets → **KV v2** at a mount/namespace (`mosaic-fleet/`), with versioning giving `rotate` semantics for free.
|
||||
- **`enrollAgent`** → issue a scoped, TTL'd token or use **AppRole** / a scoped policy per agent; Vault's native TTL + revocation is exactly the transient per-agent model. `revokeAgent` → Vault token/lease revoke (authoritative, immediate).
|
||||
- **`authenticateSelf`** → the appservice authenticates to Vault via AppRole (role_id from config, secret_id injected at deploy) or a platform auth method; consistent with how Gateway/DB secrets are handled today (RFC-001 §8). **[VERIFY]** align with whatever KBN-101 lands for Mosaic secret management (CLAUDE.md flags secrets work in flight).
|
||||
- **Trade-off (honesty):** Vault is the most capable backend but is **not** the free-and-simple default for a hobbyist stranger; hence it must not be _forced_ (G6).
|
||||
|
||||
### 4.4 Vaultwarden implementation + the org/enroll/revoke agent-account model
|
||||
|
||||
Vaultwarden (self-hostable Bitwarden-compatible server) is the **open-source-ethos default candidate** — free, self-hostable, familiar. Jason's model, mapped onto Bitwarden/Vaultwarden's org primitives:
|
||||
|
||||
1. **Operator creates one or more Bitwarden orgs** at install (e.g. `mosaic-fleet`).
|
||||
2. **The orchestrator is enrolled into the org and granted authority** to enroll/revoke agent sessions — it is the org's automation principal (admin/manager over an agents **collection**).
|
||||
3. **Agents get scoped credential access:** each agent gets access to a **collection** (or a personal vault provisioned for its spin) holding exactly the secrets its scope allows. `enrollAgent` = grant the agent principal access to its collection + provision its per-agent Matrix token entry; `revokeAgent` = remove the agent principal / revoke its access, immediately.
|
||||
4. **User + agents share scoped access:** the human operator and the agents both hold credentials in the same org, scoped by collection — humans and agents on one secret surface, mirroring RFC-001's "humans and agents on one comms surface" pattern.
|
||||
|
||||
Mapping to the interface:
|
||||
|
||||
| Interface op | Vaultwarden mechanism |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `get/put/rotate` (static) | items in an org **collection**; rotate = new item version / replace + old-version audit |
|
||||
| `enrollAgent` | create/attach agent principal to its **collection**; provision per-agent token item, TTL enforced by our teardown (Vaultwarden itself is not TTL-native — see [VERIFY]) |
|
||||
| `revokeAgent` | revoke the agent principal's org membership / collection access |
|
||||
| `authenticateSelf` | orchestrator authenticates as the org automation principal |
|
||||
|
||||
**HONEST MATURITY FLAG — [VERIFY]:** Bitwarden's clean **machine-account / service-account** primitive lives in **Bitwarden Secrets Manager**, and **Vaultwarden's coverage of Secrets Manager / machine accounts is partial and evolving.** What is known to work today on Vaultwarden: **orgs, collections, per-user (incl. a per-agent "user") vaults, and collection-scoped sharing.** What **may not** be fully there: the polished **machine-account API**, native short-TTL service credentials, and fine-grained programmatic access-token issuance equivalent to hosted Bitwarden Secrets Manager. **[VERIFY]** current Vaultwarden version's Secrets Manager / machine-account support before P-level commitment.
|
||||
|
||||
**Why this is not a blocker:** the interface is designed so **either backend is viable**. If Vaultwarden's machine-account API isn't ready, the Vaultwarden adapter implements `enrollAgent` via the **personal-vault-per-agent + org-collection** model that works _today_ (create an agent principal, share the scoped collection, we enforce TTL via orchestrator teardown rather than backend-native TTL). If an operator needs backend-native short-TTL machine credentials now, they choose the **Vault** adapter. **We are not blocked on Vaultwarden maturing**, because the `SecretBackend` abstraction lets the polished-machine-account behavior land later without changing any caller.
|
||||
|
||||
---
|
||||
|
||||
## 5. Config system
|
||||
|
||||
### 5.1 Storage & precedence
|
||||
|
||||
Config is **DB-backed** (Postgres, per the stack — CLAUDE.md/RFC-001), with sane defaults compiled into the product and install-time overrides. **Precedence, highest wins:**
|
||||
|
||||
```
|
||||
install-time value > DB override (runtime) > compiled default
|
||||
```
|
||||
|
||||
- **Compiled default** — ships in the product; what a stranger gets with zero config for every non-topology-critical key.
|
||||
- **Install-time value** — captured by the installer (§6), written to DB, and for **install-time-immutable** keys, **locked** (marked non-overridable).
|
||||
- **DB override** — runtime tuning via admin surface, allowed **only** for keys classified runtime-tunable.
|
||||
|
||||
> Nuance: "install-time > DB override" applies to **immutable** keys — the install-time value is frozen and a DB override of it is rejected. For **tunable** keys, the DB override is the live value and the install-time value is just the initial seed. The classification (§5.3) is what makes the precedence unambiguous per key.
|
||||
|
||||
### 5.2 DB schema shape
|
||||
|
||||
Illustrative (Drizzle/Postgres, per stack conventions):
|
||||
|
||||
```sql
|
||||
-- one row per config key
|
||||
CREATE TABLE comms_config (
|
||||
key text PRIMARY KEY, -- e.g. 'topology.identity.server_name'
|
||||
value jsonb NOT NULL, -- current effective value
|
||||
source text NOT NULL, -- 'install' | 'db-override' | 'default'
|
||||
mutability text NOT NULL, -- 'install-immutable' | 'runtime-tunable'
|
||||
set_by text, -- operator/agent/system that set it
|
||||
set_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT immutable_not_overridable
|
||||
CHECK (NOT (mutability = 'install-immutable' AND source = 'db-override'))
|
||||
);
|
||||
|
||||
-- append-only audit of every change (esp. attempted immutable changes)
|
||||
CREATE TABLE comms_config_audit (
|
||||
id bigserial PRIMARY KEY,
|
||||
key text NOT NULL,
|
||||
old_value jsonb,
|
||||
new_value jsonb,
|
||||
actor text NOT NULL,
|
||||
action text NOT NULL, -- 'set' | 'override' | 'rejected-immutable'
|
||||
at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
The DB `CHECK` is a belt-and-braces backstop; the application-layer config service enforces mutability and records rejected immutable-change attempts in the audit table. Secrets are **referenced** here (a `SecretRef`), never stored inline — actual secret values live in the `SecretBackend` (§4).
|
||||
|
||||
### 5.3 Install-time-immutable vs runtime-tunable — the key table
|
||||
|
||||
The single most important classification: **what can never change after install** vs **what an operator tunes anytime.** Getting `server_name` on the wrong side of this line is a foot-gun that orphans every identity.
|
||||
|
||||
| Config key | Mutability | Rationale / cost of change |
|
||||
| ------------------------------------------------ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `topology.mode` (A/B/C) | **install-immutable** | changing mode changes identity/federation semantics; a mode change is effectively a reinstall/re-home |
|
||||
| `topology.identity.server_name` | **install-immutable** | **baked into every MXID and room alias.** Changing it re-homes every identity — see re-home note below. **This is THE immutable value.** |
|
||||
| `topology.identity.server_name_kind` | **install-immutable** | domain↔ip change is a re-home (Mode C→A/B) |
|
||||
| `topology.homeserver.host` | install-immutable (**delegation-tunable**) | in Mode A you _can_ move the host if you update delegation to match — the identity is unchanged; treat as immutable-with-migration, not free |
|
||||
| `topology.homeserver.bind_ip` / `port` | runtime-tunable (ops) | operational network binding; no identity impact |
|
||||
| `topology.delegation.method` / records | tunable-with-care | can switch well-known↔SRV as long as both still resolve to the same host; validated on change |
|
||||
| `topology.federation.enabled` | **runtime-tunable (gated)** | can flip **on** only if DNS+cert preconditions pass (§2.4); flipping standalone→federated is the §7 upgrade path |
|
||||
| `topology.federation.domain_whitelist` / `peers` | runtime-tunable | add/remove peers over time; each add re-validated |
|
||||
| `tls.acme.directory_url` / `ca_kind` | runtime-tunable | can switch CA (e.g. LE→step-ca); triggers re-issue; monitor for trust-chain change |
|
||||
| `tls.acme.challenge` | runtime-tunable | switch challenge type if DNS/HTTP reachability changes |
|
||||
| `tls.client_tls_mode` | tunable-with-care | self-signed→acme is fine; acme→self-signed weakens trust (§8) |
|
||||
| `secrets.backend` | install-immutable (**migration-only**) | switching Vault↔Vaultwarden requires a secret migration; not a live flip |
|
||||
| `secrets.connection.*` | runtime-tunable | rotate backend address/auth without changing which backend |
|
||||
| `presence.heartbeat_interval_ms` | runtime-tunable | RFC-001 §4.5 default ~30s; pure tuning |
|
||||
| `presence.miss_tolerance` | runtime-tunable | RFC-001 §4.5 default 2 |
|
||||
| `escalation.dark_threshold_min` | runtime-tunable | RFC-001 §5/§11 — default 10min → fallback |
|
||||
| `escalation.hil_threshold_min` | runtime-tunable | RFC-001 §5 — default +5min → HIL |
|
||||
| `cert.expiry_warn_days` / `expiry_critical_days` | runtime-tunable | §3.4 alarm thresholds (default 14 / 3) |
|
||||
| `federation.health_probe_interval` | runtime-tunable | §3.4 |
|
||||
|
||||
**Identity re-home note (the cost of changing `server_name`):** because every MXID (`@mosaic_*:server_name`) and room alias (`#…:server_name`) embeds `server_name`, changing it means: every agent gets a **new identity**, all rooms must be **recreated/re-aliased**, signed-authorship pubkey records re-published, and federation peers re-pointed. There is **no in-place rename** in Matrix. Hence `server_name` is install-immutable and the installer gates it behind an explicit warning (§6.7). Changing it is a **migration/reinstall**, honestly (§7).
|
||||
|
||||
### 5.4 How the appservice / homeserver read config
|
||||
|
||||
- **Synapse (homeserver)** reads a _rendered_ `homeserver.yaml`. The config service **renders** Synapse's config (server_name, listeners, `federation_domain_whitelist`, `enable_registration: false`, appservice registration path, TLS/delegation) from the DB-backed config at deploy/reconfigure time. Synapse itself is not DB-config-aware; the source of truth is the product DB, and Synapse config is a **generated artifact**. Certain Synapse values (notably `server_name`) require a **Synapse restart** and are exactly the immutable ones — reinforcing §5.3.
|
||||
- **The appservice** reads config **live** from the DB config service for runtime-tunable values (thresholds, whitelist changes, cert alarm thresholds) and from the `SecretBackend` for secrets. Immutable topology values are read once at boot (they can't change under it).
|
||||
- **`packages/comms` SDK** receives the values it needs (homeserver client URL, presence intervals) from the appservice at enroll (RFC-001 §4.1 returns `{mxid, access_token, homeserver, rooms[]}`), so agents never read the config DB directly.
|
||||
- **Delegation artifacts** (`.well-known/matrix/server`, `.well-known/matrix/client`) are likewise **rendered** from config and served by the reverse proxy / homeserver.
|
||||
|
||||
---
|
||||
|
||||
## 6. Installer UX flow
|
||||
|
||||
A guided installer (`mosaic comms install` or equivalent) that captures topology, provisions certs, wires secrets, and **validates before declaring success.** It must never report success it hasn't proven. Steps:
|
||||
|
||||
**6.1 — Preflight & detection.** Detect existing DNS records for a candidate domain, existing certs, an existing reachable Synapse, an existing Vault/Vaultwarden. Offer detected values as suggestions (never as silent defaults). Detect whether the host has public inbound :80/:443 (informs challenge-type guidance, §3.2).
|
||||
|
||||
**6.2 — Primary instance (ALWAYS).** The PRIMARY/home instance is always configured — there is no "skip primary." Prompt for its identity. This is non-optional and is what makes standalone work out of the box.
|
||||
|
||||
**6.3 — Pick topology mode (A/B/C).** Ask the shape:
|
||||
|
||||
- Do you have a domain? **No →** Mode C (IP-only standalone); warn federation is impossible here (§2.4) and that `server_name` will be an IP (re-home cost if they later want federation).
|
||||
- Yes, and identity domain differs from the homeserver host? **Yes →** Mode A (split-domain); capture `server_name` + host + delegation method.
|
||||
- Yes, one domain does everything? **→** Mode B (single-domain).
|
||||
|
||||
**6.4 — Pick CA (ACME directory).** step-ca vs Let's Encrypt → capture `directory_url`, account email, and challenge type with the §3.2 guidance surfaced (e.g. "private/internal domain? → DNS-01"). Capture EAB if the CA requires it (→ SecretBackend). For step-ca, offer to point at an existing step-ca or document standing one up.
|
||||
|
||||
**6.5 — Pick secret backend.** Vault vs Vaultwarden → capture connection (address, org/namespace, bootstrap auth). If Vaultwarden, walk the org/collection setup (§4.4) and **surface the machine-account maturity [VERIFY]** honestly so the operator chooses eyes-open.
|
||||
|
||||
**6.6 — Federation (OPTIONAL).** Only offered if Mode A/B. Ask whether to enable federation now; if yes, capture peer `server_name`s and build the `domain_whitelist`. If Mode C, federation is not offered (greyed out with the explanation). Federation-off is a first-class, fully-supported outcome.
|
||||
|
||||
**6.7 — The "what can't be changed later" warning gate.** Before writing immutable config, present an explicit confirmation:
|
||||
|
||||
```
|
||||
⚠ IMMUTABLE CHOICES — read before confirming
|
||||
server_name = "mosaic.woltje.com"
|
||||
This becomes part of every agent identity (e.g. @mosaic_coordinator-1:mosaic.woltje.com)
|
||||
and every room alias. It CANNOT be changed later without re-homing every identity
|
||||
(new MXIDs for all agents, recreating all rooms). There is no in-place rename in Matrix.
|
||||
topology.mode = "split-domain" — changing modes later is a reinstall.
|
||||
secrets.backend = "vaultwarden" — switching backends later requires a secret migration.
|
||||
Type the server_name to confirm you understand it is permanent: ____________
|
||||
```
|
||||
|
||||
The operator must **retype `server_name`** to proceed — a deliberate friction gate on the one truly permanent value.
|
||||
|
||||
**6.8 — Provision & validate (no success claim until proven).** The installer then:
|
||||
|
||||
1. Renders Synapse config + delegation artifacts; brings up Synapse with `enable_registration: false`.
|
||||
2. Runs the ACME flow; obtains cert(s); verifies they're valid and installed.
|
||||
3. Authenticates to the SecretBackend; stores `hs_token`/`as_token`, enroll bootstrap; runs `health()`.
|
||||
4. **Reachability & cert validation** appropriate to mode:
|
||||
- Mode A: fetch our own `.well-known`/SRV, confirm it points at host; TLS-probe host cert as a peer would; confirm C-S discovery.
|
||||
- Mode B: TLS-probe the single domain; confirm C-S + (if federation) S2S.
|
||||
- Mode C: confirm local C-S reachability over the bind IP; confirm (self-signed or private-CA) client TLS; **explicitly report "standalone — federation not available."**
|
||||
- If federation enabled: validate each peer resolves + presents a peer-trusted cert; confirm `domain_whitelist` mutual consistency. If any peer fails, **federation is reported NOT-ready** — the primary still succeeds standalone.
|
||||
5. **Only now** declare success, with a per-capability report: `PRIMARY: ✅ | CERT: ✅ (expires in 90d, auto-renew on) | SECRETS: ✅ (vaultwarden) | FEDERATION: ✅ 1 peer / ⚠ not-ready / ⛔ n-a (Mode C)`.
|
||||
|
||||
**6.9 — Post-install.** Emit the cert-expiry monitor + federation-health probe (§3.4) into OTEL; write config to DB with correct mutability flags; print the immutable-values summary again for the record.
|
||||
|
||||
---
|
||||
|
||||
## 7. Standalone → federated upgrade path
|
||||
|
||||
An operator who started standalone later wants federation. The path depends on **whether they started with a domain**:
|
||||
|
||||
**Case 1 — started Mode A or B (had a domain), federation was just off.** _Cheap, no re-home._ `server_name` is already a real domain and identities are already minted against it. To federate:
|
||||
|
||||
1. Ensure DNS resolves for peers (their `server_name`s and yours) — likely already true.
|
||||
2. Ensure a **valid, peer-trusted cert** on the federation endpoint (if they were running client-only self-signed, they now need a real ACME cert; if already ACME, done).
|
||||
3. Set `federation.enabled = true`, populate `domain_whitelist` + `peers` (all runtime-tunable, §5.3).
|
||||
4. Re-run the installer's **federation validation** (§6.8 step 4) against each peer. On green, federation is live. **No identity change** — existing MXIDs simply become reachable cross-site. This is the intended, low-friction upgrade.
|
||||
|
||||
**Case 2 — started Mode C (IP-only), now wants federation.** _Expensive — an identity re-home, and we say so plainly._ Federation requires DNS + a valid cert (§2.4), which an IP `server_name` can never satisfy. So the operator must:
|
||||
|
||||
1. **Acquire a domain** and DNS, and provision a **valid ACME cert** (LE public, or step-ca if the domain is private — but note a _private_ domain can only federate with peers who trust that private CA root, §8).
|
||||
2. **Change `server_name` from the IP literal to the domain** — this is the **install-immutable value**, so this is a **re-home, not a config tweak**:
|
||||
- Every agent identity `@mosaic_*:192.168.1.50:8448` becomes `@mosaic_*:newdomain` — **all new MXIDs.**
|
||||
- Every room + alias must be **recreated** under the new `server_name`.
|
||||
- Signed-authorship pubkey records re-published under the new identities.
|
||||
- Any durable references to old MXIDs (escalation policies, fallback-coordinator targets, RFC-001 §5) must be re-pointed.
|
||||
3. Effectively: **treat it as a fresh install in Mode A/B with a data migration of rooms/history**, not an in-place flip. Matrix has **no in-place `server_name` rename**; this cost is intrinsic to Matrix, not to our design.
|
||||
|
||||
**Honest guidance the installer gives Mode C operators up front (§6.3):** "If there is _any_ chance you'll want to federate later, start with a domain (Mode A/B) even if you keep federation off — flipping federation on later is free, but changing an IP `server_name` to a domain later is a full identity re-home." This lets an informed operator avoid the expensive path by choosing Mode B-with-federation-off instead of Mode C.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security
|
||||
|
||||
**8.1 — Cert trust model per CA choice.**
|
||||
|
||||
- **Let's Encrypt (public):** chains to a universally-trusted root (ISRG). Peers, humans' browsers, and Element trust it with no extra distribution. Best for public domains; nothing to distribute.
|
||||
- **step-ca (private):** chains to a **root you operate**. Nothing trusts it by default. Therefore the **step-ca root must be distributed** to everyone who validates certs: peer homeservers (so cross-site S2S validates — a peer must add your root to its federation trust store, **[VERIFY]** Synapse's mechanism for trusting a custom federation CA), agent hosts, and any human client. This is the price of "total control" and airgap capability. For **federation between two private-CA sites**, both sites must trust each other's roots (or a shared root). Getting this wrong reproduces the silent-defederation failure (§3.4) — a peer that doesn't trust your root silently refuses your S2S.
|
||||
- **Mode C self-signed client TLS:** weakest — see 8.4.
|
||||
|
||||
**8.2 — Federation whitelist.** `federation_domain_whitelist` is a **hard allowlist** (RFC-001 §6/NG5): only listed Mosaic site domains may federate; no public-network federation. The installer/config validator keeps the whitelist consistent with the declared peer list. Adding a peer is an explicit, audited config change.
|
||||
|
||||
**8.3 — Secret-backend auth.** The appservice/orchestrator authenticate to the `SecretBackend` via a **bootstrap credential injected at deploy** (Vault AppRole secret_id, or Vaultwarden org automation principal), never committed, consistent with existing Gateway/DB secret handling (RFC-001 §8). The `as_token`/`hs_token` live **only** in backend + appservice memory; agents get only their own scoped, re-mintable token (§4.2). Enroll is authenticated (RFC-001 B5) so a rogue local process can't enroll a rogue agent. Backend access is scoped: an agent's credential can read only its collection/policy, never the fleet-admin scope holding the crown jewels.
|
||||
|
||||
**8.4 — Honest note: IP-only standalone with self-signed client TLS is a weaker-trust local mode.** In Mode C, client TLS may be self-signed (or a local private CA). This means: no third party vouches for the endpoint; clients must be told to trust the self-signed cert (TOFU or manual root import); there is no external validation of who's on the other end. This is **acceptable and supported for local/airgapped/homelab** use where the network is already trusted, but it is **explicitly a weaker trust posture** than a real CA. The installer states this plainly at install (§6.8 Mode C). It is one more reason Mode C cannot federate: we will not extend this weaker-trust local posture across sites (NG5).
|
||||
|
||||
**8.5 — Homeserver hardening** (inherited from RFC-001 §8, config-rendered here): `enable_registration: false` always (agents come only via the appservice), rate-limiting on, admin API bound to localhost/behind auth, media repo locked/disabled if unused, TLS terminated at our controlled proxy. These are **rendered from config** (§5.4) so a stranger gets them by default, not by remembering to set them. **[VERIFY]** current recommended Synapse hardening flags at implementation.
|
||||
|
||||
---
|
||||
|
||||
## 9. How RFC-002 integrates with RFC-001's P1–P5
|
||||
|
||||
RFC-002 is the **substrate**. Each RFC-001 phase consumes a subset of it. Critically, **P1 does not need the hard parts** — presence ships on a single clean-domain instance with no federation, no IP-only, and no secret-rotation story resolved.
|
||||
|
||||
| RFC-001 phase | RFC-002 pieces it NEEDS | RFC-002 pieces it does NOT need yet |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **P1 — Presence (first shippable slice)** | **Mode A or B, single-instance, clean domain** (§2.3). One ACME cert (LE or step-ca) via the single integration (§3). Config system minimal: `server_name` immutable + presence thresholds tunable (§5.3). Installer path 6.1–6.3, 6.4 (cert), 6.7 (immutable gate), 6.8 (validate single-instance). A **minimal** SecretBackend just holding the appservice token. | **No federation** (§2.4 gate irrelevant — single site). **No IP-only** needed for P1 (P1 wants a clean domain so Element/humans connect cleanly). **No agent-credential rotation** maturity. **No peer/whitelist** config. Vaultwarden machine-account [VERIFY] does **not** block P1. |
|
||||
| **P2 — Appservice + auto-enroll** | Full `SecretBackend` **`enrollAgent`/`revokeAgent`** (§4.1), `hs_token`/`as_token` custody (§4.2), enroll-bootstrap secret. Config: room taxonomy, per-agent token classification. Chosen backend (Vault or Vaultwarden) real. | Federation, cross-site, IP-only. |
|
||||
| **P3 — MACP v1 spec** | Config keys for MACP versioning/thresholds as runtime-tunable (§5.3); nothing new topology-wise. | Federation, secret rotation-in-anger. |
|
||||
| **P4 — Federation** | **The whole federation half of RFC-002:** Mode A delegation (§3.3), the hard DNS+cert gate (§2.4), `domain_whitelist`+peers config, cert monitoring/silent-defederation alarm (§3.4), per-CA trust distribution for cross-site (§8.1). This is where standalone→federated (§7 Case 1) and Jason's `mosaic.woltje.com`↔`mosaic.uscllc.com` shape land. | IP-only (federation excludes it by construction). |
|
||||
| **P5 — Hardening + signed-authorship + Hermes retired** | Secret **rotation runbooks** executed in anger (§4, RFC-001 E2), pubkey-record custody for Ed25519 (§4.2), cert-rotation runbook (§3.4), full homeserver hardening validated (§8.5), backend auth review (§8.3). | — |
|
||||
|
||||
**One-line integration statement:** P1 rides on the _smallest_ slice of RFC-002 (single clean-domain instance + one cert + minimal config + minimal secret storage); the federation/IP-only/backend-maturity complexity is deferred to exactly the phases that need it (mostly P4/P5). RFC-002 therefore does not gate P1.
|
||||
|
||||
---
|
||||
|
||||
## 10. Open questions
|
||||
|
||||
Deliberately few — most topology/cert/secret decisions are resolved by Jason's rulings and baked in above.
|
||||
|
||||
1. **[VERIFY] IP-only `server_name` acceptance.** Does the deployed Synapse version accept an `ip:port` `server_name` and mint usable MXIDs for standalone (§2.3)? If not, Mode C uses a fabricated local domain (`mosaic.local` via local resolver) — confirm which, since it affects the re-home wording for Mode C→A/B (§7).
|
||||
2. **[VERIFY] Vaultwarden machine-account maturity.** Confirm the current Vaultwarden version's Secrets Manager / machine-account coverage (§4.4). Determines whether the Vaultwarden adapter's `enrollAgent` uses native machine accounts or the personal-vault-per-agent + collection model. Does **not** block (interface absorbs either), but sets P2 expectations.
|
||||
3. **[VERIFY] step-ca root distribution for cross-site federation.** Confirm Synapse's supported mechanism for trusting a **custom federation CA root** (§8.1) so two private-CA sites can federate. If Synapse won't easily trust a private federation CA, private-domain federation may in practice require public certs (LE) on the federation SANs even when internal traffic uses step-ca.
|
||||
4. **Default secret backend for the published installer.** Given the open-source ethos (G6), should the installer _default-suggest_ Vaultwarden (free, self-hostable) while clearly offering Vault, or present them neutrally? Recommendation: suggest Vaultwarden as the zero-cost path with the maturity caveat surfaced, Vault as the "I need native short-TTL machine creds now" path. Jason to confirm the framing.
|
||||
5. **Single multi-SAN cert vs two certs in Mode A** (§3.3) — operational preference for identity-domain + host coverage. Minor; validate during P4.
|
||||
6. **Reconfigure-time Synapse restart policy.** Which rendered-config changes (§5.4) require a Synapse restart vs hot-reload on the deployed version, so the config service knows when a tunable change needs a bounce. **[VERIFY]** at implementation.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Real mechanics this RFC leans on (quick reference)
|
||||
|
||||
- **`server_name`** — Synapse identity domain; the `:suffix` of every MXID/alias; install-immutable; changing it = re-home (no in-place rename). Distinct from where Synapse _listens_.
|
||||
- **Delegation** — `https://<server_name>/.well-known/matrix/server` → `{"m.server":"host:port"}` and/or `_matrix._tcp.<server_name>` **SRV**; how identity-domain ≠ host is reconciled (Mode A). **[VERIFY]** precedence on deployed Synapse.
|
||||
- **`.well-known/matrix/client`** — C-S discovery so agents/Element find the homeserver host from the identity domain.
|
||||
- **`federation_domain_whitelist`** — Synapse allowlist; only listed domains federate; our hard no-public-federation boundary.
|
||||
- **ACME** — single provisioning protocol for both CAs; operator supplies **directory URL** (step-ca vs Let's Encrypt) + **challenge type**.
|
||||
- **HTTP-01 / DNS-01 / TLS-ALPN-01** — challenge types; **DNS-01 is the one for private/split-horizon/wildcard**; HTTP-01 for public single host with :80; TLS-ALPN-01 for :443-only public.
|
||||
- **step-ca ACME provisioner** — Smallstep's self-hosted CA exposing an ACME directory; enables private/internal-domain certs and total control; may require **EAB**; root must be distributed to validators.
|
||||
- **Let's Encrypt** — public ACME CA; universally-trusted chain; 90-day certs; staging endpoint for testing.
|
||||
- **Bitwarden/Vaultwarden org + collection + machine/service-account** — org holds collections; collections scope access; machine/service accounts (Bitwarden **Secrets Manager**) are the clean automation primitive but **Vaultwarden coverage is partial/evolving [VERIFY]**; personal-vault-per-agent + org-collection works today.
|
||||
- **Vault KV v2 / AppRole / lease-TTL / revoke** — the capable backend; native versioning=rotate, TTL+revoke=transient per-agent creds.
|
||||
- **Silent defederation** — a lapsed/renewal-failed federation cert causes peers to stop trusting S2S with no local error; must be monitored + alarmed (§3.4).
|
||||
|
||||
_All Matrix/ACME/secret-backend mechanics above are cited from architecture knowledge and MUST be re-verified against the actually deployed versions during implementation — every **[VERIFY]** is a checkpoint, not an assumption. Every illustrative domain (`mosaic.woltje.com`, `mosaic.uscllc.com`, `matrix.woltje.com`) is an operator-supplied example, never a product default or literal._
|
||||
@@ -18,12 +18,33 @@ set -Eeuo pipefail
|
||||
# MOSAIC_INSTALL_MODE — prompt|keep|overwrite (default: prompt)
|
||||
# MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING — 1 to bypass MCP check
|
||||
# MOSAIC_SKIP_SKILLS_SYNC — 1 to skip skill sync
|
||||
#
|
||||
# Flags (CLI args, NOT environment variables — see #869 Point-1 C2):
|
||||
# --allow-inactive-enforcement Explicit, per-invocation opt-out that lets the
|
||||
# lease-enforcement hooks (mutator-gate.py,
|
||||
# receipt-observer-client.py) be wired into
|
||||
# ~/.claude/settings.json even when this host
|
||||
# cannot confirm it can ACTIVATE them. Loud on
|
||||
# use (see mosaic-link-runtime-assets). Default
|
||||
# (flag absent) is fail-loud: the enforcement
|
||||
# hooks are NOT wired and the framework's
|
||||
# runtime-asset-link step reports a failure.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||
|
||||
# Deliberately parsed from "$@" (a real, explicit, per-invocation argument) —
|
||||
# never an environment variable — so this opt-out can never sit silently
|
||||
# inherited in a shell profile. See #869 Point-1 C2.
|
||||
ALLOW_INACTIVE_ENFORCEMENT=0
|
||||
for _arg in "$@"; do
|
||||
case "$_arg" in
|
||||
--allow-inactive-enforcement) ALLOW_INACTIVE_ENFORCEMENT=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Shared framework path-ownership manifest reader (#791). Parity with
|
||||
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
||||
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
||||
@@ -639,6 +660,10 @@ reconcile_framework_files
|
||||
# Ensure tool scripts are executable
|
||||
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
||||
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
|
||||
# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh
|
||||
# suffix — git resolves credential helpers by exact name/path, not extension —
|
||||
# so the *.sh glob above does not cover it; chmod it explicitly.
|
||||
[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true
|
||||
|
||||
ok "Framework synced to $TARGET_DIR"
|
||||
|
||||
@@ -666,10 +691,15 @@ step "Post-install tasks"
|
||||
SCRIPTS="$TARGET_DIR/tools/_scripts"
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
|
||||
if "$SCRIPTS/mosaic-link-runtime-assets" >/dev/null 2>&1; then
|
||||
link_args=()
|
||||
[[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement)
|
||||
# stdout is suppressed as before, but stderr is left connected: the
|
||||
# install-ordering guard's FAIL LOUD message (#869 Point-1 C2) must reach
|
||||
# the operator, not be swallowed silently.
|
||||
if "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}" >/dev/null; then
|
||||
ok "Runtime assets linked"
|
||||
else
|
||||
warn "Runtime asset linking failed (non-fatal)"
|
||||
warn "Runtime asset linking failed (non-fatal) — see message above for details."
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -4,6 +4,22 @@ set -euo pipefail
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
backup_stamp="$(date +%Y%m%d%H%M%S)"
|
||||
|
||||
# ─── Install-ordering guard opt-out (#869 Point-1 C2) ───────────────────────
|
||||
# Explicit, per-invocation CLI flag ONLY — deliberately NOT read from an
|
||||
# environment variable, so it can never sit as a silently-inherited default in
|
||||
# a shell profile or CI env. Absent (the default) => hard fail-loud path.
|
||||
allow_inactive_enforcement=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--allow-inactive-enforcement) allow_inactive_enforcement=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Tracks whether the Claude settings install-ordering guard (below) reported a
|
||||
# degraded (enforcement-not-wired) outcome, so this script's own exit status
|
||||
# reflects it even though the rest of the runtime-asset sync must still run.
|
||||
guard_degraded=0
|
||||
|
||||
copy_file_managed() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
@@ -24,6 +40,103 @@ copy_file_managed() {
|
||||
cp "$src" "$dst"
|
||||
}
|
||||
|
||||
# ─── Install-ordering guard for settings.json (#869 Point-1 C2) ─────────────
|
||||
#
|
||||
# settings.json is where #828's enforcement hooks (PreToolUse mutator-gate.py,
|
||||
# Stop receipt-observer-client.py) get wired unconditionally. Before copying
|
||||
# it, delegate to `mosaic __link-claude-settings` (packages/mosaic/src/commands/
|
||||
# install-ordering-guard.ts) so the wiring decision is made by importing the
|
||||
# C1 activation probe (`leaseEnforcementActivatable()`) directly, rather than
|
||||
# re-implementing the capability/supervisor checks in shell. That subcommand:
|
||||
# - activatable -> writes settings.json with hooks intact, exits 0
|
||||
# - NOT activatable -> writes settings.json with hooks STRIPPED,
|
||||
# prints an actionable message, exits 1
|
||||
# - NOT activatable + opt-out -> writes settings.json with hooks intact,
|
||||
# prints a loud warning, exits 0
|
||||
# The `mosaic` CLI is expected on PATH at this point ("No executables are
|
||||
# placed on PATH — the mosaic npm CLI is the only binary", per install.sh).
|
||||
# If it is not resolvable at all, that is itself strong evidence the
|
||||
# activation half is absent, so the same fail-loud default applies via a
|
||||
# minimal python3 fallback (this repo already depends on python3 for the
|
||||
# lease broker itself).
|
||||
copy_claude_settings_guarded() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
|
||||
local guard_args=(__link-claude-settings "$src" "$dst")
|
||||
if [[ "$allow_inactive_enforcement" == "1" ]]; then
|
||||
guard_args+=(--allow-inactive-enforcement)
|
||||
fi
|
||||
|
||||
if command -v mosaic >/dev/null 2>&1; then
|
||||
if mosaic "${guard_args[@]}"; then
|
||||
return 0
|
||||
fi
|
||||
echo "[mosaic-link] Enforcement hooks were NOT wired into $dst (see message above)." >&2
|
||||
guard_degraded=1
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[mosaic-link] ERROR: 'mosaic' CLI not found on PATH — cannot confirm lease-enforcement" >&2
|
||||
echo "[mosaic-link] activation capability. enforcement requested but activation half absent —" >&2
|
||||
echo "[mosaic-link] needs a published CLI carrying launch-runtime activation + a broker" >&2
|
||||
echo "[mosaic-link] supervisor; refusing to wire a dead gate (see #869)." >&2
|
||||
|
||||
if [[ "$allow_inactive_enforcement" == "1" ]]; then
|
||||
echo "[mosaic-link] WARNING: --allow-inactive-enforcement set — wiring $dst AS-IS (with" >&2
|
||||
echo "[mosaic-link] enforcement hooks) despite being unable to confirm activation." >&2
|
||||
copy_file_managed "$src" "$dst"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$src" "$dst" <<'PYEOF'
|
||||
import json, sys
|
||||
|
||||
src, dest = sys.argv[1], sys.argv[2]
|
||||
with open(src) as f:
|
||||
data = json.load(f)
|
||||
|
||||
hooks = data.get("hooks", {})
|
||||
|
||||
pre = hooks.get("PreToolUse", [])
|
||||
hooks["PreToolUse"] = [
|
||||
t for t in pre
|
||||
if not any("mutator-gate.py" in h.get("command", "") for h in t.get("hooks", []))
|
||||
]
|
||||
if not hooks["PreToolUse"]:
|
||||
del hooks["PreToolUse"]
|
||||
|
||||
stop = hooks.get("Stop", [])
|
||||
new_stop = []
|
||||
for t in stop:
|
||||
kept = [h for h in t.get("hooks", []) if "receipt-observer-client.py" not in h.get("command", "")]
|
||||
if kept:
|
||||
t = dict(t)
|
||||
t["hooks"] = kept
|
||||
new_stop.append(t)
|
||||
if new_stop:
|
||||
hooks["Stop"] = new_stop
|
||||
elif "Stop" in hooks:
|
||||
del hooks["Stop"]
|
||||
|
||||
if hooks:
|
||||
data["hooks"] = hooks
|
||||
else:
|
||||
data.pop("hooks", None)
|
||||
|
||||
with open(dest, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
PYEOF
|
||||
else
|
||||
cp "$src" "$dst"
|
||||
fi
|
||||
guard_degraded=1
|
||||
return 0
|
||||
}
|
||||
|
||||
remove_legacy_path() {
|
||||
local p="$1"
|
||||
|
||||
@@ -110,6 +223,13 @@ for runtime_file in \
|
||||
fi
|
||||
src="$MOSAIC_HOME/runtime/claude/$runtime_file"
|
||||
[[ -f "$src" ]] || continue
|
||||
if [[ "$runtime_file" == "settings.json" ]]; then
|
||||
# Install-ordering guard (#869 Point-1 C2): gate enforcement-hook wiring
|
||||
# on confirmed activation instead of the plain copy_file_managed used for
|
||||
# every other runtime file. See copy_claude_settings_guarded() above.
|
||||
copy_claude_settings_guarded "$src" "$HOME/.claude/$runtime_file"
|
||||
continue
|
||||
fi
|
||||
copy_file_managed "$src" "$HOME/.claude/$runtime_file"
|
||||
done
|
||||
|
||||
@@ -167,3 +287,12 @@ fi
|
||||
|
||||
echo "[mosaic-link] Runtime assets synced (non-symlink mode)"
|
||||
echo "[mosaic-link] Canonical source: $MOSAIC_HOME"
|
||||
|
||||
# Propagate the install-ordering guard's outcome (#869 Point-1 C2): every
|
||||
# other runtime asset above is best-effort/non-fatal, but a degraded
|
||||
# (enforcement-not-wired) settings.json must make THIS script's own exit
|
||||
# status non-zero so callers (framework/install.sh, finalize.ts) can surface
|
||||
# it — never silently.
|
||||
if [[ "$guard_degraded" == "1" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for issue #869 Point-1 C2 — the install-ordering guard
|
||||
# wired into mosaic-link-runtime-assets.
|
||||
#
|
||||
# Root cause under test: mosaic-link-runtime-assets copies
|
||||
# runtime/claude/settings.json (which embeds the PreToolUse mutator-gate.py
|
||||
# hook and the Stop receipt-observer-client.py hook) straight into
|
||||
# ~/.claude/settings.json, unconditionally. If the lease-broker activation
|
||||
# half cannot be confirmed on this host, wiring those hooks bricks it with a
|
||||
# fail-closed gate that can never be satisfied.
|
||||
#
|
||||
# This harness never invokes a real `mosaic` CLI build — it stubs the
|
||||
# `__link-claude-settings` contract with a fake `mosaic` on PATH so the shell
|
||||
# WIRING (does mosaic-link-runtime-assets call out correctly? does it
|
||||
# propagate a degraded outcome? does it still copy every other runtime file?
|
||||
# does --allow-inactive-enforcement forward through?) is exercised
|
||||
# independently of the TS guard's own logic (already covered by
|
||||
# install-ordering-guard.spec.ts). It also exercises the no-mosaic-on-PATH
|
||||
# python3 fallback directly.
|
||||
#
|
||||
# Scenarios:
|
||||
# 1. probe=true (fake mosaic exits 0) -> settings.json copied, script exits 0.
|
||||
# 2. probe=false (fake mosaic exits 1) -> script exits 1 (guard_degraded
|
||||
# propagated), but every OTHER runtime file is still copied.
|
||||
# 3. probe=false + --allow-inactive-enforcement -> the flag is forwarded to
|
||||
# the fake mosaic stub.
|
||||
# 4. No `mosaic` on PATH at all (activation unconfirmable) -> the python3
|
||||
# fallback strips the enforcement hooks itself and the script exits 1.
|
||||
# 5. No `mosaic` on PATH + --allow-inactive-enforcement -> the python3
|
||||
# fallback wires the hooks AS-IS and the script exits 0.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LINK_SCRIPT="$SCRIPT_DIR/mosaic-link-runtime-assets"
|
||||
|
||||
TMP_ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP_ROOT"' EXIT
|
||||
|
||||
fail=0
|
||||
fail_msg() {
|
||||
echo "FAIL: $*" >&2
|
||||
fail=1
|
||||
}
|
||||
|
||||
FIXTURE_SETTINGS='{
|
||||
"model": "opus",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{ "matcher": ".*", "hooks": [ { "type": "command", "command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude" } ] },
|
||||
{ "matcher": "Write|Edit|MultiEdit", "hooks": [ { "type": "command", "command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh" } ] }
|
||||
],
|
||||
"Stop": [
|
||||
{ "hooks": [
|
||||
{ "type": "command", "command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude" },
|
||||
{ "type": "command", "command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
}'
|
||||
|
||||
# Sets up a fresh $MOSAIC_HOME/runtime/claude/{settings.json,CLAUDE.md,
|
||||
# hooks-config.json,context7-integration.md} + fresh $HOME, echoes both paths
|
||||
# space-separated for the caller to `read`.
|
||||
new_scenario_dirs() {
|
||||
local scenario="$1"
|
||||
local base="$TMP_ROOT/$scenario"
|
||||
local mosaic_home="$base/mosaic-home"
|
||||
local home="$base/home"
|
||||
mkdir -p "$mosaic_home/runtime/claude" "$home"
|
||||
printf '%s' "$FIXTURE_SETTINGS" > "$mosaic_home/runtime/claude/settings.json"
|
||||
echo "claude.md fixture" > "$mosaic_home/runtime/claude/CLAUDE.md"
|
||||
echo '{"hooks":{}}' > "$mosaic_home/runtime/claude/hooks-config.json"
|
||||
echo "context7 fixture" > "$mosaic_home/runtime/claude/context7-integration.md"
|
||||
echo "$mosaic_home" "$home"
|
||||
}
|
||||
|
||||
settings_has_marker() {
|
||||
local file="$1" marker="$2"
|
||||
[[ -f "$file" ]] && grep -q "$marker" "$file"
|
||||
}
|
||||
|
||||
# A fake `mosaic` binary implementing only the __link-claude-settings contract
|
||||
# this harness needs: writes dest verbatim (fixture is unmodified either way —
|
||||
# this stub only exercises the CALL CONTRACT, not the TS strip logic, which
|
||||
# has its own vitest coverage) and exits with the code the scenario wants.
|
||||
# Records the args it was called with so the harness can assert forwarding.
|
||||
make_fake_mosaic() {
|
||||
local bin_dir="$1" exit_code="$2"
|
||||
mkdir -p "$bin_dir"
|
||||
cat > "$bin_dir/mosaic" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "\$@" > "$bin_dir/mosaic.args"
|
||||
if [[ "\$1" == "__link-claude-settings" ]]; then
|
||||
cp "\$2" "\$3"
|
||||
exit $exit_code
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$bin_dir/mosaic"
|
||||
}
|
||||
|
||||
# --- Scenario 1: probe=true (fake mosaic exits 0) ---------------------------
|
||||
read -r MOSAIC_HOME_1 HOME_1 < <(new_scenario_dirs scenario1)
|
||||
BIN_1="$TMP_ROOT/scenario1/bin"
|
||||
make_fake_mosaic "$BIN_1" 0
|
||||
|
||||
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_1" HOME="$HOME_1" PATH="$BIN_1:$PATH" "$LINK_SCRIPT" 2>&1)
|
||||
STATUS=$?
|
||||
[[ "$STATUS" -eq 0 ]] || fail_msg "scenario1 (probe=true): expected exit 0, got $STATUS. Output: $OUTPUT"
|
||||
[[ -f "$HOME_1/.claude/settings.json" ]] || fail_msg "scenario1: settings.json was not copied"
|
||||
|
||||
# --- Scenario 2: probe=false (fake mosaic exits 1) --------------------------
|
||||
read -r MOSAIC_HOME_2 HOME_2 < <(new_scenario_dirs scenario2)
|
||||
BIN_2="$TMP_ROOT/scenario2/bin"
|
||||
make_fake_mosaic "$BIN_2" 1
|
||||
|
||||
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_2" HOME="$HOME_2" PATH="$BIN_2:$PATH" "$LINK_SCRIPT" 2>&1)
|
||||
STATUS=$?
|
||||
[[ "$STATUS" -ne 0 ]] || fail_msg "scenario2 (probe=false, default): expected non-zero exit, got 0. Output: $OUTPUT"
|
||||
[[ -f "$HOME_2/.claude/CLAUDE.md" ]] || fail_msg "scenario2: CLAUDE.md was NOT copied even though it is independent of the settings.json guard"
|
||||
[[ -f "$HOME_2/.claude/hooks-config.json" ]] || fail_msg "scenario2: hooks-config.json was NOT copied"
|
||||
[[ -f "$HOME_2/.claude/context7-integration.md" ]] || fail_msg "scenario2: context7-integration.md was NOT copied"
|
||||
case "$OUTPUT" in
|
||||
*"NOT be wired"*|*"NOT wired"*) ;;
|
||||
*) fail_msg "scenario2: expected an actionable degraded-wiring message in output, got: $OUTPUT" ;;
|
||||
esac
|
||||
|
||||
# --- Scenario 3: probe=false + --allow-inactive-enforcement forwards the flag
|
||||
read -r MOSAIC_HOME_3 HOME_3 < <(new_scenario_dirs scenario3)
|
||||
BIN_3="$TMP_ROOT/scenario3/bin"
|
||||
make_fake_mosaic "$BIN_3" 0
|
||||
|
||||
MOSAIC_HOME="$MOSAIC_HOME_3" HOME="$HOME_3" PATH="$BIN_3:$PATH" "$LINK_SCRIPT" --allow-inactive-enforcement >/dev/null 2>&1
|
||||
RECORDED_ARGS="$(cat "$BIN_3/mosaic.args" 2>/dev/null || true)"
|
||||
case "$RECORDED_ARGS" in
|
||||
*"--allow-inactive-enforcement"*) ;;
|
||||
*) fail_msg "scenario3: --allow-inactive-enforcement was not forwarded to the mosaic CLI invocation (got: '$RECORDED_ARGS')" ;;
|
||||
esac
|
||||
|
||||
# --- Scenario 4: no `mosaic` on PATH at all -> python3 fallback strips hooks
|
||||
read -r MOSAIC_HOME_4 HOME_4 < <(new_scenario_dirs scenario4)
|
||||
EMPTY_BIN="$TMP_ROOT/scenario4/empty-bin"
|
||||
mkdir -p "$EMPTY_BIN"
|
||||
# A PATH containing only python3 (for the fallback) + core utils, no mosaic.
|
||||
FALLBACK_PATH="$EMPTY_BIN:/usr/bin:/bin"
|
||||
|
||||
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_4" HOME="$HOME_4" PATH="$FALLBACK_PATH" "$LINK_SCRIPT" 2>&1)
|
||||
STATUS=$?
|
||||
[[ "$STATUS" -ne 0 ]] || fail_msg "scenario4 (no mosaic on PATH, default): expected non-zero exit, got 0. Output: $OUTPUT"
|
||||
if settings_has_marker "$HOME_4/.claude/settings.json" "mutator-gate.py"; then
|
||||
fail_msg "scenario4: mutator-gate.py hook was wired even though mosaic could not be resolved (activation unconfirmable)"
|
||||
fi
|
||||
if settings_has_marker "$HOME_4/.claude/settings.json" "receipt-observer-client.py"; then
|
||||
fail_msg "scenario4: receipt-observer-client.py hook was wired even though mosaic could not be resolved"
|
||||
fi
|
||||
if ! settings_has_marker "$HOME_4/.claude/settings.json" "prevent-memory-write.sh"; then
|
||||
fail_msg "scenario4: the unrelated prevent-memory-write.sh hook was incorrectly dropped too"
|
||||
fi
|
||||
|
||||
# --- Scenario 5: no `mosaic` on PATH + --allow-inactive-enforcement --------
|
||||
read -r MOSAIC_HOME_5 HOME_5 < <(new_scenario_dirs scenario5)
|
||||
|
||||
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_5" HOME="$HOME_5" PATH="$FALLBACK_PATH" "$LINK_SCRIPT" --allow-inactive-enforcement 2>&1)
|
||||
STATUS=$?
|
||||
[[ "$STATUS" -eq 0 ]] || fail_msg "scenario5 (no mosaic, opt-out): expected exit 0, got $STATUS. Output: $OUTPUT"
|
||||
if ! settings_has_marker "$HOME_5/.claude/settings.json" "mutator-gate.py"; then
|
||||
fail_msg "scenario5: mutator-gate.py hook should have been wired (explicit opt-out set)"
|
||||
fi
|
||||
case "$OUTPUT" in
|
||||
*"WARNING"*"--allow-inactive-enforcement"*) ;;
|
||||
*) fail_msg "scenario5: expected a loud WARNING mentioning --allow-inactive-enforcement, got: $OUTPUT" ;;
|
||||
esac
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "install-ordering-guard regression passed (5/5 scenarios)"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
@@ -33,3 +33,64 @@ The Gitea API token is **never passed on a curl command line.** An `Authorizatio
|
||||
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
|
||||
|
||||
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
|
||||
|
||||
## Per-agent Gitea identity (Gate-16 author≠reviewer)
|
||||
|
||||
By default, git push/fetch (via `git-credential-mosaic`) and the API wrappers above (via
|
||||
`detect-platform.sh`'s `get_gitea_token`) all authenticate as the single shared Gitea
|
||||
account/token configured through `tools/_lib/credentials.sh`. That means every agent in a
|
||||
fleet commits, pushes, and opens PRs under one identity — with no cryptographic
|
||||
separation between an author and a reviewer.
|
||||
|
||||
Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent
|
||||
identity** before falling back to the shared account:
|
||||
|
||||
1. `MOSAIC_GIT_IDENTITY` environment variable, or
|
||||
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
|
||||
non-persistent shells — `git config mosaic.gitIdentity <agent-id>`), or
|
||||
3. (git-credential-mosaic only) the username git itself supplies for the credential
|
||||
request.
|
||||
|
||||
If the resolved identity has a token file at
|
||||
`~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-<agent-id>.token`, that
|
||||
identity + token is used. **Nothing configured → nothing changes**: with no per-slot
|
||||
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
|
||||
tokens.
|
||||
|
||||
### Enabling it for a clone
|
||||
|
||||
The framework installer syncs `git-credential-mosaic` to
|
||||
`~/.config/mosaic/tools/git/git-credential-mosaic` (executable) on every install/update,
|
||||
but does **not** register it as git's credential helper automatically. Registration is a
|
||||
one-time, explicit step:
|
||||
|
||||
```bash
|
||||
# Per-repo (recommended — scopes the helper to this clone only):
|
||||
git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
|
||||
# Per-worktree identity pin (Gate-16 separation):
|
||||
git config mosaic.gitIdentity <agent-id>
|
||||
```
|
||||
|
||||
This is deliberately **not** auto-registered on install/update: `credential.helper` is
|
||||
global, order-sensitive git config (`~/.gitconfig`) that can already hold an
|
||||
operator-chosen credential manager (keychain, `store`, `manager-core`, …) for
|
||||
repositories unrelated to Mosaic. Silently inserting an entry on every framework
|
||||
install/upgrade risks reordering or shadowing that operator-owned surface across the
|
||||
whole host — the same operator-owned config the installer's manifest system is
|
||||
otherwise careful never to touch. Because identity is already resolved per-worktree
|
||||
(`mosaic.gitIdentity`), the correct granularity for registering the helper is per-clone
|
||||
too, so a documented manual step is the right shape here, not a global auto-write.
|
||||
|
||||
### PowerShell parity
|
||||
|
||||
`detect-platform.ps1`'s Gitea wrappers authenticate through `tea` CLI logins
|
||||
(`Get-GiteaLoginForHost`), not a raw-token `get_gitea_token`-equivalent function — there
|
||||
is nothing to prepend the identity-resolution block to on the PowerShell side. A native
|
||||
PowerShell git-credential helper is also unnecessary: `git-credential-mosaic` is invoked
|
||||
by git's credential-helper protocol (stdin/stdout), which works identically under Git for
|
||||
Windows' bundled `bash`/`sh` when configured via `credential.helper`, without a `.ps1`
|
||||
counterpart. A `tea`-login-based per-agent identity for the PowerShell wrappers is a
|
||||
separate, larger design (mapping identities to `tea login` profiles) and is out of scope
|
||||
here.
|
||||
|
||||
@@ -505,6 +505,28 @@ get_gitea_token() {
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
local cred_loader="$script_dir/../_lib/credentials.sh"
|
||||
|
||||
# 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
|
||||
# stored per-slot token for this host, act AS that agent so API tooling
|
||||
# (pr-create, issue-create, …) authors under the right identity — matching the
|
||||
# git credential helper. Backward-compatible: nothing resolvable → shared logic below.
|
||||
local _ident="${MOSAIC_GIT_IDENTITY:-}"
|
||||
[[ -z "$_ident" ]] && _ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
||||
if [[ -n "$_ident" ]]; then
|
||||
local _idpfx=""
|
||||
case "$host" in
|
||||
git.uscllc.com) _idpfx=gitea-usc ;;
|
||||
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
|
||||
esac
|
||||
if [[ -n "$_idpfx" ]]; then
|
||||
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
||||
if [[ -r "$_idtok" ]]; then
|
||||
cat "$_idtok"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
|
||||
if [[ -f "$cred_loader" ]]; then
|
||||
local token
|
||||
|
||||
69
packages/mosaic/framework/tools/git/git-credential-mosaic
Executable file
69
packages/mosaic/framework/tools/git/git-credential-mosaic
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
# git-credential-mosaic — git credential helper — resolves Gitea tokens from
|
||||
# the Mosaic credential store at runtime so remote URLs never embed secrets.
|
||||
#
|
||||
# Install (one-time, per clone or globally):
|
||||
# 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):
|
||||
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
|
||||
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
|
||||
#
|
||||
# Resolution priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity
|
||||
# (per-worktree, survives across non-persistent shells) > git-supplied username
|
||||
# (credential.username / URL). When the resolved identity has a matching
|
||||
# per-agent token file, use it instead of the shared account. Backward
|
||||
# compatible: nothing resolvable -> shared token (unchanged behavior).
|
||||
[ "$1" = "get" ] || exit 0
|
||||
host=""; username_in=""
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && break
|
||||
case "$line" in
|
||||
host=*) host=${line#host=};;
|
||||
username=*) username_in=${line#username=};;
|
||||
esac
|
||||
done
|
||||
# Per-agent identity resolution (Gate-16 author≠reviewer separation).
|
||||
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
|
||||
# survives across non-persistent shells) > git-supplied username (credential.username
|
||||
# / 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
|
||||
git.uscllc.com) svc=gitea-usc;;
|
||||
git.mosaicstack.dev) svc=gitea-mosaicstack;;
|
||||
*) exit 0;;
|
||||
esac
|
||||
# Script-relative (not $HOME-absolute) so this resolves correctly regardless
|
||||
# of where the framework installer places tools/ under $HOME — mirrors
|
||||
# detect-platform.sh's own cred_loader resolution in this same directory.
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=../_lib/credentials.sh
|
||||
source "$script_dir/../_lib/credentials.sh"
|
||||
load_credentials "$svc" >/dev/null 2>&1 || exit 0
|
||||
# GITEA_USER is not populated by load_credentials (it only exports
|
||||
# GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's
|
||||
# git-over-HTTP auth authenticates from the token itself (the password field),
|
||||
# not from the username string, so any non-empty placeholder works here — this
|
||||
# is deliberately NOT a real account name (framework files must stay
|
||||
# operator-agnostic; see tools/quality/scripts/verify-sanitized.sh).
|
||||
echo "username=${GITEA_USER:-git}"
|
||||
echo "password=$GITEA_TOKEN"
|
||||
@@ -203,7 +203,15 @@ try:
|
||||
if not url:
|
||||
return False
|
||||
origin, path = _origin_and_path(url)
|
||||
return origin == base_origin and path == expected_path
|
||||
# Repo owner/repo slugs are case-insensitive (Gitea canonicalizes the
|
||||
# pull_request_url slug to lowercase on return), while EXPECTED_REPO_SLUG
|
||||
# is taken verbatim from GITEA_API_BASE and may be mixed-case. The
|
||||
# remainder of the path (".../pulls/<number>") is numeric, so lowercasing
|
||||
# the whole path for this comparison only relaxes case, not identity: the
|
||||
# origin tuple (scheme+host+port) above still pins the provider host, and
|
||||
# the path is still compared in FULL (no endswith/suffix match), so the
|
||||
# look-alike-host and same-host decoy-prefix protections are unchanged.
|
||||
return origin == base_origin and path.lower() == expected_path.lower()
|
||||
|
||||
if comment.get("id") != expected_id:
|
||||
raise ValueError("read-back id does not match the created id")
|
||||
|
||||
161
packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh
Executable file
161
packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh
Executable file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for `git-credential-mosaic` — per-agent Gitea identity
|
||||
# resolution (Gate-16 author≠reviewer separation).
|
||||
#
|
||||
# Covers:
|
||||
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
|
||||
# mosaic.gitIdentity (per-worktree) > git-supplied username.
|
||||
# 2. Correct per-slot token file path chosen per host
|
||||
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
||||
# 3. Per-slot token present -> emits that identity + token.
|
||||
# 4. Per-slot token absent -> falls back to the shared account
|
||||
# (backward-compat / no-op for hosts without per-slot tokens).
|
||||
# 5. Unknown/unrelated host -> exits 0 with no output (passthrough).
|
||||
#
|
||||
# Uses stubbed token files under a fake HOME + a real (throwaway) git repo.
|
||||
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
|
||||
FAKE_HOME="$WORK_DIR/home"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
# Mirror the real deployed layout (~/.config/mosaic/tools/{git,_lib}/) under the
|
||||
# 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
|
||||
# _lib/credentials.sh, not the real one, to keep this test hermetic.
|
||||
HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \
|
||||
"$FAKE_HOME/.config/mosaic/tools/git" \
|
||||
"$FAKE_HOME/.config/mosaic/tools/_lib" \
|
||||
"$REPO_DIR"
|
||||
|
||||
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
||||
chmod +x "$HELPER"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" config user.email "test@example.invalid"
|
||||
git -C "$REPO_DIR" config user.name "Test"
|
||||
|
||||
# Fake shared-account credential loader — stands in for
|
||||
# tools/_lib/credentials.sh's load_credentials(), scoped to this test only.
|
||||
cat > "$FAKE_HOME/.config/mosaic/tools/_lib/credentials.sh" <<'SH'
|
||||
load_credentials() {
|
||||
case "$1" in
|
||||
gitea-mosaicstack) GITEA_URL="https://git.mosaicstack.dev"; GITEA_TOKEN="shared-mosaicstack-token"; export GITEA_URL GITEA_TOKEN; return 0 ;;
|
||||
gitea-usc) GITEA_URL="https://git.uscllc.com"; GITEA_TOKEN="shared-usc-token"; export GITEA_URL GITEA_TOKEN; return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
SH
|
||||
|
||||
fail=0
|
||||
assert_eq() {
|
||||
local desc="$1" expected="$2" actual="$3"
|
||||
if [[ "$expected" != "$actual" ]]; then
|
||||
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
# Feed "host=<h>\nusername=<u>\n\n" on stdin (mirrors git's credential protocol)
|
||||
# and run the helper with the fake HOME, inside REPO_DIR (so `git config
|
||||
# mosaic.gitIdentity` resolves per-worktree), plus any extra env passed in $@.
|
||||
run_helper() {
|
||||
local host="$1" username_in="$2"; shift 2
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" "$@" bash "$HELPER" get <<EOF
|
||||
host=$host
|
||||
username=$username_in
|
||||
|
||||
EOF
|
||||
)
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. No identity resolvable anywhere, no per-slot token -> shared fallback
|
||||
# (backward-compat: unchanged behavior when nothing is configured).
|
||||
# ---------------------------------------------------------------------------
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||
out=$(run_helper "git.mosaicstack.dev" "")
|
||||
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. git-supplied username resolves to an identity WITH a per-slot token ->
|
||||
# that identity + token wins over the shared account.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
|
||||
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
||||
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=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity agentB
|
||||
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: password" "password=agentB-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"
|
||||
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: password" "password=agentC-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Identity resolves, but no matching per-slot token file -> falls back to
|
||||
# the shared account (per-agent identity is opt-in, not a hard requirement).
|
||||
# ---------------------------------------------------------------------------
|
||||
out=$(run_helper "git.mosaicstack.dev" "no-such-agent")
|
||||
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")
|
||||
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=')"
|
||||
# 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
|
||||
# remotes, e.g. github.com via a different credential helper).
|
||||
# ---------------------------------------------------------------------------
|
||||
out=$(run_helper "github.com" "agentA")
|
||||
assert_eq "unknown host: no output" "" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
||||
# protocol: this helper only implements get).
|
||||
# ---------------------------------------------------------------------------
|
||||
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
|
||||
host=git.mosaicstack.dev
|
||||
username=agentA
|
||||
password=whatever
|
||||
|
||||
EOF
|
||||
)
|
||||
assert_eq "store verb: no output" "" "$store_out"
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "git-credential-mosaic identity resolution regression passed"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
122
packages/mosaic/framework/tools/git/test-gitea-token-identity.sh
Executable file
122
packages/mosaic/framework/tools/git/test-gitea-token-identity.sh
Executable file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for detect-platform.sh's get_gitea_token() per-agent
|
||||
# identity resolution (Gate-16 author≠reviewer separation) — the API-tooling
|
||||
# counterpart to git-credential-mosaic, so pr-create.sh/issue-create.sh/etc.
|
||||
# open records under the resolved agent identity, not the shared account.
|
||||
#
|
||||
# Covers:
|
||||
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
|
||||
# mosaic.gitIdentity (per-worktree).
|
||||
# 2. Correct per-slot token file path chosen per host
|
||||
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
||||
# 3. Per-slot token present -> that token is returned (agent-authored calls).
|
||||
# 4. Per-slot token absent -> falls back to the shared credential-loader
|
||||
# token (backward-compat / no-op for hosts without per-slot tokens).
|
||||
# 5. Unrelated host with no shared credentials configured -> failure
|
||||
# (unchanged, existing behavior).
|
||||
#
|
||||
# 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.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}"
|
||||
FAKE_HOME="$WORK_DIR/home"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$REPO_DIR"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
|
||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||
{
|
||||
"gitea": {
|
||||
"mosaicstack": {
|
||||
"url": "https://git.mosaicstack.dev",
|
||||
"token": "shared-mosaicstack-token"
|
||||
},
|
||||
"usc": {
|
||||
"url": "https://git.uscllc.com",
|
||||
"token": "shared-usc-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
fail=0
|
||||
assert_eq() {
|
||||
local desc="$1" expected="$2" actual="$3"
|
||||
if [[ "$expected" != "$actual" ]]; then
|
||||
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
# Runs get_gitea_token for $1=host inside REPO_DIR (per-worktree git config
|
||||
# resolves there) with a fake HOME + the stub credentials.json, plus any
|
||||
# extra env passed in $@.
|
||||
call_get_gitea_token() {
|
||||
local host="$1"; shift
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
# shellcheck disable=SC2016 # deliberately deferred: $DETECT_PLATFORM_SH is
|
||||
# expanded by the INNER bash -c (via the exported env var below), not here.
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
DETECT_PLATFORM_SH="$SCRIPT_DIR/detect-platform.sh" "$@" \
|
||||
bash -c 'source "$DETECT_PLATFORM_SH"; get_gitea_token "$1"' _ "$host"
|
||||
)
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. No identity resolvable -> shared credential-loader token (unchanged).
|
||||
# ---------------------------------------------------------------------------
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||
assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot
|
||||
# token -> that token wins over the shared account.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity agentA
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||
assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentB)
|
||||
assert_eq "env beats git-config identity token" "agentB-mosaicstack-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Identity resolves but has no per-slot token for THIS host -> falls back
|
||||
# to the shared token (per-agent identity is opt-in per host).
|
||||
# ---------------------------------------------------------------------------
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity no-such-agent
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||
assert_eq "no per-slot token falls back to shared" "shared-mosaicstack-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Correct per-slot token PATH per host: same agent id, only a usc token
|
||||
# exists -> usc host returns it, mosaicstack host must NOT leak it and
|
||||
# instead falls back to the shared mosaicstack token.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity agentD
|
||||
out=$(call_get_gitea_token "git.uscllc.com")
|
||||
assert_eq "host-scoped token path (usc)" "agentD-usc-token" "$out"
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||
assert_eq "host-scoped token path (no cross-host leak)" "shared-mosaicstack-token" "$out"
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "get_gitea_token identity resolution regression passed"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
@@ -406,6 +406,13 @@ elif mode == "comment-url-wrong-repo":
|
||||
elif mode == "comment-url-suffix-injection":
|
||||
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
|
||||
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
|
||||
elif mode == "comment-mixed-case-slug":
|
||||
# #875: EXPECTED_REPO_SLUG is taken verbatim from GITEA_API_BASE and can be
|
||||
# mixed-case (e.g. "USC/uconnect"), but Gitea canonicalizes the returned
|
||||
# pull_request_url's owner/repo segment to LOWERCASE. Model that here by
|
||||
# lowercasing only the slug path, independent of the (possibly mixed-case)
|
||||
# web_base the wrapper was configured with.
|
||||
pr_url = f"{_origin}{_slug.lower()}/pulls/123"
|
||||
record = {
|
||||
"id": 456,
|
||||
"body": body,
|
||||
@@ -868,6 +875,19 @@ for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong
|
||||
assert_no_temp_leak "$bad_mode"
|
||||
done
|
||||
|
||||
# Case 15b (#875): a MIXED-CASE repo slug (as embedded verbatim in
|
||||
# GITEA_API_BASE, e.g. "USC/uconnect") must still verify when Gitea returns the
|
||||
# comment's pull_request_url with its owner/repo segment canonicalized to
|
||||
# LOWERCASE ("usc/uconnect"). This is a legitimate, unforged provider response —
|
||||
# not a spoof — so `_belongs` must accept it (case-insensitive slug compare)
|
||||
# while still requiring the origin (scheme+host+port) and the rest of the path
|
||||
# to match in full. Pre-#875-fix this fails closed on a real success
|
||||
# (false-negative); post-fix it verifies.
|
||||
run_review comment-mixed-case-slug comment durable-body https://git.mosaicstack.dev \
|
||||
https://git.mosaicstack.dev/USC/uconnect.git USC/uconnect
|
||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||
assert_no_temp_leak "comment-mixed-case-slug"
|
||||
|
||||
# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
|
||||
# pre-submit head read (which pins the review) and the post-verify re-read. The
|
||||
# review is genuinely created and verified as pinned to the OLD head, but the
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enforcement-side version-coupling gate (issue #869, Point-1 card C4).
|
||||
|
||||
Root cause this exists to guard against (#828 version skew, restated from
|
||||
the C1 activation probe in ``lease-activation-probe.ts``): the lease
|
||||
broker's ENFORCEMENT half (this toolkit — ``launch-runtime.py``,
|
||||
``mutator-gate.py``, ``revoke-lease.py``) and its ACTIVATION half
|
||||
(``execLeaseGatedRuntime()`` in ``launch.ts``, which chains the gated
|
||||
runtime through ``launch-runtime.py`` and injects ``MOSAIC_LEASE_*``) ship
|
||||
on different channels — an npm package and a framework/CLI reseed. C1 gave
|
||||
the activation half a versioned, machine-checkable identity
|
||||
(``LEASE_ACTIVATION_CAPABILITY``, printed by the CLI's hidden
|
||||
``mosaic __lease-capability`` subcommand). That identity is inert on its
|
||||
own: nothing yet asserted that ENFORCEMENT actually requires the version
|
||||
ACTIVATION advertises. This module is that assertion, owned by the
|
||||
enforcement side.
|
||||
|
||||
``EXPECTED_ACTIVATION_CAPABILITY`` below is this toolkit's own contract
|
||||
declaration — bump it only when this toolkit's launch/gate seam starts
|
||||
requiring a different activation contract (new env vars it depends on,
|
||||
changed chaining behavior, etc.), independent of any package semver, for
|
||||
the same reason C1's constant is: #828 happened precisely because a
|
||||
version number that should have moved did not.
|
||||
|
||||
This module never talks to a real broker or a real installed CLI in its
|
||||
own tests — both the probe's command resolution and its ``run`` transport
|
||||
are injectable so tests can drive every branch with fakes/stubs (see
|
||||
``version_coupling_unittest.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Final, TypedDict
|
||||
|
||||
|
||||
class ActivationCapability(TypedDict):
|
||||
name: str
|
||||
version: int
|
||||
|
||||
|
||||
# ENFORCEMENT-side expected activation contract. OWNED by this toolkit (the
|
||||
# enforcement half). Mirrors — but is deliberately a SEPARATE constant from
|
||||
# — `LEASE_ACTIVATION_CAPABILITY` in
|
||||
# `packages/mosaic/src/commands/lease-activation-probe.ts` (the activation
|
||||
# half's own declaration of what it implements). The two are compared at
|
||||
# runtime by `assert_activation_capability_matches()`; drift between them is
|
||||
# exactly the version-skew failure mode #828/#869 exist to catch, and must
|
||||
# FAIL LOUD, never a silent pass and never a dead (always-true) gate.
|
||||
EXPECTED_ACTIVATION_CAPABILITY: Final[ActivationCapability] = {
|
||||
"name": "lease-runtime-activation",
|
||||
"version": 1,
|
||||
}
|
||||
|
||||
# Matches `LEASE_CAPABILITY_PROBE_COMMAND` in lease-activation-probe.ts —
|
||||
# the hidden CLI subcommand that prints the activation half's advertised
|
||||
# capability as compact JSON.
|
||||
LEASE_CAPABILITY_PROBE_COMMAND: Final = "__lease-capability"
|
||||
|
||||
PROBE_TIMEOUT_SECONDS: Final = 2.0
|
||||
|
||||
# Override hook: a full shell-style command line (parsed with `shlex.split`)
|
||||
# to run INSTEAD of resolving `mosaic` on PATH and appending the probe
|
||||
# subcommand. Real deployments should never need this — `mosaic` is on PATH
|
||||
# whenever a runtime was launched via `mosaic <cmd>` in the first place, the
|
||||
# only real caller of this seam. It exists for integration tests that spawn
|
||||
# `launch-runtime.py` directly (never through the real CLI) to supply a
|
||||
# fake/stub CLI probe, matching the existing convention of those tests
|
||||
# supplying a fake broker and a fake runtime binary rather than depending on
|
||||
# host state.
|
||||
MOSAIC_COMMAND_OVERRIDE_VAR: Final = "MOSAIC_LEASE_VERSION_PROBE_COMMAND"
|
||||
|
||||
|
||||
class VersionCouplingError(Exception):
|
||||
"""Raised when the activation capability is absent, unreadable, or does
|
||||
not match what enforcement expects. Callers MUST fail loud on this
|
||||
(non-zero exit, clear actionable stderr) — never swallow it into a
|
||||
silent pass, and never let its absence be treated as compatible."""
|
||||
|
||||
|
||||
def _resolve_probe_command(environ: Mapping[str, str]) -> list[str] | None:
|
||||
override = environ.get(MOSAIC_COMMAND_OVERRIDE_VAR)
|
||||
if override:
|
||||
parsed = shlex.split(override)
|
||||
return parsed or None
|
||||
resolved = shutil.which("mosaic")
|
||||
if resolved is None:
|
||||
return None
|
||||
return [resolved, LEASE_CAPABILITY_PROBE_COMMAND]
|
||||
|
||||
|
||||
def default_probe_activation_capability(
|
||||
environ: Mapping[str, str] | None = None,
|
||||
*,
|
||||
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||
) -> ActivationCapability | None:
|
||||
"""Real capability lookup: resolves and executes the CLI's hidden
|
||||
``__lease-capability`` probe subcommand out-of-process (the same
|
||||
mechanism `defaultCapabilityProbe()` in lease-activation-probe.ts uses
|
||||
from the activation side) and parses its JSON stdout. Any failure to
|
||||
resolve a command, spawn it, have it exit zero, or produce a well-shaped
|
||||
``{name, version}`` JSON object is treated as NO capability (``None``)
|
||||
— fail-closed, never a fabricated/guessed capability.
|
||||
"""
|
||||
source_environment = os.environ if environ is None else environ
|
||||
command = _resolve_probe_command(source_environment)
|
||||
if command is None:
|
||||
return None
|
||||
try:
|
||||
completed = run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=PROBE_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired, ValueError):
|
||||
return None
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(completed.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if (
|
||||
not isinstance(parsed, dict)
|
||||
or not isinstance(parsed.get("name"), str)
|
||||
or not isinstance(parsed.get("version"), int)
|
||||
or isinstance(parsed.get("version"), bool)
|
||||
):
|
||||
return None
|
||||
return {"name": parsed["name"], "version": parsed["version"]}
|
||||
|
||||
|
||||
def format_mismatch_message(
|
||||
activation: ActivationCapability | None,
|
||||
expected: ActivationCapability,
|
||||
) -> str:
|
||||
"""Actionable, non-silent remediation message for either failure shape:
|
||||
absent/unreadable capability, or a present-but-incompatible one."""
|
||||
if activation is None:
|
||||
return (
|
||||
"Mosaic lease activation capability unreadable: enforcement "
|
||||
f"expects '{expected['name']}' v{expected['version']} but the "
|
||||
f"CLI's `mosaic {LEASE_CAPABILITY_PROBE_COMMAND}` probe produced "
|
||||
"no usable result (mosaic not on PATH, non-zero exit, or "
|
||||
"malformed output) — framework/CLI version skew; upgrade both "
|
||||
"as one unit; see #869."
|
||||
)
|
||||
if activation["name"] != expected["name"]:
|
||||
return (
|
||||
f"activation capability name '{activation['name']}' != "
|
||||
f"enforcement expects '{expected['name']}' — framework/CLI "
|
||||
"version skew; upgrade both as one unit; see #869"
|
||||
)
|
||||
return (
|
||||
f"activation capability v{activation['version']} != enforcement "
|
||||
f"expects v{expected['version']} — framework/CLI version skew; "
|
||||
"upgrade both as one unit; see #869"
|
||||
)
|
||||
|
||||
|
||||
def assert_activation_capability_matches(
|
||||
activation: ActivationCapability | None,
|
||||
expected: ActivationCapability = EXPECTED_ACTIVATION_CAPABILITY,
|
||||
) -> None:
|
||||
"""Raise `VersionCouplingError` unless `activation` is present AND its
|
||||
`name`/`version` exactly match `expected`. Absence is treated the same
|
||||
as a mismatch — never a silent pass."""
|
||||
if (
|
||||
activation is None
|
||||
or activation.get("name") != expected["name"]
|
||||
or activation.get("version") != expected["version"]
|
||||
):
|
||||
raise VersionCouplingError(format_mismatch_message(activation, expected))
|
||||
@@ -12,11 +12,24 @@ from collections.abc import Callable, Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from activation_version_gate import (
|
||||
EXPECTED_ACTIVATION_CAPABILITY,
|
||||
ActivationCapability,
|
||||
VersionCouplingError,
|
||||
assert_activation_capability_matches,
|
||||
default_probe_activation_capability,
|
||||
)
|
||||
from lease_generation import initialize_runtime_generation
|
||||
|
||||
MAX_FRAME: Final = 64 * 1024
|
||||
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
||||
CLAUDE_DANGEROUS_FLAG: Final = "--dangerously-skip-permissions"
|
||||
# Distinct, non-overlapping exit code for the C4 version-coupling gate (see
|
||||
# `activation_version_gate.py`) — deliberately different from the `1`
|
||||
# (broker registration failed closed) and `64` (usage error) codes already
|
||||
# owned by this script, so a version-skew denial is unambiguous in caller
|
||||
# logs/tests and is never confused with a broker-availability failure.
|
||||
EXIT_VERSION_SKEW: Final = 65
|
||||
|
||||
|
||||
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
|
||||
@@ -47,6 +60,10 @@ def main(
|
||||
request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request,
|
||||
execute: Callable[[str, list[str], dict[str, str]], object] = os.execvpe,
|
||||
initialize_generation: Callable[[Path, int], None] = initialize_runtime_generation,
|
||||
probe_activation_capability: Callable[
|
||||
[Mapping[str, str]], ActivationCapability | None
|
||||
] = default_probe_activation_capability,
|
||||
expected_activation_capability: ActivationCapability = EXPECTED_ACTIVATION_CAPABILITY,
|
||||
) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
|
||||
@@ -66,6 +83,25 @@ def main(
|
||||
command = [command[0], CLAUDE_DANGEROUS_FLAG, *command[1:]]
|
||||
|
||||
source_environment = os.environ if environ is None else environ
|
||||
|
||||
# C4 version-coupling gate (#869 Point-1): before this ENFORCEMENT half
|
||||
# chains into anything, assert that the ACTIVATION contract it is about
|
||||
# to rely on (MOSAIC_LEASE_* injection, broker chaining) matches what
|
||||
# this enforcement build expects. This is a build/deploy-defect check,
|
||||
# not a broker-availability question, so it runs before — and
|
||||
# independently of — broker registration below, and it FAILS LOUD: a
|
||||
# clear stderr message plus a dedicated non-zero exit code, never a
|
||||
# silent pass and never folded into the generic registration-failure
|
||||
# branch.
|
||||
try:
|
||||
assert_activation_capability_matches(
|
||||
probe_activation_capability(source_environment),
|
||||
expected_activation_capability,
|
||||
)
|
||||
except VersionCouplingError as version_error:
|
||||
print(str(version_error), file=sys.stderr)
|
||||
return EXIT_VERSION_SKEW
|
||||
|
||||
try:
|
||||
socket_path = Path(source_environment["MOSAIC_LEASE_BROKER_SOCKET"])
|
||||
generation = int(source_environment.get("MOSAIC_RUNTIME_GENERATION", "1"))
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh"
|
||||
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -22,6 +22,7 @@ import { registerSkillCommand } from './commands/skill.js';
|
||||
// prdy is registered via launch.ts
|
||||
import { registerLaunchCommands } from './commands/launch.js';
|
||||
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
||||
import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js';
|
||||
import { registerAuthCommand } from './commands/auth.js';
|
||||
import { registerFederationCommand } from './commands/federation.js';
|
||||
import { registerGatewayCommand } from './commands/gateway.js';
|
||||
@@ -31,10 +32,7 @@ import {
|
||||
formatAllPackagesTable,
|
||||
getInstallAllCommand,
|
||||
repairFleetCommsTools,
|
||||
runFrameworkReseed,
|
||||
refreshActiveFleetUnits,
|
||||
readRosterAgentNames,
|
||||
buildRelaunchCommands,
|
||||
runUpdateReseedFlow,
|
||||
checkFrameworkDrift,
|
||||
FRAMEWORK_RESEED_PACKAGE,
|
||||
} from './runtime/update-checker.js';
|
||||
@@ -83,6 +81,10 @@ registerLaunchCommands(program);
|
||||
|
||||
registerLeaseCapabilityProbe(program);
|
||||
|
||||
// ─── install-ordering guard (hidden; #869 Point-1 C2) ───────────────────
|
||||
|
||||
registerInstallOrderingGuardCommand(program);
|
||||
|
||||
// ─── login ──────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
@@ -440,12 +442,18 @@ program
|
||||
'--repair-tools',
|
||||
'Restore the supported current-version TOOLS contract and executable fleet helper',
|
||||
)
|
||||
.option(
|
||||
'--allow-inactive-enforcement',
|
||||
'Wire lease-enforcement hooks into settings.json even when activation cannot be confirmed ' +
|
||||
'(explicit, loud, non-default opt-out for the post-reseed install-ordering guard — see #869/#882)',
|
||||
)
|
||||
.action(
|
||||
async (opts: {
|
||||
check?: boolean;
|
||||
reseed?: boolean;
|
||||
relaunch?: boolean;
|
||||
repairTools?: boolean;
|
||||
allowInactiveEnforcement?: boolean;
|
||||
}) => {
|
||||
if (opts.repairTools) {
|
||||
const repair = repairFleetCommsTools();
|
||||
@@ -466,57 +474,24 @@ program
|
||||
// checkForAllUpdates imported statically above
|
||||
const { execSync } = await import('node:child_process');
|
||||
|
||||
// Re-seed the framework from the freshly-installed package, propagate shipped
|
||||
// systemd unit fixes to the active units, and (opt-in) relaunch durable
|
||||
// agents. Shared by the "packages updated" and the "framework drift" paths.
|
||||
// Re-seed the framework from the freshly-installed package, re-apply the
|
||||
// install-ordering guard to settings.json (#882 (b) — closes the
|
||||
// `--sync-only` bypass so `mosaic update` never leaves enforcement-hook
|
||||
// wiring stale/unguarded), propagate shipped systemd unit fixes to the
|
||||
// active units, and (opt-in) relaunch durable agents. Shared by the
|
||||
// "packages updated" and the "framework drift" paths. Extracted to
|
||||
// update-checker.ts (`runUpdateReseedFlow`) for direct unit testability.
|
||||
const reseedFramework = (reason: string): void => {
|
||||
console.log(reason);
|
||||
const reseed = runFrameworkReseed();
|
||||
if (!reseed.ok) {
|
||||
console.error(
|
||||
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
|
||||
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
|
||||
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log('✔ Framework re-seeded.');
|
||||
if (reseed.skillSyncError) {
|
||||
console.error(` ⚠ Claude skill reconciliation skipped: ${reseed.skillSyncError}`);
|
||||
}
|
||||
const skillConflicts = reseed.skillSync?.conflicts ?? [];
|
||||
const skillChanges =
|
||||
(reseed.skillSync?.registered.length ?? 0) + (reseed.skillSync?.repaired.length ?? 0);
|
||||
if (skillChanges > 0) {
|
||||
console.log(`✔ Registered ${skillChanges.toString()} Mosaic skill(s) with Claude Code.`);
|
||||
}
|
||||
for (const conflict of skillConflicts) {
|
||||
console.error(` ⚠ Skill registration skipped for ${conflict.name}: ${conflict.reason}`);
|
||||
}
|
||||
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
|
||||
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
|
||||
const units = refreshActiveFleetUnits();
|
||||
if (units.refreshed.length > 0) {
|
||||
console.log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`);
|
||||
}
|
||||
const agents = readRosterAgentNames();
|
||||
if (agents.length === 0) return;
|
||||
if (opts.relaunch) {
|
||||
console.log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`);
|
||||
for (const restart of buildRelaunchCommands(agents)) {
|
||||
try {
|
||||
execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
|
||||
} catch {
|
||||
console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
|
||||
}
|
||||
}
|
||||
console.log('✔ Agents relaunched.');
|
||||
} else {
|
||||
console.log(
|
||||
`\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` +
|
||||
'Restart them to activate the update:\n mosaic update --relaunch ' +
|
||||
'(or: mosaic fleet restart <agent>)',
|
||||
);
|
||||
const flow = runUpdateReseedFlow(reason, {
|
||||
reseed: opts.reseed,
|
||||
relaunch: opts.relaunch,
|
||||
allowInactiveEnforcement: opts.allowInactiveEnforcement === true,
|
||||
});
|
||||
if (flow.settingsGuard?.ran && flow.settingsGuard.result?.exitCode === 1) {
|
||||
// Fail-loud: enforcement hooks were refused/stripped. Surface this
|
||||
// in the command's own exit status without aborting the rest of
|
||||
// the update (mirrors mosaic-link-runtime-assets' guard_degraded).
|
||||
process.exitCode = 1;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -539,7 +514,7 @@ program
|
||||
// package is reported outdated. Detect that via the framework version and
|
||||
// re-seed so shipped launcher/runtime fixes still activate.
|
||||
const drift = checkFrameworkDrift();
|
||||
if (drift.drifted && opts.reseed !== false) {
|
||||
if (drift.drifted) {
|
||||
reseedFramework(
|
||||
`\nFramework drift detected (on-disk v${drift.installed} < bundled v${drift.bundled}) — ` +
|
||||
'the CLI was updated outside `mosaic update`. Re-seeding framework files into ' +
|
||||
@@ -577,7 +552,7 @@ program
|
||||
(r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE,
|
||||
);
|
||||
const drift = checkFrameworkDrift();
|
||||
if ((mosaicUpdated || drift.drifted) && opts.reseed !== false) {
|
||||
if (mosaicUpdated || drift.drifted) {
|
||||
reseedFramework(
|
||||
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…',
|
||||
);
|
||||
|
||||
301
packages/mosaic/src/commands/install-ordering-guard.spec.ts
Normal file
301
packages/mosaic/src/commands/install-ordering-guard.spec.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { leaseEnforcementActivatable } from './lease-activation-probe.js';
|
||||
import {
|
||||
ENFORCEMENT_HOOK_MARKERS,
|
||||
FAIL_LOUD_MESSAGE,
|
||||
guardClaudeSettingsWiring,
|
||||
loudOptOutMessage,
|
||||
runInstallOrderingGuard,
|
||||
settingsHasEnforcementHooks,
|
||||
stripEnforcementHooks,
|
||||
} from './install-ordering-guard.js';
|
||||
|
||||
/**
|
||||
* Red-first tests for issue #869 Point-1 C2 — the install-ordering guard.
|
||||
*
|
||||
* Root cause under test: `mosaic-link-runtime-assets` copies
|
||||
* `runtime/claude/settings.json` (which embeds the PreToolUse
|
||||
* `mutator-gate.py` hook and the Stop `receipt-observer-client.py` hook)
|
||||
* straight into `~/.claude/settings.json`, unconditionally. If the
|
||||
* activation half (C1: `leaseEnforcementActivatable()`) cannot be confirmed,
|
||||
* wiring those hooks bricks the host with a fail-closed gate that can never
|
||||
* be satisfied. This guard must refuse to wire in that case by default, and
|
||||
* only wire anyway on an explicit, loud opt-out.
|
||||
*
|
||||
* All fixtures use temp directories — this suite never reads or writes the
|
||||
* real `~/.claude/settings.json`.
|
||||
*/
|
||||
|
||||
const FIXTURE_SETTINGS = {
|
||||
model: 'opus',
|
||||
hooks: {
|
||||
PreCompact: [
|
||||
{
|
||||
matcher: '.*',
|
||||
hooks: [{ type: 'command', command: 'python3 revoke-lease.py --reason pre-compact' }],
|
||||
},
|
||||
],
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: '.*',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude',
|
||||
timeout: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
matcher: 'Write|Edit|MultiEdit',
|
||||
hooks: [{ type: 'command', command: '~/.config/mosaic/tools/qa/prevent-memory-write.sh' }],
|
||||
},
|
||||
],
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|MultiEdit|Write',
|
||||
hooks: [{ type: 'command', command: '~/.config/mosaic/tools/qa/qa-hook-stdin.sh' }],
|
||||
},
|
||||
],
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command:
|
||||
'python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude',
|
||||
timeout: 3,
|
||||
},
|
||||
{ type: 'command', command: '~/.config/mosaic/tools/qa/reflect-stop-hook.sh' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
enabledPlugins: { 'feature-dev@claude-plugins-official': true },
|
||||
};
|
||||
|
||||
function fixtureJson(): string {
|
||||
return JSON.stringify(FIXTURE_SETTINGS, null, 2) + '\n';
|
||||
}
|
||||
|
||||
describe('stripEnforcementHooks', () => {
|
||||
it('removes the PreToolUse mutator-gate trigger entirely', () => {
|
||||
const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS);
|
||||
const hooks = settings['hooks'] as Record<string, unknown[]>;
|
||||
const preToolUse = hooks['PreToolUse'] as Array<{ hooks: Array<{ command: string }> }>;
|
||||
expect(preToolUse.some((t) => t.hooks.some((h) => h.command.includes('mutator-gate.py')))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the sibling prevent-memory-write.sh PreToolUse trigger', () => {
|
||||
const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS);
|
||||
const hooks = settings['hooks'] as Record<string, unknown[]>;
|
||||
const preToolUse = hooks['PreToolUse'] as Array<{ hooks: Array<{ command: string }> }>;
|
||||
expect(
|
||||
preToolUse.some((t) => t.hooks.some((h) => h.command.includes('prevent-memory-write.sh'))),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('removes only the receipt-observer-client.py hook from Stop, keeping reflect-stop-hook.sh', () => {
|
||||
const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS);
|
||||
const hooks = settings['hooks'] as Record<string, unknown[]>;
|
||||
const stop = hooks['Stop'] as Array<{ hooks: Array<{ command: string }> }>;
|
||||
const commands = stop.flatMap((t) => t.hooks.map((h) => h.command));
|
||||
expect(commands.some((c) => c.includes('receipt-observer-client.py'))).toBe(false);
|
||||
expect(commands.some((c) => c.includes('reflect-stop-hook.sh'))).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves PreCompact/PostToolUse hooks byte-identical', () => {
|
||||
const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS);
|
||||
const hooks = settings['hooks'] as Record<string, unknown>;
|
||||
expect(hooks['PreCompact']).toEqual(FIXTURE_SETTINGS.hooks.PreCompact);
|
||||
expect(hooks['PostToolUse']).toEqual(FIXTURE_SETTINGS.hooks.PostToolUse);
|
||||
});
|
||||
|
||||
it('reports what it removed', () => {
|
||||
const { removed } = stripEnforcementHooks(FIXTURE_SETTINGS);
|
||||
expect(removed).toContain(`PreToolUse:${ENFORCEMENT_HOOK_MARKERS.preToolUse}`);
|
||||
expect(removed).toContain(`Stop:${ENFORCEMENT_HOOK_MARKERS.stop}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settingsHasEnforcementHooks', () => {
|
||||
it('is true for the unmodified fixture', () => {
|
||||
expect(settingsHasEnforcementHooks(FIXTURE_SETTINGS)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false after stripping', () => {
|
||||
const { settings } = stripEnforcementHooks(FIXTURE_SETTINGS);
|
||||
expect(settingsHasEnforcementHooks(settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for settings with no hooks key at all', () => {
|
||||
expect(settingsHasEnforcementHooks({ model: 'opus' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardClaudeSettingsWiring', () => {
|
||||
it('probe=false (default, no opt-out): strips enforcement hooks and reports non-zero with a loud, actionable message', () => {
|
||||
const outcome = guardClaudeSettingsWiring(fixtureJson(), {}, { activatable: () => false });
|
||||
|
||||
expect(outcome.exitCode).toBe(1);
|
||||
expect(outcome.wired).toBe(false);
|
||||
expect(settingsHasEnforcementHooks(JSON.parse(outcome.json) as Record<string, unknown>)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(outcome.logs).toHaveLength(1);
|
||||
expect(outcome.logs[0]?.level).toBe('error');
|
||||
expect(outcome.logs[0]?.message).toBe(FAIL_LOUD_MESSAGE);
|
||||
expect(outcome.logs[0]?.message).toMatch(/refusing to wire a dead gate/i);
|
||||
expect(outcome.logs[0]?.message).toMatch(/#869/);
|
||||
expect(outcome.logs[0]?.message).toMatch(/--allow-inactive-enforcement/);
|
||||
});
|
||||
|
||||
it('probe=false + explicit opt-out flag: wires hooks as-is and emits a loud warning', () => {
|
||||
const outcome = guardClaudeSettingsWiring(
|
||||
fixtureJson(),
|
||||
{ allowInactiveEnforcement: true },
|
||||
{ activatable: () => false },
|
||||
);
|
||||
|
||||
expect(outcome.exitCode).toBe(0);
|
||||
expect(outcome.wired).toBe(true);
|
||||
expect(settingsHasEnforcementHooks(JSON.parse(outcome.json) as Record<string, unknown>)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(outcome.logs).toHaveLength(1);
|
||||
expect(outcome.logs[0]?.level).toBe('warn');
|
||||
expect(outcome.logs[0]?.message).toBe(loudOptOutMessage());
|
||||
expect(outcome.logs[0]?.message).toMatch(/WITHOUT confirmed activation/);
|
||||
});
|
||||
|
||||
it('probe=true: wires hooks normally with no logs, regardless of opt-out', () => {
|
||||
const outcome = guardClaudeSettingsWiring(fixtureJson(), {}, { activatable: () => true });
|
||||
|
||||
expect(outcome.exitCode).toBe(0);
|
||||
expect(outcome.wired).toBe(true);
|
||||
expect(outcome.logs).toHaveLength(0);
|
||||
expect(JSON.parse(outcome.json)).toEqual(FIXTURE_SETTINGS);
|
||||
});
|
||||
|
||||
it('probe=true + opt-out flag set anyway: still wires normally, no spurious warning', () => {
|
||||
const outcome = guardClaudeSettingsWiring(
|
||||
fixtureJson(),
|
||||
{ allowInactiveEnforcement: true },
|
||||
{ activatable: () => true },
|
||||
);
|
||||
|
||||
expect(outcome.exitCode).toBe(0);
|
||||
expect(outcome.wired).toBe(true);
|
||||
expect(outcome.logs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('defaults to the real leaseEnforcementActivatable() when no activatable dep is injected', () => {
|
||||
// Deliberately does not assume a fixed true/false value for the real
|
||||
// probe (whether dist/cli.js happens to be built varies by environment —
|
||||
// asserting a hardcoded expectation here would make the test flaky, not
|
||||
// red-first). Instead it proves the wiring is genuinely delegated: the
|
||||
// no-deps call must agree with an explicit call to the same real
|
||||
// predicate, not some other hardcoded value.
|
||||
const reallyActivatable = leaseEnforcementActivatable();
|
||||
const outcome = guardClaudeSettingsWiring(fixtureJson());
|
||||
|
||||
if (reallyActivatable) {
|
||||
expect(outcome.exitCode).toBe(0);
|
||||
expect(outcome.wired).toBe(true);
|
||||
} else {
|
||||
expect(outcome.exitCode).toBe(1);
|
||||
expect(outcome.wired).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('runInstallOrderingGuard (file-level, temp dirs only)', () => {
|
||||
let dir: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeSrc(): string {
|
||||
dir = mkdtempSync(join(tmpdir(), 'mosaic-install-ordering-guard-'));
|
||||
const src = join(dir, 'settings.json');
|
||||
writeFileSync(src, fixtureJson());
|
||||
return src;
|
||||
}
|
||||
|
||||
it('probe=false: writes a dest settings.json with hooks stripped and returns exitCode 1', () => {
|
||||
const src = makeSrc();
|
||||
const dest = join(dir, 'claude-settings.json');
|
||||
|
||||
const result = runInstallOrderingGuard(src, dest, {}, { activatable: () => false });
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.destWritten).toBe(true);
|
||||
expect(existsSync(dest)).toBe(true);
|
||||
const written = JSON.parse(readFileSync(dest, 'utf-8')) as Record<string, unknown>;
|
||||
expect(settingsHasEnforcementHooks(written)).toBe(false);
|
||||
});
|
||||
|
||||
it('probe=false + opt-out: writes dest with hooks intact and returns exitCode 0', () => {
|
||||
const src = makeSrc();
|
||||
const dest = join(dir, 'claude-settings.json');
|
||||
|
||||
const result = runInstallOrderingGuard(
|
||||
src,
|
||||
dest,
|
||||
{ allowInactiveEnforcement: true },
|
||||
{ activatable: () => false },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const written = JSON.parse(readFileSync(dest, 'utf-8')) as Record<string, unknown>;
|
||||
expect(settingsHasEnforcementHooks(written)).toBe(true);
|
||||
});
|
||||
|
||||
it('probe=true: writes dest with hooks intact and returns exitCode 0', () => {
|
||||
const src = makeSrc();
|
||||
const dest = join(dir, 'claude-settings.json');
|
||||
|
||||
const result = runInstallOrderingGuard(src, dest, {}, { activatable: () => true });
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const written = JSON.parse(readFileSync(dest, 'utf-8')) as Record<string, unknown>;
|
||||
expect(settingsHasEnforcementHooks(written)).toBe(true);
|
||||
});
|
||||
|
||||
it('backs up a pre-existing divergent dest before overwriting (copy_file_managed parity)', () => {
|
||||
const src = makeSrc();
|
||||
const dest = join(dir, 'claude-settings.json');
|
||||
writeFileSync(dest, JSON.stringify({ preexisting: true }));
|
||||
|
||||
const result = runInstallOrderingGuard(src, dest, {}, { activatable: () => true });
|
||||
|
||||
expect(result.destWritten).toBe(true);
|
||||
expect(result.backupPath).toBeDefined();
|
||||
expect(existsSync(result.backupPath!)).toBe(true);
|
||||
expect(JSON.parse(readFileSync(result.backupPath!, 'utf-8'))).toEqual({ preexisting: true });
|
||||
});
|
||||
|
||||
it('is a no-op write when dest already matches the guarded content (idempotent)', () => {
|
||||
const src = makeSrc();
|
||||
const dest = join(dir, 'claude-settings.json');
|
||||
|
||||
const first = runInstallOrderingGuard(src, dest, {}, { activatable: () => true });
|
||||
expect(first.destWritten).toBe(true);
|
||||
|
||||
const second = runInstallOrderingGuard(src, dest, {}, { activatable: () => true });
|
||||
expect(second.destWritten).toBe(false);
|
||||
expect(second.backupPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never touches the real home directory settings path used by this test file', () => {
|
||||
// Sanity guard for the suite itself: every dest path used above lives
|
||||
// under the mkdtemp() scratch dir, never under homedir()/.claude.
|
||||
expect(dir).toContain('mosaic-install-ordering-guard-');
|
||||
});
|
||||
});
|
||||
327
packages/mosaic/src/commands/install-ordering-guard.ts
Normal file
327
packages/mosaic/src/commands/install-ordering-guard.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Install-ordering guard (issue #869, Point-1 card C2).
|
||||
*
|
||||
* Root cause this exists to guard against (#828 version skew, restated): the
|
||||
* framework reseed / install path (`framework/install.sh` →
|
||||
* `mosaic-link-runtime-assets` → copies `runtime/claude/settings.json` to
|
||||
* `~/.claude/settings.json`) wires the ENFORCEMENT half of the lease broker —
|
||||
* the `PreToolUse` `mutator-gate.py` hook and the `Stop`
|
||||
* `receipt-observer-client.py` hook — unconditionally. If the ACTIVATION half
|
||||
* (a CLI build advertising launch-runtime activation + a running broker
|
||||
* supervisor — see `lease-activation-probe.ts`, C1) is absent, the fail-closed
|
||||
* gate then denies every tool call with GATE_UNAVAILABLE: a bricked host.
|
||||
*
|
||||
* This module is the WIRING gate, not the enforcement gate: it decides
|
||||
* whether the enforcement hook entries are written into the settings.json
|
||||
* that ships to `~/.claude/`. It never touches `mutator-gate.py`'s own
|
||||
* fail-closed-on-absent-identity runtime behavior (test-locked in
|
||||
* `runtime_tools_unittest.py` / `fail-closed-regression.spec.ts`).
|
||||
*
|
||||
* Default (no opt-out): NOT activatable → strip the enforcement hook entries
|
||||
* from the written settings.json and report a non-zero outcome with a loud,
|
||||
* actionable message (see FAIL_LOUD_MESSAGE below).
|
||||
*
|
||||
* Opt-out: `--allow-inactive-enforcement` (an explicit, per-invocation CLI
|
||||
* flag — deliberately NOT an environment variable, so it can never sit as a
|
||||
* silently-inherited default in a shell profile). When set on a NOT
|
||||
* activatable host, the hooks ARE wired but a loud warning is emitted saying
|
||||
* so, and the outcome is reported ok (this is a conscious, informed choice).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import { leaseEnforcementActivatable } from './lease-activation-probe.js';
|
||||
|
||||
// ─── Enforcement hook identification ────────────────────────────────────────
|
||||
|
||||
/** Substrings that identify the two enforcement hook commands #828 wired
|
||||
* unconditionally. Matches the marker strings documented in
|
||||
* `lease-activation-probe.ts`. */
|
||||
export const ENFORCEMENT_HOOK_MARKERS = {
|
||||
preToolUse: 'mutator-gate.py',
|
||||
stop: 'receipt-observer-client.py',
|
||||
} as const;
|
||||
|
||||
interface HookEntry {
|
||||
command?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface HookTrigger {
|
||||
matcher?: string;
|
||||
hooks?: HookEntry[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type HooksMap = Record<string, HookTrigger[]>;
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function commandIncludes(hook: HookEntry, marker: string): boolean {
|
||||
return String(hook.command ?? '').includes(marker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a deep clone of `settings` with the enforcement hook entries removed:
|
||||
* - Any `PreToolUse` trigger group containing a `mutator-gate.py` command is
|
||||
* dropped in full (that trigger exists solely to run the gate).
|
||||
* - Within `Stop` trigger groups, only the individual `receipt-observer-client.py`
|
||||
* hook entry is dropped; sibling hooks in the same trigger (e.g.
|
||||
* `reflect-stop-hook.sh`) are preserved.
|
||||
* Every other hook (PreCompact/SessionStart revoke-lease, the
|
||||
* `prevent-memory-write.sh` PreToolUse trigger, PostToolUse qa/typecheck
|
||||
* hooks) is left byte-identical — this function only ever removes the two
|
||||
* markers above.
|
||||
*/
|
||||
export function stripEnforcementHooks(settings: Record<string, unknown>): {
|
||||
settings: Record<string, unknown>;
|
||||
removed: string[];
|
||||
} {
|
||||
const cloned = cloneJson(settings);
|
||||
const removed: string[] = [];
|
||||
const hooks = cloned['hooks'] as HooksMap | undefined;
|
||||
if (!hooks || typeof hooks !== 'object') {
|
||||
return { settings: cloned, removed };
|
||||
}
|
||||
|
||||
const preToolUse = hooks['PreToolUse'];
|
||||
if (Array.isArray(preToolUse)) {
|
||||
const kept = preToolUse.filter((trigger) => {
|
||||
const hasGate = (trigger.hooks ?? []).some((h) =>
|
||||
commandIncludes(h, ENFORCEMENT_HOOK_MARKERS.preToolUse),
|
||||
);
|
||||
if (hasGate) removed.push('PreToolUse:mutator-gate.py');
|
||||
return !hasGate;
|
||||
});
|
||||
if (kept.length > 0) hooks['PreToolUse'] = kept;
|
||||
else delete hooks['PreToolUse'];
|
||||
}
|
||||
|
||||
const stop = hooks['Stop'];
|
||||
if (Array.isArray(stop)) {
|
||||
const rebuilt: HookTrigger[] = [];
|
||||
for (const trigger of stop) {
|
||||
const innerHooks = trigger.hooks ?? [];
|
||||
const keptHooks = innerHooks.filter((h) => {
|
||||
const isReceiptObserver = commandIncludes(h, ENFORCEMENT_HOOK_MARKERS.stop);
|
||||
if (isReceiptObserver) removed.push('Stop:receipt-observer-client.py');
|
||||
return !isReceiptObserver;
|
||||
});
|
||||
if (keptHooks.length > 0) {
|
||||
rebuilt.push({ ...trigger, hooks: keptHooks });
|
||||
}
|
||||
}
|
||||
if (rebuilt.length > 0) hooks['Stop'] = rebuilt;
|
||||
else delete hooks['Stop'];
|
||||
}
|
||||
|
||||
if (Object.keys(hooks).length === 0) {
|
||||
delete cloned['hooks'];
|
||||
} else {
|
||||
cloned['hooks'] = hooks;
|
||||
}
|
||||
|
||||
return { settings: cloned, removed };
|
||||
}
|
||||
|
||||
/** True iff `settings` currently wires either enforcement hook. */
|
||||
export function settingsHasEnforcementHooks(settings: Record<string, unknown>): boolean {
|
||||
const hooks = settings['hooks'] as HooksMap | undefined;
|
||||
if (!hooks || typeof hooks !== 'object') return false;
|
||||
|
||||
const preToolUse = hooks['PreToolUse'] ?? [];
|
||||
const preHit = preToolUse.some((trigger) =>
|
||||
(trigger.hooks ?? []).some((h) => commandIncludes(h, ENFORCEMENT_HOOK_MARKERS.preToolUse)),
|
||||
);
|
||||
if (preHit) return true;
|
||||
|
||||
const stop = hooks['Stop'] ?? [];
|
||||
return stop.some((trigger) =>
|
||||
(trigger.hooks ?? []).some((h) => commandIncludes(h, ENFORCEMENT_HOOK_MARKERS.stop)),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Guard predicate ────────────────────────────────────────────────────────
|
||||
|
||||
export const FAIL_LOUD_MESSAGE =
|
||||
'[mosaic] ERROR: enforcement requested but activation half absent — needs a published CLI ' +
|
||||
'carrying launch-runtime activation + a broker supervisor; refusing to wire a dead gate (see #869). ' +
|
||||
'The PreToolUse mutator-gate.py hook and Stop receipt-observer-client.py hook were NOT written to ' +
|
||||
'settings.json. Fix by installing/updating the CLI and broker, then re-run the framework reseed. ' +
|
||||
'To wire anyway (NOT recommended — the fail-closed gate will deny every tool call with ' +
|
||||
'GATE_UNAVAILABLE until activation is restored), re-run with --allow-inactive-enforcement.';
|
||||
|
||||
export function loudOptOutMessage(): string {
|
||||
return (
|
||||
'[mosaic] WARNING: wiring lease-enforcement hooks (mutator-gate.py / receipt-observer-client.py) ' +
|
||||
'WITHOUT confirmed activation — --allow-inactive-enforcement was set explicitly. The fail-closed ' +
|
||||
'gate will deny every tool call (GATE_UNAVAILABLE) until the activation half (launch-runtime ' +
|
||||
'activation capability + a running broker supervisor) is present on this host (see #869).'
|
||||
);
|
||||
}
|
||||
|
||||
export type GuardLogLevel = 'error' | 'warn';
|
||||
|
||||
export interface GuardLogLine {
|
||||
level: GuardLogLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface InstallOrderingGuardOptions {
|
||||
/** Explicit, per-invocation opt-out. Never source this from an environment
|
||||
* variable — see module doc. */
|
||||
allowInactiveEnforcement?: boolean;
|
||||
}
|
||||
|
||||
export interface InstallOrderingGuardDeps {
|
||||
/** Defaults to {@link leaseEnforcementActivatable}. Injectable for tests. */
|
||||
activatable?: () => boolean;
|
||||
}
|
||||
|
||||
export interface InstallOrderingGuardOutcome {
|
||||
/** The settings.json content to write (pretty-printed, trailing newline). */
|
||||
json: string;
|
||||
/** Whether the enforcement hooks are present in `json`. */
|
||||
wired: boolean;
|
||||
/** 0 = proceed normally; 1 = enforcement was refused (fail-loud default path). */
|
||||
exitCode: 0 | 1;
|
||||
logs: GuardLogLine[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The install-ordering guard: decide whether the enforcement hooks embedded
|
||||
* in the Claude settings.json template may be wired into the settings.json
|
||||
* actually shipped to `~/.claude/`.
|
||||
*
|
||||
* - activatable → wire as-is. exitCode 0, no logs.
|
||||
* - NOT activatable, no opt-out → strip enforcement hooks. exitCode 1,
|
||||
* one 'error' log with the actionable FAIL_LOUD_MESSAGE.
|
||||
* - NOT activatable, opt-out set → wire as-is anyway. exitCode 0, one
|
||||
* 'warn' log making the risk explicit and loud.
|
||||
*
|
||||
* Pure function: takes the raw settings.json text, returns the text to write
|
||||
* plus metadata. No filesystem access — callers (the hidden CLI subcommand
|
||||
* below, or a test) own reading/writing so this stays trivially testable with
|
||||
* fakes/temp files and never risks touching a real `~/.claude/settings.json`.
|
||||
*/
|
||||
export function guardClaudeSettingsWiring(
|
||||
rawSettingsJson: string,
|
||||
options: InstallOrderingGuardOptions = {},
|
||||
deps: InstallOrderingGuardDeps = {},
|
||||
): InstallOrderingGuardOutcome {
|
||||
const parsed = JSON.parse(rawSettingsJson) as Record<string, unknown>;
|
||||
const activatable = deps.activatable ?? leaseEnforcementActivatable;
|
||||
const isActivatable = activatable();
|
||||
|
||||
const serialize = (settings: Record<string, unknown>): string =>
|
||||
JSON.stringify(settings, null, 2) + '\n';
|
||||
|
||||
if (isActivatable) {
|
||||
return {
|
||||
json: serialize(parsed),
|
||||
wired: settingsHasEnforcementHooks(parsed),
|
||||
exitCode: 0,
|
||||
logs: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (options.allowInactiveEnforcement === true) {
|
||||
return {
|
||||
json: serialize(parsed),
|
||||
wired: settingsHasEnforcementHooks(parsed),
|
||||
exitCode: 0,
|
||||
logs: [{ level: 'warn', message: loudOptOutMessage() }],
|
||||
};
|
||||
}
|
||||
|
||||
const { settings: stripped } = stripEnforcementHooks(parsed);
|
||||
return {
|
||||
json: serialize(stripped),
|
||||
wired: settingsHasEnforcementHooks(stripped),
|
||||
exitCode: 1,
|
||||
logs: [{ level: 'error', message: FAIL_LOUD_MESSAGE }],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── File-level runner (shared by the CLI action + tests) ──────────────────
|
||||
|
||||
export interface RunInstallOrderingGuardResult extends InstallOrderingGuardOutcome {
|
||||
destWritten: boolean;
|
||||
backupPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `src`, guard it, and write the result to `dest` — mirroring
|
||||
* `copy_file_managed`'s backup-on-change semantics from
|
||||
* `mosaic-link-runtime-assets` (skip the write if content is unchanged;
|
||||
* back up an existing divergent file once, timestamped). Exported standalone
|
||||
* (not only reachable via the CLI action closure) so tests can exercise real
|
||||
* file I/O against temp directories without ever touching `~/.claude/`.
|
||||
*/
|
||||
export function runInstallOrderingGuard(
|
||||
src: string,
|
||||
dest: string,
|
||||
options: InstallOrderingGuardOptions = {},
|
||||
deps: InstallOrderingGuardDeps = {},
|
||||
): RunInstallOrderingGuardResult {
|
||||
const raw = readFileSync(src, 'utf-8');
|
||||
const outcome = guardClaudeSettingsWiring(raw, options, deps);
|
||||
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
|
||||
const existing = existsSync(dest) ? readFileSync(dest, 'utf-8') : null;
|
||||
let destWritten = false;
|
||||
let backupPath: string | undefined;
|
||||
|
||||
if (existing !== outcome.json) {
|
||||
if (existing !== null) {
|
||||
const stamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\..+$/, '')
|
||||
.replace('T', '');
|
||||
backupPath = `${dest}.mosaic-bak-${stamp}`;
|
||||
writeFileSync(backupPath, existing);
|
||||
}
|
||||
writeFileSync(dest, outcome.json);
|
||||
destWritten = true;
|
||||
}
|
||||
|
||||
return { ...outcome, destWritten, backupPath };
|
||||
}
|
||||
|
||||
// ─── Hidden CLI bridge (bash → TS) ──────────────────────────────────────────
|
||||
|
||||
/** Hidden CLI subcommand name. `mosaic-link-runtime-assets` (bash) invokes
|
||||
* this instead of its generic `copy_file_managed` for the settings.json
|
||||
* runtime file specifically, so the guard's decision is made by importing
|
||||
* `leaseEnforcementActivatable()` directly rather than re-implementing the
|
||||
* capability/supervisor probes in shell. Deliberately undocumented (hidden
|
||||
* from `--help`) — internal wiring, not a user-facing command. */
|
||||
export const INSTALL_ORDERING_GUARD_COMMAND = '__link-claude-settings';
|
||||
|
||||
export function registerInstallOrderingGuardCommand(program: Command): void {
|
||||
program
|
||||
.command(`${INSTALL_ORDERING_GUARD_COMMAND} <src> <dest>`, { hidden: true })
|
||||
.description(
|
||||
'Internal: copy the Claude settings.json template, gating enforcement-hook ' +
|
||||
'wiring on lease-activation capability (#869 Point-1 C2)',
|
||||
)
|
||||
.option(
|
||||
'--allow-inactive-enforcement',
|
||||
'Wire enforcement hooks even when activation cannot be confirmed on this host ' +
|
||||
'(explicit, loud, non-default opt-out — see #869)',
|
||||
)
|
||||
.action((src: string, dest: string, opts: { allowInactiveEnforcement?: boolean }) => {
|
||||
const result = runInstallOrderingGuard(src, dest, {
|
||||
allowInactiveEnforcement: opts.allowInactiveEnforcement === true,
|
||||
});
|
||||
for (const line of result.logs) {
|
||||
(line.level === 'error' ? console.error : console.warn)(line.message);
|
||||
}
|
||||
process.exit(result.exitCode);
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { readRegularFileSecure } from '../fleet/secure-file.js';
|
||||
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
||||
import { canonicalizeRoleClass } from './fleet-personas.js';
|
||||
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
||||
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
|
||||
|
||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
||||
@@ -1237,7 +1238,6 @@ export function registerLaunchCommands(program: Command): void {
|
||||
// Direct framework script delegates
|
||||
const directCommands: Record<string, { desc: string; script: string }> = {
|
||||
init: { desc: 'Generate SOUL.md (agent identity contract)', script: 'mosaic-init' },
|
||||
doctor: { desc: 'Health audit — detect drift and missing files', script: 'mosaic-doctor' },
|
||||
sync: { desc: 'Sync skills from canonical source', script: 'mosaic-sync-skills' },
|
||||
bootstrap: {
|
||||
desc: 'Bootstrap a repo with Mosaic standards',
|
||||
@@ -1256,4 +1256,67 @@ export function registerLaunchCommands(program: Command): void {
|
||||
delegateToScript(fwScript(script), cmd.args);
|
||||
});
|
||||
}
|
||||
|
||||
// `doctor` — the framework drift audit (bash script) PLUS the #869
|
||||
// Point-1 C5 lease-enforcement activation check (TS, reusing C1's
|
||||
// `leaseEnforcementActivatable()` and C3's `checkBrokerSupervisorHealth()`).
|
||||
// Kept out of the generic `directCommands` loop above because this check
|
||||
// must run and report BEFORE the bash script's own exit, and must be able
|
||||
// to force a non-zero exit on its own — a silent pass on "enforcement
|
||||
// hooks wired but activation absent" would leave a bricked host
|
||||
// undiagnosed (see lease-doctor-check.ts docstring).
|
||||
program
|
||||
.command('doctor')
|
||||
.description('Health audit — detect drift, missing files, and #869 lease-activation gaps')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action(async (_opts: unknown, cmd: Command) => {
|
||||
checkMosaicHome();
|
||||
const leaseCheck = await runLeaseEnforcementDoctorCheck();
|
||||
const leaseCheckFailed = printLeaseDoctorCheck(leaseCheck);
|
||||
runDoctorScriptAndExit(fwScript('mosaic-doctor'), cmd.args, leaseCheckFailed);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the #869 C5 lease-enforcement doctor result using the same
|
||||
* `[mosaic-doctor]` prefix the bash audit script uses, but with a distinct
|
||||
* `[ERROR]` severity token (louder than the script's own `[WARN]`) — this is
|
||||
* a hard, actionable brick warning, not a soft drift warning, and must never
|
||||
* read as just one more line among the script's routine warnings. Silent on
|
||||
* an `ok` result, matching this file's other pre-flight checks
|
||||
* (`checkMosaicHome`, `checkFile`, `checkRuntime`) which only print on
|
||||
* failure. Returns whether the check failed, so the caller can force a
|
||||
* non-zero exit regardless of the bash script's own exit code.
|
||||
*/
|
||||
function printLeaseDoctorCheck(
|
||||
result: Awaited<ReturnType<typeof runLeaseEnforcementDoctorCheck>>,
|
||||
): boolean {
|
||||
if (result.status === 'error') {
|
||||
console.error(`[mosaic-doctor] [ERROR] ${result.message}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the bash `mosaic-doctor` audit script (inheriting stdio, same as
|
||||
* {@link delegateToScript}) and exit with a non-zero code if EITHER the
|
||||
* script itself reported failure OR the lease-enforcement check above did —
|
||||
* so `--fail-on-warn` and other script-level exit semantics are preserved,
|
||||
* but the lease-enforcement ERROR can never be masked by an otherwise-green
|
||||
* script run.
|
||||
*/
|
||||
function runDoctorScriptAndExit(scriptPath: string, args: string[], forceFailure: boolean): never {
|
||||
if (!existsSync(scriptPath)) {
|
||||
console.error(`[mosaic] Script not found: ${scriptPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
let scriptExitCode = 0;
|
||||
try {
|
||||
execFileSync('bash', [scriptPath, ...args], { stdio: 'inherit', env: process.env });
|
||||
} catch (err) {
|
||||
scriptExitCode = (err as { status?: number }).status ?? 1;
|
||||
}
|
||||
process.exit(forceFailure ? 1 : scriptExitCode);
|
||||
}
|
||||
|
||||
196
packages/mosaic/src/commands/lease-doctor-check.spec.ts
Normal file
196
packages/mosaic/src/commands/lease-doctor-check.spec.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
detectEnforcementHooksWired,
|
||||
runLeaseEnforcementDoctorCheck,
|
||||
} from './lease-doctor-check.js';
|
||||
|
||||
/**
|
||||
* Red-first tests for issue #869 Point-1 C5 — the `mosaic doctor`
|
||||
* lease-enforcement surfacing check.
|
||||
*
|
||||
* Root cause under test: enforcement hooks (`mutator-gate.py`,
|
||||
* `receipt-observer-client.py`) can be wired into `~/.claude/settings.json`
|
||||
* on a host where C1's `leaseEnforcementActivatable()` is false and/or C3's
|
||||
* `checkBrokerSupervisorHealth()` reports unhealthy. That combination fails
|
||||
* closed correctly, but must be surfaced LOUDLY by `mosaic doctor` rather
|
||||
* than silently passing — this test suite exercises the three primary
|
||||
* branches (wired+not-activatable, wired+healthy, not-wired) plus the
|
||||
* broker-unhealthy variant.
|
||||
*
|
||||
* Every dependency is injected — no real `~/.claude/settings.json` and no
|
||||
* real broker are ever touched.
|
||||
*/
|
||||
|
||||
const WIRED_SETTINGS_JSON = JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: '.*',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command:
|
||||
'python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const UNWIRED_SETTINGS_JSON = JSON.stringify({
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|MultiEdit|Write',
|
||||
hooks: [{ type: 'command', command: '~/.config/mosaic/tools/qa/qa-hook-stdin.sh' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe('detectEnforcementHooksWired', () => {
|
||||
it('detects the mutator-gate + receipt-observer markers when wired', () => {
|
||||
const result = detectEnforcementHooksWired(JSON.parse(WIRED_SETTINGS_JSON));
|
||||
expect(result.wired).toBe(true);
|
||||
expect(result.matchedMarkers).toEqual(
|
||||
expect.arrayContaining(['mutator-gate.py', 'receipt-observer-client.py']),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports not wired when no enforcement markers are present', () => {
|
||||
const result = detectEnforcementHooksWired(JSON.parse(UNWIRED_SETTINGS_JSON));
|
||||
expect(result.wired).toBe(false);
|
||||
expect(result.matchedMarkers).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports not wired for an empty settings object', () => {
|
||||
expect(detectEnforcementHooksWired({}).wired).toBe(false);
|
||||
});
|
||||
|
||||
it('detects wiring from just ONE marker (partial wiring is still dangerous)', () => {
|
||||
const onlyMutatorGate = JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: 'python3 .../mutator-gate.py --runtime claude' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const result = detectEnforcementHooksWired(JSON.parse(onlyMutatorGate));
|
||||
expect(result.wired).toBe(true);
|
||||
expect(result.matchedMarkers).toEqual(['mutator-gate.py']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runLeaseEnforcementDoctorCheck', () => {
|
||||
it('RED: wired + not-activatable ⇒ LOUD error (not a silent pass)', async () => {
|
||||
const result = await runLeaseEnforcementDoctorCheck({
|
||||
readSettingsRaw: () => WIRED_SETTINGS_JSON,
|
||||
isActivatable: () => false,
|
||||
isBrokerHealthy: async () => true,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.wired).toBe(true);
|
||||
expect(result.activatable).toBe(false);
|
||||
expect(result.message).toMatch(/activation absent/);
|
||||
expect(result.message).toMatch(/#869/);
|
||||
expect(result.message.toLowerCase()).toMatch(/brick/);
|
||||
});
|
||||
|
||||
it('wired + activatable + broker-unhealthy ⇒ LOUD error', async () => {
|
||||
const result = await runLeaseEnforcementDoctorCheck({
|
||||
readSettingsRaw: () => WIRED_SETTINGS_JSON,
|
||||
isActivatable: () => true,
|
||||
isBrokerHealthy: async () => false,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.wired).toBe(true);
|
||||
expect(result.brokerHealthy).toBe(false);
|
||||
expect(result.message).toMatch(/broker not healthy/);
|
||||
});
|
||||
|
||||
it('wired + not-activatable + broker-unhealthy ⇒ LOUD error citing both reasons', async () => {
|
||||
const result = await runLeaseEnforcementDoctorCheck({
|
||||
readSettingsRaw: () => WIRED_SETTINGS_JSON,
|
||||
isActivatable: () => false,
|
||||
isBrokerHealthy: async () => false,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.message).toMatch(/activation absent/);
|
||||
expect(result.message).toMatch(/broker not healthy/);
|
||||
});
|
||||
|
||||
it('GREEN: wired + activatable + broker-healthy ⇒ ok', async () => {
|
||||
const result = await runLeaseEnforcementDoctorCheck({
|
||||
readSettingsRaw: () => WIRED_SETTINGS_JSON,
|
||||
isActivatable: () => true,
|
||||
isBrokerHealthy: async () => true,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
expect(result.wired).toBe(true);
|
||||
expect(result.activatable).toBe(true);
|
||||
expect(result.brokerHealthy).toBe(true);
|
||||
});
|
||||
|
||||
it('GREEN: not-wired ⇒ ok, no false alarm (activation/broker never probed)', async () => {
|
||||
let activatableCalled = false;
|
||||
let brokerCalled = false;
|
||||
|
||||
const result = await runLeaseEnforcementDoctorCheck({
|
||||
readSettingsRaw: () => UNWIRED_SETTINGS_JSON,
|
||||
isActivatable: () => {
|
||||
activatableCalled = true;
|
||||
return false;
|
||||
},
|
||||
isBrokerHealthy: async () => {
|
||||
brokerCalled = true;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
expect(result.wired).toBe(false);
|
||||
expect(result.activatable).toBeNull();
|
||||
expect(result.brokerHealthy).toBeNull();
|
||||
// Not wired must short-circuit — never even consult activation/broker.
|
||||
expect(activatableCalled).toBe(false);
|
||||
expect(brokerCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('GREEN: settings.json absent ⇒ ok (never touches a real file — readSettingsRaw is injected)', async () => {
|
||||
const result = await runLeaseEnforcementDoctorCheck({
|
||||
readSettingsRaw: () => null,
|
||||
isActivatable: () => false,
|
||||
isBrokerHealthy: async () => false,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
expect(result.wired).toBe(false);
|
||||
});
|
||||
|
||||
it('GREEN: malformed settings.json ⇒ ok (parse errors are not this card’s failure class)', async () => {
|
||||
const result = await runLeaseEnforcementDoctorCheck({
|
||||
readSettingsRaw: () => '{ not valid json',
|
||||
isActivatable: () => false,
|
||||
isBrokerHealthy: async () => false,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
});
|
||||
});
|
||||
210
packages/mosaic/src/commands/lease-doctor-check.ts
Normal file
210
packages/mosaic/src/commands/lease-doctor-check.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Lease-enforcement doctor check (issue #869, Point-1 card C5).
|
||||
*
|
||||
* Root cause this guards against (#828 version skew, the same one C1/C3
|
||||
* exist for): the Claude Code enforcement hooks (`mutator-gate.py` gating
|
||||
* PreToolUse, `receipt-observer-client.py` observing Stop) can be WIRED into
|
||||
* `~/.claude/settings.json` on a host where the ACTIVATION half is absent —
|
||||
* no compatible CLI build (C1's `leaseEnforcementActivatable()`), or no
|
||||
* healthy broker supervisor (C3's `checkBrokerSupervisorHealth()`). That
|
||||
* combination is a silent brick: every gated tool call denies with
|
||||
* GATE_UNAVAILABLE, and the fail-closed behavior is *correct* — but nothing
|
||||
* surfaces it to an operator running `mosaic doctor` on an already-bricked
|
||||
* host.
|
||||
*
|
||||
* This module answers one question — "if I ran right now, would I be
|
||||
* bricked?" — by combining:
|
||||
*
|
||||
* 1. wiring detection: does `~/.claude/settings.json` reference either
|
||||
* enforcement-hook marker (`mutator-gate.py` / `receipt-observer-client.py`)?
|
||||
* 2. C1's `leaseEnforcementActivatable()` — could activation satisfy
|
||||
* enforcement if it were exercised right now?
|
||||
* 3. C3's `checkBrokerSupervisorHealth()` — is the broker supervisor
|
||||
* actually healthy?
|
||||
*
|
||||
* Not wired ⇒ ok (nothing to activate, no false alarm). Wired AND activatable
|
||||
* AND broker-healthy ⇒ ok. Wired AND (NOT activatable OR broker unhealthy) ⇒
|
||||
* a LOUD, actionable error — this module never silently passes that state.
|
||||
*
|
||||
* Every dependency (settings read, activation probe, broker-health check) is
|
||||
* injectable so tests can drive every branch without ever touching a real
|
||||
* `~/.claude/settings.json` or a real broker.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { leaseEnforcementActivatable, type ActivationProbeDeps } from './lease-activation-probe.js';
|
||||
import {
|
||||
checkBrokerSupervisorHealth,
|
||||
resolveBrokerSupervisorPaths,
|
||||
} from '../lease-broker/broker-supervisor.js';
|
||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||
|
||||
/** Markers identifying the two enforcement-hook halves wired via the
|
||||
* framework reseed. Either marker's presence in `settings.json` means
|
||||
* enforcement is wired — a host can be bricked with just one half present. */
|
||||
const ENFORCEMENT_HOOK_MARKERS = ['mutator-gate.py', 'receipt-observer-client.py'] as const;
|
||||
|
||||
export interface EnforcementHooksWiredResult {
|
||||
readonly wired: boolean;
|
||||
readonly matchedMarkers: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the Claude Code enforcement hooks (mutator-gate /
|
||||
* receipt-observer) are wired into an already-parsed `settings.json`.
|
||||
* Pure/testable — takes parsed JSON, never touches the filesystem itself.
|
||||
*/
|
||||
export function detectEnforcementHooksWired(settings: unknown): EnforcementHooksWiredResult {
|
||||
const serialized = JSON.stringify(settings ?? {});
|
||||
const matchedMarkers = ENFORCEMENT_HOOK_MARKERS.filter((marker) => serialized.includes(marker));
|
||||
return { wired: matchedMarkers.length > 0, matchedMarkers };
|
||||
}
|
||||
|
||||
export interface LeaseDoctorCheckDeps {
|
||||
/**
|
||||
* Read raw `settings.json` text; return `null` if the file is absent.
|
||||
* Defaults to reading the real `~/.claude/settings.json`. ALWAYS inject a
|
||||
* fake in tests — never point this at a real host's settings file.
|
||||
*/
|
||||
readSettingsRaw?: () => string | null;
|
||||
/** Defaults to {@link leaseEnforcementActivatable} (C1). Inject for tests. */
|
||||
isActivatable?: (deps?: ActivationProbeDeps) => boolean;
|
||||
/**
|
||||
* Defaults to a real broker-supervisor health check (C3) rooted at
|
||||
* `mosaicHome`. Inject for tests — never point this at a real broker.
|
||||
*/
|
||||
isBrokerHealthy?: () => Promise<boolean>;
|
||||
/** Mosaic home used to resolve default broker-supervisor paths. Defaults to
|
||||
* `$MOSAIC_HOME` or `~/.config/mosaic`. */
|
||||
mosaicHome?: string;
|
||||
}
|
||||
|
||||
export type LeaseDoctorCheckStatus = 'ok' | 'error';
|
||||
|
||||
export interface LeaseDoctorCheckResult {
|
||||
readonly status: LeaseDoctorCheckStatus;
|
||||
readonly wired: boolean;
|
||||
/** `null` when hooks are not wired (activation/broker were never probed). */
|
||||
readonly activatable: boolean | null;
|
||||
/** `null` when hooks are not wired (activation/broker were never probed). */
|
||||
readonly brokerHealthy: boolean | null;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
function defaultReadSettingsRaw(): string | null {
|
||||
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
||||
try {
|
||||
return readFileSync(settingsPath, 'utf8');
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isEnoent(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
);
|
||||
}
|
||||
|
||||
function defaultMosaicHome(): string {
|
||||
return process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME;
|
||||
}
|
||||
|
||||
async function defaultIsBrokerHealthy(mosaicHome: string): Promise<boolean> {
|
||||
// `frameworkRoot` only feeds SOURCE paths (unit/wrapper/daemon file
|
||||
// locations for `applyBrokerSupervisor`); the health check only reads
|
||||
// TARGET paths (`unitTargetPath`, `socketPath`), both derived from
|
||||
// `mosaicHome`/`homeDir`/`env` alone. Passing `mosaicHome` again here is
|
||||
// therefore safe and never resolves or touches a framework checkout.
|
||||
const paths = resolveBrokerSupervisorPaths({ mosaicHome, frameworkRoot: mosaicHome });
|
||||
return (await checkBrokerSupervisorHealth(paths)).healthy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface the #869 fail-closed brick scenario as a LOUD `mosaic doctor`
|
||||
* error. See module docstring for the full decision table.
|
||||
*/
|
||||
export async function runLeaseEnforcementDoctorCheck(
|
||||
deps: LeaseDoctorCheckDeps = {},
|
||||
): Promise<LeaseDoctorCheckResult> {
|
||||
const readSettingsRaw = deps.readSettingsRaw ?? defaultReadSettingsRaw;
|
||||
const mosaicHome = deps.mosaicHome ?? defaultMosaicHome();
|
||||
const isActivatable = deps.isActivatable ?? leaseEnforcementActivatable;
|
||||
const isBrokerHealthy = deps.isBrokerHealthy ?? (() => defaultIsBrokerHealthy(mosaicHome));
|
||||
|
||||
const raw = readSettingsRaw();
|
||||
if (raw === null) {
|
||||
return {
|
||||
status: 'ok',
|
||||
wired: false,
|
||||
activatable: null,
|
||||
brokerHealthy: null,
|
||||
message: 'Claude Code settings.json not found — lease-enforcement hooks not wired.',
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
// Malformed settings.json is a different failure class than this card
|
||||
// owns (C2 guards install-time writes); report ok rather than
|
||||
// misattributing a parse error to the #869 activation gap.
|
||||
return {
|
||||
status: 'ok',
|
||||
wired: false,
|
||||
activatable: null,
|
||||
brokerHealthy: null,
|
||||
message:
|
||||
'Claude Code settings.json could not be parsed — skipping lease-enforcement wiring check.',
|
||||
};
|
||||
}
|
||||
|
||||
const { wired, matchedMarkers } = detectEnforcementHooksWired(parsed);
|
||||
if (!wired) {
|
||||
return {
|
||||
status: 'ok',
|
||||
wired: false,
|
||||
activatable: null,
|
||||
brokerHealthy: null,
|
||||
message:
|
||||
'Lease-enforcement hooks not wired in ~/.claude/settings.json — nothing to activate.',
|
||||
};
|
||||
}
|
||||
|
||||
const activatable = isActivatable();
|
||||
const brokerHealthy = await isBrokerHealthy();
|
||||
|
||||
if (activatable && brokerHealthy) {
|
||||
return {
|
||||
status: 'ok',
|
||||
wired: true,
|
||||
activatable,
|
||||
brokerHealthy,
|
||||
message: `Lease-enforcement hooks wired (${matchedMarkers.join(', ')}) — activation capability present and broker healthy.`,
|
||||
};
|
||||
}
|
||||
|
||||
const reasons: string[] = [];
|
||||
if (!activatable) reasons.push('activation absent (leaseEnforcementActivatable() is false)');
|
||||
if (!brokerHealthy) {
|
||||
reasons.push('broker not healthy (checkBrokerSupervisorHealth() reports unhealthy)');
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
wired: true,
|
||||
activatable,
|
||||
brokerHealthy,
|
||||
message:
|
||||
`Lease-enforcement hooks (${matchedMarkers.join(', ')}) are wired in ~/.claude/settings.json, but ${reasons.join(' and ')}. ` +
|
||||
'Every gated tool call will fail closed and BRICK this agent (see #869). ' +
|
||||
'Remediate by activating the lease-broker supervisor (systemd unit + socket) or by removing the enforcement hooks from ~/.claude/settings.json.',
|
||||
};
|
||||
}
|
||||
@@ -47,6 +47,22 @@ const piLifecyclePath = join(frameworkRoot, 'runtime/pi/lease-lifecycle.ts');
|
||||
const prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh');
|
||||
const prdyUpdatePath = join(frameworkRoot, 'tools/prdy/prdy-update.sh');
|
||||
const remediationHandlerPath = join(frameworkRoot, 'tools/qa/remediation-hook-handler.sh');
|
||||
|
||||
// C4 (#869 Point-1): launch-runtime.py now asserts, before anything else,
|
||||
// that the CLI's advertised lease-activation capability (normally read via
|
||||
// the hidden `mosaic __lease-capability` subcommand) matches what
|
||||
// enforcement expects — see framework/tools/lease-broker/
|
||||
// activation_version_gate.py. This suite drives launch-runtime.py directly
|
||||
// as a subprocess (never through the real `mosaic` CLI), so — exactly like
|
||||
// the fake broker (daemon.py) and fake `claude` binaries already used
|
||||
// below — it must supply a fake activation-capability probe rather than
|
||||
// depend on a real `mosaic` binary being on PATH. `MOSAIC_LEASE_VERSION_PROBE_COMMAND`
|
||||
// is launch-runtime.py's injection point for that fake; this literal
|
||||
// {name, version} pair must be kept in sync with
|
||||
// `EXPECTED_ACTIVATION_CAPABILITY` (activation_version_gate.py) and
|
||||
// `LEASE_ACTIVATION_CAPABILITY` (lease-activation-probe.ts) — all three
|
||||
// currently agree on v1.
|
||||
const leaseCapabilityProbeStub = `python3 -c "import json; print(json.dumps({'name': 'lease-runtime-activation', 'version': 1}))"`;
|
||||
const children: ChildProcess[] = [];
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
@@ -184,6 +200,7 @@ raise SystemExit(0 if len(session_id) == 64 and denied else 1)
|
||||
MOSAIC_PRDY_RUNTIME: 'claude',
|
||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||
MOSAIC_RUNTIME_GENERATION: '1',
|
||||
MOSAIC_LEASE_VERSION_PROBE_COMMAND: leaseCapabilityProbeStub,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -743,6 +760,7 @@ describe('whole mutator-class lease gate', () => {
|
||||
...process.env,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||
MOSAIC_RUNTIME_GENERATION: '1',
|
||||
MOSAIC_LEASE_VERSION_PROBE_COMMAND: leaseCapabilityProbeStub,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -761,6 +779,7 @@ describe('whole mutator-class lease gate', () => {
|
||||
...process.env,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: join(tmpdir(), 'missing-mosaic-broker.sock'),
|
||||
MOSAIC_RUNTIME_GENERATION: '1',
|
||||
MOSAIC_LEASE_VERSION_PROBE_COMMAND: leaseCapabilityProbeStub,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -878,6 +897,7 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and observers_prese
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||
MOSAIC_RUNTIME_GENERATION: '1',
|
||||
MOSAIC_LEASE_VERSION_PROBE_COMMAND: leaseCapabilityProbeStub,
|
||||
},
|
||||
proxyGate: () =>
|
||||
Promise.resolve({
|
||||
|
||||
@@ -39,6 +39,18 @@ LAUNCHER = load_tool("lease_runtime_launcher", "launch-runtime.py")
|
||||
GATE = load_tool("lease_mutator_gate", "mutator-gate.py")
|
||||
|
||||
|
||||
def matching_activation_probe(*_args: object, **_kwargs: object) -> dict[str, object]:
|
||||
"""Fake activation-capability probe matching what enforcement expects
|
||||
(C4, #869 Point-1). Injected into `LAUNCHER.main()` calls below that are
|
||||
exercising OTHER branches (registration, exec, generation init, ...) so
|
||||
the new version-coupling gate — which runs before those — never blocks
|
||||
on host state (no real `mosaic` CLI on PATH in a test sandbox). The
|
||||
version-coupling gate's OWN behavior (match/mismatch/absent) is covered
|
||||
by its dedicated red-first tests in `version_coupling_unittest.py`."""
|
||||
|
||||
return dict(LAUNCHER.EXPECTED_ACTIVATION_CAPABILITY)
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
def __init__(self, *chunks: bytes):
|
||||
self.chunks = list(chunks)
|
||||
@@ -95,6 +107,7 @@ class LaunchRuntimeTest(unittest.TestCase):
|
||||
request=request,
|
||||
execute=execute,
|
||||
initialize_generation=initialize_generation,
|
||||
probe_activation_capability=matching_activation_probe,
|
||||
)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
@@ -127,6 +140,7 @@ class LaunchRuntimeTest(unittest.TestCase):
|
||||
request=lambda *_args: {"ok": True, "session_id": "e" * 64},
|
||||
execute=lambda *args: executed.append(args),
|
||||
initialize_generation=lambda *_args: None,
|
||||
probe_activation_capability=matching_activation_probe,
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(
|
||||
@@ -153,6 +167,7 @@ class LaunchRuntimeTest(unittest.TestCase):
|
||||
request=lambda *_args: {"ok": True, "session_id": "f" * 64},
|
||||
execute=lambda *args: executed.append(args),
|
||||
initialize_generation=lambda *_args: None,
|
||||
probe_activation_capability=matching_activation_probe,
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(executed[0][0:2], ("pi", ["pi", "--print", "hello"]))
|
||||
@@ -188,6 +203,7 @@ class LaunchRuntimeTest(unittest.TestCase):
|
||||
environ=environment,
|
||||
request=lambda *_args, value=reply: value,
|
||||
execute=lambda *args: executed.append(args),
|
||||
probe_activation_capability=matching_activation_probe,
|
||||
)
|
||||
self.assertEqual(result, 1)
|
||||
self.assertEqual(executed, [])
|
||||
@@ -203,6 +219,7 @@ class LaunchRuntimeTest(unittest.TestCase):
|
||||
initialize_generation=lambda *_args: (_ for _ in ()).throw(
|
||||
OSError("unsafe state")
|
||||
),
|
||||
probe_activation_capability=matching_activation_probe,
|
||||
),
|
||||
1,
|
||||
)
|
||||
@@ -220,6 +237,7 @@ class LaunchRuntimeTest(unittest.TestCase):
|
||||
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/x"},
|
||||
request=request,
|
||||
execute=lambda *_args: self.fail("must not execute"),
|
||||
probe_activation_capability=matching_activation_probe,
|
||||
),
|
||||
1,
|
||||
)
|
||||
@@ -233,6 +251,7 @@ class LaunchRuntimeTest(unittest.TestCase):
|
||||
request=lambda *_args: {"ok": True, "session_id": "c" * 64},
|
||||
execute=lambda *_args: (_ for _ in ()).throw(OSError("missing")),
|
||||
initialize_generation=lambda *_args: None,
|
||||
probe_activation_capability=matching_activation_probe,
|
||||
),
|
||||
1,
|
||||
)
|
||||
|
||||
287
packages/mosaic/src/mutator-gate/version_coupling_unittest.py
Normal file
287
packages/mosaic/src/mutator-gate/version_coupling_unittest.py
Normal file
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Red-first tests for issue #869 Point-1 C4 — the enforcement/activation
|
||||
version-coupling assertion at the `launch-runtime.py` seam.
|
||||
|
||||
Root cause under test (#828 restated): the lease broker's ENFORCEMENT half
|
||||
(this toolkit) and its ACTIVATION half (`execLeaseGatedRuntime()` in
|
||||
`launch.ts`, chained through `launch-runtime.py`) shipped on different
|
||||
channels and drifted. C1 (`lease-activation-probe.ts`) gave the activation
|
||||
half a versioned, machine-checkable identity
|
||||
(`LEASE_ACTIVATION_CAPABILITY`, printed via the hidden CLI subcommand
|
||||
`mosaic __lease-capability`). C4 (this module + `activation_version_gate.py`)
|
||||
is the assertion that actually USES that identity: enforcement must refuse
|
||||
to proceed — loudly, with an actionable remediation message, never a
|
||||
silent pass — unless the activation capability it observes exactly matches
|
||||
what enforcement expects.
|
||||
|
||||
Every case here drives the seam with injected fakes/stubs (a fake
|
||||
`probe_activation_capability` callable at the `launch-runtime.py` level, or
|
||||
a fake `run` transport at the `activation_version_gate` level) — never a
|
||||
real broker, a real installed CLI, or a real `mosaic` binary on PATH.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
TOOLS_DIR = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
||||
if str(TOOLS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(TOOLS_DIR))
|
||||
|
||||
|
||||
def load_tool(module_name: str, filename: str):
|
||||
spec = importlib.util.spec_from_file_location(module_name, TOOLS_DIR / filename)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"unable to load {filename}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
# Loaded under distinct module names from runtime_tools_unittest.py's own
|
||||
# LAUNCHER/GATE loads — importlib.util.module_from_spec() gives each load a
|
||||
# fresh module object regardless of name collisions, but distinct names keep
|
||||
# tracebacks/debugging unambiguous when both files run in the same process.
|
||||
LAUNCHER = load_tool("lease_runtime_launcher_version_coupling", "launch-runtime.py")
|
||||
VERSION_GATE = load_tool("lease_activation_version_gate_test", "activation_version_gate.py")
|
||||
|
||||
|
||||
def matching_capability() -> dict[str, object]:
|
||||
return dict(VERSION_GATE.EXPECTED_ACTIVATION_CAPABILITY)
|
||||
|
||||
|
||||
class AssertActivationCapabilityMatchesTest(unittest.TestCase):
|
||||
"""Unit-level coverage of `activation_version_gate.py`'s own assertion,
|
||||
isolated from the launch-runtime.py seam it is wired into below."""
|
||||
|
||||
def test_matching_capability_passes_silently(self) -> None:
|
||||
VERSION_GATE.assert_activation_capability_matches(matching_capability())
|
||||
# No exception is the assertion; nothing further to check.
|
||||
|
||||
def test_absent_capability_fails_closed_not_silent_pass(self) -> None:
|
||||
with self.assertRaises(VERSION_GATE.VersionCouplingError) as raised:
|
||||
VERSION_GATE.assert_activation_capability_matches(None)
|
||||
message = str(raised.exception)
|
||||
self.assertIn("#869", message)
|
||||
self.assertIn("upgrade", message.lower())
|
||||
|
||||
def test_version_mismatch_message_is_actionable(self) -> None:
|
||||
expected = {"name": "lease-runtime-activation", "version": 1}
|
||||
mismatched = {"name": "lease-runtime-activation", "version": 2}
|
||||
with self.assertRaises(VERSION_GATE.VersionCouplingError) as raised:
|
||||
VERSION_GATE.assert_activation_capability_matches(mismatched, expected)
|
||||
message = str(raised.exception)
|
||||
self.assertIn("v2", message)
|
||||
self.assertIn("v1", message)
|
||||
self.assertIn("#869", message)
|
||||
self.assertIn("upgrade", message.lower())
|
||||
self.assertIn("version skew", message.lower())
|
||||
|
||||
def test_name_mismatch_fails_loud(self) -> None:
|
||||
expected = {"name": "lease-runtime-activation", "version": 1}
|
||||
mismatched = {"name": "some-other-capability", "version": 1}
|
||||
with self.assertRaises(VERSION_GATE.VersionCouplingError) as raised:
|
||||
VERSION_GATE.assert_activation_capability_matches(mismatched, expected)
|
||||
message = str(raised.exception)
|
||||
self.assertIn("some-other-capability", message)
|
||||
self.assertIn("lease-runtime-activation", message)
|
||||
self.assertIn("#869", message)
|
||||
|
||||
def test_reversed_drift_newer_activation_than_enforcement_expects_also_fails(self) -> None:
|
||||
# A build/deploy where ACTIVATION shipped ahead of ENFORCEMENT is
|
||||
# exactly as much version skew as the reverse (#828's actual shape
|
||||
# was enforcement ahead of activation) — the assertion must not special
|
||||
# case direction.
|
||||
expected = {"name": "lease-runtime-activation", "version": 1}
|
||||
newer_activation = {"name": "lease-runtime-activation", "version": 2}
|
||||
with self.assertRaises(VERSION_GATE.VersionCouplingError):
|
||||
VERSION_GATE.assert_activation_capability_matches(newer_activation, expected)
|
||||
|
||||
|
||||
class ProbeActivationCapabilityTest(unittest.TestCase):
|
||||
"""Coverage of the probe's command resolution and fail-closed transport
|
||||
handling — never spawns a real `mosaic` process."""
|
||||
|
||||
def test_returns_none_when_mosaic_is_not_resolvable_on_path(self) -> None:
|
||||
result = VERSION_GATE.default_probe_activation_capability(
|
||||
{"PATH": "/nonexistent-bin-dir-for-869-c4-test"}
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_override_command_is_parsed_and_the_probe_subcommand_is_not_double_appended(
|
||||
self,
|
||||
) -> None:
|
||||
captured: list[list[str]] = []
|
||||
|
||||
class FakeCompleted:
|
||||
returncode = 0
|
||||
stdout = '{"name": "lease-runtime-activation", "version": 1}'
|
||||
|
||||
def fake_run(argv: list[str], **_kwargs: object) -> FakeCompleted:
|
||||
captured.append(argv)
|
||||
return FakeCompleted()
|
||||
|
||||
result = VERSION_GATE.default_probe_activation_capability(
|
||||
{VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: "/fake/mosaic __lease-capability"},
|
||||
run=fake_run,
|
||||
)
|
||||
self.assertEqual(result, {"name": "lease-runtime-activation", "version": 1})
|
||||
self.assertEqual(captured, [["/fake/mosaic", "__lease-capability"]])
|
||||
|
||||
def test_fails_closed_on_nonzero_exit_malformed_json_and_missing_fields(self) -> None:
|
||||
class NonZeroExit:
|
||||
returncode = 1
|
||||
stdout = '{"name": "lease-runtime-activation", "version": 1}'
|
||||
|
||||
class MalformedOutput:
|
||||
returncode = 0
|
||||
stdout = "not-json"
|
||||
|
||||
class MissingVersion:
|
||||
returncode = 0
|
||||
stdout = '{"name": "lease-runtime-activation"}'
|
||||
|
||||
class WrongShapeVersion:
|
||||
returncode = 0
|
||||
stdout = '{"name": "lease-runtime-activation", "version": "1"}'
|
||||
|
||||
class BooleanVersion:
|
||||
# bool is a subclass of int in Python; must not be accepted as
|
||||
# a version number.
|
||||
returncode = 0
|
||||
stdout = '{"name": "lease-runtime-activation", "version": true}'
|
||||
|
||||
for fake in (
|
||||
NonZeroExit(),
|
||||
MalformedOutput(),
|
||||
MissingVersion(),
|
||||
WrongShapeVersion(),
|
||||
BooleanVersion(),
|
||||
):
|
||||
with self.subTest(stdout=fake.stdout, returncode=fake.returncode):
|
||||
result = VERSION_GATE.default_probe_activation_capability(
|
||||
{VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: "/fake/mosaic"},
|
||||
run=lambda *_a, fake=fake, **_kw: fake,
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_fails_closed_on_timeout_and_transport_error(self) -> None:
|
||||
def timeout_run(*_args: object, **_kwargs: object) -> None:
|
||||
raise subprocess.TimeoutExpired(cmd="mosaic", timeout=2.0)
|
||||
|
||||
def oserror_run(*_args: object, **_kwargs: object) -> None:
|
||||
raise OSError("no such file or directory")
|
||||
|
||||
for run_fake in (timeout_run, oserror_run):
|
||||
with self.subTest(run=run_fake.__name__):
|
||||
result = VERSION_GATE.default_probe_activation_capability(
|
||||
{VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: "/fake/mosaic"},
|
||||
run=run_fake,
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class LaunchRuntimeVersionCouplingSeamTest(unittest.TestCase):
|
||||
"""End-to-end (still fully faked) coverage of the seam as wired into
|
||||
`launch-runtime.py`'s `main()` — the strongest natural enforcement point
|
||||
per the C4 card, run before any broker registration."""
|
||||
|
||||
def _run(self, *, probe):
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
def request(_path: Path, payload: dict[str, object]) -> dict[str, object]:
|
||||
calls["registered"] = True
|
||||
calls["request"] = payload
|
||||
return {"ok": True, "session_id": "a" * 64}
|
||||
|
||||
def execute(command: str, argv: list[str], environment: dict[str, str]) -> None:
|
||||
calls["executed"] = (command, argv, environment)
|
||||
|
||||
def initialize_generation(_path: Path, _generation: int) -> None:
|
||||
calls["generation_initialized"] = True
|
||||
|
||||
stderr = io.StringIO()
|
||||
with redirect_stderr(stderr):
|
||||
result = LAUNCHER.main(
|
||||
["--runtime", "claude", "--", "claude", "--print", "hello"],
|
||||
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/run/test/broker.sock"},
|
||||
request=request,
|
||||
execute=execute,
|
||||
initialize_generation=initialize_generation,
|
||||
probe_activation_capability=probe,
|
||||
)
|
||||
return result, stderr.getvalue(), calls
|
||||
|
||||
def test_matching_activation_version_passes_and_the_gate_proceeds(self) -> None:
|
||||
result, stderr_text, calls = self._run(probe=lambda *_a, **_kw: matching_capability())
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(stderr_text, "")
|
||||
self.assertTrue(calls.get("registered"))
|
||||
self.assertIn("executed", calls)
|
||||
|
||||
def test_version_mismatch_fails_loud_denies_and_never_registers_or_execs(self) -> None:
|
||||
expected = LAUNCHER.EXPECTED_ACTIVATION_CAPABILITY
|
||||
mismatched = {"name": expected["name"], "version": expected["version"] + 1}
|
||||
result, stderr_text, calls = self._run(probe=lambda *_a, **_kw: mismatched)
|
||||
|
||||
self.assertEqual(result, LAUNCHER.EXIT_VERSION_SKEW)
|
||||
self.assertNotEqual(result, 0)
|
||||
self.assertIn("#869", stderr_text)
|
||||
self.assertIn(f"v{mismatched['version']}", stderr_text)
|
||||
self.assertIn(f"v{expected['version']}", stderr_text)
|
||||
self.assertIn("upgrade", stderr_text.lower())
|
||||
# Never reaches broker registration or exec — the version gate is a
|
||||
# hard stop, not advisory.
|
||||
self.assertNotIn("registered", calls)
|
||||
self.assertNotIn("executed", calls)
|
||||
|
||||
def test_name_mismatch_fails_loud(self) -> None:
|
||||
expected = LAUNCHER.EXPECTED_ACTIVATION_CAPABILITY
|
||||
mismatched = {"name": "some-other-capability", "version": expected["version"]}
|
||||
result, stderr_text, calls = self._run(probe=lambda *_a, **_kw: mismatched)
|
||||
|
||||
self.assertEqual(result, LAUNCHER.EXIT_VERSION_SKEW)
|
||||
self.assertIn("#869", stderr_text)
|
||||
self.assertIn("some-other-capability", stderr_text)
|
||||
self.assertNotIn("registered", calls)
|
||||
self.assertNotIn("executed", calls)
|
||||
|
||||
def test_absent_activation_capability_fails_closed_not_a_silent_pass(self) -> None:
|
||||
result, stderr_text, calls = self._run(probe=lambda *_a, **_kw: None)
|
||||
|
||||
self.assertEqual(result, LAUNCHER.EXIT_VERSION_SKEW)
|
||||
self.assertNotEqual(result, 0)
|
||||
self.assertIn("#869", stderr_text)
|
||||
self.assertNotIn("registered", calls)
|
||||
self.assertNotIn("executed", calls)
|
||||
|
||||
def test_version_gate_runs_before_and_independently_of_broker_registration(self) -> None:
|
||||
def request_must_not_be_called(*_args: object, **_kwargs: object) -> dict[str, object]:
|
||||
self.fail("broker must not be contacted when activation version is mismatched")
|
||||
|
||||
stderr = io.StringIO()
|
||||
with redirect_stderr(stderr):
|
||||
result = LAUNCHER.main(
|
||||
["--runtime", "claude", "--", "claude"],
|
||||
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/run/test/broker.sock"},
|
||||
request=request_must_not_be_called,
|
||||
probe_activation_capability=lambda *_a, **_kw: None,
|
||||
)
|
||||
self.assertEqual(result, LAUNCHER.EXIT_VERSION_SKEW)
|
||||
|
||||
def test_dedicated_exit_code_never_collides_with_usage_or_registration_codes(self) -> None:
|
||||
# Distinctness guard: a version-skew denial must never be mistaken
|
||||
# for the pre-existing usage error (64) or registration/exec
|
||||
# fail-closed code (1) this script already owns.
|
||||
self.assertNotIn(LAUNCHER.EXIT_VERSION_SKEW, (0, 1, 64))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,424 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
ENFORCEMENT_HOOK_MARKERS,
|
||||
FAIL_LOUD_MESSAGE,
|
||||
settingsHasEnforcementHooks,
|
||||
} from '../commands/install-ordering-guard.js';
|
||||
import {
|
||||
runUpdatePathSettingsGuard,
|
||||
runUpdateReseedFlow,
|
||||
type FrameworkReseedResult,
|
||||
} from './update-checker.js';
|
||||
|
||||
/**
|
||||
* Red-first tests for issue #882 (b) — the `mosaic update --sync-only`
|
||||
* install-ordering-guard bypass (Mos-ruled "Option C").
|
||||
*
|
||||
* Root cause under test: `runFrameworkReseed()` runs the package's
|
||||
* install.sh with MOSAIC_SYNC_ONLY=1, which exits after the file-system
|
||||
* phase, BEFORE the "Post-install tasks" step that would otherwise run
|
||||
* `mosaic-link-runtime-assets` — the only place the #869 Point-1 C2
|
||||
* install-ordering guard evaluated whether the lease-enforcement hooks
|
||||
* (PreToolUse mutator-gate.py / Stop receipt-observer-client.py) may be
|
||||
* wired into `~/.claude/settings.json`. A plain `mosaic update` therefore
|
||||
* never re-evaluated that decision. These tests prove the post-reseed step
|
||||
* added to close that gap (`runUpdatePathSettingsGuard`, wired into the
|
||||
* `mosaic update` reseed flow via `runUpdateReseedFlow`) reuses the EXACT
|
||||
* C2 guard — no forked logic — and is skipped only when `--no-reseed` means
|
||||
* there was nothing to re-seed/re-link in the first place.
|
||||
*
|
||||
* All fixtures use temp directories — this suite never reads or writes the
|
||||
* real `~/.claude/settings.json` or `~/.config/mosaic`.
|
||||
*/
|
||||
|
||||
const FIXTURE_SETTINGS = {
|
||||
model: 'opus',
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: '.*',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude',
|
||||
timeout: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command:
|
||||
'python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude',
|
||||
timeout: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
function fixtureJson(): string {
|
||||
return JSON.stringify(FIXTURE_SETTINGS, null, 2) + '\n';
|
||||
}
|
||||
|
||||
describe('runUpdatePathSettingsGuard', () => {
|
||||
let root: string;
|
||||
let mosaicHome: string;
|
||||
let claudeHome: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeTemplate(): void {
|
||||
root = mkdtempSync(join(tmpdir(), 'mosaic-update-settings-guard-'));
|
||||
mosaicHome = join(root, 'mosaic-home');
|
||||
claudeHome = join(root, 'claude-home');
|
||||
mkdirSync(join(mosaicHome, 'runtime', 'claude'), { recursive: true });
|
||||
writeFileSync(join(mosaicHome, 'runtime', 'claude', 'settings.json'), fixtureJson());
|
||||
}
|
||||
|
||||
it('does not run when there is no settings.json template to re-link', () => {
|
||||
root = mkdtempSync(join(tmpdir(), 'mosaic-update-settings-guard-'));
|
||||
mosaicHome = join(root, 'mosaic-home');
|
||||
claudeHome = join(root, 'claude-home');
|
||||
// Deliberately no runtime/claude/settings.json under mosaicHome.
|
||||
|
||||
const outcome = runUpdatePathSettingsGuard(mosaicHome, claudeHome);
|
||||
|
||||
expect(outcome.ran).toBe(false);
|
||||
expect(outcome.result).toBeUndefined();
|
||||
expect(existsSync(join(claudeHome, 'settings.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('activatable=false (default, no opt-out): strips enforcement hooks and fails loud, exactly as install-time', () => {
|
||||
makeTemplate();
|
||||
|
||||
const outcome = runUpdatePathSettingsGuard(
|
||||
mosaicHome,
|
||||
claudeHome,
|
||||
{},
|
||||
{ activatable: () => false },
|
||||
);
|
||||
|
||||
expect(outcome.ran).toBe(true);
|
||||
expect(outcome.result?.exitCode).toBe(1);
|
||||
expect(outcome.result?.wired).toBe(false);
|
||||
expect(outcome.result?.logs).toHaveLength(1);
|
||||
expect(outcome.result?.logs[0]?.level).toBe('error');
|
||||
expect(outcome.result?.logs[0]?.message).toBe(FAIL_LOUD_MESSAGE);
|
||||
|
||||
const written = JSON.parse(readFileSync(join(claudeHome, 'settings.json'), 'utf-8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(settingsHasEnforcementHooks(written)).toBe(false);
|
||||
});
|
||||
|
||||
it('activatable=true: wires hooks normally, no strip, no logs', () => {
|
||||
makeTemplate();
|
||||
|
||||
const outcome = runUpdatePathSettingsGuard(
|
||||
mosaicHome,
|
||||
claudeHome,
|
||||
{},
|
||||
{ activatable: () => true },
|
||||
);
|
||||
|
||||
expect(outcome.ran).toBe(true);
|
||||
expect(outcome.result?.exitCode).toBe(0);
|
||||
expect(outcome.result?.wired).toBe(true);
|
||||
expect(outcome.result?.logs).toHaveLength(0);
|
||||
|
||||
const written = JSON.parse(readFileSync(join(claudeHome, 'settings.json'), 'utf-8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(settingsHasEnforcementHooks(written)).toBe(true);
|
||||
expect(written).toEqual(FIXTURE_SETTINGS);
|
||||
});
|
||||
|
||||
it('activatable=false + --allow-inactive-enforcement: wires hooks anyway with a loud warning', () => {
|
||||
makeTemplate();
|
||||
|
||||
const outcome = runUpdatePathSettingsGuard(
|
||||
mosaicHome,
|
||||
claudeHome,
|
||||
{ allowInactiveEnforcement: true },
|
||||
{ activatable: () => false },
|
||||
);
|
||||
|
||||
expect(outcome.ran).toBe(true);
|
||||
expect(outcome.result?.exitCode).toBe(0);
|
||||
expect(outcome.result?.wired).toBe(true);
|
||||
expect(outcome.result?.logs).toHaveLength(1);
|
||||
expect(outcome.result?.logs[0]?.level).toBe('warn');
|
||||
expect(outcome.result?.logs[0]?.message).toMatch(/WITHOUT confirmed activation/);
|
||||
|
||||
const written = JSON.parse(readFileSync(join(claudeHome, 'settings.json'), 'utf-8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(settingsHasEnforcementHooks(written)).toBe(true);
|
||||
});
|
||||
|
||||
it('never touches the real home directory settings path used by this test file', () => {
|
||||
// Sanity guard for the suite itself.
|
||||
makeTemplate();
|
||||
expect(mosaicHome).toContain('mosaic-update-settings-guard-');
|
||||
expect(claudeHome).toContain('mosaic-update-settings-guard-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runUpdateReseedFlow (the `mosaic update` post-reseed guard wiring, #882 (b))', () => {
|
||||
const okReseed: FrameworkReseedResult = { ok: true };
|
||||
|
||||
it('--no-reseed: the reseed is never attempted and the settings guard is never invoked', () => {
|
||||
const doReseed = vi.fn(() => okReseed);
|
||||
const doGuard = vi.fn(() => ({ ran: true }));
|
||||
const doRefresh = vi.fn(() => ({ refreshed: [], ok: true }));
|
||||
const doReadRoster = vi.fn(() => []);
|
||||
const log = vi.fn();
|
||||
const warnLog = vi.fn();
|
||||
const errorLog = vi.fn();
|
||||
|
||||
const result = runUpdateReseedFlow(
|
||||
'should never be printed',
|
||||
{ reseed: false },
|
||||
{
|
||||
runFrameworkReseed: doReseed,
|
||||
runUpdatePathSettingsGuard: doGuard,
|
||||
refreshActiveFleetUnits: doRefresh,
|
||||
readRosterAgentNames: doReadRoster,
|
||||
log,
|
||||
warnLog,
|
||||
errorLog,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.attempted).toBe(false);
|
||||
expect(doReseed).not.toHaveBeenCalled();
|
||||
expect(doGuard).not.toHaveBeenCalled();
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
expect(errorLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reseed ran + activatable=false: the guard fires (hooks stripped) and the fail-loud message is surfaced, not swallowed', () => {
|
||||
const doReseed = vi.fn(() => okReseed);
|
||||
const doGuard = vi.fn(() => ({
|
||||
ran: true,
|
||||
result: {
|
||||
json: '{}',
|
||||
wired: false,
|
||||
exitCode: 1 as const,
|
||||
logs: [{ level: 'error' as const, message: FAIL_LOUD_MESSAGE }],
|
||||
destWritten: true,
|
||||
},
|
||||
}));
|
||||
const doRefresh = vi.fn(() => ({ refreshed: [], ok: true }));
|
||||
const doReadRoster = vi.fn(() => []);
|
||||
const log = vi.fn();
|
||||
const warnLog = vi.fn();
|
||||
const errorLog = vi.fn();
|
||||
|
||||
const result = runUpdateReseedFlow(
|
||||
'Re-seeding…',
|
||||
{ reseed: true },
|
||||
{
|
||||
runFrameworkReseed: doReseed,
|
||||
runUpdatePathSettingsGuard: doGuard,
|
||||
refreshActiveFleetUnits: doRefresh,
|
||||
readRosterAgentNames: doReadRoster,
|
||||
log,
|
||||
warnLog,
|
||||
errorLog,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.attempted).toBe(true);
|
||||
expect(doReseed).toHaveBeenCalledTimes(1);
|
||||
expect(doGuard).toHaveBeenCalledTimes(1);
|
||||
expect(result.settingsGuard?.result?.exitCode).toBe(1);
|
||||
// The guard's fail-loud message must reach the operator (stderr), never swallowed.
|
||||
expect(errorLog).toHaveBeenCalledWith(FAIL_LOUD_MESSAGE);
|
||||
});
|
||||
|
||||
it('reseed ran + activatable=true: the guard wires hooks with no error output', () => {
|
||||
const doReseed = vi.fn(() => okReseed);
|
||||
const doGuard = vi.fn(() => ({
|
||||
ran: true,
|
||||
result: {
|
||||
json: '{}',
|
||||
wired: true,
|
||||
exitCode: 0 as const,
|
||||
logs: [],
|
||||
destWritten: true,
|
||||
},
|
||||
}));
|
||||
const doRefresh = vi.fn(() => ({ refreshed: [], ok: true }));
|
||||
const doReadRoster = vi.fn(() => []);
|
||||
const log = vi.fn();
|
||||
const warnLog = vi.fn();
|
||||
const errorLog = vi.fn();
|
||||
|
||||
const result = runUpdateReseedFlow(
|
||||
'Re-seeding…',
|
||||
{ reseed: true },
|
||||
{
|
||||
runFrameworkReseed: doReseed,
|
||||
runUpdatePathSettingsGuard: doGuard,
|
||||
refreshActiveFleetUnits: doRefresh,
|
||||
readRosterAgentNames: doReadRoster,
|
||||
log,
|
||||
warnLog,
|
||||
errorLog,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.attempted).toBe(true);
|
||||
expect(result.settingsGuard?.result?.exitCode).toBe(0);
|
||||
expect(errorLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('threads --allow-inactive-enforcement through to the settings guard', () => {
|
||||
const doReseed = vi.fn(() => okReseed);
|
||||
const doGuard = vi.fn(() => ({
|
||||
ran: true,
|
||||
result: {
|
||||
json: '{}',
|
||||
wired: true,
|
||||
exitCode: 0 as const,
|
||||
logs: [{ level: 'warn' as const, message: 'opt-out warning' }],
|
||||
destWritten: true,
|
||||
},
|
||||
}));
|
||||
const doRefresh = vi.fn(() => ({ refreshed: [], ok: true }));
|
||||
const doReadRoster = vi.fn(() => []);
|
||||
const warnLog = vi.fn();
|
||||
|
||||
runUpdateReseedFlow(
|
||||
'Re-seeding…',
|
||||
{ reseed: true, allowInactiveEnforcement: true },
|
||||
{
|
||||
runFrameworkReseed: doReseed,
|
||||
runUpdatePathSettingsGuard: doGuard,
|
||||
refreshActiveFleetUnits: doRefresh,
|
||||
readRosterAgentNames: doReadRoster,
|
||||
log: vi.fn(),
|
||||
warnLog,
|
||||
errorLog: vi.fn(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(doGuard).toHaveBeenCalledWith(undefined, undefined, {
|
||||
allowInactiveEnforcement: true,
|
||||
});
|
||||
expect(warnLog).toHaveBeenCalledWith('opt-out warning');
|
||||
});
|
||||
|
||||
it('reseed failure: the settings guard is not invoked (nothing was re-seeded to re-link)', () => {
|
||||
const doReseed = vi.fn(
|
||||
() => ({ ok: false, reason: 'installer not found' }) as FrameworkReseedResult,
|
||||
);
|
||||
const doGuard = vi.fn(() => ({ ran: true }));
|
||||
const doRefresh = vi.fn(() => ({ refreshed: [], ok: true }));
|
||||
const doReadRoster = vi.fn(() => []);
|
||||
const errorLog = vi.fn();
|
||||
|
||||
const result = runUpdateReseedFlow(
|
||||
'Re-seeding…',
|
||||
{ reseed: true },
|
||||
{
|
||||
runFrameworkReseed: doReseed,
|
||||
runUpdatePathSettingsGuard: doGuard,
|
||||
refreshActiveFleetUnits: doRefresh,
|
||||
readRosterAgentNames: doReadRoster,
|
||||
log: vi.fn(),
|
||||
warnLog: vi.fn(),
|
||||
errorLog,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.attempted).toBe(true);
|
||||
expect(result.settingsGuard).toBeUndefined();
|
||||
expect(doGuard).not.toHaveBeenCalled();
|
||||
expect(errorLog).toHaveBeenCalledWith(expect.stringContaining('Framework re-seed skipped'));
|
||||
});
|
||||
|
||||
it('end-to-end (real runUpdatePathSettingsGuard, real temp files): reseed ok + activatable=false strips hooks in the live settings.json path', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-update-reseed-flow-e2e-'));
|
||||
try {
|
||||
const mosaicHome = join(root, 'mosaic-home');
|
||||
const claudeHome = join(root, 'claude-home');
|
||||
mkdirSync(join(mosaicHome, 'runtime', 'claude'), { recursive: true });
|
||||
writeFileSync(join(mosaicHome, 'runtime', 'claude', 'settings.json'), fixtureJson());
|
||||
// Pre-existing (stale, install-time) settings.json still carrying the
|
||||
// enforcement hooks — this is the exact state #882 (b) left behind.
|
||||
mkdirSync(claudeHome, { recursive: true });
|
||||
writeFileSync(join(claudeHome, 'settings.json'), fixtureJson());
|
||||
|
||||
const errorLog = vi.fn();
|
||||
const result = runUpdateReseedFlow(
|
||||
'Re-seeding…',
|
||||
{ reseed: true },
|
||||
{
|
||||
runFrameworkReseed: () => okReseed,
|
||||
runUpdatePathSettingsGuard: (mh, ch, options, deps) =>
|
||||
// Exercise the REAL function (imported above), pointed at temp dirs,
|
||||
// with the activation probe faked to prove this is not a live-host test.
|
||||
runUpdatePathSettingsGuardWithFakeActivation(
|
||||
mh ?? mosaicHome,
|
||||
ch ?? claudeHome,
|
||||
options,
|
||||
deps,
|
||||
),
|
||||
refreshActiveFleetUnits: () => ({ refreshed: [], ok: true }),
|
||||
readRosterAgentNames: () => [],
|
||||
log: vi.fn(),
|
||||
warnLog: vi.fn(),
|
||||
errorLog,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.settingsGuard?.result?.exitCode).toBe(1);
|
||||
const written = JSON.parse(
|
||||
readFileSync(join(claudeHome, 'settings.json'), 'utf-8'),
|
||||
) as Record<string, unknown>;
|
||||
expect(settingsHasEnforcementHooks(written)).toBe(false);
|
||||
expect(errorLog).toHaveBeenCalledWith(FAIL_LOUD_MESSAGE);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function runUpdatePathSettingsGuardWithFakeActivation(
|
||||
mosaicHome: string,
|
||||
claudeHome: string,
|
||||
options: Parameters<typeof runUpdatePathSettingsGuard>[2],
|
||||
_deps: Parameters<typeof runUpdatePathSettingsGuard>[3],
|
||||
): ReturnType<typeof runUpdatePathSettingsGuard> {
|
||||
return runUpdatePathSettingsGuard(mosaicHome, claudeHome, options, { activatable: () => false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity check: the enforcement markers this suite exercises must match the
|
||||
* ones the C2 guard (`install-ordering-guard.ts`) actually looks for, so a
|
||||
* drift in either module's marker strings would fail this suite loudly
|
||||
* rather than silently passing on the wrong hooks.
|
||||
*/
|
||||
describe('marker parity with the C2 guard', () => {
|
||||
it('the fixture uses the same marker commands the guard matches on', () => {
|
||||
const preToolUse = FIXTURE_SETTINGS.hooks.PreToolUse[0]?.hooks[0]?.command ?? '';
|
||||
const stop = FIXTURE_SETTINGS.hooks.Stop[0]?.hooks[0]?.command ?? '';
|
||||
expect(preToolUse).toContain(ENFORCEMENT_HOOK_MARKERS.preToolUse);
|
||||
expect(stop).toContain(ENFORCEMENT_HOOK_MARKERS.stop);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,12 @@ import {
|
||||
readRegularFileSecure,
|
||||
} from '../fleet/secure-file.js';
|
||||
import { getDefaultSkillPaths, syncClaudeSkills, type SkillSyncResult } from '../commands/skill.js';
|
||||
import {
|
||||
runInstallOrderingGuard,
|
||||
type InstallOrderingGuardDeps,
|
||||
type InstallOrderingGuardOptions,
|
||||
type RunInstallOrderingGuardResult,
|
||||
} from '../commands/install-ordering-guard.js';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -908,6 +914,175 @@ export function runFrameworkReseed(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Post-reseed install-ordering guard (#882, Point-2 precondition) ────────
|
||||
//
|
||||
// Root cause (restated): `runFrameworkReseed` above runs the package's
|
||||
// install.sh with MOSAIC_SYNC_ONLY=1, which — by design (see install.sh) —
|
||||
// exits after the file-system phase, BEFORE the "Post-install tasks" step
|
||||
// that runs `mosaic-link-runtime-assets`. That script is where the #869
|
||||
// Point-1 C2 install-ordering guard (`runInstallOrderingGuard`,
|
||||
// `packages/mosaic/src/commands/install-ordering-guard.ts`) decides whether
|
||||
// the lease-enforcement hooks (PreToolUse mutator-gate.py / Stop
|
||||
// receipt-observer-client.py) get wired into `~/.claude/settings.json`. A
|
||||
// plain `mosaic update` reseed therefore never re-evaluated that wiring
|
||||
// decision against current activation state — the bypass this closes.
|
||||
//
|
||||
// `runUpdatePathSettingsGuard` re-applies the EXACT SAME guard (no forked
|
||||
// logic) against the MANAGED settings.json template the reseed just
|
||||
// refreshed (`<mosaicHome>/runtime/claude/settings.json`) and the live
|
||||
// `<claudeHome>/settings.json` — mirroring `copy_claude_settings_guarded`'s
|
||||
// src/dest pair in `mosaic-link-runtime-assets`.
|
||||
|
||||
export interface UpdatePathSettingsGuardResult {
|
||||
/** False when there is no settings.json template on disk to re-link (e.g. a
|
||||
* framework layout that predates runtime/claude/settings.json) — nothing to
|
||||
* guard, so the guard did not run. */
|
||||
ran: boolean;
|
||||
result?: RunInstallOrderingGuardResult;
|
||||
}
|
||||
|
||||
export function runUpdatePathSettingsGuard(
|
||||
mosaicHome = join(homedir(), '.config', 'mosaic'),
|
||||
claudeHome = process.env['CLAUDE_HOME'] ?? join(homedir(), '.claude'),
|
||||
options: InstallOrderingGuardOptions = {},
|
||||
deps: InstallOrderingGuardDeps = {},
|
||||
): UpdatePathSettingsGuardResult {
|
||||
const src = join(mosaicHome, 'runtime', 'claude', 'settings.json');
|
||||
if (!existsSync(src)) {
|
||||
return { ran: false };
|
||||
}
|
||||
const dest = join(claudeHome, 'settings.json');
|
||||
return { ran: true, result: runInstallOrderingGuard(src, dest, options, deps) };
|
||||
}
|
||||
|
||||
// ─── update-reseed flow (extracted for testability; called from cli.ts) ────
|
||||
//
|
||||
// Everything `mosaic update`'s `.action()` does once it has decided a reseed
|
||||
// should happen (both call sites already gate on `opts.reseed !== false`
|
||||
// before invoking this). Extracted out of cli.ts so the post-reseed guard
|
||||
// wiring (#882 (b)) — and the `--no-reseed` short-circuit — are directly unit
|
||||
// testable with injected fakes, matching the existing update-checker
|
||||
// conventions (see update-checker.reseed.spec.ts).
|
||||
|
||||
export interface UpdateReseedFlowOptions {
|
||||
/** Mirrors the CLI's `--no-reseed` flag (commander sets `reseed: false`
|
||||
* when passed). `false` is a pure no-op: nothing is reseeded and the
|
||||
* post-reseed settings guard is not invoked either — there is nothing to
|
||||
* re-link. */
|
||||
reseed?: boolean;
|
||||
relaunch?: boolean;
|
||||
/** Threads `--allow-inactive-enforcement` to the post-reseed settings
|
||||
* guard, identically to the install path (see install-ordering-guard.ts).
|
||||
* Never sourced from an environment variable — explicit per-invocation
|
||||
* opt-out only. */
|
||||
allowInactiveEnforcement?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateReseedFlowDeps {
|
||||
runFrameworkReseed?: typeof runFrameworkReseed;
|
||||
runUpdatePathSettingsGuard?: typeof runUpdatePathSettingsGuard;
|
||||
refreshActiveFleetUnits?: typeof refreshActiveFleetUnits;
|
||||
readRosterAgentNames?: typeof readRosterAgentNames;
|
||||
execSync?: typeof execSync;
|
||||
log?: (message: string) => void;
|
||||
warnLog?: (message: string) => void;
|
||||
errorLog?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface UpdateReseedFlowResult {
|
||||
/** Whether a reseed was actually attempted (false only for `--no-reseed`). */
|
||||
attempted: boolean;
|
||||
reseed?: FrameworkReseedResult;
|
||||
settingsGuard?: UpdatePathSettingsGuardResult;
|
||||
}
|
||||
|
||||
export function runUpdateReseedFlow(
|
||||
reason: string,
|
||||
options: UpdateReseedFlowOptions = {},
|
||||
deps: UpdateReseedFlowDeps = {},
|
||||
): UpdateReseedFlowResult {
|
||||
if (options.reseed === false) {
|
||||
// Nothing to re-seed, and therefore nothing to re-link/guard either.
|
||||
return { attempted: false };
|
||||
}
|
||||
|
||||
const log = deps.log ?? console.log;
|
||||
const warnLog = deps.warnLog ?? console.warn;
|
||||
const errorLog = deps.errorLog ?? console.error;
|
||||
const doReseed = deps.runFrameworkReseed ?? runFrameworkReseed;
|
||||
const doGuard = deps.runUpdatePathSettingsGuard ?? runUpdatePathSettingsGuard;
|
||||
const doRefresh = deps.refreshActiveFleetUnits ?? refreshActiveFleetUnits;
|
||||
const doReadRoster = deps.readRosterAgentNames ?? readRosterAgentNames;
|
||||
const exec = deps.execSync ?? execSync;
|
||||
|
||||
log(reason);
|
||||
const reseed = doReseed();
|
||||
if (!reseed.ok) {
|
||||
errorLog(
|
||||
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
|
||||
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
|
||||
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
|
||||
);
|
||||
return { attempted: true, reseed };
|
||||
}
|
||||
log('✔ Framework re-seeded.');
|
||||
if (reseed.skillSyncError) {
|
||||
errorLog(` ⚠ Claude skill reconciliation skipped: ${reseed.skillSyncError}`);
|
||||
}
|
||||
const skillConflicts = reseed.skillSync?.conflicts ?? [];
|
||||
const skillChanges =
|
||||
(reseed.skillSync?.registered.length ?? 0) + (reseed.skillSync?.repaired.length ?? 0);
|
||||
if (skillChanges > 0) {
|
||||
log(`✔ Registered ${skillChanges.toString()} Mosaic skill(s) with Claude Code.`);
|
||||
}
|
||||
for (const conflict of skillConflicts) {
|
||||
errorLog(` ⚠ Skill registration skipped for ${conflict.name}: ${conflict.reason}`);
|
||||
}
|
||||
|
||||
// #882 (b): re-apply the install-ordering guard (C2) to the MANAGED
|
||||
// settings.json the reseed just refreshed. install.sh's sync-only mode
|
||||
// never reaches the post-install step that would otherwise do this, so
|
||||
// `mosaic update` must do it itself — closing the bypass for every update
|
||||
// path. Never swallow the guard's fail-loud/opt-out output on this path.
|
||||
const settingsGuard = doGuard(undefined, undefined, {
|
||||
allowInactiveEnforcement: options.allowInactiveEnforcement === true,
|
||||
});
|
||||
if (settingsGuard.ran && settingsGuard.result) {
|
||||
for (const line of settingsGuard.result.logs) {
|
||||
(line.level === 'error' ? errorLog : warnLog)(line.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
|
||||
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
|
||||
const units = doRefresh();
|
||||
if (units.refreshed.length > 0) {
|
||||
log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`);
|
||||
}
|
||||
const agents = doReadRoster();
|
||||
if (agents.length > 0) {
|
||||
if (options.relaunch) {
|
||||
log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`);
|
||||
for (const restart of buildRelaunchCommands(agents)) {
|
||||
try {
|
||||
exec(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
|
||||
} catch {
|
||||
errorLog(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
|
||||
}
|
||||
}
|
||||
log('✔ Agents relaunched.');
|
||||
} else {
|
||||
log(
|
||||
`\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` +
|
||||
'Restart them to activate the update:\n mosaic update --relaunch ' +
|
||||
'(or: mosaic fleet restart <agent>)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { attempted: true, reseed, settingsGuard };
|
||||
}
|
||||
|
||||
// ─── Framework drift detection (#642) ────────────────────────────────────────
|
||||
//
|
||||
// `mosaic update` only re-seeds the framework when the @mosaicstack/mosaic
|
||||
|
||||
@@ -13,22 +13,37 @@ import {
|
||||
type SkillSyncResult as ClaudeSkillSyncResult,
|
||||
} from '../commands/skill.js';
|
||||
|
||||
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
|
||||
/**
|
||||
* Link runtime assets. Returns a warning string when the install-ordering
|
||||
* guard (#869 Point-1 C2) reported a degraded outcome — i.e. the
|
||||
* lease-enforcement hooks were NOT wired into ~/.claude/settings.json because
|
||||
* this host could not confirm it can activate them — so the caller can
|
||||
* surface it via `p.warn(...)` instead of it being swallowed by `stdio:
|
||||
* 'pipe'`. Non-fatal either way: the wizard always continues.
|
||||
*/
|
||||
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): string | undefined {
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
||||
if (existsSync(script)) {
|
||||
try {
|
||||
spawnSync('bash', [script], {
|
||||
timeout: 30000,
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
...(skipClaudeHooks ? { MOSAIC_SKIP_CLAUDE_HOOKS: '1' } : {}),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal: wizard continues
|
||||
if (!existsSync(script)) return undefined;
|
||||
try {
|
||||
const result = spawnSync('bash', [script], {
|
||||
timeout: 30000,
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
...(skipClaudeHooks ? { MOSAIC_SKIP_CLAUDE_HOOKS: '1' } : {}),
|
||||
},
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
const stderr = (result.stderr ?? '').trim();
|
||||
return (
|
||||
stderr || 'Runtime asset linking reported a non-zero exit (see mosaic doctor for details).'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: wizard continues
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface SyncSkillsResult {
|
||||
@@ -201,7 +216,7 @@ export async function finalizeStage(
|
||||
// copied into ~/.claude/ while still linking the other runtime files.
|
||||
spin.update('Linking runtime assets...');
|
||||
const skipClaudeHooks = state.hooks?.accepted === false;
|
||||
linkRuntimeAssets(state.mosaicHome, skipClaudeHooks);
|
||||
const linkWarning = linkRuntimeAssets(state.mosaicHome, skipClaudeHooks);
|
||||
|
||||
// 4. Sync skills (only installs the user-selected subset)
|
||||
let skillsResult: SyncSkillsResult = { success: true, installedCount: 0 };
|
||||
@@ -236,6 +251,10 @@ export async function finalizeStage(
|
||||
|
||||
spin.stop('Installation complete');
|
||||
|
||||
// Surface the install-ordering guard's outcome (#869 Point-1 C2) — never
|
||||
// silent, even though the wizard continues either way.
|
||||
if (linkWarning) p.warn(linkWarning);
|
||||
|
||||
// Report skill install failure clearly (non-fatal but user should know)
|
||||
if (!skillsResult.success && skillsResult.failureReason) {
|
||||
p.warn(skillsResult.failureReason);
|
||||
|
||||
Reference in New Issue
Block a user