Compare commits
6 Commits
feat/869-c
...
docs/rfc-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5555cc7595 | ||
|
|
01dd2fadb3 | ||
| 529c177830 | |||
| a32ce4c8f9 | |||
| d351caad36 | |||
| 76b86a246e |
@@ -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._
|
||||
@@ -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 && 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"
|
||||
"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:*",
|
||||
|
||||
@@ -32,10 +32,7 @@ import {
|
||||
formatAllPackagesTable,
|
||||
getInstallAllCommand,
|
||||
repairFleetCommsTools,
|
||||
runFrameworkReseed,
|
||||
refreshActiveFleetUnits,
|
||||
readRosterAgentNames,
|
||||
buildRelaunchCommands,
|
||||
runUpdateReseedFlow,
|
||||
checkFrameworkDrift,
|
||||
FRAMEWORK_RESEED_PACKAGE,
|
||||
} from './runtime/update-checker.js';
|
||||
@@ -445,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();
|
||||
@@ -471,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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -544,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 ' +
|
||||
@@ -582,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)…',
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user