Compare commits

..

12 Commits

Author SHA1 Message Date
Jarvis
a39bafb8e3 test(fleet): stabilize documentation surface scan
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 19:41:18 -05:00
Jarvis
1f8c5a6f0a test(fleet): close markdown scanner bypasses
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:56:54 -05:00
Jarvis
bfa08e9651 test(fleet): close documentation code grammar
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:39:05 -05:00
Jarvis
862dbd5204 test(fleet): harden static command parsing
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:32:43 -05:00
Jarvis
42c7980dfb test(fleet): recognize env argv0 operands
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:45:09 -05:00
Jarvis
30fad7a2b3 test(fleet): harden documentation command scanner
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:09:44 -05:00
Jarvis
587ac423c2 test(mosaic): close fleet command parser gaps
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:34:53 -05:00
Jarvis
0b106651f8 test(fleet): harden wrapped command validation
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:41:19 -05:00
Jarvis
22ad7dbbed test(fleet): normalize env wrapper options
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:22:09 -05:00
Jarvis
0db300dce5 fix(fleet): close documentation safety gaps
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:53:34 -05:00
Jarvis
17aa94ca1a fix(fleet): correct operator documentation validation
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:04:06 -05:00
Jarvis
0aee2c0981 docs(fleet): add operator configuration guide
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:27:43 -05:00
143 changed files with 2132 additions and 16306 deletions

View File

@@ -7,3 +7,4 @@ pnpm-lock.yaml
.claude/
docs/tess/TASKS.md
docs/scratchpads/
packages/mosaic/src/fleet/testdata/documentation-publication-v1/inline-migration-v1.json

View File

@@ -42,27 +42,6 @@ steps:
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
# Blocking gate (#791): a framework upgrade must never write or delete an
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
# keep mode is a single cp-based path that must not depend on rsync), and that a
# corrupt/empty/missing manifest aborts fail-closed leaving operator files
# untouched (B2/B3). The rollback gate proves a mid-sync failure is rolled back
# from the pre-update snapshot (B1). The durable-snapshot gate (#791 PR2) proves
# the retained, operator-scoped pre-update backup is taken before any mutation
# (0700/0600, secret never logged, retention-pruned) and that the post-sync
# verify net restores any operator file a manifest bug lets the sync touch. The
# migration matrix pins the v2→v3 contract-file semantics. Pure bash, no
# node_modules — runs early alongside sanitization.
upgrade-guard:
image: *node_image
commands:
- apk add --no-cache bash rsync
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
typecheck:
image: *node_image
commands:
@@ -71,7 +50,6 @@ steps:
depends_on:
- install
- sanitization
- upgrade-guard
# lint, format, and test are independent — run in parallel after typecheck
lint:

View File

@@ -22,10 +22,10 @@
FROM node:24-alpine
# Native toolchain required to compile node-gyp deps on musl, plus the
# postgresql-client used by the test step's pg_isready readiness probe. `bash`,
# `git`, and `jq` are baked here too — framework shell tests and the shipped
# Codex review wrappers require them without per-run installation in ci.yml.
RUN apk add --no-cache python3 make g++ postgresql-client bash git jq
# postgresql-client used by the test step's pg_isready readiness probe. `bash`
# is baked here too — the sanitization step in ci.yml otherwise does a per-run
# `apk add bash`.
RUN apk add --no-cache python3 make g++ postgresql-client bash
# Pin pnpm to the repo's packageManager version via corepack.
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate

View File

@@ -97,10 +97,7 @@ mosaic config path # Print config file path
```bash
mosaic doctor # Health audit — detect drift and missing files
mosaic sync # Sync skills from canonical source
mosaic skill list # Audit Claude skill registrations and conflicts
mosaic skill register <name> # Register one canonical skill with Claude Code
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
mosaic update # Update CLI/framework and auto-register canonical skills
mosaic update # Check for and install CLI updates
mosaic wizard # Full guided setup wizard
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
mosaic coord init # Initialize a new orchestration mission
@@ -352,8 +349,6 @@ bash tools/install.sh --yes # Non-interactive, accept all defaults
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
```
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
## Contributing
```bash

View File

@@ -1,23 +1,25 @@
# Documentation Sitemap
## Compaction refresh lease broker
- [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings.
- [Broker operations](guides/lease-broker-operations.md) — protected paths, startup, fail-closed recovery posture, distinct-principal deployment, and residual risk.
- [Lease-broker security notes](architecture/lease-broker-security.md) — identity, whole-class authorization, threat boundaries, and coordinator review requirements.
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
## CLI and skill management
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
- [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes.
## Fleet configuration management
- [Generated environment boundary](fleet/reference/generated-env-boundary.md) — roster-derived launch projection, strict local data, legacy quarantine, and downstream interface evidence.
- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — local-tmux schema v2 parsing and structural validation.
- [Role classes and authority](fleet/reference/role-classes.md) — canonical role resolver and protected authority boundaries.
- [Executable asset dispositions](fleet/migration/example-profile-disposition.md) — shipped v1 fixture/profile/service validation posture.
- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
- [Desired, derived, and observed state](fleet/concepts/desired-vs-observed-state.md) — roster authority, generation, ownership, and drift.
- [Identity, class, and runtime](fleet/concepts/identity-class-runtime.md) — stable name, display alias, class, runtime, provider, and model separation.
- [Role authority and leases](fleet/concepts/role-authority-and-leases.md) — validator/merge-gate separation and bounded lease authority.
- [Generated launch chain](fleet/concepts/generated-env-launch-chain.md) — strict data parsing, precedence, and quarantine.
- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — schema, supported values, required fields, defaults, and constraints.
- [Fleet CLI reference](fleet/reference/cli.md) — local desired-state commands, JSON/exit behavior, and gateway-catalog separation.
- [Lifecycle transitions](fleet/reference/lifecycle-transitions.md) — create/apply/reboot/migration/rollback boundaries.
- [Status and drift](fleet/reference/status-and-drift.md) — desired/managed/observed state and current/future classifications.
- [Safe agent CRUD](fleet/how-to/create-update-delete-agent.md) — expected generation, dry-run, and partial-failure recovery.
- [Local lifecycle operations](fleet/how-to/start-stop-restart.md) — persisted versus one-shot actions.
- [Configurable interaction instance](fleet/how-to/configure-tess-interaction.md) and [validator instance](fleet/how-to/configure-ultron-validator.md) — generic identities and protected limits.
- [Reconcile and recover](fleet/operations/reconcile-and-recover.md) — plan/apply lock and recovery behavior.
- [Environment quarantine](fleet/operations/env-quarantine.md) — private evidence and value-free diagnostics.
- [Systemd/tmux troubleshooting](fleet/operations/systemd-tmux-troubleshooting.md) — socket, holder, unmanaged-session, and lock decisions.
- [Backup/restore boundary](fleet/operations/backup-restore.md) and [upgrade-assets hold](fleet/operations/upgrade-assets.md).
- [v1-to-v2 migration preview](fleet/migration/v1-to-v2.md) and [executable artifact dispositions](fleet/migration/example-profile-disposition.md).
- [FCM M5 closure evidence](reports/documentation/758-fleet-config-ia-closure.md) and [approved deferrals](reports/deferred/758-fleet-config-deferrals.md).
## Official channel plugins

View File

@@ -53,7 +53,7 @@ Active workstream is **W1 — Federation v1**. Workers should:
> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 |
| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation |
| FCM-M1-002 | done | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | #768 squash `a5e8e55`; shared resolver and canonical authority/alias validation delivered |
@@ -62,10 +62,10 @@ Active workstream is **W1 — Federation v1**. Workers should:
| FCM-M2-002 | done | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | #773 squash `bc5e736`; generation-guarded atomic CRUD and recovery contracts delivered |
| FCM-M3-001 | done | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | #785 squash `4990905`; exact roster-owned systemd/tmux reconcile and lifecycle contracts delivered |
| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only |
| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference |
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session |
| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral |
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI |
| FCM-M4-001 | done | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | PR #788; final head `d63bb0206a1d312ab8352ec1d3ca3631146b0baa`; tree `4da210da9a71b035130d4160a4a2e691bdfde2da`; squash `9745bc3f29c26b021a478b7ad03cfb494f6c9de3`; descendant-main pipeline 1855 terminal success |
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | HOLD: never starts a previously stopped agent or kills an unproven unmanaged session; not authorized by FCM-M5-001 |
| FCM-M5-001 | in-progress | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Sole owner: this FCM-M5-001 delivery on the recorded branch; must close every checklist item or record an approved deferral |
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | HOLD: final #758 gate; quality, independent code/security review, validator certificate, merge-gate approval, and green CI remain out of M5-001 |
## Thin-core prompt diet (#528) — feat/contract-thin-core

View File

@@ -1,24 +0,0 @@
# Authenticated external lease broker protocol
The compaction-refresh lease broker is a Linux-only, newline-framed JSON protocol over a Unix stream socket. It is runtime-neutral; M1 consumers are limited to Claude and Pi. This is an internal process boundary, not an HTTP API, so it is intentionally absent from OpenAPI.
The broker, never the caller, obtains `(pid, uid, gid)` from kernel `SO_PEERCRED`. It correlates the PID with `/proc/<pid>/stat` field 22 (`starttime`) and mints `session_id` on `register_anchor`. Presence of `session_id` in that request is refused even when its value is `null` or empty. Later requests must originate from the anchor or a descendant. The broker walks parent PIDs to the `(pid,starttime)` anchor and then rereads every walked PID's starttime before accepting the chain.
## Request and response boundary
Each connection carries exactly one UTF-8 JSON object followed by one newline, capped at 64 KiB. The protocol deliberately uses EOF to prove that there is exactly one frame: immediately after writing the newline, the client **MUST half-close its write side** with `shutdown(SHUT_WR)` (or Node `socket.end()`) before awaiting the response. A client that writes a newline but leaves its write side open receives no successful response; the broker's one-second connection deadline fails closed. Malformed, unterminated, multiple (including a delayed second frame), or oversized frames fail closed. Responses are one JSON object and one newline. Success has `{"ok":true,...}`; refusal has `{"ok":false,"code":"TYPED_CODE"}`. Requests are:
- `register_anchor`: `action`, non-negative `runtime_generation`; no `session_id` field.
- `authenticate`: `action`, broker-minted `session_id`, non-negative `runtime_generation`.
- `mint_token`: authenticated identity plus `binding` containing exactly `compaction_epoch`, `request_epoch`, `h_source`, `h_payload`, and `schema_version`.
- `consume_token`: authenticated identity plus `token`.
- `begin_verification`: authenticated identity, runtime (`claude` or `pi`), cycle `binding`, and a TTL no greater than 300 seconds. The broker revokes existing authority first, enters `PENDING_VERIFICATION`, and returns a single-use promotion token.
- `promote_lease`: authenticated identity plus the exact pending promotion token. The broker commits token consumption before making `VERIFIED` visible.
- `revoke_lease`: authenticated observer signal; deletes pending tokens and makes the session `UNVERIFIED` immediately.
- `authorize_tool`: authenticated identity, runtime, and exact runtime-reported tool name. The broker returns an explicit allow/deny decision from the whole-class policy and current lease.
A higher generation for the same anchor atomically replaces the stored incarnation and deletes all prior tokens and lease authority for that session. A lower generation is stale. Tokens are 256-bit values from the operating-system cryptographic RNG and are single use. At most 256 pending tokens may be persisted; another mint fails with `TOKEN_CAPACITY` before mutation. Successful consumption deletes the token, while a replay still fails with `TOKEN_REPLAY`. Live v1 token records retain the existing `consumed: false` schema.
VERIFIED leases are volatile and monotonic-time bounded: broker restart, generation change, explicit observer revocation, or expiry returns the session to `UNVERIFIED`. `begin_verification` always revokes before minting a new prerequisite. `promote_lease` is valid only from the matching pending cycle; persistence failure rolls token and lease state back, while post-rename durability uncertainty terminates the broker. The WI-1 token is the atomic promotion prerequisite substrate. A later receipt implementation must satisfy that prerequisite but cannot replace the mechanical mutator gate as safety authority.
State replacement serializes and enforces the 4 MiB maximum before opening a temporary file, then uses a mode-`0600` temporary file, `fsync`, atomic rename, and parent-directory `fsync`. Every broker mutation snapshots the prior v1 state. A commit failure before rename restores that snapshot and leaves durable state unchanged. A failure after rename makes durability uncertain, so the store is poisoned without rolling memory back and the daemon terminates rather than serving with divergent state. Existing state is opened without following symlinks, must be a bounded regular file at mode `0600`, and is fully schema- and invariant-validated before use. Persisted tokens must be unconsumed, match their session's current generation, and remain within the 256-token cap. Session identity is uniquely keyed by `(anchor_pid,anchor_starttime)`; duplicate logical sessions for one anchor refuse startup. State integrity or mode failures refuse startup. The daemon does not log session IDs or tokens.

View File

@@ -1,14 +0,0 @@
# WI-1 lease broker security notes
- Trusted identity comes only from Linux `SO_PEERCRED` plus `/proc` starttime, never request identity fields.
- Descendant authorization is anchored to `(pid,starttime)` and uses a complete second starttime pass to fail closed on disappearance or PID-reuse races.
- Runtime generations are monotonic per anchor; a bump revokes prior-incarnation tokens before persistence commits.
- Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources.
- Framing and persistence failures fail closed. Sensitive tokens are not logged.
- Built-in `0700`/`0600` filesystem modes provide same-principal hardening only, not socket authenticity against the same UID. WI-1 provides no distinct-principal isolation. That stronger deployment requires an external protected proxy, ACL, or service boundary, and the boundary must preserve authenticated client identity for the broker's `SO_PEERCRED` and ancestry authorization rather than substituting a shared proxy identity.
- WI-2 whole-class authorization denies every consequential, unknown, and custom tool while UNVERIFIED; it does not inspect shell strings or trust wrapper selection. First-class Claude/Pi, both Claudex dispatch modes, PRDY, QA remediation, coord, orchestrator, and fleet starts converge on broker register-before-exec; Claudex additionally installs the mandatory all-tools hook inside its preserved isolated config and fails closed on unsafe settings.
- The permanent `check-runtime-launches.py` suite/CI guard scans production source for direct literal, absolute-path, process-API, command-array, and dynamic Claude/Pi launches. It has no bypass allowlist: an unrecognized launch form fails CI until routed through the common boundary.
- WI-2 promotion consumes a WI-1 cycle token before VERIFIED becomes visible. Observer revocation, runtime-generation replacement, broker restart, and monotonic TTL expiry remove authority.
- Receipt observation, payload construction, compaction observers, and constrained recovery implementation remain later surfaces. A receipt can become a promotion prerequisite but is never the safety mechanism.
Coordinator security review must rerun the real socket/peercred and mutator-gate acceptance suites on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration.

View File

@@ -1,72 +0,0 @@
# Whole mutator-class lease gate
WI-2 adds the framework-native authorization boundary for Claude (including the supported Claudex overlay) and Pi. Every runtime-reported tool name reaches the lease broker before execution. The gate classifies capabilities by the whole tool class; it never parses a Bash command to decide whether that particular string looks read-only.
## Default-deny policy
While a session is not VERIFIED, only these exact classes are allowed:
- Claude: `Read`, `Grep`, `Glob`, `Ls`, `Find`
- Pi: `read`, `grep`, `find`, `ls`
- Both runtimes: the fixed `mosaic_context_recover` primitive
Every other built-in, unknown tool, and custom/MCP tool is consequential by default and is denied. This includes Claude `Bash`, `Edit`, `Write`, and `NotebookEdit`, plus Pi `bash`, `edit`, and `write`. A compromised model therefore cannot bypass Mosaic wrappers by selecting raw `git`, `curl`, `kubectl`, provider, deployment, or filesystem commands inside a generic mutator—the generic mutator itself is blocked before its input executes.
## Broker-owned transition order
The authenticated broker is the sole lease writer:
1. `begin_verification` revokes existing authority and pending tokens first, then records `PENDING_VERIFICATION` and mints one WI-1 single-use promotion token bound to the exact cycle.
2. `promote_lease` accepts only that session/generation/binding/token combination.
3. Token consumption commits before the volatile lease becomes VERIFIED. Promotion is last and cannot be reached directly from UNVERIFIED.
4. `revoke_lease`, a runtime-generation increase, broker restart, or monotonic expiry removes mutator authority.
The initial TTL is capped at the ratified 300-second maximum. A caller may request a shorter positive TTL but cannot lengthen the maximum. Dual compaction-hook miss within an unexpired lease remains the ratified bounded T-A residual; once either observer revokes or TTL expires, the next consequential tool is denied.
A receipt is only a future promotion prerequisite. It is not an obedience, residency, or safety proof and never replaces this mechanical gate.
## Runtime adapters
`launch-runtime.py` registers itself with the broker and then `exec`s Claude or Pi so PID/starttime remain the authenticated parent anchor. It exports only the broker-minted session ID and current generation to descendants.
- Claude installs `mutator-gate.py` as an all-tools (`.*`) `PreToolUse` hook.
- `mosaic claudex` and `mosaic yolo claudex` preserve their isolated `CLAUDE_CONFIG_DIR`, merge the mandatory hook into that isolated `settings.json`, and use the same register-before-exec launcher. Malformed or symlinked isolated settings deny launch.
- Pi invokes the same executable from its `tool_call` handler.
The executable submits the runtime's actual tool name to `authorize_tool`. Missing identity, malformed input/reply, timeout, broker unavailability, or denial exits with status 2 and blocks fail-closed.
## Runtime-launch choke-point and permanent guard
Every repository-owned Claude/Pi launch entry converges on `launch-runtime.py`, either directly or through `mosaic``execLeaseGatedRuntime`. PRDY init/update and QA remediation invoke the wrapper directly so their existing prompts, dangerous-permission behavior, working directory, and environment survive without skipping broker registration. The raw Claude `--dangerously-skip-permissions` primitive is owned only by `launch-runtime.py`; callers request semantic `--dangerous` mode, and the wrapper validates Claude before injecting the primitive. `@mosaicstack/coord` rewrites direct Claude commands to `mosaic claude` and rejects unknown custom Claude launchers.
`check-runtime-launches.py` is the permanent completeness guard. It scans production shell, TypeScript/JavaScript, Python, and data launch definitions under `packages/`, `apps/`, `plugins/`, and `tools/`; direct literal, absolute-path, process-API, dynamic, command-substitution, `eval`, and variable-execution runtime launches fail. Shell comments are stripped with quote awareness, wrapper prefixes are tokenized with `shlex`, and only an invocation in command position with `--runtime` before the command separator is gated. Literal and tracked-variable command tokens use one terminal resolver after any nesting of `exec`, `command`, `nohup`, or `env` plus assignments. A direct command always wins over an inert marker on the same line. Independently, the raw dangerous primitive anywhere outside the choke-point is RED.
The command parser is a best-effort CI defense, not a complete shell interpreter. Alias/function redefinition, sourced commands, generated scripts, and encoded pipelines are intentionally residual rather than an invitation to chase an unbounded shell language. Two runtime controls backstop that residual surface: primitive ownership rejects a dangerous launch even when command identity is alias-indirected, and Claude's global `.*` `PreToolUse` hook invokes the broker gate for non-dangerous launches. Without `MOSAIC_LEASE_SESSION_ID`, representative read, mutator, and custom/MCP tools all fail closed with `GATE_UNAVAILABLE`. Hook absence or replacement remains in the documented T-C boundary.
### Parser stopping criterion
- **A — realistic parser matrix:** comments, inert strings/assignments, heredocs, continuations, chained commands, command substitution, `eval`, bare tracked variables, and quoted/unquoted tracked variables behind `exec`, `command`, `nohup`, or `env` are permanent RED regressions. Prefix-variable forms are covered in both multiline and same-line assignment shapes.
- **B — residual backstops:** a dangerous alias-indirected launch is RED solely through primitive anchoring; a parser-missed non-dangerous alias launch is paired with an acceptance test proving the global all-tools hook denies every representative tool class as `GATE_UNAVAILABLE` without a lease.
- **C — independent fresh review:** the parser class is considered complete only when reviewers find no new non-overlapping realistic evasion on the exact head. A and B are repository evidence; C is supplied by the fresh review round.
All three layers are load-bearing and complementary. The guard is mandatory in `@mosaicstack/mosaic`'s test script, so root CI fails on a future realistic bypass. Real-socket tests separately prove PRDY init/update and QA receive broker sessions and deny an unverified mutator.
The live inventory is emitted by:
```bash
python3 packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py --root . --json
```
| Production launch family | Gated entries |
| ------------------------------------------------------ | ------------: |
| `@mosaicstack/coord` default/configured Claude command | 2 |
| Fleet runtime start | 1 |
| QA remediation + generated QA command | 2 |
| Orchestrator command construction/session launches | 3 |
| PRDY init/update | 2 |
| Mosaic Claude/Pi/Claudex adapter and wrapper boundary | 4 |
| **Total** | **14 / 14** |
## Assurance boundary
This closes T-A after an observer fires or lease expiry and T-B for in-runtime tool calls. Hook/extension absence, a runtime executing outside the gated launcher, ptrace/same-UID broker replacement, and other fully rotted behavior remain T-C. Server-side branch protection and required PR review/CI remain the irreducible line for protected repository mutations.

View File

@@ -1,290 +0,0 @@
# Design — #791: Framework upgrades must not destroy operator-owned config under `~/.config/mosaic`
- **Issue:** mosaicstack/stack#791
- **Branch:** `feat/791-upgrade-config-protection` (off `origin/main` `9745bc3f`)
- **Author:** ms-791 worker lane
- **Status:** Phase 1 — DESIGN, awaiting MS-LEAD confirmation before implementation
- **Ratified scope (Mos-approved, not re-litigated):** deliver **(b) strict ownership separation [PRIMARY]** + **(a) transactional pre-update snapshot [safety net]** + **(d) regeneration-from-SSOT [recovery]**. **(c) periodic backup timer is DEFERRED** — noted as future work only.
---
## 1. Current updater behavior + exact wipe mechanism (evidence)
### 1.1 What runs on `mosaic update`
`mosaic update` re-seeds the framework by invoking the **bash installer** in sync-only, keep mode:
- `packages/mosaic/src/runtime/update-checker.ts:509` `buildReseedCommand()` returns
`bash <frameworkRoot>/install.sh` with env `MOSAIC_SYNC_ONLY=1`, `MOSAIC_INSTALL_MODE=keep`,
`MOSAIC_HOME=<mosaicHome>`.
- The same `install.sh` is the direct/`tools/install.sh` upgrade path and the framework-vN migration path.
So the destructive surface is **`packages/mosaic/framework/install.sh`**.
### 1.2 The wipe
`sync_framework()` (`install.sh:177`) performs, in `keep` mode:
```
rsync -a --delete --exclude .git --exclude .framework-version --exclude '*.pre-constitution.bak' \
[--exclude "/$path" for each PRESERVE_PATHS entry] SOURCE_DIR/ TARGET_DIR/
```
- `install.sh:199``rsync -a --delete`. **`--delete` prunes every path in `~/.config/mosaic`
that is NOT present in the shipped framework source**, unless excluded.
- `install.sh:47``PRESERVE_PATHS` is the **only** thing standing between `--delete` and operator
data. It is a _denylist of exclusions_:
```
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md"
"memory" "sources" "credentials" "fleet/roster.yaml" "fleet/roster.json" "fleet/agents"
"fleet/run" "fleet/backlog" "fleet/roles.local")
```
- The cp-fallback (no rsync) is equally destructive: `install.sh:223`
`find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ... -exec rm -rf {} +` then re-copies source, restoring
only PRESERVE_PATHS globs.
**Root-cause model:** _"Everything under `~/.config/mosaic` is framework-owned and pruneable UNLESS
explicitly preserved."_ Any operator path the list forgets is destroyed on the next upgrade.
### 1.3 The exact operator paths wiped
Cross-referencing the issue's operator-owned list against `PRESERVE_PATHS`:
| Operator path (issue #791) | In PRESERVE_PATHS? | Fate on `mosaic update` |
| ----------------------------------------------------------------- | --------------------------------------- | ----------------------- |
| `agents/*.conf` (per-agent runtime) | **NO** | **WIPED** |
| `policy/*.md` (operator overlays) | **NO** | **WIPED** |
| `*.local.md` (SOUL/USER/STANDARDS) | **NO** | **WIPED** |
| harvester / SOP artifacts + timers | **NO** | **WIPED** |
| `tools/_lib/credentials.json` | **NO** (`credentials/` dir ≠ this path) | **WIPED** |
| `fleet/agents/*.env` | yes (`fleet/agents`, added by #631) | survives |
| `memory/`, `fleet/roster.*`, `fleet/backlog`, `fleet/roles.local` | yes | survives |
The `fleet/agents`, `memory`, `fleet/backlog` entries were **retro-added after prior incidents**
(#631). This whack-a-mole is the structural signature of a denylist.
**Stale-comment evidence:** `update-checker.ts:492` claims the reseed preserves
"`SOUL/USER/*.local/credentials`" — but `PRESERVE_PATHS` contains **no `*.local` entry**. The code
documents protection it does not deliver.
### 1.4 Second code path (TS) — already non-destructive, but drifted
`FileConfigAdapter.syncFramework()` (`packages/mosaic/src/config/file-adapter.ts:157`) →
`syncDirectory()` (`packages/mosaic/src/platform/file-ops.ts:66`) is a **copy-overlay**: it copies
source over target and skips preserved paths, but **never deletes** target paths absent from source
(`file-ops.ts:77-109`). It is used by the wizard/init flow, not `mosaic update`.
Two problems remain:
1. Its `preservePaths` (`file-adapter.ts:164-185`) has **already diverged** from `install.sh` — it is
**missing `fleet/backlog` and `fleet/roles.local`**. Two hand-maintained denylists, drifted. This
is direct evidence for a single shared SSOT manifest.
2. Even non-destructive, it will happily _overwrite_ an operator file that collides with a
framework-shipped path unless that path is on its (incomplete) preserve list.
### 1.5 Existing snapshot is inadequate for rollback
`make_snapshot()`/`restore_snapshot()` (`install.sh:76-87`) copy `TARGET_DIR` to `mktemp -d` under
`/tmp`, restore **only on `ERR/INT/TERM` trap**, and are **deleted on success** (`cleanup_snapshot`,
`install.sh:345`). Consequences: ephemeral `/tmp`, no retention, no post-success rollback, and **no
`mosaic restore`**. It is crash-safety only, not the transactional safety net #791 requires.
---
## 2. Fix (b) — Strict ownership separation [PRIMARY / root cause]
### 2.1 Ownership model (invert to allow-list)
Replace _"framework-owned unless preserved"_ with _"operator-owned unless framework-owned"_, resolved
**per target path** with operator carve-outs winning inside shared framework subtrees.
Two declared lists, one SSOT data file shipped in the framework
(`framework/framework-manifest.json`), consumed by **both** bash and TS:
- **`framework` globs** — paths the updater is entitled to create / overwrite / prune. Authored to
match exactly what the framework ships in `packages/mosaic/framework/` (e.g. `CONSTITUTION.md`,
`AGENTS.md`, `STANDARDS.md`, `TOOLS.md`, `guides/**`, `constitution/**`, `templates/**`, `tools/**`,
`skills/**`, `mcp/**`, `defaults/**`, `fleet/examples/**`, `fleet/roles/**`, `fleet/profiles/**`,
`fleet/roster.schema.json`).
- **`operatorReserved` globs** — NEVER written or pruned, even nested inside a `framework` subtree;
these **win** over `framework` (deny-wins / most-specific-wins). At minimum:
`agents/**`, `policy/**`, `memory/**`, `sources/**`, `credentials/**`, `*.local.md`,
`tools/_lib/credentials.json`, `fleet/roster.yaml`, `fleet/roster.json`, `fleet/agents/**`,
`fleet/run/**`, `fleet/backlog/**`, `fleet/roles.local/**`, plus operator harvester/SOP artifacts.
### 2.2 Ownership resolution for a target path `P`
1. `P` matches `operatorReserved` → **operator-owned**: updater MUST NOT write, MUST NOT delete.
2. else `P` matches `framework` → **framework-owned**: may overwrite; may prune **only if absent from
the current SOURCE** (a genuinely retired framework file).
3. else (matches neither) → **UNKNOWN ⇒ operator-owned by default (fail-safe)**: never delete.
Rule 3 is the actual root-cause fix: an operator path the manifest authors forget is still protected,
because _unknown defaults to operator_. A denylist can never provide this guarantee.
### 2.3 Sync mechanism change (the mechanically-critical part)
`--delete` cannot express "prune only framework-owned" without re-enumerating every operator path
(the denylist trap). So:
1. **Drop `--delete` from the bulk sync.** Copy `SOURCE → TARGET` non-destructively (writes/overwrites
all framework files; deletes nothing). rsync without `--delete`, or the existing overlay copy.
2. **Explicit manifest-scoped prune pass.** Iterate the **`framework` manifest** (not the whole tree);
for each framework path present in `TARGET` but **absent in `SOURCE`**, delete it — after
re-checking it does not match `operatorReserved`. Because the prune iterates only declared
framework globs, operator/unknown paths are **structurally unreachable** by deletion.
This is implemented in both bash `sync_framework()` and TS `syncFramework()` from the shared manifest.
A pure **prune-planner** function (TS) computes the delete-set from
`(manifest, sourceListing, targetListing)` so the invariant is unit-testable in isolation.
`PRESERVE_PATHS` becomes redundant (kept as a defense-in-depth alias mapping to `operatorReserved`, or
removed) — either way the two lists stop drifting because they read one file.
### 2.4 HARD GATE test — "upgrade touches no path outside the manifest"
Filesystem-observation test in the existing `test-install-migration.sh` harness pattern (mktemp
`MOSAIC_HOME`, `MOSAIC_SYNC_ONLY=1`), plus TS specs:
1. Seed a throwaway `TARGET` with a realistic operator mix — one sentinel per operator class:
`agents/x.conf`, `policy/p.md`, `SOUL.local.md`, `memory/m.md`,
`tools/_lib/credentials.json` (with a secret value), `fleet/agents/a.env`, `fleet/roster.yaml`,
`harvester/sop.md`, **and a deliberately-unanticipated `unknown-operator-dir/x`**.
2. Record hash+mtime of every sentinel.
3. Run the upgrade from a `SOURCE` containing none of those operator paths.
4. **Assert:** every sentinel exists, byte-identical, **mtime unchanged** (not even rewritten). The
`unknown-operator-dir` surviving proves the fail-safe default — a denylist could not pass this case.
5. **Positive controls:** framework files WERE updated; a retired framework file WAS pruned.
6. **Property test** (TS prune-planner): for fuzzed operator paths, `deleteSet ⊆ {matches framework ∧
in target ∧ not in source}` and `deleteSet ∩ operatorReserved = ∅`.
---
## 3. Fix (a) — Transactional pre-update snapshot [safety net]
- **Destination:** `${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-ts>/`.
**Outside `~/.config/mosaic`** (so no future sync can sweep it) and outside any repo.
- **Perms:** dir `0700`, files `0600` — enforced with `umask 077` around the copy **and** explicit
`chmod`. Never world-readable.
- **Scope:** the operator-owned surface (`operatorReserved` paths that exist) — bounded; does not copy
the framework tree.
- **Timing:** taken before ANY mutation in the upgrade flow.
- **Post-sync verify + selective restore:** after sync, diff the operator surface against the snapshot;
since (b) should never touch operator paths, any diff means a manifest bug — restore the affected
paths from the snapshot and warn loudly. This is precisely (a) catching a miss in (b).
- **Retention:** keep N most-recent (default 5; `MOSAIC_BACKUP_RETENTION` override); prune older.
- **`mosaic restore`:** `--list` (default, dry-run) enumerates snapshots by timestamp;
`--from <ts>` restores that snapshot over the operator surface, confirmation-gated. Reports
counts/paths only.
- **Secret-safety:** snapshot copy and restore never emit file **contents**; only paths/counts.
Tests assert `0700/0600` and that no secret value appears in stdout/stderr.
---
## 4. Fix (d) — Regeneration-from-SSOT [recovery]
The incident's live blast radius: `fleet/agents/*.env` (systemd `EnvironmentFile` sources) gone →
`mosaic-agent@<name>` boots **unit defaults** on restart (because `EnvironmentFile=-...` is
absent-tolerant) → **silent identity/runtime/workdir downgrade**.
The SSOT for those `.env` files is the roster. The reconciler **already** separates a
`regenerate-projections-from-roster` projection phase from lifecycle
(`packages/mosaic/src/fleet/fleet-reconciler.ts:93,234`; env rendering in
`generated-env-boundary.ts:149-264`).
**`mosaic fleet regen`** is therefore a **thin recovery-framed wrapper over the existing projection
phase** — it does NOT reimplement fleet logic and does NOT preempt in-flight FCM cards (M4/M5):
- Regenerates derivable config (per-agent `*.env.generated`, unit files) from roster SSOT.
- **Preview-first:** dry-run default; `--write` to apply. Idempotent.
- **Never restarts agents** (the recovery order forbids restart-before-verify).
- Prints the runbook's next step (verify `EnvironmentFile` resolves, THEN restart).
Alternatively documentable as `install.sh --relink` per the issue; `mosaic fleet regen` is preferred
because it reuses the merged reconciler plumbing.
---
## 5. Secret-safety approach (secrev surface)
- Snapshots/backups: `0700`/`0600`, outside any repo, never world-readable. (§3)
- No secret **value** ever emitted to logs/stdout/stderr by snapshot, restore, sync, or regen —
paths/counts only. Adversarial test: a secret value placed in `tools/_lib/credentials.json` must
never appear in installer or command output.
- `tools/_lib/credentials.json` is an explicit `operatorReserved` carve-out inside the framework-owned
`tools/**` subtree — it is never overwritten or pruned.
- The HARD GATE test doubles as a secret-safety test (asserts the credentials sentinel is untouched).
---
## 6. Test plan (TDD, tests-first, ≥85% on new code, co-located `*.spec.ts`)
1. **Manifest SSOT parity** — bash and TS resolve identical framework/operator sets from the one file;
a test fails if either path hard-codes a divergent list.
2. **Manifest completeness** — every path shipped in `framework/` is covered by a `framework` glob (so
a new shipped file cannot silently fall outside the manifest and become un-prunable/undeclared).
3. **HARD GATE** — upgrade touches nothing outside the manifest, incl. the unanticipated-path case
(§2.4).
4. **Prune-planner** unit + property tests (§2.4.6).
5. **Snapshot** — perms `0700/0600`, correct destination, retention prune, secret value absent from
output.
6. **Restore** — `--list` / `--from` round-trip restores operator surface byte-exact; confirmation
gate; no secret leakage.
7. **Regen** — roster→env projection deterministic + idempotent; dry-run makes no writes; `--write`
restores `*.env`; **never** issues a lifecycle/restart call.
8. **Cross-path regression** — TS `syncFramework` and bash `install.sh` agree on a shared fixture
(closes the current #631-style drift).
Gates before every push: `pnpm typecheck && pnpm lint && pnpm format:check` + mosaic package tests
green. Never `--no-verify`.
---
## 7. web1 recovery runbook (operator-agnostic; web1 specifics live in the issue as evidence only)
For a currently-wiped fleet EnvironmentFile state — **do NOT service-restart while
`fleet/agents/*.env` is absent** (a restart boots unit defaults and silently downgrades identity):
1. **Regenerate:** `mosaic fleet regen --write` — rebuild `~/.config/mosaic/fleet/agents/*.env` from
roster SSOT.
2. **Verify each unit resolves to the intended runtime/workdir** _before_ any restart:
`systemctl --user show mosaic-agent@<name> -p EnvironmentFile` and confirm the generated env exists
and carries the intended `MOSAIC_AGENT_*` runtime/workdir values.
3. **Only then** `systemctl --user restart mosaic-agent@<name>`, one unit at a time.
If config (not just fleet env) was lost, `mosaic restore --list` → `mosaic restore --from <ts>` before
step 1.
---
## 8. Proposed PR split (reviewable; DAG-ordered)
| PR | Scope | Depends | Review focus |
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -------------------------- |
| PR1 | **PRIMARY** — shared `framework-manifest.json` + ownership resolver + non-deleting sync + scoped prune (bash + TS) + **HARD GATE** + prune-planner tests | — | correctness (root fix) |
| PR2 | **Safety net** — pre-update snapshot (`~/.local/state`, 0700/0600, retention) + post-sync verify/restore + `mosaic restore` | PR1 | **secrev** (backup/secret) |
| PR3 | **Recovery** — `mosaic fleet regen` (projection-only, preview-first, no restart) + docs (upgrade-safety + recovery runbook) | PR1 | correctness + docs |
Rationale: PR1 closes the failure class on its own; if PR2/PR3 slip, the class stays fixed. Each PR is
one reviewable unit with its own tests ≥85%. Independent review (author≠reviewer) on all; **secrev** on
PR2 (and PR1's secret-sentinel assertions).
## 9. Deferred (noted per scope)
**(c) periodic backup timer** — a systemd user timer snapshotting operator dirs on a cadence
(defense-in-depth for non-upgrade losses). Explicitly **out of scope now**; future phase.
## 10. Constraints honored
- **Framework-PR firewall:** manifest + logic are operator-agnostic; no SOUL/USER/operator specifics
in framework code; web1 details are issue evidence only.
- **Capacity-fill:** must not preempt M5-001 or #790; `fleet regen` reuses merged FCM-M3 plumbing and
does not overlap FCM-M4/M5 migration cards.
- **Delivery gates:** TDD tests-first, ≥85% new-code coverage, trunk-based squash PRs, independent
review + secrev, completion = merged PR + descendant-main green + #791 closed.
---
**Requesting MS-LEAD confirmation of:** (1) the manifest allow-list + non-deleting-sync + scoped-prune
approach as the (b) root-cause fix; (2) snapshot destination/retention + `mosaic restore` UX;
(3) `mosaic fleet regen` as a projection-only wrapper; (4) the 3-PR split. Implementation begins only
on your confirmation.

View File

@@ -5,28 +5,17 @@
This checklist is an acceptance contract for documentation and examples. It does not authorize
schema, runtime, systemd, role, profile, or live-fleet changes. An item is complete only when its
named artifact exists, is linked from the fleet documentation entry point, and its evidence is
recorded in the M0 task/PR.
recorded in the M5 closure report and linked deferral evidence.
## M0 baseline acceptance
- [ ] `docs/PRD.md` states the roster as desired-state SSOT; generated environment, systemd,
tmux, and heartbeat artifacts as non-authoritative projections; and fail-closed handling of
unsupported or quarantined legacy input.
- [ ] `docs/PRD.md` defines the required classes and authority boundary: `validator` certifies but
does not merge; `merge-gate` remains sole approve-to-land/merge authority; `team-leader`
capacity is lease-bounded; `interaction` is request/status only; instance names such as Tess
and Ultron remain configurable.
- [ ] `docs/PRD.md` defines local lifecycle semantics for `enabled`, persisted desired state, and
observed state, including stopped-state preservation through migration, apply, and reboot.
- [ ] `docs/PRD.md` defines the generated-env/local-override boundary, explicitly denies arbitrary
command overrides in M1M5, and requires key-name/hash-only quarantine diagnostics.
- [ ] `docs/PRD.md` identifies the M1M5 local-tmux scope and excludes remote reconciliation,
connector mutation, secret references, arbitrary commands/channels, gateway convergence, and
UI configuration storage.
- [ ] `docs/TASKS.md` contains the complete M0M5 one-card/one-PR dependency DAG for #758 with
agent tier, branch, dependency, estimate, and evidence expectations.
- [ ] `docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` classifies every current shipped
fleet example, profile, and service preset before M1 implementation starts.
- [x] `docs/PRD.md` states the roster as desired-state SSOT; generated environment, systemd, tmux, and heartbeat artifacts as non-authoritative projections; and fail-closed handling of unsupported or quarantined legacy input.
- [x] `docs/PRD.md` defines the required classes and authority boundary: `validator` certifies but does not merge; `merge-gate` remains sole approve-to-land/merge authority; `team-leader` capacity is lease-bounded; `interaction` is request/status only; instance names such as Tess and Ultron remain configurable.
- [x] `docs/PRD.md` defines local lifecycle semantics for `enabled`, persisted desired state, and observed state, including stopped-state preservation through migration, apply, and reboot.
- [x] `docs/PRD.md` defines the generated-env/local-override boundary, explicitly denies arbitrary command overrides in M1M5, and requires key-name/hash-only quarantine diagnostics.
- [x] `docs/PRD.md` identifies the M1M5 local-tmux scope and excludes remote reconciliation, connector mutation, secret references, arbitrary commands/channels, gateway convergence, and UI configuration storage.
- [x] `docs/TASKS.md` contains the complete M0M5 one-card/one-PR dependency DAG for #758 with agent tier, branch, dependency, estimate, and evidence expectations.
- [x] `docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` classifies every current shipped fleet example, profile, and service preset before M1 implementation starts.
## Required documentation IA for M1M5
@@ -72,15 +61,18 @@ recorded in the M0 task/PR.
## Cross-cutting evidence gates
- [ ] Every retained or migrated YAML/JSON example, profile, and service preset validates through the
same executable schema and shared baseline-plus-`roles.local` resolver used by the CLI.
- [ ] Every retired example/profile/service preset has a replacement link and deprecation note; no
unresolved legacy class or tool-policy alias remains silently shipped.
- [ ] Documentation examples contain no secret values, arbitrary command override, or product-hardcoded
Tess/Ultron identity.
- [ ] CLI snippets distinguish local fleet desired-state commands from the separate gateway-backed
`mosaic agent` catalog.
- [ ] Migration, quarantine, lifecycle, status, and troubleshooting documentation state that values of
legacy sensitive keys are never printed.
- [ ] M5 release review verifies links, schema/example validation, and that all checklist rows have
owner/evidence or an explicit approved deferral.
- [x] Every retained or migrated YAML/JSON example, profile, and service preset validates through the same declared executable production parser/resolver path recorded by the disposition inventory; versioned v1 fixtures are not forced through the v2 compiler.
- [x] Every retired example/profile/service preset has a replacement link and deprecation note; no unresolved legacy class or tool-policy alias remains silently shipped.
- [x] Documentation examples contain no secret values, arbitrary command override, or product-hardcoded Tess/Ultron identity.
- [x] CLI snippets distinguish local fleet desired-state commands from the separate gateway-backed mosaic agent catalog.
- [x] Migration, quarantine, lifecycle, status, and troubleshooting documentation state that values of legacy sensitive keys are never printed.
- [x] M5 documentation validation verifies required IA paths, local file and heading-fragment links, the canonical roster through the production compiler/resolver, and fenced/canonical-example safety checks.
- [ ] FCM-M5-001 does not deterministically assert owner/evidence/deferral metadata for every checklist row. Closure and deferral reports provide human-reviewable evidence only; broader assertion coverage remains unclaimed.
## Held downstream gates
These unchecked items are intentionally outside FCM-M5-001 and are not authorized by this checklist:
- [ ] FCM-M4-002 executes and evidences live cutover, canary, stopped-state preservation, and rollback.
- [ ] FCM-M5-002 completes independent exact-head review and issues the validator certificate.
- [ ] The exact PR head reaches terminal-green CI after independent review.

View File

@@ -8,11 +8,11 @@ Generated environment files are rebuildable projections, not an operator-editabl
| Layer | Responsibility |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
| Projection writer | Renders deterministic `fleet/agents/<name>.env.generated` from the roster. |
| Optional local data | Reads a strict, data-only `fleet/agents/<name>.env.local`; it cannot shadow generated keys. |
| systemd | Starts the launcher with `env -i` and fixed bootstrap data. It does not preload either environment file. |
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
| runtime launch | Derives the fixed `mosaic yolo <runtime>` argument array from validated roster data, then seeds the runtime contract. |
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
@@ -20,7 +20,7 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
## Generated and local files
`<name>.env.generated` is complete, deterministic, and written only by Mosaic. Its ordered keys are:
<name>.env.generated is complete, deterministic, and written only by Mosaic. Its ordered keys are:
```dotenv
MOSAIC_AGENT_NAME=<roster name>
@@ -33,12 +33,12 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
MOSAIC_TMUX_SOCKET=<roster socket or empty>
```
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. `mosaic fleet add`
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
it has no generated-launch adapter and cannot be added through this path.
`<name>.env.local` is optional and may contain only non-secret machine data:
<name>.env.local is optional and may contain only non-secret machine data:
- `MOSAIC_RUNTIME_BIN`
- `MOSAIC_HEARTBEAT_RUN_DIR`
@@ -52,9 +52,9 @@ private, non-symlink paths. Violations fail closed before tmux interaction.
## Legacy input and diagnostics
A legacy `<name>.env` is input only during projection generation. Roster-owned keys are regenerated;
A legacy <name>.env is input only during projection generation. Roster-owned keys are regenerated;
valid allowed local data can move to `.env.local`; invalid legacy input is privately retained at
`<name>.env.quarantine`. Neither legacy nor quarantine files are launch authority.
<name>.env.quarantine. Neither legacy nor quarantine files are launch authority.
Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command
text, credentials, or other values.
@@ -62,11 +62,11 @@ text, credentials, or other values.
## Launch and stop behavior
The launcher obtains the agent's socket only from the validated generated projection. It creates or
checks the exact `=<agent-name>` tmux target; it never uses an ambient socket or fuzzy session match.
checks the exact =<agent-name> tmux target; it never uses an ambient socket or fuzzy session match.
The same strict parser runs before exact-stop behavior. A fresh native Pi heartbeat remains authoritative;
the shell sidecar only provides fallback state when the native marker is stale or absent.
`mosaic agent comms-block <exact-member>` can inspect that exact roster member's resolved Fleet-Comms
mosaic agent comms-block <exact-member> can inspect that exact roster member's resolved Fleet-Comms
block. It is a read-only inspection tool and fails loudly for an unknown exact member or missing roster.
On Linux, the installed roster, TOOLS contract, and executable helper are opened through a held
descriptor chain rooted at `/`; every managed path component uses no-follow traversal, and content plus

View File

@@ -14,7 +14,7 @@ parallel resolver. The current executable implementation and per-artifact outcom
| Shipped file | Current class evidence | M0 disposition decision | Required M1/M4 evidence |
| ---------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `framework/fleet/examples/coding.yaml` | `orchestrator`, `enhancer`, `implementer`, `reviewer` | Migrate: `implementer → code`, `reviewer → review`; retain orchestration/enhancer intent | v2 fixture validates; role aliases and authority matrix tested |
| `framework/fleet/examples/coding.yaml` | `orchestrator`, `enhancer`, `implementer`, `reviewer` | Migrate: implementer → code, reviewer → review; retain orchestration/enhancer intent | v2 fixture validates; role aliases and authority matrix tested |
| `framework/fleet/examples/general.yaml` | `orchestrator`, `enhancer`, `worker` | Migrate only after operator chooses a concrete canonical role for `worker`; no implicit conversion | Explicit replacement class, or versioned v1 fixture/retirement note |
| `framework/fleet/examples/hybrid.yaml` | `orchestrator`, `enhancer`, `implementer`, `researcher`, `reviewer` | Migrate aliases; resolve `researcher` through existing role resolver or retain/version | Shared resolver validation; no ad-hoc class scanner |
| `framework/fleet/examples/local-canary.yaml` | `orchestrator`, `implementer`, `reviewer` | Migrate aliases; preserve its local-tmux canary purpose | v2 fixture validates and preserves safe stopped/running behavior |
@@ -35,13 +35,13 @@ parallel resolver. The current executable implementation and per-artifact outcom
## Service presets
| Shipped file | Current policy evidence | M0 disposition decision | Required M1/M4 evidence |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `framework/fleet/services/operator-interaction.yaml` | Generic policy only: `runtime: pi`, `model: openai/gpt-5.6-sol`, `reasoning: high`, `tool_policy: operator-interaction`; provisioning supplies the agent name as data | Retain as a generic service policy, not a Tess identity. Migrate `tool_policy: operator-interaction` only through the approved interaction tool-policy alias/semantic resolver; do not infer a class or machine name from this file. | Service-policy fixture validates runtime/model/reasoning and alias behavior; generic provisioning proves a configured interaction instance is supplied without a hardcoded Tess name. |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `framework/fleet/services/operator-interaction.yaml` | Generic policy only: runtime: pi, model: openai/gpt-5.6-sol, reasoning: high, tool_policy: operator-interaction; provisioning supplies the agent name as data | Retain as a generic service policy, not a Tess identity. Migrate tool_policy: operator-interaction only through the approved interaction tool-policy alias/semantic resolver; do not infer a class or machine name from this file. | Service-policy fixture validates runtime/model/reasoning and alias behavior; generic provisioning proves a configured interaction instance is supplied without a hardcoded Tess name. |
## Required disposition controls
1. **No silent aliasing:** only `implementer → code`, `reviewer → review`, and
`operator-interaction → interaction` are approved deterministic aliases in this M0 baseline.
1. **No silent aliasing:** only implementer → code, reviewer → review, and
operator-interaction → interaction are approved deterministic aliases in this M0 baseline.
`worker`, `analyst`, `canary`, and domain-specific classes require resolver evidence or an
explicit version/retirement decision.
2. **No identity hardcoding:** Tess and Ultron are optional instance/display names. An example/profile

View File

@@ -33,7 +33,7 @@ The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's
- **AC-NS-4** — TTL is enforced on claims; token caps remain advisory until a real meter exists.
- **AC-NS-5** — Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
- **AC-NS-6** — A user can declare a system type and the fleet provisions the matching persona roster + topology from the baseline library, with no code change.
- **AC-NS-7** — A user-customized persona (edited or added via the orchestrator) survives `mosaic update`: baseline reseed never clobbers user overrides.
- **AC-NS-7** — A user-customized persona (edited or added via the orchestrator) survives mosaic update: baseline reseed never clobbers user overrides.
## Workstreams

View File

@@ -97,7 +97,7 @@ success_criteria:
- id: AC-NS-7
text: >-
A user-customized persona (edited or added via the orchestrator) survives
`mosaic update`: baseline reseed never clobbers user overrides.
mosaic update: baseline reseed never clobbers user overrides.
workstreams:
- id: A

View File

@@ -8,7 +8,7 @@
## Mission
Turn the proven fleet primitives into a **user-installable, AI-free-configurable fleet product**:
a user runs `mosaic fleet init`, answers a few questions (general / coding / research / hybrid),
a user runs mosaic fleet init, answers a few questions (general / coding / research / hybrid),
gets a recommended set of agents plus one always-on orchestrator wired for chat-ops, and can
operate, mutate, re-create, and observe the fleet — over tmux today and Matrix tomorrow — from
CLI/TUI and (designed-for) the webUI.
@@ -22,9 +22,9 @@ functional, we use the fleet itself to continue the work.
### A. Configure-without-AI CLI
| ID | Requirement |
| --- | ------------------------------------------------------------------------------------------------------------- |
| R1 | `mosaic fleet` command set is functional end-to-end (init/install/start/stop/status/ps/verify + agent verbs). |
| R2 | `mosaic fleet init` is an interactive, **AI-free** CLI wizard. |
| --- | ----------------------------------------------------------------------------------------------------------- |
| R1 | mosaic fleet command set is functional end-to-end (init/install/start/stop/status/ps/verify + agent verbs). |
| R2 | mosaic fleet init is an interactive, **AI-free** CLI wizard. |
| R3 | Init asks the **configuration type**: `general`, `coding`, `research`, `hybrid`, … (extensible). |
| R4 | Based on the answer, the fleet is populated with a **recommended set of agents** (a preset). |
| R5 | **Exactly one main orchestrator agent** is always configured, regardless of type. |
@@ -35,11 +35,11 @@ functional, we use the fleet itself to continue the work.
### B. Comms & orchestrator chat-ops
| ID | Requirement |
| --- | --------------------------------------------------------------------------------------------------------------------------------- |
| --- | ----------------------------------------------------------------------------------------------------------------------------- |
| R6 | Init can wire the orchestrator to a chat connector — **Telegram / Discord / Matrix / Slack** — for command + comms. |
| R7 | Designed with the end-goal of **Matrix comms on a locally-controlled server**. |
| R16 | Fleet supports **tmux AND Matrix** comms, **user-configurable** at init or any time. Not all users want Matrix. |
| R19 | **"Mos" orchestrator on Discord** (`chan 1517622518662434996` / `srv 1112631390438166618`) on `w-jarvis` — the first live target. |
| R19 | **"Mos" orchestrator on Discord** (chan 1517622518662434996 / srv 1112631390438166618) on `w-jarvis` — the first live target. |
### C. Runtime, health, lifecycle
@@ -64,15 +64,15 @@ functional, we use the fleet itself to continue the work.
- **Orchestrator agent:** always present; carries the chat connector config (connector type + target IDs) so it can be commanded over chat. tmux is the substrate; the connector bridges chat ↔ the orchestrator session.
- **Comms layers (R16):** (1) **tmux** inter-agent (`agent-send`, proven) — default, always available. (2) **chat connector** for human↔orchestrator (Discord now; Matrix the strategic target). (3) **Matrix** as the locally-controlled cross-agent bus (future). Connector is pluggable + reconfigurable.
- **Heartbeat (R15):** runtime-agnostic launcher sidecar already covers pi/claude/codex (#584). Refine per-runtime (native HB) with the **custom Pi harness** (R14) + a Claude path.
- **Updates (R13):** `mosaic update` (CLI) + a fleet-aware harness-update step that refreshes pi/claude/codex and re-launches agents safely (drain → update → relaunch via the durable launcher).
- **webUI (R18):** the fleet exposes machine-readable state (`fleet ps --json` already carries tenant/host/heartbeat/managed) + control verbs (start/stop/watch/send); webUI consumes these (control plane rides federation per north star). Ensure a stable JSON contract + a terminate/attach(butt-in) path.
- **Updates (R13):** mosaic update (CLI) + a fleet-aware harness-update step that refreshes pi/claude/codex and re-launches agents safely (drain → update → relaunch via the durable launcher).
- **webUI (R18):** the fleet exposes machine-readable state (fleet ps --json already carries tenant/host/heartbeat/managed) + control verbs (start/stop/watch/send); webUI consumes these (control plane rides federation per north star). Ensure a stable JSON contract + a terminate/attach(butt-in) path.
## Phases (incremental, each shippable)
| Phase | Deliverable | Notes |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| **F1 Presets + init wizard** | preset rosters (general/coding/research/hybrid) + always-orchestrator + AI-free `fleet init` selecting a preset; re-init idempotent | R1R5, R8, R10, R17 |
| **F2 Connector + Mos-on-Discord** | orchestrator chat-connector config (Discord first) + **Mos live on Discord `1517…`/`1112…`** on w-jarvis | R6, R19, partial R16 |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| **F1 Presets + init wizard** | preset rosters (general/coding/research/hybrid) + always-orchestrator + AI-free fleet init selecting a preset; re-init idempotent | R1R5, R8, R10, R17 |
| **F2 Connector + Mos-on-Discord** | orchestrator chat-connector config (Discord first) + **Mos live on Discord 1517…/1112…** on w-jarvis | R6, R19, partial R16 |
| **F3 Heartbeat + harness** | HB confirmed for claude + pi/gpt; **custom Pi harness** (tool usage, native HB, model self-report); graceful harness updates | R13, R14, R15 |
| **F4 Matrix + comms toggle** | Matrix connector (local server) + user toggle tmux/Matrix at init/anytime | R7, R16 |
| **F5 Orchestrator-mutable fleet** | orchestrator can add/remove agents at runtime | R9 |
@@ -82,28 +82,28 @@ functional, we use the fleet itself to continue the work.
## Work division (proposed — confirm with dragon-lin)
- **Jarvis @ w-jarvis (Lead):** F1 presets+wizard, F2 connector+Mos-on-Discord, F5 mutability, F6 webUI hooks; merge authority + dual-engine reviews; co-testing on w-jarvis.
- **coder @ dragon-lin:** F3 custom Pi harness + harness-update flow (pi/codex-savvy); plus its in-flight constitution P4P6 (P4 installer rework underpins `fleet init`/updates — coordinate the install path). Co-testing on dragon-lin (R11).
- **coder @ dragon-lin:** F3 custom Pi harness + harness-update flow (pi/codex-savvy); plus its in-flight constitution P4P6 (P4 installer rework underpins fleet init/updates — coordinate the install path). Co-testing on dragon-lin (R11).
- **Shared:** F4 Matrix (whoever has bandwidth); F7 testing/docs continuous.
## Immediate target: Mos on Discord (F2 first slice)
The discord plugin is available (`~/.claude.json`). Path: configure the **orchestrator** as a durable
The discord plugin is available (~/.claude.json). Path: configure the **orchestrator** as a durable
fleet session running Claude Code with the discord plugin bridged to channel `1517622518662434996`
(server `1112631390438166618`) on w-jarvis, with the existing Discord Bridge Protocol (ack within
~3s, reply via `mcp__discord__reply`, no `AskUserQuestion`). Heartbeat via the launcher sidecar.
## Success criteria
- A non-AI user can `mosaic fleet init`, pick a type, and get a working fleet + orchestrator.
- **Mos answers in Discord `1517…`** on w-jarvis.
- Fleet runs + is observable (`fleet ps`) on **both** w-jarvis and dragon-lin.
- A non-AI user can mosaic fleet init, pick a type, and get a working fleet + orchestrator.
- **Mos answers in Discord 1517…** on w-jarvis.
- Fleet runs + is observable (fleet ps) on **both** w-jarvis and dragon-lin.
- Harness updates handled gracefully; HB healthy for claude + pi/gpt agents.
- Docs let a new operator install/configure/use the fleet.
- Re-init + orchestrator mutation work.
## Assumptions (veto-able)
- `ASSUMPTION:` presets ship as example rosters under the framework (`fleet/examples/*.yaml`), selected by `init`.
- `ASSUMPTION:` presets ship as example rosters under the framework (fleet/examples/\*.yaml), selected by `init`.
- `ASSUMPTION:` chat connectors are pluggable; Discord first (target exists), Matrix is the strategic default later.
- `ASSUMPTION:` "Mos" = a Claude Code orchestrator session with the discord plugin (reuses the documented Discord Bridge Protocol).
- `ASSUMPTION:` per north star, runtimes default to Codex/pi-on-Codex for workers; the orchestrator "Mos" runs Claude Code (in Claude Code, which is allowed).

View File

@@ -10,8 +10,8 @@
The durable tmux fleet runs on the isolated `mosaic-fleet` socket. That isolation
(which protects the operator's default tmux) makes the fleet **invisible** to default
tooling, and truth is split across three planes no single command joins — systemd
(`systemctl --user`), tmux (`-L mosaic-fleet`), and the process tree (`pstree`).
`agent tail` (`capture-pane`) returns **blank for full-screen TUIs**, and `agent send`
(systemctl --user), tmux (-L mosaic-fleet), and the process tree (`pstree`).
agent tail (`capture-pane`) returns **blank for full-screen TUIs**, and agent send
confirms only keystroke injection, not acceptance. Net: the operator has near-zero
observability and no safe way to watch a session.
@@ -33,21 +33,21 @@ observability and no safe way to watch a session.
## Functional requirements
| ID | Requirement |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR-1 | `mosaic fleet ps [--json]` prints one row per roster agent joining: name · tenant · host · runtime · systemd(active/enabled) · pane(alive/dead) · pid · idle · **last-heartbeat age** · **drift** flag (roster runtime ≠ actual pane command) · **boot-enable** warning (active but `UnitFileState=disabled`). |
| FR-2 | **Heartbeat protocol v1** (see below); `dogfood-agent.py` implements the responder. `fleet ps` issues probes (or reads last-seen) and reports health per FR-1. |
| FR-3 | `mosaic agent watch <name>` opens a **read-only** view of the pane (grouped session or `tmux attach -r`) that cannot send keystrokes and does not shrink the agent's window. |
| FR-4 | `mosaic agent attach <name>` remains the **explicit** interactive-takeover path (separate verb, documented as the only one that can type). |
| FR-5 | `mosaic agent send <name> --verify` confirms the message was **accepted** (not left as an unsubmitted draft) and returns non-zero if delivery cannot be verified. |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| FR-1 | mosaic fleet ps [--json] prints one row per roster agent joining: name · tenant · host · runtime · systemd(active/enabled) · pane(alive/dead) · pid · idle · **last-heartbeat age** · **drift** flag (roster runtime ≠ actual pane command) · **boot-enable** warning (active but `UnitFileState=disabled`). |
| FR-2 | **Heartbeat protocol v1** (see below); `dogfood-agent.py` implements the responder. fleet ps issues probes (or reads last-seen) and reports health per FR-1. |
| FR-3 | mosaic agent watch <name> opens a **read-only** view of the pane (grouped session or tmux attach -r) that cannot send keystrokes and does not shrink the agent's window. |
| FR-4 | mosaic agent attach <name> remains the **explicit** interactive-takeover path (separate verb, documented as the only one that can type). |
| FR-5 | mosaic agent send <name> --verify confirms the message was **accepted** (not left as an unsubmitted draft) and returns non-zero if delivery cannot be verified. |
| FR-6 | All structured output (`--json`) includes `tenant_id` and `host` fields. |
## Heartbeat protocol v1
- **Probe:** operator/`fleet ps` writes a sentinel line to the agent's input or a
well-known per-agent heartbeat file path `~/.config/mosaic/fleet/run/<agent>.hb`.
- **Response:** the runtime updates `<agent>.hb` with `ts=<iso8601> pid=<pid> status=<ok|busy>`
- **Probe:** operator/fleet ps writes a sentinel line to the agent's input or a
well-known per-agent heartbeat file path ~/.config/mosaic/fleet/run/<agent>.hb.
- **Response:** the runtime updates <agent>.hb with ts=<iso8601> pid=<pid> status=<ok|busy>
on a fixed interval (default 15s) and on demand when probed.
- **Health rule:** `healthy` if `now - ts <= 3 × interval`; else `stale`; missing file = `unknown`.
- **Health rule:** `healthy` if now - ts <= 3 × interval; else `stale`; missing file = `unknown`.
- **Contract:** every runtime (dogfood stub now; claude/codex/pi/opencode in Phase 3)
MUST emit the heartbeat. The protocol is file-based so it works for headless stubs and
full-screen TUIs alike (no `capture-pane` dependency).
@@ -56,15 +56,15 @@ observability and no safe way to watch a session.
## Acceptance criteria
- `mosaic fleet ps` shows all 5 live sessions on `mosaic-fleet` with correct
- mosaic fleet ps shows all 5 live sessions on `mosaic-fleet` with correct
pane/pid/idle and flags the dogfood **drift** (`canary-pi` runtime=pi but pane runs
`dogfood-agent.py`) and the **boot-enable** gap (active but disabled).
- Killing one agent's pane flips its row to dead/stale within one `interval`.
- `agent watch` shows live output and provably cannot type into the pane; detaching
- agent watch shows live output and provably cannot type into the pane; detaching
leaves the agent's window size unchanged.
- `agent send --verify` returns success on an accepting pane and non-zero on a wedged/draft pane.
- Quality gates green: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, plus
`pnpm --filter @mosaicstack/mosaic test`.
- agent send --verify returns success on an accepting pane and non-zero on a wedged/draft pane.
- Quality gates green: pnpm typecheck, pnpm lint, pnpm format:check, plus
pnpm --filter @mosaicstack/mosaic test.
- Independent review passed; dogfood evidence captured against the live fleet.
## Test plan
@@ -72,18 +72,18 @@ observability and no safe way to watch a session.
- Unit/CLI specs in `packages/mosaic/src/commands/fleet.spec.ts` (and a new
`fleet-ps`/`watch`/`send-verify` spec) using the injected `CommandRunner` to assert
exact tmux/systemd command construction and JSON shape (tenant+host present).
- Situational: run against the live `mosaic-fleet` fleet; capture `fleet ps` output,
a kill-and-detect cycle, a read-only `watch`, and a `send --verify` pass/fail pair.
- Situational: run against the live `mosaic-fleet` fleet; capture fleet ps output,
a kill-and-detect cycle, a read-only `watch`, and a send --verify pass/fail pair.
## Known limitations
- **Verify heuristic is best-effort:** `agent send --verify` uses a `>` -prefix draft
- **Verify heuristic is best-effort:** agent send --verify uses a > -prefix draft
heuristic that is specific to pi/claude TUIs. Draft detection for codex and opencode
TUIs is best-effort only; those runtimes may not use the same input-line indicator.
- **Pane-change check is the best Phase-2 signal; verify now polls up to a bounded
timeout:** `agent send --verify` captures a BEFORE snapshot, sends the message, then
timeout:** agent send --verify captures a BEFORE snapshot, sends the message, then
polls `capture-pane` every ~400 ms up to a configurable total timeout (default ~6 s,
controlled by `--verify-timeout <ms>`). On each poll it runs classifySendResult: if
controlled by --verify-timeout <ms>). On each poll it runs classifySendResult: if
the pane shows 'accepted' or 'draft' the loop exits immediately; while the result is
'unverifiable' (no pane change yet) it keeps polling. After the timeout with no
definitive result, it fails closed: exit 1 with "no pane change after send". This
@@ -92,15 +92,15 @@ observability and no safe way to watch a session.
requires a runtime acknowledgement (Phase-3 heartbeat-ack); the bounded pane-change
poll is the best signal available against an opaque TUI for Phase-2.
- **Blank AFTER capture fails closed:** Full-screen TUIs (claude, codex, opencode, pi)
render blank for `tmux capture-pane`. When the AFTER snapshot is empty, `send --verify`
render blank for tmux capture-pane. When the AFTER snapshot is empty, send --verify
returns non-zero with an "unverifiable" message rather than silently succeeding. This
is an intentional fail-closed design (FR-5).
- **`agent watch` uses a grouped viewer session:** `tmux attach -r` directly against the
agent session lets the viewer terminal shrink the agent's window. `agent watch` instead
creates a throwaway grouped session (`tmux new-session -d -t '=<agent>' -s
'<agent>-watch-<pid>'`), attaches read-only to that session, and kills it on detach.
- **agent watch uses a grouped viewer session:** tmux attach -r directly against the
agent session lets the viewer terminal shrink the agent's window. agent watch instead
creates a throwaway grouped session (tmux new-session -d -t '=<agent>' -s
'<agent>-watch-<pid>'), attaches read-only to that session, and kills it on detach.
The grouped session shares the agent's windows but has independent sizing, so the
agent's window is never affected. `tmux attach` is still interactive and requires
agent's window is never affected. tmux attach is still interactive and requires
inherited stdio; the `interactiveRunner` handles TTY passthrough.
## Surfaces & parity (MVP-X1)

63
docs/fleet/README.md Normal file
View File

@@ -0,0 +1,63 @@
# Fleet Configuration Management
This book documents the local roster-v2 desired-state control plane delivered under issue #758. The normative requirements are the [FCM section of the repository PRD](../PRD.md#fleet-declarative-configuration-management-workstream-fcm-758), not the older fleet-suite or observability planning pages.
## Authority boundary
<MOSAIC_HOME>/fleet/roster.yaml is the sole writable desired-state authority for local fleet membership, launch policy, and persisted lifecycle. Generated environment files, systemd enablement, tmux sessions, heartbeat files, and status output are derived or observed. Rebuild projections from the roster; never edit them as desired state.
This control plane is local tmux/systemd only. Remote/SSH entries and connectors are inventory, not reconciliation targets. Arbitrary commands, channels, secret references, gateway catalog convergence, and UI configuration storage are outside this workstream. `mos-comms` is temporary transport glue, not permanent fleet architecture.
## Choose the right workflow
1. **Need to inspect intent?** Read the roster and use mosaic fleet get; see [desired versus observed state](concepts/desired-vs-observed-state.md).
2. **Need to inspect reality?** Use `status` or `doctor`; use `verify` for a strict non-zero drift/ownership gate. These commands do not repair anything.
3. **Need to change membership or persisted policy?** Use generation-guarded `plan`, `create`, `update`, or `delete`; see [safe CRUD](how-to/create-update-delete-agent.md).
4. **Need a one-time runtime action?** Use `start`, `stop`, or `restart`. These do not change persisted desired state.
5. **Need convergence?** Review apply --dry-run, resolve blockers, then use `apply` with the same current generation; see [reconcile and recover](operations/reconcile-and-recover.md).
6. **Need v1 migration evidence?** Use preview only. Cutover, canary, and rollback remain held for FCM-M4-002.
7. **Need the gateway-backed agent catalog?** That is the separate mosaic agent surface, not local fleet desired state.
## Concepts
- [Desired versus observed state](concepts/desired-vs-observed-state.md)
- [Identity, class, runtime, provider, and model](concepts/identity-class-runtime.md)
- [Role authority and leases](concepts/role-authority-and-leases.md)
- [Generated environment launch chain](concepts/generated-env-launch-chain.md)
## Operator how-to
- [Create, inspect, update, and delete](how-to/create-update-delete-agent.md)
- [Start, stop, restart, and reconcile](how-to/start-stop-restart.md)
- [Configure an interaction instance](how-to/configure-tess-interaction.md)
- [Configure a validator instance](how-to/configure-ultron-validator.md)
- [Customize roles](how-to/customize-roles.md)
## Operations and recovery
- [Reconcile and recover](operations/reconcile-and-recover.md)
- [Environment quarantine](operations/env-quarantine.md)
- [Systemd/tmux troubleshooting](operations/systemd-tmux-troubleshooting.md)
- [Backup and restore boundary](operations/backup-restore.md)
- [Upgrade and asset-drift hold](operations/upgrade-assets.md)
## Reference and migration
- [Roster v2 fields](reference/roster-v2-fields.md) · [executable JSON Schema](reference/roster-v2.schema.json) · [validated example](examples/roster-v2.yaml)
- [CLI and exit codes](reference/cli.md)
- [Role classes](reference/role-classes.md)
- [Lifecycle transitions](reference/lifecycle-transitions.md)
- [Status and drift](reference/status-and-drift.md)
- [Generated environment boundary](reference/generated-env-boundary.md)
- [v1-to-v2 preview](migration/v1-to-v2.md)
- [Example/profile dispositions](migration/example-profile-disposition.md)
- [Legacy class aliases](migration/legacy-class-aliases.md)
## Acceptance evidence and holds
- [M0/M5 IA checklist](FLEET-CONFIG-DOCS-IA-CHECKLIST.md)
- [Legacy example/profile inventory](LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md)
- [M5 closure evidence](../reports/documentation/758-fleet-config-ia-closure.md)
- [Approved-existing deferrals and live-action holds](../reports/deferred/758-fleet-config-deferrals.md)
The canonical publishing source remains this repository. This card does not publish externally, run a migration, operate a live fleet, or close parent issue #758.

View File

@@ -8,13 +8,13 @@
> Status: `not-started` | `in-progress` | `done` | `blocked` | `failed`
| id | status | description | depends_on | agent | pr | notes |
| ------------- | ----------- | ------------------------------------------------------------------------------------------------------------------ | --------------------- | ----------- | --- | --------------------------------------------------------------------------------------------------------------------------- |
| ------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | --------------------- | ----------- | --- | --------------------------------------------------------------------------------------------------------------------------- |
| FLEET-OBS-000 | done | Plan: north-star + Phase-2 PRD + workstream scaffolding | — | lead | — | persisted 2026-06-20 on `feat/fleet-observability` |
| FLEET-OBS-001 | done | Heartbeat protocol v1 spec finalized in PRD + framework doc | FLEET-OBS-000 | lead | — | file-based `~/.config/mosaic/fleet/run/<agent>.hb`; spec in PRD |
| FLEET-OBS-002 | in-progress | Implement heartbeat responder in `dogfood-agent.py` | FLEET-OBS-001 | fleet-coder | — | dispatched to ad-hoc `mosaic yolo` fleet agent (dogfood) |
| FLEET-OBS-003 | done | `mosaic fleet ps` — join systemd+tmux+proc+idle+heartbeat; tenant+host tagged; drift + boot-enable flags; `--json` | FLEET-OBS-001 | worker | — | commit ab47831; LIVE-verified on mosaic-fleet; caught canary-pi DRIFT + BOOT-ENABLE. Polish: idleSeconds parse returns null |
| FLEET-OBS-004 | done | `mosaic agent watch <name>` — read-only join (no resize, no keystrokes) | FLEET-OBS-000 | worker | — | `attach -r`; verb wired |
| FLEET-OBS-005 | done | `mosaic agent send --verify` — delivery/acceptance receipt | FLEET-OBS-000 | worker | — | --verify flag; draft-heuristic verify |
| FLEET-OBS-001 | done | Heartbeat protocol v1 spec finalized in PRD + framework doc | FLEET-OBS-000 | lead | — | file-based ~/.config/mosaic/fleet/run/<agent>.hb; spec in PRD |
| FLEET-OBS-002 | in-progress | Implement heartbeat responder in `dogfood-agent.py` | FLEET-OBS-001 | fleet-coder | — | dispatched to ad-hoc mosaic yolo fleet agent (dogfood) |
| FLEET-OBS-003 | done | mosaic fleet ps — join systemd+tmux+proc+idle+heartbeat; tenant+host tagged; drift + boot-enable flags; `--json` | FLEET-OBS-001 | worker | — | commit ab47831; LIVE-verified on mosaic-fleet; caught canary-pi DRIFT + BOOT-ENABLE. Polish: idleSeconds parse returns null |
| FLEET-OBS-004 | done | mosaic agent watch <name> — read-only join (no resize, no keystrokes) | FLEET-OBS-000 | worker | — | attach -r; verb wired |
| FLEET-OBS-005 | done | mosaic agent send --verify — delivery/acceptance receipt | FLEET-OBS-000 | worker | — | --verify flag; draft-heuristic verify |
| FLEET-OBS-006 | done | CLI specs for ps/watch/send-verify (tenant+host shape, command construction) | FLEET-OBS-003,004,005 | worker | — | 62 tests green (31 new); re-verified by lead |
| FLEET-OBS-007 | not-started | Framework doc: fleet observability guide + verbs | FLEET-OBS-003,004,005 | lead | — | `docs/guides/` or `framework/tools/.../README` |
| FLEET-OBS-008 | not-started | Independent review + dogfood verification on live fleet | FLEET-OBS-002..007 | reviewer | — | author ≠ reviewer; capture evidence in scratchpad |
@@ -22,6 +22,6 @@
## Proposed MVP rollup row (for the MVP orchestrator — not written by this workstream)
```
```text-table
| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |
```

View File

@@ -2,10 +2,10 @@
The **backlog** is Mosaic's native backlog-of-record for fleet work. It is built
end-to-end on Mosaic's own storage layer (`@mosaicstack/db`, drizzle/Postgres)
and surfaced as `mosaic fleet backlog <sub> --json`.
and surfaced as mosaic fleet backlog <sub> --json.
> **Mosaic-native, no Hermes.** This backlog REPLACES the former Hermes adapter.
> There is **no** runtime dependency on Hermes, `hermes kanban`, or `~/.hermes`
> There is **no** runtime dependency on Hermes, hermes kanban, or ~/.hermes
> anywhere in this feature. Anything previously delegated to Hermes is recreated
> here on Mosaic's own Postgres storage layer.
@@ -18,7 +18,7 @@ engine (no sqlite, no raw client).
| ---------------------------------- | -------------------- | ---------------------------------------------------------------- |
| `DATABASE_URL` injected at runtime | Full server Postgres | the verified runtime database; it never authorizes migration/DDL |
| `PGLITE_DATA_DIR` set (no URL) | Embedded PGlite | that directory |
| neither (default) | Embedded PGlite | `~/.config/mosaic/fleet/backlog` |
| neither (default) | Embedded PGlite | ~/.config/mosaic/fleet/backlog |
PGlite is real Postgres semantics in-process — including the row locks the atomic
claim relies on — so the **same code** runs on a laptop (embedded, single-host
@@ -28,9 +28,9 @@ For embedded PGlite only, the local backlog routine may prepare its local schema
### Update safety
The embedded PGlite store lives under `~/.config/mosaic/fleet/backlog`, which is
The embedded PGlite store lives under ~/.config/mosaic/fleet/backlog, which is
listed in `PRESERVE_PATHS` in `packages/mosaic/framework/install.sh`. This means
`mosaic update` (which runs the framework sync with `rsync --delete`) will **not**
mosaic update (which runs the framework sync with rsync --delete) will **not**
wipe the operator's backlog — same protection as the roster, per-agent env, and
heartbeat run dir.
@@ -46,10 +46,10 @@ A card is one row in the `backlog` table:
| `phase` | text (nullable) | Board/phase grouping (see below). |
| `priority` | int (default 0) | **Higher = sooner.** Claim picks the max-priority ready card. |
| `status` | enum | `ready` \| `claimed` \| `blocked` \| `done`. |
| `depends_on` | jsonb `string[]` | DAG edges — ids of cards this one depends on. |
| `depends_on` | jsonb string[] | DAG edges — ids of cards this one depends on. |
| `claim_owner` | text (nullable) | Owner token of the active claim. |
| `claim_ttl_seconds` | int (nullable) | TTL of the active claim. |
| `claimed_at` | timestamptz (null) | When the claim was taken. `claimed_at + ttl` = expiry. |
| `claimed_at` | timestamptz (null) | When the claim was taken. claimed_at + ttl = expiry. |
| `attempts` | int (default 0) | Incremented each time the card is claimed. |
| `idempotency_key` | text (unique, null) | Dedups `create`; NULLs are distinct in Postgres. |
| `acceptance` | jsonb (nullable) | Acceptance criteria (array of strings or object). |
@@ -65,12 +65,12 @@ would add ceremony without benefit.
### Board / phase convention
`phase` is a free-form grouping string used as the board column / milestone label
(e.g. `M1`, `fleet`, `infra`). `list --phase <phase>` filters to one board lane.
(e.g. `M1`, `fleet`, `infra`). list --phase <phase> filters to one board lane.
`priority` orders cards **within** the ready pool regardless of phase.
## Status lifecycle
```
```text-diagram
create
@@ -87,51 +87,49 @@ would add ceremony without benefit.
- **blocked** — explicitly parked; never auto-claimed.
- **done** — completed; satisfies dependents.
## Atomic claim (`FOR UPDATE SKIP LOCKED`) + TTL
## Atomic claim (FOR UPDATE SKIP LOCKED) + TTL
`claim` is atomic. Inside a single transaction it locks candidate `ready` rows
with `SELECT ... FOR UPDATE SKIP LOCKED` (via the drizzle `sql` operator), picks
with SELECT ... FOR UPDATE SKIP LOCKED (via the drizzle `sql` operator), picks
the highest-priority deps-satisfied card, and flips it to `claimed`. Because a row
already locked by a concurrent claimer is **skipped**, two claimers can **never**
both win the same card — the loser falls through to the next candidate or gets
`null`. (Proven by the concurrency tests in `packages/db/src/backlog.spec.ts`.)
- **Deps gate:** a card is only claimable when every id in `depends_on` is `done`.
- **TTL:** `claim --ttl <sec>` (default **900s**) records `claim_ttl_seconds`.
- **reclaim:** releases claims whose `claimed_at + ttl` is in the past (expired)
back to `ready`, clearing the claim fields. `reclaim --id <id>` force-releases a
- **TTL:** claim --ttl <sec> (default **900s**) records `claim_ttl_seconds`.
- **reclaim:** releases claims whose claimed_at + ttl is in the past (expired)
back to `ready`, clearing the claim fields. reclaim --id <id> force-releases a
specific card regardless of expiry. This is how a crashed worker's card returns
to the pool.
## CLI — `mosaic fleet backlog <sub> --json`
## CLI — mosaic fleet backlog <sub> --json
All subcommands support `--json`.
| Subcommand | Purpose |
| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `create --id --title [--body --phase --priority --depends-on --acceptance --idempotency-key]` | Create a card; `idempotency_key` dedups (repeat returns the existing card). |
| `list [--status --phase --ready-only]` | List cards. `--ready-only` = status `ready` AND all deps `done`. |
| `claim --owner [--ttl <sec> --id <id>]` | Atomically claim the highest-priority ready card (or `--id`). Returns the card or `null`. |
| `reclaim [--id <id>]` | Release expired claims (or a specific card) back to `ready`. |
| `link --from --to` | Add a `depends_on` edge (`--from` depends on `--to`). |
| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| create --id --title [--body --phase --priority --depends-on --acceptance --idempotency-key] | Create a card; `idempotency_key` dedups (repeat returns the existing card). |
| list [--status --phase --ready-only] | List cards. `--ready-only` = status `ready` AND all deps `done`. |
| claim --owner [--ttl <sec> --id <id>] | Atomically claim the highest-priority ready card (or `--id`). Returns the card or `null`. |
| reclaim [--id <id>] | Release expired claims (or a specific card) back to `ready`. |
| link --from --to | Add a `depends_on` edge (`--from` depends on `--to`). |
| `stats` | Counts by status, oldest-ready age, expired-claim count. |
| `block --id` | Set a card to `blocked`. |
| `complete --id` | Set a card to `done` (releases any claim). |
| block --id | Set a card to `blocked`. |
| complete --id | Set a card to `done` (releases any claim). |
### Example
```sh
# Seed two cards, the second depends on the first.
Seed two cards; the second depends on the first. Because A2 is gated on A1, claim returns A1 first. Finish A1, then list A2 as ready. Recover stalled work.
```fleet-command
mosaic fleet backlog create --id A1 --title "schema" --priority 5
mosaic fleet backlog create --id A2 --title "service" --depends-on A1 --priority 9
# A2 is gated on A1, so claim returns A1 first.
mosaic fleet backlog claim --owner worker-1 --ttl 600 --json
# Finish A1; now A2 is ready.
mosaic fleet backlog complete --id A1
mosaic fleet backlog list --ready-only --json
# Recover stalled work.
mosaic fleet backlog reclaim --json
```

View File

@@ -0,0 +1,42 @@
# Desired, Derived, and Observed Fleet State
## One writable authority
The canonical local v2 roster at <MOSAIC_HOME>/fleet/roster.yaml is desired state. Membership, stable identity, class, runtime/provider/model selection, launch policy, enablement, and persisted `running`/`stopped` intent are written only through generation-guarded roster mutations.
Derived projections are reproducible consequences of that authority:
- <name>.env.generated;
- exact roster-named tmux sessions on the configured socket after reconciliation;
- systemd service targets managed by installation/reconciliation.
Current systemd unit enablement is not yet lifecycle-conformant at boot: installation can enable every
agent unit, and the launcher projection does not carry `enabled` or `desired_state`. Therefore reboot
preservation for stopped/disabled agents remains an FCM-M3-002 acceptance hold, not a guaranteed
projection behavior.
Observed evidence available to current roster-v2 status commands includes systemd active state, tmux
presence, holder ownership, and unmanaged sessions. Heartbeat files are observational in the wider fleet,
but roster-v2 `status`, `doctor`, and `verify` do not currently read them. Observation never writes back
to the roster.
## Generation and ownership
`generation` is a positive integer concurrency fence. A mutating request must provide the current value. Successful changed CRUD increments it exactly once; stale or concurrent writers fail before mutation. Apply/reconcile rereads the canonical roster under a private exclusive lock and uses only that generation and content for effects.
Ownership is exact, never fuzzy. Reconciliation is limited to roster names, the configured socket, the exact holder session, a private installation identity, and private managed paths. An ownership mismatch, unmanaged session, unsafe path, stale generation, or ambiguous lock fails closed.
## Drift decisions
| Observation | Interpretation | Safe response |
| ---------------------------------------- | ------------------------ | ---------------------------------------------------------------------- |
| Generated file differs or is missing | Derived projection drift | Review apply --dry-run; regenerate from the roster. |
| Desired `running`, exact session missing | `missing-session` | Diagnose ownership/runtime, then reconcile if safe. |
| Desired `stopped`, exact session present | `unexpected-session` | Inspect; reconciliation may stop only the proven roster target. |
| Disabled agent running | `disabled-running` | Inspect; disabled state wins during explicit safe reconciliation. |
| Unknown session on the configured socket | Unmanaged state | Report only. Do not adopt, rename, or kill it. |
| Heartbeat stale in the wider fleet | Liveness evidence | Diagnose separately; current roster-v2 status does not read heartbeat. |
`status` and `doctor` classify. `verify` is also observational but exits non-zero when ownership, drift, or unmanaged-state checks fail. `plan`/apply --dry-run validates proposed projection and lifecycle work without mutation. `apply` and `reconcile` converge only after all preconditions pass.
A partial projection failure does not roll the roster back. Treat the committed roster as authority and regenerate. A lifecycle failure after projection completion preserves both roster and projections for inspection. Sensitive legacy values are never printed; diagnostics are bounded to stable codes, key names where applicable, and hashes.

View File

@@ -0,0 +1,23 @@
# Generated Environment Launch Chain
The launcher consumes validated data, not shell configuration.
1. Read and validate the canonical roster.
2. Render deterministic <name>.env.generated data from that roster.
3. Parse optional <name>.env.local through a strict allowlist.
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
5. Derive the runtime command from validated runtime/model/reasoning data.
6. Target only the exact configured tmux socket and roster session after ownership checks.
## File precedence and ownership
| File | Owner | Use |
| ----------------- | ------------------------ | --------------------------------------------------------------------------- |
| `.env.generated` | Mosaic projection writer | Complete deterministic roster projection. Rebuild; do not edit. |
| `.env.local` | Operator | Optional, private, strict machine-local data. Cannot shadow generated keys. |
| `.env` | Legacy input | One-time migration input only; never launch authority. |
| `.env.quarantine` | Private quarantine | Retained unsafe legacy evidence; never loaded by the launcher. |
Neither systemd nor the launcher sources these files. No `eval`, shell expansion, arbitrary `MOSAIC_AGENT_COMMAND`, channel, or secret-reference compatibility path exists. Safe legacy generated keys are regenerated, allowed local keys are relocated, and unsafe material is quarantined.
Diagnostics never expose the rejected value, credential material, or command text. They are bounded to stable rule code, key name where safe, and SHA-256 content identity. See [generated environment reference](../reference/generated-env-boundary.md) and [quarantine operations](../operations/env-quarantine.md).

View File

@@ -0,0 +1,20 @@
# Fleet Identity, Class, and Runtime
Each roster field has one job. Do not use names or model strings as authority shortcuts.
| Concern | Field | Contract |
| ----------------------- | ----------------------------- | -------------------------------------------------------------------------------------------- |
| Stable machine identity | agents[].name | Unique, immutable mutation target and exact service/session name. |
| Display identity | agents[].alias | Human-facing label only; may be changed and grants no authority. |
| Behavioral contract | agents[].class | Resolves through the shared baseline plus `roles.local` persona library. |
| Tool boundary | agents[].tool_policy | Must match protected canonical classes; cannot independently grant authority. |
| Harness | agents[].runtime | One of `claude`, `codex`, `opencode`, or `pi`, declared in `runtimes`. |
| Backend selection | agents[].provider and `model` | Explicit non-empty data; capability validity is not inferred from the display name or class. |
| Effort | agents[].reasoning | `low`, `medium`, or `high`. |
| Local placement | `working_directory` | Explicit safe local work path; not remote placement authority. |
Tess and Ultron are conventional instance/display names only. They are not products, required machine identities, role aliases, or authority-bearing classes. A configurable interaction instance uses class: interaction; a configurable validation instance uses class: validator. Any stable name and alias satisfying the structural contract may be used.
Class aliases are deliberately narrow: implementer → code, reviewer → review, and operator-interaction → interaction. No runtime, provider, model, persona prose, or instance name changes this mapping. See [role classes](../reference/role-classes.md) and the [validated generic example](../examples/roster-v2.yaml).
Roster v2 is local-only. It contains no host/SSH placement, connector, channel, secret-reference, arbitrary-command, per-agent socket, or gateway mapping fields. Those concerns require separate requirements and threat models.

View File

@@ -0,0 +1,22 @@
# Fleet Role Authority and Leases
Role content describes behavior; protected authority is immutable code metadata derived only from the canonical class.
## Required workstream classes
`code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction` are required FCM classes. `merge-gate` is additionally protected because it remains the sole approve-to-land and merge authority.
| Class | Authority | Boundary |
| -------------------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `merge-gate` | Approve-to-land and merge | Sole merge authority. |
| `validator` | Issue independent validation evidence/certificate | Never approves landing or merges. |
| `orchestrator` | Orchestrate topology and issue bounded leases | Does not gain merge authority. |
| `team-leader` | Use explicitly leased capacity | Cannot issue leases or mutate roster, credentials, topology authority, or merge state. |
| `interaction` | Receive requests and report status | Cannot orchestrate, issue leases, mutate configuration, or merge. |
| `code`, `review`, `enhancer`, custom classes | No protected authority by default | Persona prose cannot grant protected powers. |
A lease is capacity authorization from an orchestrator, not ownership. It must identify a bounded task or period and does not alter the leased agent's roster identity, role contract, credentials, authority, or persisted lifecycle. Expiry/revocation returns capacity; it does not rewrite the roster.
Semantic validation rejects protected class/tool-policy mismatch in either direction. An instance named Ultron with class: validator remains validation-only. An instance named Tess with class: interaction remains request/status-only. Renaming either instance changes no authority.
For resolver layering and safe customization, see [role classes](../reference/role-classes.md) and [customize roles](../how-to/customize-roles.md).

View File

@@ -0,0 +1,61 @@
version: 2
generation: 1
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: ~/src
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: code-example
alias: Code Example
class: code
runtime: pi
provider: example-provider
model: example-model
reasoning: medium
tool_policy: code
working_directory: ~/src
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: false
- name: interaction-example
alias: Interaction Example
class: interaction
runtime: pi
provider: example-provider
model: example-model
reasoning: low
tool_policy: interaction
working_directory: ~/src
persistent_persona: true
reset_between_tasks: false
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: false
- name: validator-example
alias: Validator Example
class: validator
runtime: pi
provider: example-provider
model: example-model
reasoning: high
tool_policy: validator
working_directory: ~/src
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: false

View File

@@ -15,7 +15,7 @@ core.
Connectors implement one small, uniform interface (`src/fleet/connectors/types.ts`):
```ts
```typescript
interface OrchestratorConnector {
readonly kind: 'tmux' | 'discord' | 'matrix';
send(message: OutboundMessage): Promise<SendResult>; // orchestrator → human
@@ -25,11 +25,11 @@ interface OrchestratorConnector {
```
- **send / subscribe / health** — the only surface fleet core depends on. `SendResult` is the
ack half; `health()` is the liveness half.
ack half; health() is the liveness half.
- **Thread-aware by metadata** — `OutboundMessage.threadId` / `InboundMessage.threadId` are
optional, so thread-capable connectors (Matrix rooms/threads, the future first-party Mosaic
Discord plugin) fit **without an interface change**.
- **Registry** (`registry.ts`) — implementations register a factory by kind; `createConnector(config)`
- **Registry** (`registry.ts`) — implementations register a factory by kind; createConnector(config)
resolves one from roster config. Phase 1 ships the registry + `resolveConnectorKind` (defaults
`tmux` when a roster declares no connector — **back-compat**); the factories land in Phase 2.
@@ -39,7 +39,7 @@ A roster may carry an optional `connector` block (`roster.schema.json`); absent
```yaml
connector:
kind: matrix # tmux | discord | matrix
kind: matrix
matrix:
homeserver_url: https://matrix.example.internal
user_id: '@mos:example.internal'
@@ -56,10 +56,10 @@ The connector speaks the **Matrix client-server API** directly over HTTPS (`fetc
for MVP), so it is **homeserver-agnostic**:
| Op | Matrix CS-API |
| ----------- | ------------------------------------------------------------------------ |
| `send` | `PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}` |
| `subscribe` | `GET /_matrix/client/v3/sync` (long-poll, `since` token) → room timeline |
| `health` | `GET /_matrix/client/versions` (reachable) + `…/account/whoami` (authed) |
| ----------- | ----------------------------------------------------------------------- |
| `send` | PUT /\_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId} |
| `subscribe` | GET /\_matrix/client/v3/sync (long-poll, `since` token) → room timeline |
| `health` | GET /\_matrix/client/versions (reachable) + …/account/whoami (authed) |
| threads | `m.thread` relations ↔ `threadId` |
## Local homeserver (infra, not connector code)
@@ -79,7 +79,7 @@ homeserver choice is a **deployment** concern (a Phase-2 deploy guide), not conn
| ----- | --------------------------------------------------------------------------------------- | ------- |
| **1** | Connector interface + types, registry + kind resolution, roster `connector` schema, doc | ✅ yes |
| 2 | Matrix CS-API client (fetch-based send/sync/health) + registered factory + tests | follow |
| 2 | `fleet init` / `configure` connector-selection UX; roster parse wires the block | follow |
| 2 | fleet init / `configure` connector-selection UX; roster parse wires the block | follow |
| 2 | systemd launch wiring so the orchestrator starts on the chosen connector | follow |
| 3 | Conduit deploy guide; first-party Mosaic Discord (threads) registers as a connector | follow |

View File

@@ -0,0 +1,21 @@
# Configure an Interaction Instance
An interaction instance is a configurable local roster member with canonical class: interaction and matching tool_policy: interaction. “Tess” may be used as a display alias, but neither that alias nor the stable name is required or authority-bearing.
Use the [validated generic roster](../examples/roster-v2.yaml) as the safe shape. Choose a unique stable `name`, any descriptive `alias`, a supported declared runtime, explicit provider/model/reasoning, and a safe work directory. Start with:
```yaml
name: interaction-example
alias: Interaction Example
class: interaction
tool_policy: interaction
lifecycle:
enabled: true
desired_state: stopped
```
Plan the complete agent payload with the current roster generation, then create it without `--persisted-start`. Creation defaults to enabled/stopped and performs no runtime action. Review the resulting roster and projection plan before any later lifecycle decision.
The interaction class is request/status only. It cannot orchestrate, issue leases, mutate the roster/configuration, grant credentials, certify validation, approve landing, or merge. Connector and channel configuration are outside roster v2; do not add connector, channel, secret, command, remote-host, or gateway fields.
See [safe CRUD](create-update-delete-agent.md), [identity separation](../concepts/identity-class-runtime.md), and [role authority](../concepts/role-authority-and-leases.md).

View File

@@ -0,0 +1,21 @@
# Configure a Validator Instance
A validator instance is a configurable local roster member with canonical class: validator and matching tool_policy: validator. “Ultron” may be used as a display alias, but it is not a required identity, class alias, product name, or source of authority.
Use the [validated generic roster](../examples/roster-v2.yaml) as the safe shape. Choose a unique stable name and explicit supported runtime/provider/model/reasoning values. Start stopped:
```yaml
name: validator-example
alias: Validator Example
class: validator
tool_policy: validator
lifecycle:
enabled: true
desired_state: stopped
```
Plan the full payload with the current generation and create without `--persisted-start`. Creation writes desired state and projections only; it does not launch a validator.
`validator` may issue independent validation evidence or a certificate. It has no approve-to-land or merge authority. `merge-gate` remains the sole protected merge authority, and changing the validator's name, alias, persona prose, runtime, provider, model, or tool-policy text cannot elevate it.
Certificate consumption and final release evidence remain FCM-M5-002 gates. This page does not create a certificate or authorize merge. See [safe CRUD](create-update-delete-agent.md) and [role authority](../concepts/role-authority-and-leases.md).

View File

@@ -4,20 +4,20 @@ Use the local roster-v2 control plane only. These commands change desired state
## Read and plan first
```sh
```fleet-synopsis
mosaic fleet get <name>
mosaic fleet plan create --expected-generation <n> --agent '<json>'
mosaic fleet plan update <name> --expected-generation <n> --agent '<json>'
mosaic fleet plan delete <name> --expected-generation <n>
```
`plan create` takes the name from `--agent`. `plan update` and `plan delete` require the target name immediately after the operation. A plan is deterministic and side-effect free: it validates the complete proposed roster and projection targets without changing files. Use `--dry-run` on `create`, `update`, or `delete` for the same no-write result.
plan create takes the name from `--agent`. plan update and plan delete require the target name immediately after the operation. A plan is deterministic and side-effect free: it validates the complete proposed roster and projection targets without changing files. Use `--dry-run` on `create`, `update`, or `delete` for the same no-write result.
Every successful command prints JSON. `get` returns `{ "generation", "agent" }`; mutation results contain `plan`, `applied`, `authoritativeRoster`, and `projections`.
Every successful command prints JSON. `get` returns { "generation", "agent" }; mutation results contain `plan`, `applied`, `authoritativeRoster`, and `projections`.
## Create safely
```sh
```fleet-command
mosaic fleet create --expected-generation 7 --agent '{
"name":"coder0",
"alias":"Coder 0",
@@ -34,20 +34,20 @@ mosaic fleet create --expected-generation 7 --agent '{
}'
```
Create defaults to `enabled: true` and `desired_state: stopped`. It does not start a process. Add `--persisted-start` only to persist `desired_state: running`; that still does not start a runtime in this M2 command. The JSON payload is an allowlist of the roster-v2 fields shown above plus `launch.yolo`; command, channel, secret-reference, and other unknown keys are rejected rather than ignored. The JSON error exposes only a stable code, never the rejected value.
Create defaults to enabled: true and desired_state: stopped. It does not start a process. Add `--persisted-start` only to persist desired_state: running; that still does not start a runtime in this M2 command. The JSON payload is an allowlist of the roster-v2 fields shown above plus `launch.yolo`; command, channel, secret-reference, and other unknown keys are rejected rather than ignored. The JSON error exposes only a stable code, never the rejected value.
## Update and delete safely
```sh
mosaic fleet update coder0 --expected-generation 8 --agent '<complete JSON agent payload>'
mosaic fleet delete coder0 --expected-generation 9
```fleet-synopsis
mosaic fleet update <name> --expected-generation <n> --agent '<complete JSON agent payload>'
mosaic fleet delete <name> --expected-generation <n>
```
Updates require a complete agent JSON payload and preserve the stable name. Delete removes only the exact roster-owned `coder0.env.generated` projection. It retains `coder0.env.local`, legacy `coder0.env`, `coder0.env.quarantine`, and every unrelated projection. A delete dry-run leaves all of those files byte-identical.
## Handle generation conflicts
Every mutation requires the current authoritative `--expected-generation`. A stale value returns JSON `error.code: "stale-generation"` with a non-zero exit. Reload with `mosaic fleet get <name>` or reread the roster, plan again using the returned generation, then retry. A concurrent mutation returns `concurrent-mutation`; do not force or bypass the lock.
Every mutation requires the current authoritative `--expected-generation`. A stale value returns JSON error.code: "stale-generation" with a non-zero exit. Reload with mosaic fleet get <name> or reread the roster, plan again using the returned generation, then retry. A concurrent mutation returns `concurrent-mutation`; do not force or bypass the lock.
## Interpret partial failures
@@ -71,4 +71,4 @@ This is not a rollback and not a no-op: reload the roster because its generation
Handled validation errors and partial projection failures exit non-zero. `plan`/`--dry-run` and normal mutation JSON make the state explicit; scripts should use both the exit code and `authoritativeRoster`/`projections`, not `applied` alone.
The commands operate only on `<mosaic-home>/fleet/roster.yaml`, the local roster desired-state authority. They do not accept arbitrary commands, channels, secrets, remote/connector actions, migration/canary actions, or runtime lifecycle operations.
The commands operate only on <mosaic-home>/fleet/roster.yaml, the local roster desired-state authority. They do not accept arbitrary commands, channels, secrets, remote/connector actions, migration/canary actions, or runtime lifecycle operations.

View File

@@ -2,8 +2,8 @@
Mosaic resolves persona contracts through two layers:
1. `fleet/roles/<canonical-class>.md` — seeded baseline contract.
2. `fleet/roles.local/<canonical-class>.md` — operator override or custom role; this layer wins.
1. fleet/roles/<canonical-class>.md — seeded baseline contract.
2. fleet/roles.local/<canonical-class>.md — operator override or custom role; this layer wins.
The same shared resolver is used by profile validation, provisioning, roster-v2 semantic validation,
and launch-time persona injection.
@@ -34,11 +34,11 @@ A custom class remains supported when a readable contract exists for the exact i
The release-notes role (`class: release-notes`) prepares operator-reviewed release copy.
```
Save it as `fleet/roles.local/release-notes.md`, then reference `class: release-notes` and a matching
`tool_policy: release-notes` in roster v2. Adding only a `LIBRARY.md` row is insufficient.
Save it as `fleet/roles.local/release-notes.md`, then reference class: release-notes and a matching
tool_policy: release-notes in roster v2. Adding only a `LIBRARY.md` row is insufficient.
Names such as `worker`, `analyst`, and `canary` are not built-in aliases; they need genuine custom
contracts. `agents[].alias`, Tess, and Ultron are display names and cannot select a class.
contracts. agents[].alias, Tess, and Ultron are display names and cannot select a class.
## Validation and authority boundaries

View File

@@ -2,22 +2,22 @@
Use the canonical local roster-v2 command surface:
```sh
```fleet-synopsis
mosaic fleet apply --expected-generation <n> --dry-run
mosaic fleet apply --expected-generation <n>
mosaic fleet reconcile --expected-generation <n>
mosaic fleet start <name> --expected-generation <n>
mosaic fleet stop <name> --expected-generation <n>
mosaic fleet restart <name> --expected-generation <n>
mosaic fleet status [name]
mosaic fleet status [<name>]
mosaic fleet verify
mosaic fleet doctor
```
Start with `--dry-run`. It validates roster semantics, deterministic projections, private managed paths, exact holder ownership, and named-socket state without changing files or lifecycle state. `apply` and `reconcile` rebuild derived projections and enforce only persisted roster state: enabled `running` agents may start, while stopped or disabled agents are not started.
Start with `--dry-run`. It validates roster semantics, deterministic projections, private managed paths, exact holder ownership, and named-socket state without changing files or lifecycle state. Explicit `apply` and `reconcile` rebuild derived projections and enforce persisted roster state: enabled `running` agents may start, while stopped or disabled agents are not started. This guarantee does not extend to reboot/service activation yet; boot preservation remains an FCM-M3-002 hold.
`start`, `stop`, and `restart` are explicit one-shot exact-service actions. They do not persist a lifecycle change. Roster CRUD is the only way to change persisted desired state.
`start`, `stop`, and `restart` are explicit one-shot exact-service actions. They do not persist a lifecycle change. `update` preserves the agent's existing lifecycle, and no delivered operation changes durable lifecycle after creation.
Every command prints JSON. Observation commands report drift without mutation; `verify` exits non-zero on ownership mismatch, unmanaged sessions, or drift. A failed apply that wrote some derived projections reports `projections: "incomplete"` with bounded recovery to regenerate from the roster. A lifecycle failure after projections reports incomplete lifecycle work; it is never represented as a rollback or no-op.
Every command prints JSON. Observation commands report drift without mutation; `verify` exits non-zero on ownership mismatch, unmanaged sessions, or drift. A failed apply that wrote some derived projections reports projections: "incomplete" with bounded recovery to regenerate from the roster. A lifecycle failure after projections reports incomplete lifecycle work; it is never represented as a rollback or no-op.
These commands are local only. Remote/SSH/connector entries are inventory/validation-only. Commands do not accept arbitrary runtime commands, channels, secrets, generated-file desired state, or arbitrary tmux sockets.

View File

@@ -11,7 +11,7 @@ artifact is added, removed, or left without one of the dispositions below.
## Disposition rules
- **Explicit v1 fixture:** the artifact is loaded through the existing v1 roster parser and must
declare `version: 1`. It remains a compatibility fixture; it is not silently treated as a v2
declare version: 1. It remains a compatibility fixture; it is not silently treated as a v2
roster or given inferred aliases.
- **Canonical profile:** the artifact is loaded through `loadProfiles`, which uses the shared
baseline-plus-`roles.local` persona resolver and rejects unreadable or unresolved classes.
@@ -59,7 +59,7 @@ rollback; those gates belong to FCM-M4-002. See [v1-to-v2 preview](./v1-to-v2.md
## Running the guard
```bash
```fleet-command
pnpm --filter @mosaicstack/mosaic test -- v1-v2-migration.spec.ts \
-t "validates all 13 shipped artifacts and executes ready previews for every v1 fixture"
```

View File

@@ -2,14 +2,14 @@
**Issue:** #758 · **Card:** FCM-M4-001 · **Effect boundary:** preview only
`mosaic fleet migrate-v1 preview` inventories a v1 roster and emits a canonical v2 candidate plus
mosaic fleet migrate-v1 preview inventories a v1 roster and emits a canonical v2 candidate plus
recovery evidence. It does not write a roster, apply environment projections, invoke systemd or
`tmux`, contact connectors or remote hosts, launch an agent, run a canary, or execute rollback.
FCM-M4-002 owns reversible cutover and rollback.
## Inputs
```bash
```fleet-command
mosaic fleet migrate-v1 preview \
--source roster-v1.yaml \
--decisions migration-decisions.json \
@@ -45,13 +45,13 @@ be marked disabled. Observed-stopped agents always remain stopped.
## Field disposition
| v1 field | v2 disposition |
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version`, `transport`, `tmux`, `defaults`, `runtimes` | Inventoried and structurally compiled; omitted runtimes retain v1 built-in defaults, while each explicitly declared runtime without a reset field follows the production v1 `/clear` fallback; present-empty holder/work-directory/reset values block |
| agent `name`, `alias`, `runtime`, working directory, persona/reset flags | Copied or explicitly defaulted only when absent; present-empty alias/work-directory values block for explicit disposition. Canonical `~`/`~/...` values stay unchanged in roster evidence and traversal-free forms expand only at the shared production environment-projection boundary before unchanged absolute-path validation |
| agent `name`, `alias`, `runtime`, working directory, persona/reset flags | Copied or explicitly defaulted only when absent; present-empty alias/work-directory values block for explicit disposition. Canonical ~/~/... values stay unchanged in roster evidence and traversal-free forms expand only at the shared production environment-projection boundary before unchanged absolute-path validation |
| `provider`, `model_hint`, `reasoning_level` | Explicit provider/model/reasoning decisions; no model-hint inference |
| `class`, `tool_policy` | Only approved aliases canonicalize automatically; other classes require explicit preserve/replace disposition and shared-resolver validation |
| `kickstart_template` | No v2 field; explicit inventory-only disposition required |
| agent `host`, `ssh` | `host != fleetHost` is demonstrably remote and inventory-only; `host == fleetHost` stays local; SSH targets with or without an explicit user must agree with `host`; ssh-only, missing fleet-host evidence, or contradictory targets block |
| agent `host`, `ssh` | host != fleetHost is demonstrably remote and inventory-only; host == fleetHost stays local; SSH targets with or without an explicit user must agree with `host`; ssh-only, missing fleet-host evidence, or contradictory targets block |
| agent `socket` | Same-host candidate only when it matches the canonical fleet socket; conflicts block for explicit future disposition |
| root `connector` | Inventory-only; never contacted or reconciled |
| unknown fields or snake/camel synonym collisions | Inventoried and block readiness |
@@ -61,8 +61,8 @@ be marked disabled. Observed-stopped agents always remain stopped.
| legacy `.env` containing strict local keys | `relocate-local`; preserve those keys in `.env.local` during a later reviewed cutover |
| legacy `.env` containing forbidden/unsafe/sensitive/malformed keys | `quarantine`; private input only, with diagnostics limited to code, key, and SHA-256 |
The only automatic aliases are `implementer → code`, `reviewer → review`, and
`operator-interaction → interaction`. Similar or domain-specific names are never inferred. Automatic
The only automatic aliases are implementer → code, reviewer → review, and
operator-interaction → interaction. Similar or domain-specific names are never inferred. Automatic
classes do not accept competing disposition records. Semantic validation delegates to the existing
baseline-plus-`roles.local` resolver after the candidate is compiled by the existing v2 compiler.

View File

@@ -44,9 +44,9 @@ The Fleet inherits — does not re-invent — the MVP's hard requirements:
| MVP req | What it means for the Fleet |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| MVP-X1 three-surface parity | fleet observability/control reachable via **CLI + TUI + webUI** (CLI first; webUI is required for parity, not optional) |
| MVP-X2 multi-tenant isolation | one tenant = one **Linux uid** (own `systemd --user`, socket, `~/.config/mosaic`); no cross-tenant leakage |
| MVP-X2 multi-tenant isolation | one tenant = one **Linux uid** (own systemd --user, socket, ~/.config/mosaic); no cross-tenant leakage |
| MVP-X3 auth (BetterAuth/SSO) | operator→fleet and cross-host views are auth-gated through the platform's existing auth |
| MVP-X4 quality gates | `pnpm typecheck`/`lint`/`format:check` green before any push |
| MVP-X4 quality gates | pnpm typecheck/`lint`/`format:check` green before any push |
| MVP-X5 federated topology | cross-host fleet visibility rides the **federation** boundary (W1), not a bespoke broker |
| MVP-X6 OTEL tracing | heartbeats, sends, and lifecycle events emit spans; `traceparent` crosses the federation boundary |
| MVP-X7 trunk merge | branch from `main`, squash-merge via PR, never push to `main` |
@@ -56,9 +56,9 @@ The Fleet inherits — does not re-invent — the MVP's hard requirements:
One **definition** is the source of truth; the **session** is how it runs.
| Layer | Owner | Phase-2 reality | Destination |
| -------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Definition + identity + auth** | gateway / `mosaic-as` (scoped tokens, #541) | `roster.yaml` (tenant-tagged) | one definition; `mosaic agent --new` materializes it |
| **Tenancy boundary** | **Linux uid per tenant** (linger, own `systemd --user`, own socket, own `~/.config/mosaic`) | one tenant: `jarvis` = tenant zero | uid-per-tenant; federation aggregates across hosts |
| -------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Definition + identity + auth** | gateway / `mosaic-as` (scoped tokens, #541) | `roster.yaml` (tenant-tagged) | one definition; mosaic agent --new materializes it |
| **Tenancy boundary** | **Linux uid per tenant** (linger, own systemd --user, own socket, own ~/.config/mosaic) | one tenant: `jarvis` = tenant zero | uid-per-tenant; federation aggregates across hosts |
| **Runtime** | per-tenant tmux session on isolated socket | dogfood stub sessions (live now on `mosaic-factory`) | claude/codex/pi/opencode TUIs |
| **Liveness** | **heartbeat protocol** every runtime answers | protocol defined + dogfood stub answers it | all runtimes answer; "healthy" ≠ "pane alive" |
| **Observation** | read-only `watch` (native tmux) + `pipe-pane` stream | CLI `watch`/`ps`; explicit opt-in `attach` for control | + auth-gated webUI streams |
@@ -68,7 +68,7 @@ One **definition** is the source of truth; the **session** is how it runs.
> **PoC socket hygiene:** the PoC fleet runs on the **default tmux socket** (no `-L`).
> The named production-isolation socket is **`mosaic-fleet`** (matches the product brand);
> an absent roster `socket_name` means the default socket everywhere (spawn, `fleet ps`,
> an absent roster `socket_name` means the default socket everywhere (spawn, fleet ps,
> onboarding cheat-sheet). The legacy dogfood canary still runs on the old `mosaic-factory`
> socket pending migration.
@@ -177,7 +177,7 @@ routing flow**, **concurrency** (the spend multiplier), and **hard API-token $-l
are enforced at the orchestrator + routing boundary, not inside individual workers (a worker never
decides its own budget — see delegation discipline).
**Budget CLI UX (#558):** `mosaic budget set --reset-at` sets the window reset; reset-datetimes
**Budget CLI UX (#558):** mosaic budget set --reset-at sets the window reset; reset-datetimes
carry **confidence tags** (`user` / `provider` / `estimated` / `unknown`); and **urgency/criticality
is a dispatch-gate modifier** — high-urgency work may override even-spread pacing **within
authorization**. (Also feeds the budgeting workstream, not only this doc.)
@@ -185,14 +185,14 @@ authorization**. (Also feeds the budgeting workstream, not only this doc.)
## Observation model
| Verb | Behavior |
| ----------------------------------- | -------------------------------------------------------------------------------------------------- |
| `mosaic fleet ps` | one table joining systemd + tmux + process + idle + last-heartbeat, with drift + boot-enable flags |
| `mosaic agent watch <name>` | **read-only** join (grouped session / `-r`), no resize tyranny, no keystrokes |
| `mosaic agent attach <name>` | explicit interactive takeover (the only path that can type) |
| `mosaic agent send <name> --verify` | confirms message **accepted**, not merely keystroke-injected |
| --------------------------------- | -------------------------------------------------------------------------------------------------- |
| mosaic fleet ps | one table joining systemd + tmux + process + idle + last-heartbeat, with drift + boot-enable flags |
| mosaic agent watch <name> | **read-only** join (grouped session / `-r`), no resize tyranny, no keystrokes |
| mosaic agent attach <name> | explicit interactive takeover (the only path that can type) |
| mosaic agent send <name> --verify | confirms message **accepted**, not merely keystroke-injected |
> Why the current PoC blocks observation: sessions live on the isolated `mosaic-factory`
> socket (invisible to default `tmux ls`), the only sanctioned read is `capture-pane`
> socket (invisible to default tmux ls), the only sanctioned read is `capture-pane`
> (blank for full-screen TUIs), and `attach` is read-write + resizes the session. The
> verbs above restore "join and observe" safely.
@@ -214,7 +214,7 @@ compromised pane cannot corrupt or exfiltrate the register.
| Layer | Responsibility | Implementation |
| ---------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Register** | Source of truth: agents, missions, tasks, heartbeats, spend | Postgres `fleet` schema — existing stack instance (`@mosaicstack/db`) |
| **Access** | Typed, auth-gated API | Gateway `fleet/*` routes |
| **Access** | Typed, auth-gated API | Gateway fleet/\* routes |
| **Dispatcher** | Brief classification, BOD review, planning/coding/review/test/deploy sequencing + gates → fleet task dispatch | **forge pipeline engine** (`runPipeline`/`resumePipeline`, brief classifier, BOD) **+ thin `forge-exec` adapter → `agent-send.sh`**; NOT a new daemon — forge is reused, only stage→agent dispatch is new |
| **Orchestrator (Mos)** | Goals, missions, judgment, user/PA interface | Context-light; sets intent → re-engages only for decisions |
@@ -236,7 +236,7 @@ role implementation.
`docs/TASKS.md` and `MISSION-MANIFEST.md` are **generated projections** of the DB,
not hand-maintained. The dispatcher (or a scheduled job) renders Markdown from
`fleet.*` tables and commits the output. DB is authoritative; docs are for human
fleet.\* tables and commits the output. DB is authoritative; docs are for human
reference.
### Spend
@@ -267,11 +267,11 @@ re-evaluate if isolation or write-volume demands it.
## Phased roadmap
| Phase | Outcome | Status |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
| 01 | tmux PoC, hardening, published CLI v0.0.34 (#565#568) | ✅ done |
| **2 — Observability** | `fleet ps` (host+tenant aware join), heartbeat protocol + dogfood stub answers it, `agent watch` (read-only), `agent send --verify` receipts | ▶ now |
| **2 — Observability** | fleet ps (host+tenant aware join), heartbeat protocol + dogfood stub answers it, agent watch (read-only), agent send --verify receipts | ▶ now |
| 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: **orchestrator + enhancer**; ephemeral workers per lane) | planned |
| 4 — Unified definition | one agent schema in gateway; `mosaic agent --new` → materialized per-tenant session; uid-tenant provisioning; **`fleet` schema migration + `forge-exec` TaskExecutor adapter (forge → `agent-send.sh`)** | planned |
| 4 — Unified definition | one agent schema in gateway; mosaic agent --new → materialized per-tenant session; uid-tenant provisioning; **`fleet` schema migration + `forge-exec` TaskExecutor adapter (forge → `agent-send.sh`)** | planned |
| 5 — Control plane | federation-backed cross-host × cross-tenant fleet view; **webUI** (surface chosen then) for MVP-X1 parity; **central register live (spend ledger, docs-as-projections, multi-host Kanban)** | planned |
## Decisions of record (2026-06-20, with Jason)
@@ -285,9 +285,9 @@ re-evaluate if isolation or write-volume demands it.
- Delivery: **CLI-first now**, dogfood against the live stub fleet; webUI deferred to Phase 5.
- Runtimes: fleet agents default to **Codex / pi-on-Codex**; **Claude is reserved for Claude
Code only** (avoid alternate-harness API pricing). Validated durable recipe:
`mosaic yolo pi --model openai-codex/gpt-5.5:high`. Durable detached launch requires the
mosaic yolo pi --model openai-codex/gpt-5.5:high. Durable detached launch requires the
runtime-bin on PATH (baked into the pane command) + boot-survival (`enable` + linger),
which `fleet init` should automate.
which fleet init should automate.
## Decisions of record (2026-06-22, with Jason)
@@ -304,19 +304,18 @@ re-evaluate if isolation or write-volume demands it.
- **Session context cap = 200k tokens (GLOBAL to all Claude sessions):** Claude Code sessions are
capped at a **max 200k-token context window**. Long-running sessions extended toward 1M tokens
have proven **worse in practice** (degraded steering, off-plan divergence); 200k is the standard.
**Enforcement split:** the _window_ lives in **`~/.claude/settings.json`** (host-global) as
`"autoCompactWindow": 200000` + `"autoCompactEnabled": true`; the _1M-disable_ lives in **launch
**Enforcement split:** the _window_ lives in **~/.claude/settings.json** (host-global) as
"autoCompactWindow": 200000 + "autoCompactEnabled": true; the _1M-disable_ lives in **launch
ENV** (`CLAUDE_CODE_DISABLE_1M_CONTEXT=1`, plus `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000`) wherever
a `[1m]` model can be selected (`mos-claude.service` + the fleet Claude launcher), so every Claude
a [1m] model can be selected (`mos-claude.service` + the fleet Claude launcher), so every Claude
agent is capped at spawn. (settings = window; env = 1M-disable.)
- **Worker context bound (#8):** workers are kept context-bounded via the **ephemeral-per-lane
lifecycle + native compaction**, not via the 200k knob. The explicit `autoCompactWindow` 200k knob
**stays Claude-specific** — the _principle_ (bounded context) extends to workers, the _knob_ does not.
- **Orchestrator delegation discipline:** the orchestrator **delegates all delivery work** to
subagents / workflows / ultracode / coder agents and confines its own context to \*\*orchestration
- the personal-assistant lane\*\*. Keeping delivery out of the orchestrator's window keeps its
context unpolluted and measurably reduces off-plan divergence. The orchestrator coordinates and
decides; it does not implement.
subagents / workflows / ultracode / coder agents and confines its own context to the personal-assistant
lane. Keeping delivery out of the orchestrator's window keeps its context unpolluted and measurably
reduces off-plan divergence. The orchestrator coordinates and decides; it does not implement.
- **Budget governance is fleet doctrine:** token/API-dollar budgeting is a first-class fleet concern
(see "Budget & token governance"). OAuth-sub usage-vs-limit feedback is ingested per account, spend
is **auto-paced EVEN-SPREAD over remaining time** (rapid/overspend only on explicit authorization),
@@ -344,7 +343,7 @@ re-evaluate if isolation or write-volume demands it.
### Control plane & central register
- **Store:** Postgres (existing stack instance, dedicated `fleet` schema via `@mosaicstack/db`). SQLite rejected: (1) it is a local file — structurally incompatible with a multi-host fleet; (2) concurrent multi-agent writes caused repeated corruption in Hermes. "SQLite + access service" rejected as reinventing a DB server badly; "LLM agent gating DB access" rejected as slow, expensive, and a single point of failure.
- **Access:** gateway API only (`apps/gateway`, `fleet/*` routes). No raw DB credentials in any agent/dispatcher pane — directly mitigates the tmux attack-surface concern.
- **Access:** gateway API only (`apps/gateway`, fleet/\* routes). No raw DB credentials in any agent/dispatcher pane — directly mitigates the tmux attack-surface concern.
- **Dispatcher = forge (reuse, not a new build):** the dispatcher IS `@mosaicstack/forge`'s pipeline engine (`runPipeline`/`resumePipeline` + brief classifier + BOD persona loader), a fully-implemented software-factory pipeline (brief → BOD review → 3 planning stages → coding → review/remediation → testing → deploy). We do **not** design/build a new dispatcher and do **not** re-implement sequencing, gate logic, or brief classification. The only new fleet-owned piece is a thin **`forge-exec` TaskExecutor adapter** (suggested package `packages/forge-exec`) mapping a `ForgeTask``agent-send.sh` dispatch to a named fleet agent — forge's single missing piece. It is tracked as a Gitea issue and built **post-PoC** (not now).
- **Register backs forge:** the Postgres `fleet` register is genuinely new (neither forge nor the fleet has cross-project state). It BACKS forge's pipeline state (durable `resumePipeline`, cross-host) plus cross-project missions/tasks/Kanban.
- **'board' role = forge BOD:** the north-star role-library 'board' role IS forge's Board-of-Directors — reused, not reinvented.
@@ -357,9 +356,9 @@ re-evaluate if isolation or write-volume demands it.
- **Per-agent model switch (operator-configurable, NOT a global lock):** model selection is
**per-agent**, never a host-global pin. Claude sessions MUST NOT be locked to a single model in
`~/.claude/settings.json`; each agent chooses its model independently. The plumbing already exists —
roster `model_hint``MOSAIC_AGENT_MODEL``start-agent-session.sh` appends `--model <hint>` to that
agent's harness (claude or pi); settable today via `mosaic fleet add|edit <agent> --model <hint>`.
~/.claude/settings.json; each agent chooses its model independently. The plumbing already exists —
roster `model_hint``MOSAIC_AGENT_MODEL``start-agent-session.sh` appends --model <hint> to that
agent's harness (claude or pi); settable today via mosaic fleet add|edit <agent> --model <hint>.
**North-star target:** surface this as a **per-agent model switch in the webUI** (with CLI/TUI parity
per MVP-X1) — read the roster, expose a per-agent model dropdown, write `model_hint` back, and restart
that one agent to apply. Unset = inherit the harness default. This **composes with** the budget
@@ -385,7 +384,7 @@ re-evaluate if isolation or write-volume demands it.
self-hosted homeserver (Conduit default, Synapse alt). Matrix is named here as the strategic
future transport — peer to tmux/Discord, not superseded by them.
- **tmux fleet attack-surface hardening.** Many always-on tmux sessions are an attack surface;
`tmux send-keys` / socket access could enable malicious action against agents directly.
tmux send-keys / socket access could enable malicious action against agents directly.
Mitigations to build toward: socket ownership/perms, per-tenant socket isolation (already an
invariant), authenticated `agent-send`, and an audit of who can write to any pane. **Post-MVP
unless a P0 surfaces.** The control-plane register reinforces this (gateway-API access = no raw
@@ -418,9 +417,9 @@ re-evaluate if isolation or write-volume demands it.
---
> **Release procedure (drift re-capture, 2026-06-22):** `mosaic update` only propagates new fleet
> **Release procedure (drift re-capture, 2026-06-22):** mosaic update only propagates new fleet
> commands when the **CLI version is bumped** — without a version bump, fleet command changes never
> reach installed hosts. The release/version-bump procedure (bump → publish → `mosaic update`
> reach installed hosts. The release/version-bump procedure (bump → publish → mosaic update
> [→ `--relaunch`]) must be documented so fleet changes actually land. (Also feeds the budgeting
> workstream.)
>

View File

@@ -30,7 +30,7 @@ connector entry.
The preview evidence deliberately records:
- `executable: false`;
- executable: false;
- required backup artifacts;
- source and candidate identities;
- lifecycle observations and resulting desired states;

View File

@@ -0,0 +1,20 @@
# Environment Quarantine Operations
Legacy <name>.env is input evidence, never current launch authority. Projection preparation classifies it deterministically:
- generated roster keys → discard and regenerate;
- allowed strict local keys → relocate to private `.env.local`;
- malformed, duplicate, unknown, sensitive-looking, shell-bearing, unsafe, or command-override entries → move the legacy input to private `.env.quarantine`.
## Safe response
1. Stop and read the stable error code and reported key name/hash. Do not request or paste the value.
2. Confirm the canonical roster contains the intended non-sensitive desired state.
3. If the key is an allowed local machine-data field, place only its validated data form in `.env.local` under private permissions.
4. Remove unsupported intent rather than translating it into commands, channels, secret references, or unknown MOSAIC*AGENT*\* keys.
5. Regenerate `.env.generated` from the roster and rerun a dry-run/verification gate.
6. Retain quarantine evidence privately until the operator's normal retention process permits removal.
The launcher never reads quarantine. Public/JSON diagnostics expose stable code, key name where safe, and SHA-256 only—never a legacy sensitive value, credential, rejected command, or full line. Quarantine does not prove remediation, backup, migration, or rollback.
See [generated launch chain](../concepts/generated-env-launch-chain.md), [generated environment boundary](../reference/generated-env-boundary.md), and [migration field disposition](../migration/v1-to-v2.md#field-disposition).

View File

@@ -2,10 +2,12 @@
## Safe sequence
1. Read `mosaic fleet doctor` and `mosaic fleet status`.
2. Run `mosaic fleet apply --expected-generation <n> --dry-run`.
1. Read mosaic fleet doctor and mosaic fleet status.
2. Run mosaic fleet apply --expected-generation <n> --dry-run.
3. Resolve stale generation, ownership mismatch, unsafe path, projection validation, or unmanaged-session findings before applying.
4. Run `mosaic fleet apply --expected-generation <n>` only after the plan is understood.
4. Run mosaic fleet apply --expected-generation <n> only after the plan is understood.
This is per-generation convergence, not a rolling canary. Executable canary cutover/rollback remains held for FCM-M4-002; rolling local release evidence remains FCM-M5-002. Do not approximate either with repeated live apply commands.
The reconciler uses the exact roster tmux socket, exact holder session, private installation holder identity, and the complete expected global environment. For mutations it acquires its exclusive lock before rereading the canonical roster and fencing its generation; only that under-lock roster drives validation, planning, projections, and lifecycle effects. Before effects, its exclusive lock proves real private `MOSAIC_HOME` and `fleet` ancestors, uses a private `0600` lock leaf, and binds cleanup to the created file identity and ownership token. A fake holder, contaminated global environment, missing identity, unsafe lock path, or unmanaged session fails closed. It does not adopt, kill, or rename any unproven session. A crash can leave a stale lock for explicit operator inspection; reconciliation deliberately does not guess ownership or remove it.
@@ -23,4 +25,4 @@ The roster is never changed by reconciliation. If derived projection application
}
```
If projections completed but lifecycle work failed, JSON reports `projections: "complete"`, `lifecycle: "incomplete"`, and the bounded action `rerun-after-inspecting-owned-resources`. If lock cleanup cannot be proven after an effect result, it adds `cleanup: { "code": "lock-cleanup-failed", "action": "inspect-lock-before-retry" }` without changing the known projection, lifecycle, or primary recovery truth. Inspect the retained lock before retrying; no rollback, release, or stale-lock removal is implied. Results do not include environment values, secrets, or privileged command content.
If projections completed but lifecycle work failed, JSON reports projections: "complete", lifecycle: "incomplete", and the bounded action `rerun-after-inspecting-owned-resources`. If lock cleanup cannot be proven after an effect result, it adds cleanup: { "code": "lock-cleanup-failed", "action": "inspect-lock-before-retry" } without changing the known projection, lifecycle, or primary recovery truth. Inspect the retained lock before retrying; no rollback, release, or stale-lock removal is implied. Results do not include environment values, secrets, or privileged command content.

View File

@@ -0,0 +1,24 @@
# Systemd and tmux Troubleshooting
Start with read-only mosaic fleet status, `doctor`, and `verify`. Do not manually adopt, rename, terminate, or recreate sessions while ownership is ambiguous.
## Decision table
| Finding | Meaning | Safe next step |
| ------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Empty roster `tmux.socket_name` | Literal default tmux server | Do not substitute the named `mosaic-fleet` socket. Use roster-derived commands only. |
| Non-empty socket | Exact named socket | Never target another socket or infer a per-agent socket. |
| holder: missing | Required exact holder absent | Inspect installation/projection readiness; do not create an unproven holder manually. |
| `ownership-mismatch` | Holder identity or global environment differs | Stop. Verify private install identity and managed paths before retry. |
| `missing-session` | Desired-running roster agent lacks exact session | Check service/runtime preconditions; review apply dry-run. |
| `unexpected-session` | Desired-stopped roster agent still has exact session | Confirm ownership; only reconciler may target the exact proven roster member. |
| `disabled-running` | Disabled roster member is observed running | Inspect and reconcile only after ownership proof. |
| `unmanagedSessions` | Unknown session exists on configured named socket | Report and investigate separately. Reconciler will not kill or adopt it. |
| stale/concurrent generation | Desired state changed since plan | Reload roster/generation and recompute the plan. |
| stale or ambiguous lock | Prior writer/cleanup cannot be proven | Inspect ownership; do not blindly remove the lock. |
| projection failure | Derived files incomplete | Keep roster as authority and regenerate projections. |
| lifecycle failure | Projections complete, runtime convergence incomplete | Inspect the exact owned resource, then rerun with current generation. |
Systemd state, tmux state, heartbeat, and generated files are observations/projections, not alternate desired state. Explicit apply/reconcile honors stopped/disabled intent, but current unit enablement and launcher projections do not yet prove lifecycle-safe reboot; inspect unit enablement before reboot and treat stopped/disabled boot preservation as an FCM-M3-002 hold. Current roster-v2 status commands also do not read heartbeat files. Executable gates do not provide site cutover/rollback or package asset-revision repair.
Errors and troubleshooting output never print legacy sensitive values, credential contents, or privileged command text. Use stable codes, key names/hashes, exact roster identities, and bounded recovery actions. See [status and drift](../reference/status-and-drift.md) and [reconcile and recover](reconcile-and-recover.md).

View File

@@ -0,0 +1,18 @@
# Upgrade and Installed-Asset Drift
Fleet source assets and installed assets can differ after an update, but FCM-M5-001 does not add a trustworthy source-versus-installed revision detector or refresh command. Do not infer freshness from checkout presence, timestamps, generated environment files, running sessions, or a ready migration preview.
## Current safe boundary
- The canonical roster remains authority and must survive package/framework refresh.
- Generated projections are rebuilt from that roster after the installed contract is independently verified.
- Operator `roles.local`, `.env.local`, and private quarantine evidence are not generated assets and must not be overwritten.
- Baseline roles, schemas, examples, service presets, launcher helpers, and systemd templates must move as one reviewed release set.
- Remote/connector inventory and `mos-comms` are not promoted into permanent architecture by an update.
- No update may start an agent persisted stopped, adopt an unmanaged session, or bypass generation/ownership checks.
## Explicit hold
FCM-M5-002 owns deterministic asset-drift checks, safe package/update refresh evidence, rolling local canary, independent validation certificate, and release evidence. Until that card lands, this page is an operational hold rather than an executable procedure: use the repository/release review path, preserve backups, and do not claim source/installed parity without exact revision evidence from the future validator.
See [approved deferrals](../../reports/deferred/758-fleet-config-deferrals.md) and [backup/restore boundary](backup-restore.md).

View File

@@ -4,17 +4,17 @@ FCM-M2-002 provides local roster-v2 create, get, update, delete, and plan operat
## CLI contract
The commands operate only on the canonical `<mosaic-home>/fleet/roster.yaml` v2 authority and print one JSON object to stdout. `--agent` is a JSON object with the roster agent fields expressed as `className`, `toolPolicy`, `workingDirectory`, `persistentPersona`, `resetBetweenTasks`, and `launch: { "yolo": boolean }`.
The commands operate only on the canonical <mosaic-home>/fleet/roster.yaml v2 authority and print one JSON object to stdout. `--agent` is a JSON object with the roster agent fields expressed as `className`, `toolPolicy`, `workingDirectory`, `persistentPersona`, `resetBetweenTasks`, and launch: { "yolo": boolean }.
```sh
```fleet-synopsis
mosaic fleet get <name>
mosaic fleet plan <create|update|delete> [name] --expected-generation <n> [--agent '<json>'] [--persisted-start]
mosaic fleet plan <create|update|delete> [<name>] --expected-generation <n> [--agent '<json>'] [--persisted-start]
mosaic fleet create --expected-generation <n> --agent '<json>' [--dry-run] [--persisted-start]
mosaic fleet update <name> --expected-generation <n> --agent '<json>' [--dry-run]
mosaic fleet delete <name> --expected-generation <n> [--dry-run]
```
`get` returns the authoritative generation and the selected agent. `plan create` derives its name from `--agent`; `plan update <name>` and `plan delete <name>` require the target name. `--agent` accepts only the documented roster-v2 request fields and `launch.yolo`; unknown keys such as commands, channels, or secret references are rejected. Rejection diagnostics return only the stable `invalid-request` code and never echo a rejected value. `plan` and `--dry-run` validate the complete proposed roster and projections but write neither the roster nor projections. `--persisted-start` is available only for a create request: it records `desired_state: running`, but does not start a process. Without it, create records `enabled: true` and `desired_state: stopped`. Handled failures return JSON with `error.code` and exit non-zero; unclassified validation/projection failures use the redacted `mutation-failed` code.
`get` returns the authoritative generation and the selected agent. plan create derives its name from `--agent`; plan update <name> and plan delete <name> require the target name. `--agent` accepts only the documented roster-v2 request fields and `launch.yolo`; unknown keys such as commands, channels, or secret references are rejected. Rejection diagnostics return only the stable `invalid-request` code and never echo a rejected value. `plan` and `--dry-run` validate the complete proposed roster and projections but write neither the roster nor projections. `--persisted-start` is available only for a create request: it records desired_state: running, but does not start a process. Without it, create records enabled: true and desired_state: stopped. Handled failures return JSON with `error.code` and exit non-zero; unclassified validation/projection failures use the redacted `mutation-failed` code.
## Generation, validation, and idempotency
@@ -22,7 +22,7 @@ Each create, update, or delete request includes `expectedGeneration`. A request
`planFleetAgentMutation` is deterministic and side-effect free. `executeFleetAgentMutation` validates the complete proposed roster through the existing structural and shared persona resolver, prepares generated/local/quarantine projections, and writes the roster authority atomically before applying derived projections. Equivalent create retries and delete requests for an already-absent agent are idempotent no-ops.
Delete removes only the exact `<name>.env.generated` projection for the removed roster entry. Operator-owned `<name>.env.local`, legacy `<name>.env`, quarantine records, and unrelated projections remain untouched. An already-absent generated projection is treated as stale derived state, not as a failed mutation.
Delete removes only the exact <name>.env.generated projection for the removed roster entry. Operator-owned <name>.env.local, legacy <name>.env, quarantine records, and unrelated projections remain untouched. An already-absent generated projection is treated as stale derived state, not as a failed mutation.
## Result and recovery
@@ -40,4 +40,4 @@ Mutation results are JSON-safe objects with `applied`, `authoritativeRoster`, `p
}
```
Dry-runs and idempotent no-ops report `authoritativeRoster: "unchanged"` and `projections: "not-applied"`; a complete mutation reports `"committed"` and `"complete"`. Recovery output identifies the authoritative roster path and regeneration action only. It never contains generated/local/quarantine values, credentials, or command text. A recovery result exits non-zero because the authoritative roster was persisted but derived projections require regeneration. Regenerate projections from the roster before attempting another mutation.
Dry-runs and idempotent no-ops report authoritativeRoster: "unchanged" and projections: "not-applied"; a complete mutation reports "committed" and "complete". Recovery output identifies the authoritative roster path and regeneration action only. It never contains generated/local/quarantine values, credentials, or command text. A recovery result exits non-zero because the authoritative roster was persisted but derived projections require regeneration. Regenerate projections from the roster before attempting another mutation.

View File

@@ -1,27 +1,46 @@
# Fleet Control-Plane CLI
The local roster-v2 control plane is `mosaic fleet`.
The local desired-state surface is mosaic fleet. It is distinct from the gateway-backed mosaic agent catalog and from legacy compatibility commands that act on roster v1.
```text
mosaic fleet apply --expected-generation <n> [--dry-run]
mosaic fleet reconcile --expected-generation <n> [--dry-run]
mosaic fleet start [name] --expected-generation <n> [--dry-run]
mosaic fleet stop [name] --expected-generation <n> [--dry-run]
mosaic fleet restart [name] --expected-generation <n> [--dry-run]
mosaic fleet status [name]
## Roster-v2 desired-state commands
| Command | Effect | Generation | Output |
| ---------------------------------------------------------- | ---------------------------------------------- | ---------- | -------------------------- |
| mosaic fleet get <name> | Read one authoritative agent | no | One JSON object |
| mosaic fleet plan <create\|update\|delete> ... | Validate proposed CRUD and projections | required | One JSON object; no writes |
| mosaic fleet create ... [--dry-run] [--persisted-start] | Add desired state; default enabled/stopped | required | One JSON object |
| mosaic fleet update <name> ... [--dry-run] | Replace mutable agent fields | required | One JSON object |
| mosaic fleet delete <name> ... [--dry-run] | Remove roster member/generated projection | required | One JSON object |
| mosaic fleet apply ... [--dry-run] | Plan or converge projections/lifecycle | required | One JSON object |
| mosaic fleet reconcile ... [--dry-run] | Alias of the same convergence contract | required | One JSON object |
| mosaic fleet start\|stop\|restart [<name>] ... [--dry-run] | Exact one-shot lifecycle action | required | One JSON object |
| mosaic fleet status [<name>] | Observe desired/managed/runtime state | no | One JSON object |
| mosaic fleet verify | Strict observational drift/ownership gate | no | One JSON object |
| mosaic fleet doctor | Classify local drift and recovery context | no | One JSON object |
| mosaic fleet migrate-v1 preview ... | Non-mutating field-complete migration evidence | no | One JSON object |
CRUD syntax and full payload shape are documented in [agent mutations](agent-mutations.md). Reconciliation syntax:
```fleet-synopsis
mosaic fleet apply --expected-generation <n>
mosaic fleet reconcile --expected-generation <n>
mosaic fleet start [<name>] --expected-generation <n> [--dry-run]
mosaic fleet stop [<name>] --expected-generation <n> [--dry-run]
mosaic fleet restart [<name>] --expected-generation <n> [--dry-run]
mosaic fleet status [<name>]
mosaic fleet verify
mosaic fleet doctor
mosaic fleet migrate-v1 preview --source <path> --decisions <path> --observations <path>
```
`migrate-v1 preview` is non-mutating: it emits value-free v1 inventory, a canonical semantically
validated v2 candidate when ready, sanitized environment dispositions, and non-executable recovery
evidence. It has no write, apply, canary, or rollback option. Missing preview inputs also return one stable
blocked JSON object and a non-zero exit, rather than Commander text. See
[the migration preview contract](../migration/v1-to-v2.md).
`get` is the read/show operation for one v2 agent. Full roster parsing and semantic validation occur on every v2 mutation/reconcile path; there is no separate mutable “config store.” The executable JSON Schema and validated example provide offline structural evidence. The PRD requires an explicit programmatic mosaic fleet validate operation, but the current CLI does not expose one; do not substitute another command or claim that requirement is delivered. This remains an implementation gap for #758.
`apply` and `reconcile` use roster desired state. `start`, `stop`, and `restart` are exact local one-shot lifecycle effects and never persist a desired-state edit. `status`, `verify`, and `doctor` are observational.
## JSON and exit behavior
Commands emit one JSON object. Handled precondition errors emit `{ "error": { "code": "..." } }` and exit non-zero. Partial derived/lifecycle effects use explicit `authoritativeRoster`, `projections`, `lifecycle`, and bounded `recovery` fields; they never claim rollback. Any additive `cleanup` diagnostic also exits non-zero, even where known effects are complete: it is not a clean completion and the lock requires inspection before retry.
Roster-v2 CRUD and reconciler precondition failures emit { "error": { "code": "..." } } and exit non-zero. Migration preview has its own result envelope: a non-ready preview emits { "status": "blocked", "blockers": [...] } and exits non-zero rather than using the CRUD/reconciler error object. Use both exit status and command-specific state fields. A partial reconciliation result distinguishes `authoritativeRoster`, `projections`, `lifecycle`, `recovery`, and optional `cleanup`; it never claims automatic rollback. `verify` exits non-zero for drift, ownership failure, or unmanaged sessions. Sensitive legacy values, credentials, and rejected command text are never printed.
This control plane is separate from the gateway-backed `mosaic agent` catalog. It is local-only and rejects remote/connector lifecycle mutation, arbitrary command/channel/secret input, and unproven tmux ownership.
## Compatibility and scope
Roster-v1 initialization, provisioning, profiles/personas, and historical fleet add/remove remain compatibility surfaces, not roster-v2 CRUD aliases. New v2 automation should use the table above. migrate-v1 preview writes nothing and has no cutover, canary, or rollback option.
mosaic agent is a separate catalog/transport surface; it does not own <MOSAIC_HOME>/fleet/roster.yaml desired state. Remote/SSH reconciliation, connector mutation, arbitrary commands/channels, secret references, and gateway convergence are rejected or outside scope.

View File

@@ -1,6 +1,6 @@
# Fleet Generated Environment Boundary
**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** unreleased/card-local
**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** merged contract
The local fleet roster is the desired-state authority. A launch reads a deterministic,
roster-derived generated projection and an optional strictly data-only local file; neither file is
@@ -8,14 +8,14 @@ a second roster or a command configuration surface.
## Paths and ownership
For agent `<name>` under `<MOSAIC_HOME>/fleet/agents/`:
For agent <name> under <MOSAIC_HOME>/fleet/agents/:
| Path | Owner | Purpose |
| ----------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `<name>.env.generated` | Mosaic projection writer | Complete deterministic launch data rendered from the authoritative roster. |
| `<name>.env.local` | Operator | Optional, constrained local machine data. It cannot shadow generated keys. |
| `<name>.env` | Legacy input only | Read once during projection generation, then regenerated/relocated or privately quarantined. It is never a launch authority. |
| `<name>.env.quarantine` | Mosaic quarantine | Mode-`0600` private record of forbidden legacy input; it is never read by the launcher. |
| --------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| <name>.env.generated | Mosaic projection writer | Complete deterministic launch data rendered from the authoritative roster. |
| <name>.env.local | Operator | Optional, constrained local machine data. It cannot shadow generated keys. |
| <name>.env | Legacy input only | Read once during projection generation, then regenerated/relocated or privately quarantined. It is never a launch authority. |
| <name>.env.quarantine | Mosaic quarantine | Mode-`0600` private record of forbidden legacy input; it is never read by the launcher. |
The systemd templates do not load either environment file. They invoke Bash with a fixed, cleared
bootstrap environment; the launcher reads and validates `.env.generated` and `.env.local` itself before
@@ -44,7 +44,7 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
MOSAIC_TMUX_SOCKET=<roster socket or empty>
```
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. `fleet add`
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add
uses that same runtime authority and rejects any other runtime before it writes the roster or changes
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
@@ -62,7 +62,7 @@ Local paths must be safe absolute paths and the interval must be a positive inte
quoted/export syntax, duplicate keys, unknown keys, generated-key shadowing, sensitive key names,
and `MOSAIC_AGENT_COMMAND` are rejected. The launcher derives the only executable command from the
validated runtime, model, and reasoning data; no arbitrary command compatibility path exists. When a
Pi runtime writes a fresh `<name>.hb.native` marker, its native heartbeat remains authoritative; the
Pi runtime writes a fresh <name>.hb.native marker, its native heartbeat remains authoritative; the
shell sidecar resumes its `status=ok` fallback only after that marker is stale or absent.
## Legacy disposition
@@ -79,13 +79,13 @@ consolidated downstream interface packet. Status is deliberately separated from
product release version has been evidenced for this interface set.
| Interface | Canonical public path and version | Tracker/release status | Downstream limit |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| M1 structural compiler | `parseRosterV2` in `packages/mosaic/src/fleet/roster-v2.ts`; schema `docs/fleet/reference/roster-v2.schema.json`; roster `version: 2` | FCM-M1-001 is recorded done, merged as #764 (`aa5b43b`); no released product version is asserted here. | Parse YAML/JSON and canonicalize a supplied v2 site roster without writes. |
| M1 semantic resolver | `validateRosterV2Semantics` in `packages/mosaic/src/fleet/roster-v2.ts`; baseline `framework/fleet/roles/` plus `roles.local/` | FCM-M1-002 remains `in-progress` in `docs/TASKS.md`; unreleased. | Reuse the shared resolver only; no parallel role resolver or lifecycle action. |
| M1 disposition evidence | `packages/mosaic/src/fleet/example-profile-dispositions.ts`; `docs/fleet/migration/example-profile-disposition.md`; retained fixture `version: 1` | FCM-M1-003 remains `not-started` in `docs/TASKS.md`; unreleased even though these checkout artifacts are inspectable. | Inspect fixture/profile/service disposition evidence only; it is not migration authorization. |
| M2 generated boundary | `packages/mosaic/src/fleet/generated-env-boundary.ts`; generated projection contract in this document | FCM-M2-001 card-local and uncommitted; unreleased. | Render/write a roster-derived projection; local input is never authority. |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| M1 structural compiler | `parseRosterV2` in `packages/mosaic/src/fleet/roster-v2.ts`; schema `docs/fleet/reference/roster-v2.schema.json`; roster version: 2 | FCM-M1-001 is recorded done, merged as #764 (`aa5b43b`); no released product version is asserted here. | Parse YAML/JSON and canonicalize a supplied v2 site roster without writes. |
| M1 semantic resolver | `validateRosterV2Semantics` in `packages/mosaic/src/fleet/roster-v2.ts`; baseline `framework/fleet/roles/` plus `roles.local/` | FCM-M1-002 merged as #768 (`a5e8e55`); no released product version is asserted here. | Reuse the shared resolver only; no parallel role resolver or lifecycle action. |
| M1 disposition evidence | `packages/mosaic/src/fleet/example-profile-dispositions.ts`; `docs/fleet/migration/example-profile-disposition.md`; retained fixture version: 1 | FCM-M1-003 merged as #770 (`e9c4aa3`); checkout evidence remains validation, not migration authorization. | Inspect fixture/profile/service disposition evidence only; it is not migration authorization. |
| M2 generated boundary | `packages/mosaic/src/fleet/generated-env-boundary.ts`; generated projection contract in this document | FCM-M2-001 merged as #772 (`191efae`); no released product version is asserted here. | Render/write a roster-derived projection; local input is never authority. |
The canonical source remains `<MOSAIC_HOME>/fleet/roster.yaml` for the current local fleet path.
The canonical source remains <MOSAIC_HOME>/fleet/roster.yaml for the current local fleet path.
Generated environment data is a rebuildable projection, not an operator-editable source of membership,
runtime policy, or lifecycle state.

View File

@@ -1,14 +1,21 @@
# Local Fleet Lifecycle Transitions
FCM-M3-001 uses the roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` fields as the only desired-state authority. Systemd, tmux, generated environment files, and heartbeats are derived or observed state.
Roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` are the only persisted lifecycle authority. Systemd, tmux, generated environment, and heartbeat state are derived or observed.
| Command | Desired-state write | Runtime effect | Preconditions |
| --------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `fleet apply` / `fleet reconcile` | Never | Rebuilds projections, then starts only enabled agents desired `running`; stops disabled or desired-`stopped` roster agents | Current generation; private managed paths; valid projections; proven holder ownership; no unmanaged named-socket sessions |
| `fleet start <name>` | Never | One-shot exact `mosaic-agent@<name>.service` start | Current generation; exact enabled roster name; proven ownership |
| `fleet stop <name>` | Never | One-shot exact service stop | Current generation; exact roster name; proven ownership |
| `fleet restart <name>` | Never | One-shot exact service restart | Current generation; exact roster name; proven ownership |
| Event | Desired-state write | Runtime effect | Safety boundary |
| ------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| fleet create | Adds enabled/stopped by default; `--persisted-start` records running | None | Generation-guarded; validates full roster/projections. |
| fleet update | Preserves the existing enabled/desired state; updates other mutable fields | None | Generation-guarded; stable name and lifecycle are immutable on this path. |
| fleet delete | Removes exact roster member | None | Removes only generated projection; retains local/quarantine evidence. |
| fleet apply / `reconcile` | Never | Rebuilds projections; starts only enabled/running; stops disabled or stopped roster members | Current generation, private lock/paths, semantic validity, holder ownership, no unmanaged named-socket sessions. |
| fleet start <name> | Never | One-shot exact service start | Exact enabled roster name and proven ownership. |
| fleet stop <name> | Never | One-shot exact service stop | Exact roster name and proven ownership. |
| fleet restart <name> | Never | One-shot exact service restart | Exact enabled roster name and proven ownership. |
| Reboot/service activation | Never | Current installation may activate enabled units without honoring roster lifecycle | **Held for FCM-M3-002:** boot preservation for stopped/disabled agents is not yet proven; inspect/disable units rather than assuming lifecycle-safe reboot. |
| v1 migration preview | Never | None | Observed active+present maps running; inactive+missing maps stopped; ambiguity blocks. |
| Cutover/canary | Held for FCM-M4-002 | Not implemented by preview | Must preserve every observed stopped state. |
| Rollback | Held for FCM-M4-002 | Not implemented | Must restore selected authority/projections without surprise starts or unmanaged targeting. |
A stopped roster agent is never started by `apply` or `reconcile`. Direct lifecycle commands are explicit one-shot actions and do not change persisted desired state. Use roster CRUD with the explicit persisted-start option to change that desired state.
Explicit apply/reconcile never starts a stopped roster agent. Direct lifecycle commands are explicit one-shot actions and do not persist intent. The current update operation preserves `existing.lifecycle`; there is no delivered generation-guarded CRUD operation for changing durable lifecycle after creation. Reboot preservation for stopped/disabled agents is not yet guaranteed because current enabled units and launcher projections do not carry the persisted lifecycle fence; that acceptance evidence remains FCM-M3-002.
All mutations require `--expected-generation <n>` and acquire one private roster-adjacent reconciliation lock before projection or lifecycle effects. Missing or stale generations and concurrent writers fail before effects; the lock is released after success, partial failure, or thrown lifecycle failure. Stale, ownership, unmanaged-session, unsupported-runtime, path, projection, and lifecycle-precondition failures return stable redacted JSON errors and a non-zero exit. No command targets a fuzzy tmux name, arbitrary socket, arbitrary command, channel, secret, or generated file as authority.
Missing/stale generation, concurrent writer, unsafe path, ownership mismatch, unmanaged session, unsupported runtime, invalid projection, and lifecycle precondition failures return stable redacted JSON and non-zero status. No command targets fuzzy names, arbitrary sockets/commands/channels/secrets, or generated files as authority. Legacy sensitive values are never printed.

View File

@@ -16,7 +16,7 @@ Only these legacy class aliases are recognized:
No other alias is inferred. In particular, `worker`, `analyst`, and `canary` are custom classes only
when an operator supplies a readable contract for that exact class. Tess and Ultron are instance
names, not classes. `agents[].alias` is display-only and cannot grant authority.
names, not classes. agents[].alias is display-only and cannot grant authority.
Canonicalization happens before role lookup. For example, requesting `implementer` resolves
`code.md`; a separate `roles.local/implementer.md` cannot redefine the legacy alias. A canonical

View File

@@ -50,36 +50,38 @@ agents:
## Root fields
| Field | Required | Constraint | Meaning |
| ------------ | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | yes | integer constant `2` | Identifies this contract. Version `1` is explicitly rejected by this compiler and remains on the existing v1 path until M4 migration. |
| `generation` | yes | positive safe integer | Desired-state generation. M2 uses it for mutation guards; M1 does not mutate it. |
| `transport` | yes | constant `tmux` | M1M5 support local tmux only. |
| `tmux` | yes | strict object | Explicit local socket and holder-session configuration. |
| `defaults` | yes | strict object | Default work directory and one supported local runtime. |
| `runtimes` | yes | non-empty object | Declared local runtime reset policy map. |
| `agents` | yes | non-empty array | Local fleet entries. Duplicate stable names are rejected. |
| Field | Required | Default | Constraint | Meaning |
| ------------ | -------- | ------- | --------------------- | ------------------------------------------------------------------------------------------------- |
| `version` | yes | none | integer constant `2` | Identifies this contract. Version `1` stays on the compatibility path pending explicit migration. |
| `generation` | yes | none | positive safe integer | Desired-state generation and mutation/reconcile concurrency fence. |
| `transport` | yes | none | constant `tmux` | M1M5 support local tmux only. |
| `tmux` | yes | none | strict object | Explicit local socket and holder-session configuration. |
| `defaults` | yes | none | strict object | Default work directory and one supported local runtime. |
| `runtimes` | yes | none | non-empty object | Declared local runtime reset policy map. |
| `agents` | yes | none | non-empty array | Local fleet entries. Duplicate stable names are rejected. |
## Nested fields
All nested fields in the v2 schema are required and have no implicit default. CRUD `create` is the only higher-level convenience: it records lifecycle.enabled: true and desired_state: stopped unless `--persisted-start` explicitly records running. That convenience still performs no runtime action.
| Path | Required | Constraint |
| ---------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
| `tmux.socket_name` | yes | `[A-Za-z0-9_.-]*`; empty string means the literal default tmux server, while a non-empty value names a socket |
| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` |
| -------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------ |
| `tmux.socket_name` | yes | [A-Za-z0-9_.-]\*; empty string means the literal default tmux server, while a non-empty value names a socket |
| `tmux.holder_session` | yes | non-empty [A-Za-z0-9_.-]+ |
| `defaults.working_directory` | yes | non-empty string |
| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| `runtimes.<runtime>.reset_command` | yes | non-empty string; runtime key must be a supported local runtime |
| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity |
| `agents[].alias` | yes | non-empty display string |
| `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 |
| `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card |
| `agents[].reasoning` | yes | `low`, `medium`, or `high` |
| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 |
| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans |
| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 |
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
| runtimes.<runtime>.reset_command | yes | non-empty string; runtime key must be a supported local runtime |
| agents[].name | yes | unique [A-Za-z0-9][A-Za-z0-9_.-]\* stable machine identity |
| agents[].alias | yes | non-empty display string |
| agents[].class | yes | [a-z][a-z0-9-]\*; structural only in M1, semantic role resolution is FCM-M1-002 |
| agents[].runtime | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| agents[].provider, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card |
| agents[].reasoning | yes | `low`, `medium`, or `high` |
| agents[].tool_policy | yes | [a-z][a-z0-9-]\*; structural only in M1 |
| agents[].persistent_persona, `reset_between_tasks` | yes | booleans |
| agents[].lifecycle.enabled | yes | boolean; stored now, reconciled in FCM-M3-001 |
| agents[].lifecycle.desired_state | yes | `running` or `stopped` |
| agents[].launch.yolo | yes | boolean; structured data only, not an arbitrary command escape hatch |
## Semantic handoff
@@ -97,12 +99,12 @@ Semantic validation:
`operator-interaction` to `interaction`;
- canonicalizes `tool_policy` with the same exact alias table;
- rejects protected class/tool-policy mismatches in either direction, while accepting
`class: operator-interaction` with `tool_policy: operator-interaction` as canonical
class: operator-interaction with tool_policy: operator-interaction as canonical
`interaction`;
- derives immutable protected authority only from canonical class; and
- accepts custom baseline or `roles.local` classes without granting protected authority.
`agents[].alias` remains display-only. Tess and Ultron are instance names, never semantic classes.
agents[].alias remains display-only. Tess and Ultron are instance names, never semantic classes.
Canonicalization happens before role-layer lookup, so a legacy-named override cannot redefine an
alias as separate authority. See [Role Classes and Authority](./role-classes.md) and
[Customize Fleet Roles](../how-to/customize-roles.md).
@@ -112,7 +114,7 @@ lifecycle mutation.
## Fail-closed boundary
Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed,
Every object is additionalProperties: false. The compiler rejects unknown, missing, malformed,
and wrong-type fields before producing a model. It specifically rejects remote/SSH/host/socket
per-agent fields, connector blocks, secret references, channel fields, arbitrary command fields,
and gateway fields because they are unsupported in the local-tmux M1 contract. It does not silently

View File

@@ -1,13 +1,25 @@
# Local Fleet Status and Drift
`mosaic fleet status [name]`, `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, change desired state, start services, stop services, restart services, or mutate tmux.
mosaic fleet status [<name>], `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, mutate desired state, operate lifecycle, or change tmux.
The report distinguishes:
## State dimensions
- `missing-session`: an enabled agent desired `running` has no exact roster-named tmux session.
- `unexpected-session`: a desired-`stopped` agent still has its exact session.
- `disabled-running`: a disabled roster agent has its exact session.
- `unmanagedSessions`: sessions on the configured named socket that are neither the exact holder nor an exact roster agent.
- `holder`: `owned`, `missing`, or `ownership-mismatch` after exact holder, private install identity, and complete global tmux environment validation.
- **Desired:** roster membership, generation, enabled flag, and persisted running/stopped target.
- **Managed/derived:** generated environment and expected exact service/session topology.
- **Observed by current roster-v2 commands:** systemd active state, tmux presence, exact holder ownership, and unmanaged sessions.
`doctor` and `status` classify rather than adopt, destroy, or repair unmanaged state. `verify` is observational too, but exits non-zero if ownership cannot be proven, unmanaged sessions exist, or drift is present. Reconciliation fails closed under those conditions and never kills or adopts an unmanaged session.
Implemented drift classifications include:
- `missing-session`: enabled/desired-running agent lacks its exact session;
- `unexpected-session`: desired-stopped agent has its exact session;
- `disabled-running`: disabled roster agent has its exact session;
- `unmanagedSessions`: named-socket sessions that are neither exact holder nor roster agent;
- `holder`: `owned`, `missing`, or `ownership-mismatch` after private identity and global environment checks.
Generated projection failures/staleness are surfaced by plan/apply preparation and bounded recovery fields rather than adopted as configuration. Heartbeat remains wider-fleet observational evidence, never desired state, but the current roster-v2 `status`, `doctor`, and `verify` commands do not read heartbeat files. A provable removed-agent projection may be treated as stale derived state during deletion, but general projection-orphan classification and installed source-versus-asset revision mismatch remain FCM-M4-002/M5-002 holds; current commands must not claim those future checks.
## Command behavior
`status` and `doctor` classify rather than adopt, destroy, or repair. `verify` is observational too, but exits non-zero if ownership cannot be proven, unmanaged sessions exist, or drift is present. Reconciliation fails closed under those conditions and never kills or adopts an unmanaged session.
Doctor/error output uses stable codes and bounded recovery context. Migration, quarantine, lifecycle, status, and troubleshooting output never prints a legacy sensitive value, credential, or privileged command text.

View File

@@ -8,9 +8,8 @@
4. [Adding New Agent Tools](#adding-new-agent-tools)
5. [Adding New MCP Tools](#adding-new-mcp-tools)
6. [Database Schema and Migrations](#database-schema-and-migrations)
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
8. [API Endpoint Reference](#api-endpoint-reference)
9. [Local Fleet Canary](./fleet-local-canary.md)
7. [API Endpoint Reference](#api-endpoint-reference)
8. [Local Fleet Canary](./fleet-local-canary.md)
---
@@ -354,37 +353,6 @@ defined there.
---
## Claude Code Skill Bridge
The framework's canonical skill root is `~/.config/mosaic/skills/`; Claude Code
requires registrations under `~/.claude/skills/`. The implementation in
`packages/mosaic/src/commands/skill.ts` owns only direct-child symlinks whose
resolved target remains inside the canonical root.
Security invariants:
1. Validate the user-supplied name before filesystem access against
`[A-Za-z0-9][A-Za-z0-9._-]*`. Separators, control characters, whitespace,
`..`, absolute paths, and leading `-` are invalid; filesystem-derived invalid
names are escaped before terminal output.
2. Never replace a real file, directory, foreign symlink, or live misdirected
symlink in the Claude skill directory.
3. Repair a dangling link only when its lexical target is inside the canonical
Mosaic skills root.
4. Unregister only a symlink pointing inside that root.
5. Enumerate canonical directories at runtime; never hardcode framework skill
names.
`finalizeStage` reconciles after wizard/framework synchronization, and
`runFrameworkReseed` reconciles after the sync-only `mosaic update` path. A
foreign conflict is reported but does not prevent unrelated canonical skills
from registering. Filesystem tests use injected temporary roots in
`skill.spec.ts`, `finalize-skills.spec.ts`, and `update-checker.reseed.spec.ts`.
M1 intentionally manages Claude Code only. Pi's Mosaic launcher can discover the
canonical root directly. Codex still relies on the existing full skill-sync
linker and needs separate parity analysis before this lifecycle API is extended.
## API Endpoint Reference
All endpoints are served by the gateway at `http://localhost:14242` by default.

View File

@@ -98,39 +98,6 @@ Expected results:
that means the unit ran, not that an agent pane is live. Treat tmux
`has-session`, `list-panes`, process tree, and logs as the liveness evidence.
## Recovery — rebuild generated env projections
Each agent's `~/.config/mosaic/fleet/agents/<name>.env.generated` is a
deterministic projection of `roster.yaml` (the SSOT) that the launcher
(`start-agent-session.sh`) sources at start. If an upgrade or a manual mistake
wipes or diverges those projections, rebuild them from the roster with
`mosaic fleet regen` — do NOT restart the affected unit first.
```bash
mosaic fleet regen # dry-run (default): show create/rebuild plan per agent
mosaic fleet regen --json # same plan, machine-readable
mosaic fleet regen --write # rebuild fleet/agents/<name>.env.generated on disk
```
`regen` is projection-only and **never restarts an agent** — it has no path to
systemd lifecycle. It is dry-run by default, deterministic/idempotent, uses the
same roster→env mapping as `mosaic fleet reconcile`, and emits paths and counts
only (never the projected `KEY=value` body). After `--write`, verify each unit
resolves the intended values before restarting one unit at a time. The unit sets
no `EnvironmentFile=``start-agent-session.sh` sources `.env.generated` itself —
so verify the generated file directly and the launcher path, not a nonexistent
`EnvironmentFile` property:
```bash
test -f ~/.config/mosaic/fleet/agents/<name>.env.generated
systemctl --user cat mosaic-agent@<name> | grep ExecStart
systemctl --user restart mosaic-agent@<name>
```
Full recovery runbook and the three-layer #791 protection model (manifest
ownership → pre-update snapshot/restore → regen): see
[Upgrade Safety & Recovery](./upgrade-safety-and-recovery.md).
## Release Preflight
Run this checklist before cutting or dogfooding a fleet release:

View File

@@ -1,36 +0,0 @@
# Lease broker operations
Place the socket and state file in a dedicated directory with mode `0700`. Start the packaged daemon with:
```bash
python3 "$MOSAIC_HOME/tools/lease-broker/daemon.py" \
--socket /run/user/1000/mosaic-lease/broker.sock \
--state /run/user/1000/mosaic-lease/state.json
```
The broker refuses an existing parent directory whose mode is not exactly `0700`, an existing state file not at `0600`, corrupt/incompatible state, or an already-existing socket path. After bind it sets the socket to `0600`. It never silently unlinks a pre-existing socket. On normal termination it unlinks only the socket inode it created, so it does not remove a replacement path.
Before launching Claude, Claudex, or Pi, export the socket path; `mosaic` then runs the runtime through the packaged register-and-exec wrapper:
```bash
export MOSAIC_LEASE_BROKER_SOCKET=/run/user/1000/mosaic-lease/broker.sock
mosaic claude # or: mosaic claudex, mosaic yolo claudex, mosaic pi
```
The wrapper obtains a broker-minted session ID and `exec`s the runtime without changing its PID/starttime anchor. The all-tools Claude `PreToolUse` hook and Pi `tool_call` handler inherit that identity. Claudex retains its isolated proxy environment and config directory; Mosaic merges the mandatory all-tools hook into that isolated `settings.json` before invoking the same wrapper. PRDY init/update, QA remediation, coord, orchestrator, and fleet launchers also converge on this boundary. Broker registration failure, unsafe isolated settings, or missing identity denies launch/tool execution fail-closed; broker timeout/unavailability and malformed replies also block tools.
Run the permanent launch inventory locally with:
```bash
python3 packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py --root .
```
The same check runs in the Mosaic package test suite and therefore in root CI. Any direct Claude/Pi binary launch must be replaced with `launch-runtime.py`, `execLeaseGatedRuntime`, or the gated `mosaic` runtime command; do not add static allowlist exceptions.
Clients must complete the request boundary before waiting for a reply. After sending the single JSON object and its terminating newline, the client **MUST half-close the socket's write side** (`shutdown(SHUT_WR)` in POSIX clients; `socket.end()` in Node) and only then await the response. Merely calling `write()` and waiting is invalid: the broker waits for EOF to enforce the exact-one-frame contract and fails closed at its one-second deadline. Do not replace `end()` with `write()` in client helpers. A delayed second frame remains malformed and is rejected.
There is no automated recovery workflow yet. `mosaic_context_recover` is reserved as the only unverified mutator class, but its fixed payload/receipt implementation lands in a later WI. After a crash, preserve the protected state file and restart only after verifying that no broker owns the socket. Restart intentionally clears all volatile VERIFIED leases. A leftover socket requires an operator to verify the owning service is stopped and remove that exact socket deliberately. Corrupt, oversized, symlinked, or non-regular state fails closed; do not overwrite it. Preserve it for incident review and establish new state only through an explicit operational decision, which invalidates prior sessions and tokens.
## Security posture
Directory `0700` plus socket/state `0600` is built-in same-principal hardening only: it excludes other UIDs but does **not** stop the same UID from unlinking and counterfeiting the socket. It therefore does not close T-C same-UID replacement. WI-1 does not provide a distinct-principal boundary. A stronger distinct-principal deployment requires an external protected proxy, ACL, or service boundary that clients cannot unlink or rebind and that preserves the authenticated client identity required by the broker's `SO_PEERCRED` and ancestry checks. Server-side branch protection remains the irreducible backstop.

View File

@@ -1,147 +0,0 @@
# Upgrade Safety & Recovery
How Mosaic protects operator-owned configuration under `~/.config/mosaic` across
framework upgrades, and how to recover if a projection is ever lost.
A framework upgrade runs `install.sh` in keep-mode (`MOSAIC_INSTALL_MODE=keep`,
`MOSAIC_SYNC_ONLY=1`) to refresh framework-owned files in place. The incident
this hardening addresses: an upgrade that silently overwrites or deletes a file
the operator owns — credentials, personas, a roster, or a generated agent env —
with no snapshot to fall back to.
Protection is layered. Each layer is independent; a later layer catches what an
earlier one misses.
## Layer 1 — Manifest-owned sync (prevention)
The single source of truth for ownership is
[`framework-manifest.txt`](../../packages/mosaic/framework/framework-manifest.txt).
Both the bash installer and the TypeScript sync path resolve every path against
this one file (parity is enforced by test), so they can never drift.
- Ownership is **allow-list, deny-wins**: a path is framework-owned only if a
`[framework]` glob matches and no `[operator]` carve-out overrides it.
- **Unknown paths default to operator** (fail-safe): a file the manifest never
anticipated is treated as operator-owned and is never pruned.
- Keep-mode does a non-deleting copy plus an explicit, manifest-scoped prune that
only ever iterates framework globs — operator and unknown paths are
structurally unreachable by the prune.
Result: a correct upgrade cannot touch operator config at all.
## Layer 2 — Durable pre-update snapshot + verify net (safety + rollback)
Before **any** mutation, the installer snapshots the operator-owned surface that
exists into:
```
${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-timestamp>/
```
- `0700` directories / `0600` files (`umask 077`, scoped and restored),
outside `~/.config/mosaic` and outside any repo.
- **Fail-open**: a snapshot failure warns but never aborts the upgrade it
protects.
- Retention is `MOSAIC_BACKUP_RETENTION` snapshots (default 5).
After the sync, a **verify net** compares each snapshot file against its target
and restores (with a loud warning) any operator file the upgrade diverged or
removed — a divergence means a manifest bug slipped through Layer 1.
Inspect and restore snapshots with the CLI:
```bash
mosaic restore --list # dry-run: enumerate snapshots by timestamp
mosaic restore --from <UTC-timestamp> # restore the operator surface from one snapshot
mosaic restore --from <ts> --dry-run # preview a specific restore without writing
```
`mosaic restore` reports **counts and relative paths only** — it never emits file
contents, so a secret in `tools/_lib/credentials.json` is never echoed. Restores
are confirmation-gated (`--yes` or `MOSAIC_ASSUME_YES`) and write each leaf
atomically with `O_NOFOLLOW` (a symlink swapped in after the snapshot fails
closed rather than following out of the managed tree).
## Layer 3 — Regeneration from roster SSOT (recovery)
Some operator files are **derived** and do not need a byte-for-byte snapshot to
recover — they can be rebuilt from their source of truth. The fleet's per-agent
generated env projections are the prime case:
- `~/.config/mosaic/fleet/agents/<name>.env.generated` is a deterministic
projection of `~/.config/mosaic/fleet/roster.yaml`.
- The launcher (`start-agent-session.sh`, invoked by
`mosaic-agent@<name>.service`) sources that generated projection to establish
each agent's identity, runtime, model, and working directory. If it is missing
or wrong, the agent cannot launch with its intended identity.
`mosaic fleet regen` rebuilds those projections from the roster SSOT:
```bash
mosaic fleet regen # dry-run (default): show what would be rebuilt
mosaic fleet regen --json # same, machine-readable
mosaic fleet regen --write # rebuild the projections on disk
```
- **Dry-run by default.** Nothing is written until you pass `--write`.
- **Deterministic and idempotent** — the projection is a pure function of the
roster, so repeated `--write` runs produce byte-identical files.
- **Projection-only. It never restarts an agent.** Recovery order forbids
restart-before-verify; `regen` has no path to systemd lifecycle at all.
- **It rebuilds only `<name>.env.generated`** — it never writes, relocates, or
deletes the operator-owned `.env` / `.env.local` surface.
- It **validates the roster the same way `reconcile` does** (persona resolution
and protected-class tool-policy match), so a hand-edited or corrupt roster is
rejected rather than projected, and a `--write` takes the shared reconcile
lock so it cannot race a concurrent reconcile.
- Output is **paths and counts only** — the rendered `KEY=value` body is never
echoed.
`regen` uses the exact same roster→env mapping as `mosaic fleet reconcile`, so a
recovered projection matches what a normal reconcile would have written.
## Recovery runbook — wiped `fleet/agents/*.env.generated`
If an upgrade (or a manual mistake) has left an agent without its generated
projection, **do not restart the unit first** — a launch against a missing
projection fails closed, and any stale state must be corrected before restart,
not after.
1. **Prefer a snapshot restore if one exists** (byte-exact operator state):
```bash
mosaic restore --list
mosaic restore --from <UTC-timestamp>
```
2. **Otherwise regenerate the derived projections from the roster SSOT:**
```bash
mosaic fleet regen # confirm the plan (create vs rebuild per agent)
mosaic fleet regen --write # rebuild fleet/agents/<name>.env.generated
```
3. **Verify each unit will resolve the intended runtime/workdir _before_ any
restart.** The unit sets **no** `EnvironmentFile=` — it launches from a minimal
environment and `start-agent-session.sh` sources `.env.generated` itself, so
verify the generated file directly and confirm the launcher path:
```bash
# Confirm fleet/agents/<name>.env.generated exists and carries the intended
# MOSAIC_AGENT_* values (name, runtime, model, workdir, socket).
test -f ~/.config/mosaic/fleet/agents/<name>.env.generated
# Confirm the unit launches the session script that reads it.
systemctl --user cat mosaic-agent@<name> | grep ExecStart
```
4. **Only then restart, one unit at a time:**
```bash
systemctl --user restart mosaic-agent@<name>
```
## See also
- Design: [`docs/design/791-upgrade-config-protection.md`](../design/791-upgrade-config-protection.md)
- Fleet operations: [`docs/guides/fleet-local-canary.md`](./fleet-local-canary.md)
- Ownership SSOT: [`packages/mosaic/framework/framework-manifest.txt`](../../packages/mosaic/framework/framework-manifest.txt)

View File

@@ -183,8 +183,6 @@ non-interactive use:
--no-auto-launch # Skip auto-launch of wizard after install
```
Unrecognized flags or positional arguments fail before installation starts and print the supported-option usage.
Or if installed globally:
```bash
@@ -309,39 +307,6 @@ mosaic quality-rails
---
### Claude Code Skill Registration
Mosaic stores canonical skills under `~/.config/mosaic/skills/`. Claude Code scans
`~/.claude/skills/`, so Mosaic maintains one symlink per skill between those
directories.
```bash
mosaic skill list
mosaic skill register <name>
mosaic skill unregister <name>
```
- `register` is idempotent and repairs a dangling Mosaic-owned link. Names use
the safe grammar `[A-Za-z0-9][A-Za-z0-9._-]*`; files, directories, foreign
symlinks, path traversal, absolute paths, and names beginning with `-` are
refused.
- `unregister` is idempotent when no entry exists. It removes only symlinks that
point inside `~/.config/mosaic/skills/`; foreign entries are never removed.
- `list` reports `registered`, `unregistered`, `dangling`, `foreign`,
`foreign-dangling`, or `misdirected` for each canonical or Claude entry.
Install, wizard finalization, and `mosaic update` framework re-seeding reconcile
every canonical skill automatically. A skill directory added after initial
setup therefore receives its Claude bridge without a per-skill code change or
manual `ln -s`. If Claude Code is already running, use `/reload-skills` or start
a new session after registration so its in-process skill registry rescans.
This command group is Claude-only in M1. Pi can consume Mosaic's canonical skill
root through its Mosaic launcher configuration and does not need this Claude
bridge. Codex has a separate link path managed by the legacy full skill-sync
script; equivalent lifecycle management remains follow-up scope and is not
changed here.
## Sub-package Commands
Each Mosaic sub-package exposes its full API surface through the `mosaic` CLI.

View File

@@ -0,0 +1,54 @@
# FCM-M5-001 Fleet Documentation Deferrals and Holds
**Issue:** #758 · **Branch:** `docs/758-fleet-config-operator-docs`
These are accepted existing DAG boundaries, not omissions silently claimed as delivered.
## FCM-M3-002 hold
- Boot/reboot preservation for roster members persisted stopped or disabled.
- Current installation may enable all agent units, while the launcher projection does not yet carry
`lifecycle.enabled` or `desired_state`; documentation therefore does not claim lifecycle-safe reboot.
- Heartbeat/liveness integration into roster-v2 `status`, `doctor`, and `verify`; current observations
cover systemd active state, tmux sessions, holder ownership, and unmanaged sessions only.
## FCM-M4-002 hold
- Executable v1-to-v2 cutover, reversible canary, and rollback.
- Stale-projection/orphan migration classification and current-host managed/unmanaged fixture coverage.
- Any live migration, lifecycle, systemd/tmux/session, or rollback action.
M5 docs describe prerequisites and the preview boundary only. A ready preview is not migration or rollback evidence.
## Explicit validate-operation gap
- `FCM-REQ-03` requires a documented programmatic `mosaic fleet validate` operation.
- The current CLI does not expose that operation. Existing mutation/reconcile validation and the
documentation example test are not a replacement for the missing command.
- FCM-M5-001 documents this implementation gap without inventing syntax, JSON, exit behavior, or an
owning implementation card. Parent #758 must remain open until the requirement is implemented and
evidenced or the PRD/DAG is explicitly revised through the authoritative process.
## FCM-M5-002 hold
- Deterministic source-versus-installed asset revision detection and safe refresh implementation.
- Rolling local canary, independent validator certificate, final release evidence, merge-gate approval, and parent #758 closure.
`operations/upgrade-assets.md` is therefore a fail-closed hold, not an invented procedure.
## Compatibility interpretation
The M0 cross-cutting row requiring every retained/migrated artifact to validate through the executable contract is satisfied by each artifact's declared executable disposition, not by forcing versioned v1 fixtures through the v2 parser:
- retained examples are explicit `version: 1` fixtures validated by the production v1 parser;
- canonical profiles validate through the shared baseline plus `roles.local` resolver;
- the service preset validates through its production service-policy reader;
- migration candidates validate through the production v2 compiler and shared semantic resolver.
The executable disposition inventory rejects undeclared additions/removals and prevents silent legacy drift.
## Repository-wide documentation structure
The accepted #758 IA is the domain book under `docs/fleet/`. Creating global `USER-GUIDE`, `ADMIN-GUIDE`, or `DEVELOPER-GUIDE` books and cleaning unrelated pre-existing `docs/` root files are outside this bounded card. The repository sitemap links the fleet book. No HTTP/API/auth contract changed, so OpenAPI and endpoint-index updates are not applicable.
Canonical documentation remains in-repository; no external publishing or generated publishing output is in scope. Parent issue #758 stays open through M5.

View File

@@ -0,0 +1,44 @@
# FCM-M5-001 Fleet Documentation IA Closure Evidence
**Issue:** #758 · **Task:** FCM-M5-001
## Artifact map
- Fleet entry point and desired/observed decision tree: `docs/fleet/README.md`.
- Concepts: `docs/fleet/concepts/` covers authority/projections, identity separation, role authority/leases, and the generated launch chain.
- Operator workflows: `docs/fleet/how-to/` covers CRUD, lifecycle, interaction and validator instances, and role overrides.
- Operations: `docs/fleet/operations/` covers reconciliation/recovery, quarantine, systemd/tmux troubleshooting, backup/restore boundaries, and upgrade-asset holds.
- References: executable schema, complete field/default/constraint reference, CLI/JSON/exit behavior, lifecycle/status/drift, role authority, and generated environment boundary under `docs/fleet/reference/`.
- Migration: preview field map, lifecycle preservation, backup/recovery prerequisites, aliases, and executable artifact dispositions under `docs/fleet/migration/`.
- Navigation: `docs/SITEMAP.md` and the fleet entry point.
## Acceptance mapping
| Checklist area | Evidence |
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Roster authority and fail-closed legacy handling | Root PRD FCM-REQ-01/05/08; desired/observed and quarantine pages. |
| Classes and authority | Root PRD FCM-REQ-07; role authority concept/reference; configurable interaction/validator how-tos. |
| Lifecycle | Root PRD FCM-REQ-04; lifecycle transition table and operator lifecycle how-to. |
| Local-only generated launch boundary | Root PRD FCM-REQ-05/09; generated launch concept/reference. |
| Complete DAG and artifact inventory | `docs/TASKS.md`; M0 inventory; executable disposition tests. |
| IA pages | Every path named by the M0 checklist exists and is linked from `docs/fleet/README.md`. |
| Examples | `docs/fleet/examples/roster-v2.yaml` validates through production v2 compiler/shared resolver; shipped artifact dispositions validate through declared production readers. |
| Links | Deterministic local Markdown link test covers the entire fleet book and sitemap, including local heading-fragment resolution. |
| Sensitive/example safety | Validator scans backtick- and tilde-fenced fleet-book examples plus the canonical roster for sensitive-looking keys, common credential formats (including Anthropic, OpenAI project, and Stripe restricted keys), path-qualified privileged commands, package-manager/root commands, arbitrary command override, and hardcoded Tess/Ultron identities; findings report only file/block and violation kind, never matched values. |
| Holds | `docs/reports/deferred/758-fleet-config-deferrals.md` records M3-002, M4-002, M5-002, compatibility, and repository-structure boundaries. |
## Documentation completion checklist
- [x] Root PRD exists and remains the #758 requirements authority.
- [ ] The accepted project-specific fleet book is indexed, but it is not complete against `FCM-REQ-03`: the required explicit programmatic `mosaic fleet validate` operation is not implemented. The CLI reference and deferral report record this gap without inventing behavior.
- [x] Sitemap links the fleet entry point and operator-critical pages.
- [x] No HTTP/API/auth contract changed; OpenAPI/endpoint rows are not applicable.
- [x] Working evidence remains under `docs/scratchpads/`; closure and deferral evidence remains under `docs/reports/`.
- [x] Canonical source remains in-repository; no external publishing action is in scope.
- [ ] Independent exact-head documentation review, PR CI, and FCM-M5-002 release certificate remain post-PR gates and are not claimed here.
## Live-action boundary
No migration, canary, rollback, deployment, systemd/tmux/session operation, generated projection, or product mutation was performed. `roster.yaml` remains the sole writable desired-state authority. `mos-comms` remains temporary. Parent issue #758 remains open.
Validation command results and exact commit/tree evidence are recorded in the task scratchpad and PR body after execution.

View File

@@ -1,529 +0,0 @@
# Scratchpad — #791 Upgrade config protection (ms-791 worker lane)
**Lane:** web1:ms-791 → reports to MS-LEAD (web1:mosaic-100). Do NOT contact Jason/Mos directly.
**Worktree:** `/home/hermes/agent-work/stack-agents-dir-791`, branch `feat/791-upgrade-config-protection`
off `origin/main` `9745bc3f` (verified exact head).
## Mission prompt (verbatim intent)
Protect operator-owned config under `~/.config/mosaic` from framework-upgrade wipes. Ratified
combination (Mos-approved, do NOT re-litigate): (b) strict ownership separation [PRIMARY] + (a)
transactional pre-update snapshot [safety net] + (d) regeneration-from-SSOT [recovery]. (c) periodic
timer DEFERRED. HARD GATE: unit test that an upgrade run touches NO path outside the manifest.
Design-first: write design doc, send to MS-LEAD, WAIT for confirmation before impl.
## Session 1 (2026-07-16) — Phase 1 design
### Evidence gathered (wipe mechanism, file/line)
- `mosaic update``update-checker.ts:509` `buildReseedCommand``bash install.sh`
(`MOSAIC_SYNC_ONLY=1`, `MOSAIC_INSTALL_MODE=keep`).
- Wipe = `packages/mosaic/framework/install.sh:199` `rsync -a --delete` + `PRESERVE_PATHS` denylist
(`install.sh:47`). cp-fallback `install.sh:223` `find ... -exec rm -rf`.
- Denylist gaps → WIPED: `agents/*.conf`, `policy/*.md`, `*.local.md`, harvester/SOP,
`tools/_lib/credentials.json`.
- Stale comment `update-checker.ts:492` claims `*.local` preserved — PRESERVE_PATHS has no such entry.
- TS path `file-adapter.ts:157``file-ops.ts:66` `syncDirectory` = non-destructive copy-overlay, BUT
its preserve list (`file-adapter.ts:164`) already DRIFTED from install.sh (missing `fleet/backlog`,
`fleet/roles.local`). Evidence for single shared manifest SSOT.
- Existing snapshot (`install.sh:76`) = /tmp, crash-trap only, deleted on success → inadequate; no
`mosaic restore`.
- `fleet-reconciler.ts:93,234` already has `regenerate-projections-from-roster` phase separate from
lifecycle → `mosaic fleet regen` = thin projection-only wrapper (no restart), no FCM-M4/M5 preemption.
### Design decisions
- **(b)** Invert to allow-list: shared `framework/framework-manifest.json` (framework globs +
operatorReserved carve-outs); resolve per-path, deny-wins; **UNKNOWN ⇒ operator (fail-safe)**.
Mechanism: drop `--delete`; non-deleting bulk copy + explicit manifest-scoped prune pass (iterate
framework globs only → operator/unknown structurally unreachable). Pure prune-planner fn for tests.
- **(a)** Snapshot to `~/.local/state/mosaic/backups/pre-update-<ts>/` 0700/0600, retention N=5,
post-sync verify+restore, `mosaic restore --list/--from`. No secret values in output.
- **(d)** `mosaic fleet regen` projection-only, preview-first, never restart.
- HARD GATE test includes a deliberately-unanticipated operator path to prove fail-safe default.
- **PR split:** PR1 manifest+guard (root fix, ships alone) → PR2 snapshot/restore (secrev) → PR3
regen+docs. PR2/PR3 depend on PR1.
### Status
Design doc written: `docs/design/791-upgrade-config-protection.md`. Sent to MS-LEAD.
## Session 1 (cont.) — MS-LEAD CONFIRMED → Phase 2 GO
All 4 asks approved. Binding conditions:
- TDD tests-first, red-first proof per PR; ≥85% new-code; co-located `*.spec.ts`; never `--no-verify`.
- HARD GATE test (§2.4, unanticipated sentinel survives byte-identical + mtime unchanged) = MERGE-BLOCKING for PR1.
- Manifest-completeness test (§6.2) required.
- Bash+TS read ONE shared `framework-manifest.json`; parity test (§6.1) required (closes #631 drift class).
- UNKNOWN⇒operator (rule 3) non-negotiable. Keep prune-planner PURE.
- `fleet regen`: NEVER restart; dry-run default, `--write` to apply; "never issues restart" test mandatory.
- Independent review every PR; PR2 dedicated secrev.
- One PR at a time through DAG. Report PR1 exact head + red→green evidence for review commission.
### Now: implementing PR1 (manifest + resolver + non-deleting sync + scoped prune + guard tests).
## Session 2 (2026-07-16) — PR1 built, tests-first, red→green proven
Deviation noted to MS-LEAD in PR: manifest is `framework-manifest.txt` (line-oriented), NOT `.json`.
Rationale: keep the bash installer free of a python3/jq dependency. The "ONE shared file, parity-
tested" requirement is honored — `manifest-parity.spec.ts` drives the bash resolver as a subprocess
and asserts byte-identical ownership vs the TS resolver over 34 probe paths spanning every class.
### PR1 artifacts
- SSOT: `packages/mosaic/framework/framework-manifest.txt` ([framework]/[operator], deny-wins, fail-safe).
- TS resolver: `src/framework/manifest.ts` (pure: parse/matchGlob/resolveOwnership/frameworkSubtreeRoots/
planPrune) + `manifest.spec.ts` (18 tests incl. planPrune property test + §6.2 completeness).
- Bash resolver: `framework/tools/_lib/manifest.sh` (compiled globs → fork-free `manifest_is_framework`;
CLI `resolve|subtree-roots|classify`). Sourced by install.sh.
- HARD GATE (§2.4): `framework/tools/quality/scripts/test-upgrade-manifest-guard.sh` — keep-mode reseed,
10 operator sentinels (incl. unanticipated `unknown-operator-dir/x`, `harvester/sop.md`,
`fleet/my-fleet.yaml`) survive byte-identical + mtime-unchanged; retired framework file pruned;
secret value absent from output. RED=31 fail (orig install.sh) → GREEN=48 pass (fixed).
- install.sh: keep mode now manifest-driven (`sync_framework_keep`, no `--delete`); overwrite unchanged.
PRESERVE_PATHS denylist deleted.
- TS sync: `file-ops.syncDirectory` gains `isOperatorOwned` guard; `file-adapter.syncFramework` derives
it from `loadManifest` — hardcoded (drifted) preservePaths deleted. Fixture uses the REAL manifest.
- Parity: `manifest-parity.spec.ts` (§6.1) — bash↔TS agree on 34 paths + subtree roots.
- Migration matrix `test-install-migration.sh`: F6 flipped — `my-fleet.yaml` now MUST survive (fail-safe).
- CI: new merge-blocking `upgrade-guard` step (`.woodpecker/ci.yml`) runs both bash suites (adds rsync).
- update-checker.ts reseed comment corrected to the manifest model.
### Gates (all green)
- `pnpm typecheck` ✓ · `pnpm lint` ✓ · `pnpm format:check`
- Full mosaic vitest: 1062 passed (cli-smoke needs `pnpm build` first — build-artifact dep, not this change).
- HARD GATE 48/48 · migration 21/21 · parity 3/3 · manifest 18/18 · file-adapter 8/8.
### PR opened + reported (2026-07-16)
- **PR #802** http://git.mosaicstack.dev/mosaicstack/stack/pulls/802 — base `main`@`9745bc3f`,
head `34e55d4a` (commit `feat(mosaic): manifest-owned upgrade guard…`). 15 files, +1160/-142.
- Reported PR head + red→green evidence to MS-LEAD (web1:mosaic-100); queued (lead busy).
Standing by for the independent-review commission at head `34e55d4a`.
- **TWO items flagged to MS-LEAD for decision (awaiting reply):**
1. Deviation `.txt` vs `.json` — confirm accept (parity-tested) or convert to `.json`+jq.
2. `pr-create -i 791` appended `Fixes #791` → would auto-close the tracking issue on PR1 merge
while PR2/PR3 remain. Recommended edit to `Part of #791`; awaiting go-ahead to patch PR body.
- DO NOT start PR2/PR3 until PR1 merges (DAG; one PR at a time).
### MS-LEAD ruling → #797 ledger-survival sentinel folded into PR1 (2026-07-16)
MS-LEAD ruled both my decisions: (1) `.txt` format ACCEPTED (parity must be strict/merge-blocking incl.
format edge cases + negative probe); (2) trailer `Fixes #791``Part of #791` APPROVED (patched PR #802
body via Gitea API — tracking issue no longer auto-closes on PR1 merge). Plus Mos-ELEVATED merge-blocker
(spec `~/agent-work/planning/epic-796/791-ledger-survival-sentinel-SPEC.md`): #797 Runtime Session Ledger
must survive upgrade. Two coupled deliverables landed in PR1:
- (i) Carve-out: `fleet/run/**` was ALREADY an explicit `[operator]` entry — glob matches the spec's
pinned `fleet/run/**` EXACTLY, so NO divergence to route back to planner-opus. Strengthened its comment
to name the ledger (`fleet/run/sessions/` events.ndjson + ledger.json) so it is unmistakably load-bearing.
- (ii) HARD-GATE sentinel: seeded populated ledger (events.ndjson 3 events + ledger.json node+edge+gen,
0600 under 0700) into test-upgrade-manifest-guard.sh sentinels; asserts byte-identical + mtime-unchanged
+ dir-perms unchanged. Negative control (retired framework file IS pruned) relabeled explicitly.
HARD GATE now 58/58 (was 48).
- Decision-1 parity hardening: format-edge fixtures (comments/blanks/whitespace, duplicate+overlapping
globs deny-wins, section/glob-ordering independence) + explicit UNKNOWN→operator negative probe, driven
through BOTH resolvers via MANIFEST_FILE override. Parity 7/7 (was 3).
- RED-FIRST honesty note: the bash ledger sentinel stays GREEN even against the pre-fix installer (the
ledger was incidentally safe from the rsync --delete bug; overall pre-fix run 30/58 as expected). The
carve-out's TRUE load-bearing value (deny-wins if framework ownership ever broadens to `fleet/**`) is
isolated by a dedicated resolver-seam red→green in manifest.spec.ts: WITHOUT `fleet/run/**` operator
entry + hypothetical `fleet/**` framework → ledger resolves framework and planPrune DELETES it (RED);
WITH the carve-out → deny-wins → operator, unprunable (GREEN). manifest.spec.ts 21/21 (was 18).
- Gates all green: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1069 passed · HARD GATE 58/58
· migration 21/21. Committing FORWARD on the branch (NOT rebasing 34e55d4a out from under review).
### MS-LEAD REQUEST CHANGES @ 0a5e703a → B1/B2/B3 fixed red-first (2026-07-16)
MS-LEAD returned REQUEST CHANGES (routed merge-blockers satisfied; 2 CRITICAL reliability defects from
the commissioned independent review). Fixed forward on the branch, red-first:
- **B1 (CRITICAL) — dead ERR trap.** install.sh had `set -euo pipefail` (no `-E`), so the
`trap restore_snapshot ERR` never fired for a failure inside sync_framework_keep() (function body) —
a mid-sync abort left a half-written target with NO rollback. Fix: `set -Eeuo pipefail` (errtrace) +
disarm the trap at the top of restore_snapshot() to prevent re-entrancy. New gate
`test-upgrade-rollback.sh`: injects a mid-sync `cp` EACCES (read-only divergent framework file);
Part A asserts the shipped installer rolls back (restore message fires AND target byte-identical to
pre-upgrade); Part B control strips `-E` and asserts the rollback message does NOT fire (dead trap) —
self-verifying red→green. 7/7.
- **B2/B3 (CRITICAL) — empty/unreadable/malformed manifest divergence.** Pre-fix: TS `parseManifest('')`
returned `{framework:[],operator:[]}` (NO throw) → silent no-op "Installation complete"; bash aborted
fragilely (the `_manifest_compile` `"${MANIFEST_OPERATOR[@]:-}"` artifact returned 1 with no message)
AND the CLI dispatch swallowed manifest_load's rc (no `|| exit`) so `resolve` exited 0 resolving
everything operator. Fix (fail-loud + identical both langs):
* TS `parseManifest`: throw on zero framework entries; `loadManifest`: wrap read error →
"Cannot read framework manifest …".
* bash `manifest_load`: explicit unreadable guard (`[[ ! -r ]]`) + zero-`[framework]` guard, both loud
stderr + return 1; `_manifest_compile` gets explicit `return 0` (kills the empty-array artifact);
CLI dispatch `manifest_load … || exit 1`.
* `finalize.ts`: wrap syncFramework → `spin.stop('Framework sync aborted …')` + rethrow (never falls
through to "Installation complete").
Tests: manifest.spec.ts +5 fail-closed (empty/comment-only/operator-only/empty-section/missing);
manifest-parity.spec.ts +7 failure-mode parity (both reject empty/comment-only/operator-only/
empty-section/entry-before-header/unknown-header/missing — TS throws, bash CLI exits non-zero+stderr);
HARD GATE +4 end-to-end fail-closed matrices (empty/operator-only/malformed/missing → abort non-zero,
manifest error surfaced, every operator sentinel byte-identical). RED proven by reverting
manifest.ts+manifest.sh to HEAD → 12 new tests fail; restore → 40/40 green.
- **Non-blocking addressed.** MEDIUM install.sh:222 find-empty now warns on a real failure instead of
blanket `|| true`. LOW: corrected the "both destructive paths rsync vs cp" overstatement in the HARD
GATE header + cp-fallback comment + ci.yml (keep mode is a single cp-based path; the rsync-present vs
-absent runs prove rsync-independence). `.pre-constitution.bak` triage: single-shot backup is
intentional (reconcile_framework_files backs up once), no change.
- Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1081 passed (was 1069, +12) · HARD GATE
118/118 (was 58) · rollback 7/7 (new) · migration 21/21. No --no-verify. Rollback test wired into
ci.yml upgrade-guard. Committing FORWARD (no rebase of 34e55d4a/0a5e703a).
### Codex round 2 (pre-push self-review) → blockers A/B + should-fix C fixed red-first (2026-07-16)
Before committing round 1 I re-ran codex on the change set; it surfaced two fresh reliability defects
and one messaging defect on the SAME rollback/manifest path. Fixed forward, red-first:
- **Blocker-A (CRITICAL) — signal trap resumed instead of terminating.** A bash INT/TERM handler that
merely `restore_snapshot` (returns) does NOT terminate the script — execution RESUMES past the
interrupt, cleans the snapshot and reports success, leaving a partial post-interrupt update. Fix:
`trap 'restore_snapshot; exit 1' ERR INT TERM` so both the errtrace (ERR) and signal (INT/TERM) paths
exit non-zero. Rollback test Part C: a `cp` shim that `kill -TERM $PPID` mid-sync then succeeds (so
set -e never fires and only the signal path governs) → asserts abort non-zero + restore fires + does
NOT print "file phase complete"; control strips `exit 1` and asserts the buggy resume-to-success.
- **Blocker-B (CRITICAL) — degenerate `[framework]` section resolved everything operator.** A manifest
whose framework entries are all empty / bare-dot (`/`, `./`, `.`, `..`) passed the non-empty guard yet
yielded zero usable globs → nothing is framework → a keep-mode sync silently no-ops (bash resolved
`operator`, exit 0). Fix (both langs, parity): reject when no entry has a char other than `/`/`.`
TS `isUsableFrameworkGlob` = `/[^/.]/.test(normalizeRel(glob))`, throws `ManifestError`; bash mirror
loops `[[ "$(_manifest_norm "$_g")" =~ [^/.] ]]`, loud stderr + return 1. Tests: manifest.spec.ts
`it.each(['/','./','.','..','/\n./'])` throw; parity +3 `expectBothReject` (root-slash/dot-slash/
bare-dot). RED: reverting the guard makes `[framework]\n/` resolve `operator` exit 0.
- **Should-fix-C — misleading abort message.** finalize.ts printed one generic "may be partially
applied" for every sync failure. A `ManifestError` is a PRE-sync validation abort (manifest is
validated before any copy) → nothing was written; conflating it with a mid-copy failure misdirects
recovery. Fix: introduce `ManifestError` (exported from manifest.ts, thrown by every fail-closed
parse/load path), and classify in finalize.ts — ManifestError → "no files were changed"; any other →
"may be partially applied". New co-located `finalize-sync-abort.spec.ts` (3 tests) asserts both
branches re-throw the original error + the correct message, and that config writes are never reached.
RED proven by collapsing the classification → the ManifestError test fails.
- Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1094 (was 1081, +3 finalize-abort;
manifest specs already counted) · HARD GATE 193/193 · rollback 14/14 · migration 21/21.
### Codex round 3 (pre-push self-review) → blockers D1/D2 fixed red-first (2026-07-16)
Re-ran codex again; it found two more rollback-path gaps `set -E` cannot catch. Fixed forward, red-first:
- **Blocker-D1 (CRITICAL) — `find` scan failures swallowed by process substitution.** Both the overlay
copy and the scoped prune consumed `< <(find … -print0)`. Bash does NOT propagate the producer's exit
status to the `while`, so an EACCES/I/O failure mid-scan truncates the file list yet leaves the loop
exiting 0 → a partial upgrade commits and reports success; the ERR/restore trap never fires. Fix:
`_scan_or_die` runs `find … -print0 > "$tmp"` to completion, checks its status, and returns non-zero
(→ ERR trap → restore) on failure; both loops now read from the checked temp file. Rollback test
Part D: a `find` shim that fails every `-print0` scan → shipped installer aborts non-zero + restores +
emits "Could not enumerate framework files" + target byte-identical; control neuters the `# D1-GUARD`
`return 1` → find failure swallowed, upgrade wrongly reports "file phase complete", no rollback.
- **Blocker-D2 (CRITICAL) — silent `set -e` exit on a failed target reset.** restore_snapshot did a bare
`rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"` (trap disarmed, under set -e). If `rm`/`mkdir` fails —
possibly after `rm` deleted part of the target — the script exits immediately, skipping the cp AND the
recovery pointer, leaving a half-removed target and an orphaned snapshot the operator can't locate.
Fix: `if ! rm -rf … || ! mkdir -p …; then fail "Snapshot restore could not reset … preserved at:
$SNAPSHOT_DIR — copy it back …"; return 1; fi` (tested like the cp -a check; snapshot NOT deleted).
Rollback test Part E: cp-poison triggers restore + an `rm` shim fails `rm -rf <TARGET>` → shipped
emits the recovery pointer, the named snapshot dir survives, secret value never leaked; control deletes
the recovery line → operator gets no pointer. RED: reverting D1+D2 → 7 shipped/control assertions fail.
- Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1094 · HARD GATE 193/193 ·
rollback 28/28 (was 14, +14 for D1/D2 with controls) · migration 21/21. shellcheck clean on new lines.
No --no-verify. Committing FORWARD (no rebase of 34e55d4a/0a5e703a).
## Session 3 (2026-07-16) — PR1 MERGED, starting PR2 (durable snapshot + restore + secrev)
PR1 (#802) squash-merged → main `32a0ffba`; issue #791 stays open (3-PR DAG umbrella). Independent Opus
adversarial/security review APPROVED at head `af627e75` (Gitea RoR cmt 17892); lead ran rollback 28/28 +
HARD GATE 193/193 green; CI #1877 green. PR2 UNBLOCKED.
PR2 branch: `feat/791-pr2-snapshot-restore` off `origin/main` 32a0ffba. Same treatment applies:
tests-first red-first, independent review + durable Gitea Reviewer-of-Record comment BEFORE MS-LEAD runs
the queue guard/merge. Report PR2 number + exact head when ready. PR body: `Part of #791` (NOT Fixes).
### PR2 scope (ratified §3/§5 of design doc, Mos-approved — do NOT re-litigate)
- **(a) Durable pre-update snapshot** to `${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-ts>/`
— OUTSIDE ~/.config/mosaic and any repo. Perms dir 0700 / files 0600 (umask 077 + explicit chmod).
Scope = operator-owned surface that EXISTS (operatorReserved paths), not the framework tree. Taken
BEFORE any mutation. Retention N=5 (`MOSAIC_BACKUP_RETENTION`), prune older.
- **Post-sync verify + selective restore**: diff operator surface vs snapshot; (b) should never touch
operator paths, so ANY diff = manifest bug → restore affected paths + warn loudly. (a) catches a (b) miss.
- **`mosaic restore`** (TS CLI): `--list` (default, dry-run) enumerates snapshots by ts; `--from <ts>`
restores over operator surface, confirmation-gated. Counts/paths only.
- **Secret-safety (secrev)**: snapshot/restore NEVER emit file contents; only paths/counts. Tests assert
0700/0600 AND that a secret value seeded in tools/_lib/credentials.json never appears in any output.
### PR2 implementation status (2026-07-16, ready-for-review)
All three tasks implemented, red-first proven, unit-green:
- **Task #10 — durable snapshot (install.sh)**: `backup_root()`/`enumerate_operator_files()`/
`prune_durable_snapshots()`/`make_durable_snapshot()` wired into keep-mode main() after `manifest_load`,
before any mutation. umask 077 + explicit chmod 700/600. UTC ts, collision suffix. FAIL-OPEN (a backup
failure never aborts the upgrade it protects). Retention `MOSAIC_BACKUP_RETENTION` (default 5), in-place
`sort -r -o` prune (no `mv` — stays inside the rsync-absent coreutils whitelist).
- **Task #11 — post-sync verify net (install.sh)**: `verify_operator_surface()` runs after sync (trap
disarmed), `cmp -s` each snapshot file vs target; restores any diverged/missing operator file + warns
loudly (a divergence = manifest bug). VERIFY-NET wired before `cleanup_snapshot`.
- **Task #12`mosaic restore` (TS)**: `src/commands/restore.ts` + co-located spec (19 tests).
`--list` default (dry-run enumerate), `--from <ts>` confirmation-gated restore, `--dry-run`, `--yes`/
`MOSAIC_ASSUME_YES`. Injectable `confirm` for testability (proceed/decline/env-bypass covered). Restored
files forced 0600. Registered in `cli.ts`. Path convention mirrors install.sh `backup_root()`.
- **CI**: `.woodpecker/ci.yml` upgrade-guard runs the new `test-upgrade-durable-snapshot.sh` gate.
- **Gates green**: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1241 (+5) ·
durable-snapshot 26/26 · manifest-guard 193/193 · rollback 28/28 · migration 21/21.
Est. new-code coverage ≈93% (only the interactive readline default + process.exit-on-error uncovered).
- Regression fixed: PR2's `date`/`sort`/`mv` broke the rsync-absent manifest-guard PATH whitelist →
made date/sort fail-open, replaced `mv` with in-place `sort -o`, added `date sort` to the test whitelist
+ isolated `XDG_STATE_HOME`. All 193 manifest-guard assertions green under restricted PATH.
- Codex code-review + security-review (secrev) run on the uncommitted diff before commit.
### PR2 review round 1 — findings + remediations (2026-07-16, pre-PR)
Codex code-review returned **request-changes** (1 blocker + 3 should-fix); Codex security-review returned
**high** (1 high + 1 medium). Deduped to 5 distinct defects, ALL legitimate, ALL fixed FORWARD, each with
a red-first regression test whose control neuters exactly the guard under test:
- **A · BLOCKER — verify net undid the legacy bin/ migration (install.sh).** On a pre-v2 install `bin/**`
is operator-classified, so the durable snapshot captured it; `run_migrations()` deletes bin/ on purpose,
but `verify_operator_surface()` then saw it "missing" and healed it back — the migration would be silently
undone forever once the version stamps. **Fix:** `MIGRATION_REMOVED_PATHS[]` recorded by run_migrations
(`bin`,`rails`) + `is_migration_removed()` skip in the verify loop (`# MIGRATION-SKIP-GUARD`).
**Test:** Part 6 — v1 fixture with bin/; shipped keeps it removed + stamps v3; control (guard stripped)
wrongly restores bin/tool.sh.
- **B · HIGH (CWE-59) — restore/verify wrote secrets THROUGH a symlink (install.sh + restore.ts).** An
attacker swapping an operator path (e.g. tools/_lib/credentials.json) for a symlink after the snapshot
would make `cp`/`copyFileSync` write the snapshot's secret out through the link. **Fix (bash):** refuse a
symlinked ancestor (`has_symlinked_parent`), drop a symlinked leaf before restore
(`# SYMLINK-LEAF-GUARD`). **Fix (TS):** reuse audited `secure-file.ts``assertCanonicalContainment`
+ `ensureManagedDirectory` on every dst, open the leaf `O_NOFOLLOW|O_CREAT|O_TRUNC` 0600 (ELOOP =
fail-closed). **Tests:** Part 7 (shipped leaves external exfil target untouched, restores a real 0600
file; control leaks the secret through the link) + restore.spec symlinked-leaf/ancestor cases (red-first).
- **C · MEDIUM/should-fix (CWE-22) — `--from` traversal escaped the backup root (restore.ts).**
`join(root, from)` accepted `../poison`. **Fix:** validate the selector against
`^\d{8}T\d{6}Z(?:-\d+)?$`, build exactly `join(root,'pre-update-'+ts)`, `lstat` (reject symlinked snap
dir). **Test:** restore.spec `it.each` of 6 malformed selectors + `--from ../poison` fail-closed (red-first).
- **D · should-fix — verify `mkdir -p` unguarded under set -e (install.sh).** A parent replaced by a
regular file aborted the installer before the recovery pointer printed. **Fix:** guard `mkdir -p`, warn
+ `continue` on failure (keeps healing remaining files).
- **E · should-fix — snapshot `umask 077` leaked process-global (install.sh).** Later sync copies/dirs
inherited 0600/0700. **Fix:** save `old_umask`, restore on EVERY return path (`# UMASK-RESTORE-NORMAL`).
**Test:** Part 8 — synced framework file is 0644 while the secret backup stays 0600; control (restore
stripped) makes the synced file 0600.
**Full gate suite re-run after fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic
vitest **1252** · restore.spec **30** · durable-snapshot **41** · manifest-guard 193 · rollback 28 ·
migration 21. shellcheck clean on all new lines; new test markers mirror the existing `# VERIFY-NET`
anchor convention. NOTE: codex self-review does NOT satisfy the independent-review gate — an independent
(author≠reviewer) review + durable Gitea Reviewer-of-Record comment is still required before MS-LEAD merges.
## Session 4 (2026-07-16) — PR2 MERGED, PR3 built (fleet regen — recovery layer)
PR2 (#811) squash-merged → main `31607a4a`; issue #791 stays open (final PR of the 3-PR DAG). Independent
exact-head RoR at `d12c5f78` APPROVE (Gitea cmt 17904); #1882 green; busybox-portable Part 7 control fix
verified in-Alpine. PR3 UNBLOCKED.
PR3 branch: `feat/791-pr3-fleet-regen` off `origin/main` 31607a4. Same discipline: tests-first red-first,
independent review + durable Gitea RoR BEFORE MS-LEAD runs the queue guard/merge. PR body `Part of #791`.
### PR3 scope (ratified §4/§7 of design doc) — `mosaic fleet regen`
Projection-only recovery command: rebuilds each `fleet/agents/<name>.env.generated` from `roster.yaml`
(SSOT). Dry-run default; `--write` applies; `--json` machine output. Structural guarantee: NO code path to
systemd lifecycle — **never restarts an agent**. Single-SSOT: reuses `projectRosterV2AgentGeneratedEnv`
(extracted, shared with the reconciler apply path) so regen and reconcile cannot drift. Secrev: paths +
counts only, never the rendered KEY=value body.
New files: `commands/fleet-regen-command.ts` (+ `.spec.ts`), guide `docs/guides/upgrade-safety-and-recovery.md`
(three-layer model: PR1 manifest ownership → PR2 snapshot/restore → PR3 regen; do-NOT-restart-before-verify
runbook), regen reference added to `docs/guides/fleet-local-canary.md`. Wired in `commands/fleet.ts`.
### Independent review (3 reviewers: subagent code-reviewer + codex code-review + codex security) → 4 fixes, red-first
- **A · BLOCKER (codex) — regen mutated/deleted legacy operator env.** `applyPreparedAgentEnvironmentProjection`
also writes `.env.local`/`.env.quarantine` and unlinks legacy `.env`. Violated projection-only contract.
**Fix:** NEW generated-only boundary primitives `prepareGeneratedAgentEnvironmentProjection` +
`applyPreparedGeneratedAgentEnvironmentProjection` (write ONLY `<name>.env.generated`). regen now has no
code path that touches `.env`/`.env.local`/`.env.quarantine`. **Test:** projection-only leaves legacy `.env`
verbatim, no local/quarantine fabricated.
- **B · should-fix (codex + subagent + security) — partial write on mid-loop failure.** Interleaved
prepare/apply left earlier agents written when a later agent failed prepare. **Fix:** PREPARE ALL agents
before writing ANY (mirrors reconciler `defaultPrepareProjections`). **Test:** 2nd agent's projection
pre-seeded 0644 → prepare rejects → coder0 NOT written, exit 1.
- **C · subagent — semantic-validation bypass.** Default readRoster skipped `validateRosterV2Semantics`, so
a tampered protected-class `tool_policy` would be silently projected. **Fix:** default readRoster now runs
`validateRosterV2Semantics` (persona resolution + protected-class match), rolesDir/overrideDir defaults
mirroring the reconciler. **Test:** merge-gate agent w/ tool_policy=code → fails closed, no write.
- **D · MEDIUM (codex security, CWE-362) — concurrent-reconcile race.** regen `--write` wrote without the
reconcile lock. **Fix:** `--write` acquires `acquirePrivateReconcileLock(mosaicHome)` for the whole
read-prepare-apply sequence, released in `finally`; dry-run stays lock-free. **Test:** pre-held lock →
regen fails closed, no write.
**Gate suite after fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest **1265**
(regen spec 13, incl. 4 new red-first regressions). NOTE: codex self-review does NOT satisfy the
independent-review gate — an independent (author≠reviewer) review + durable Gitea RoR is still required
before MS-LEAD merges. STOP at PR-open for MS-LEAD's exact-head review; do NOT self-merge.
## Session 5 — PR3 review round 2 (finding L + M1/M2/M3), red-first fixes
Second review pass on the lock-cleanup plumbing surfaced one round-1 residual (L) and three round-2
findings (M1 blocker, M2/M3 should-fix). All fixed red-first (RED proven per-finding, then GREEN).
- **L · should-fix (codex r1) — mutation-lock release swallowed unlink failures.** regen's
`acquirePrivateRosterMutationLock` release copied CRUD's `unlink().catch(()=>{})`, hiding a stale
`roster.yaml.mutation.lock`. **Fix:** its release PROPAGATES the unlink fault (finding-J stale-lock
warning then fires for this lock too). **Test:** acquire real lock, `rm` it, assert `release()` rejects.
- **M1 · BLOCKER (codex r2) — replacement-lock race.** The propagating release from L did an
UNCONDITIONAL `unlink(lockPath)` without proving ownership. If the lock is cleared + re-created by
another writer mid-op, regen deletes the STRANGER's live lock → a third writer enters → mutual
exclusion defeated. **Fix (reuse, not reimplement):** generalized the reconciler's ownership-proving
lock body into shared `acquirePrivateManagedRosterLock(mosaicHome, lockLeaf, busyMessage, openLock)`;
`acquirePrivateReconcileLock` delegates to it (behavior-identical: same leaf/codes/messages), and a NEW
hardened `acquirePrivateRosterMutationLock` (now in fleet-reconciler.ts, leaf `roster.yaml.mutation.lock`)
records dev/ino + ownership token and RE-PROVES ownership (`assertLockOwnership`) before unlinking —
fails closed as `lock-cleanup-failed` if replaced. Removed the crud-based export; reverted
`acquireMutationLock` (fleet-agent-crud.ts) to its original inline empty-file/swallowing-release form
(CRUD behavior intentionally unchanged). Compatibility: CRUD empty-file `wx` and regen tokened `wx`
contend on the same path but never co-own (wx winner owns; loser → concurrent-mutation), so the token
is only ever read back by the same regen invocation. **Test:** acquire, `rm`+recreate lock (new inode),
assert `release()` rejects AND the replacement survives (not unlinked).
- **M2 · should-fix (codex r2) — acquire-unwind fault dropped.** The acquire-failure catch discarded
`releaseFleetLocks`' return (a possible fault on the already-held first lock). **Fix:** capture and
augment — `const releaseFault = await releaseFleetLocks(releases); throw augmentWithLockCleanupFault(error, releaseFault);`
(symmetric to finding J). **Test:** mutation lock acquires w/ faulting release + reconcile acquire
throws → thrown error mentions stale/lock, nothing written.
- **M3 · should-fix (codex r2 + subagent REQUEST-CHANGES) — cleanup warning named only reconcile lock.**
Finding L made the mutation-lock release fault reachable, so the `cleanup` marker can originate from
EITHER lock. **Fix:** `formatFleetRegenReport`'s WARNING now names BOTH `roster.yaml.mutation.lock` and
`roster.yaml.reconcile.lock`, matching `augmentWithLockCleanupFault`. **Test:** fault the mutation-lock
release specifically → report names both lock files.
**Refactor note (no cycle):** neither fleet-reconciler nor fleet-agent-crud imports the other; regen
imports lock acquirers from fleet-reconciler and the projection mapping from fleet-reconciler. The two
reconcile-lock reviewers reconciled: independent reviewer validated acquire-time empty-file compatibility
(preserved), codex flagged RELEASE-time replacement race (closed by ownership proof) — non-contradictory.
**Gate suite after fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest **1275**
(regen spec 23, incl. 7 red-first lock regressions E/F/G/K/L/M1/M2/M3). RED proven per-finding by
temporary revert before re-applying each fix. Independent (author≠reviewer) review of M1/M2/M3 + codex
code/security re-run in flight. STOP at PR-open for MS-LEAD's exact-head review + durable Gitea RoR; do
NOT self-merge; #791 umbrella stays OPEN; PR body `Part of #791`.
### Round 3 review (after M1/M2/M3) — independent review PASS + codex residual-TOCTOU disposition
Three reviewers on the post-M1/M2/M3 head:
- **Independent (subagent, author≠reviewer) — PASS.** Verified M1/M2/M3 all correctly fixed; "never
restarts" is STRUCTURAL (runner never referenced in executable code); no secrets; no deadlock (only
regen holds both locks); tests meaningful (assert inode preservation + exact lock-file names). Raised:
- **should-fix #1 (fixed, red-first):** generalizing the lock helper left `assertSafeLockLeafIfPresent`/
`assertLockOwnership` hardcoding "reconciliation lock" in thrown messages → a MUTATION-lock fault
misreported as the reconcile lock, undercutting M3's accurate-diagnosis goal. **Fix:** thread
`lockLabel = fleet/<leaf>` through both helpers + the generic lock-io messages, so every fault names
the actual lock file. Red-first: strengthened the M1 test to assert `/roster\.yaml\.mutation\.lock/`
(RED: got "reconciliation lock"; GREEN after). Also resolves nit #3 (generic-message drift).
- **nit #2 (fixed):** `FleetRegenResult.cleanup` JSDoc still said "the shared reconcile lock"; now names
both locks (regen holds both).
- **nit #4 (fixed):** removed the redundant duplicate `assertLockOwnership` call before unlink
(pre-existing in merged main; harmless but dead — dropped since the fn was already being touched).
- **Codex security — clean (risk: none).** Validates roster semantics, constrains env values, no shell
eval, no secret output, generated-only writes, serialized against both locks.
- **Codex code — request-changes, 1 "blocker": residual check-then-unlink TOCTOU.** Between the final
`assertLockOwnership` and the path-based `unlink`, an external actor could vacate our inode and a new
writer grab the path, so the unlink deletes the stranger's lock. **Disposition: documented known
limitation, NOT fixed in PR3.** Rationale: (1) byte-identical to the MERGED, shipped reconcile-lock
release on origin/main (fleet-reconciler.ts L654-659) — not introduced here; (2) UNREACHABLE within the
`wx` writer protocol — no Mosaic writer removes a lock it doesn't own (wx fails EEXIST while our inode
exists), so only external interference can vacate our inode in the sub-instruction window; (3) the
ownership guard DOES close the reachable case (stale-lock reaper/operator cleared our lock + another
writer took it BEFORE release began → fail closed, don't delete stranger's lock); (4) the true atomic
fix — fd-held advisory lock (flock/lockf) adopted by ALL fleet writers (CRUD + reconcile + regen) — is
a cross-cutting mechanism change touching merged CRUD + reconciler, out of scope for a projection-only
recovery PR. Documented honestly in the acquirer doc + M1 test comment. **The binding independent
review did NOT treat this as a blocker.** Recommendation to MS-LEAD: proceed to PR-open + spin a
SEPARATE follow-up issue for the fd-advisory-lock migration; MS-LEAD adjudicates scope at exact-head
review (merge authority).
**Gates after round-3 fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest
**1275** (regen spec 23). Fresh codex code re-run in flight to confirm no NEW issues from the label fix.
---
## Session 6 — Round 4/5 convergence (stranded-lock robustness)
**Two independent reviewers converged on the SAME should-fix** on the init-failure cleanup path,
strengthening confidence it was real:
- **Codex code-review-5 — 0 blockers, 1 should-fix.** "Stat failure after lock creation strands the new
lock." When `handle.stat()` ITSELF fails right after the `wx` create (transient EIO/EBADF), `created`
is `undefined`, so `removeOwnedLockLeafBestEffort` had `if (!created) return;` → no cleanup → the
just-created `roster.yaml.mutation.lock`/`reconcile.lock` is stranded, permanently blocking future
regen + CRUD. (Notably NO blocker, and the TOCTOU is no longer flagged in code-review as of r5.)
- **Independent delta reviewer (author≠reviewer, pr-review-toolkit) — no blockers, same should-fix.**
Independently flagged the identical `!created` gap; validated FIX 1 (label threading — no call site
missed, codes unchanged, no test depended on old text) and FIX 2 (dev/ino-guarded cleanup, best-effort,
happy-path release reuses captured dev/ino) as correct. Suggested an unconditional best-effort unlink
in the `!created` branch; I took the **safer** variant below.
- **Codex security-review-5 — 0 crit / 0 high / 1 medium.** The single medium is the SAME residual
check-then-unlink TOCTOU already dispositioned in round 3 (its own remediation = "migrate every writer
to an fd-held advisory lock" = the follow-up issue). No new security finding. No secrets.
**Fix (red-first, safer than an unconditional unlink):** thread the persisted random `token` into
`removeOwnedLockLeafBestEffort`. Two independent ownership proofs now: primary dev/ino (unchanged), and a
**fallback** when the post-create stat failed — read the leaf and unlink ONLY if its content equals our
`randomUUID()` token. Only OUR lock carries that token, so a CRUD (empty) or differently-tokened
replacement is never deleted. `tokenPersisted` guards passing the token (only after `writeFile` lands).
Doubly-degenerate case (stat fails AND token write never landed) leaves the lock in place rather than
risk deleting a stranger's file — requires two independent fs faults on a just-created fd; documented.
- **Red-first proof:** new test `does not strand the lock file when the post-create stat itself fails`
injects a real `wx` create + a Proxy handle whose `stat()` rejects (writeFile/close succeed), asserts
`exists(lockPath) === false`. RED before fix (`expected true to be false` — lock stranded); GREEN after.
- **Also fixed (delta nit #3):** `fleet-regen-command.ts` `acquireRosterMutationLock` JSDoc said "CRUD's
private lock"; the default is the reconciler's hardened ownership-proving acquirer for the same
`fleet/roster.yaml.mutation.lock` path. Corrected.
- **PR-description note (delta nit #2):** FIX 1 also collapsed a pre-existing duplicate back-to-back
`assertLockOwnership` call in the release closure (identical args, no intervening logic) into one — a
no-op simplification of merged code, not a behavior change. Called out so a future reader doesn't
wonder if the duplicate had a purpose.
**Gates after round-4 fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest
**1277** (regen spec now 25: +1 stat-failure stranded-lock regression). Residual TOCTOU still deferred to
the fd-advisory-lock follow-up issue; MS-LEAD adjudicates scope at exact-head review (merge authority).
---
## Session 6 — Round 6 (persona-root wiring)
**Codex code-review-6 — 0 blockers, 1 should-fix (NEW, distinct from the lock work).** "Forward
configured persona directories to regen." `registerFleetRegenCommand` was registered at
`fleet.ts:2069` with only `{ runner, mosaicHome }`, discarding `deps.reconcileDeps.rolesDir` /
`overrideDir`. The regen command ALREADY has those seams (validates roster semantics via
`validateRosterV2Semantics({ rolesDir, overrideDir })`, defaulting to `<mosaicHome>/fleet/roles{,.local}`),
but the top-level wiring never forwarded the configured roots. **Impact:** in a deployment with custom
persona roots, `fleet reconcile` (which honors the overrides) would ACCEPT a roster while `fleet regen`
REJECTS the same roster (persona resolution against the wrong default dir) — blocking the recovery
command and violating the documented "resolves personas the SAME way reconcile does" contract.
**Fix (red-first):** forward `rolesDir`/`overrideDir` from `deps.reconcileDeps` into
`registerFleetRegenCommand` at `fleet.ts:2069`. Red-first test `forwards configured persona roots
(rolesDir/overrideDir) from reconcileDeps into regen`: seeds personas ONLY under a custom root, leaves
the default `<home>/fleet/roles` empty, registers with `reconcileDeps: { rolesDir, overrideDir }`, and
requires `fleet regen` to SUCCEED. RED before fix (`expected 1 not to be 1` — regen validated against the
empty default and exited 1); GREEN after.
**Codex security-review-6 — 0 crit / 0 high / 1 medium.** Same residual check-then-unlink TOCTOU, now
noted at BOTH the release closure and the init-cleanup path; remediation = fd-held advisory lock across
all writers = the SAME deferred follow-up item. No new security finding, no secrets.
**Independent confirmation review of the token-fallback fix (Session 6/round 4) — PASS, no findings.**
All 7 verification points confirmed; reviewer mechanically reverted `removeOwnedLockLeafBestEffort` to
the pre-fix `if (!created) return;` and re-ran the new test → RED (`expected true to be false`),
confirming the test genuinely pins the fix; restored after. No lint/type issues; doc-comment accurate.
**Gates after round-6 fix (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest
**1278** (regen spec now 26: +1 persona-root wiring regression).
---
## Session 6 — Round 7 convergence (review CLOSED for PR-open)
- **Codex code-review-7 — 0 blockers, 1 should-fix = the residual TOCTOU** (previously a "blocker" in r3,
dropped in r4/r5, now re-surfaced as a should-fix). **Codex security-review-7 — 0 crit / 0 high /
1 medium = the SAME residual TOCTOU.** Codex has CONVERGED: the only remaining finding across both
streams is that one race, whose own remediation is "fd-held advisory lock shared by all fleet writers"
= the deferred follow-up. No new distinct finding; the wiring fix introduced nothing.
- **Independent confirmation review of the persona-root wiring fix — PASS, no findings.** Reviewer
mechanically reverted the two forwarded lines → RED (`Roster v2 agent "coder0" class "code" does not
resolve to a readable persona` → exit 1), restored → GREEN (26 regen + 204 fleet tests). Confirmed the
optional-chaining fallback preserves default-deployment behavior and no type/lint issue.
**Review disposition for PR-open:** ALL actionable findings fixed red-first across rounds 36 (label
threading, stranded-lock on init failure, stat-failure strand, persona-root wiring). The residual
check-then-unlink TOCTOU is the ONLY open item and is DEFERRED to a follow-up issue (fd-advisory-lock
migration across CRUD + reconcile + regen) — byte-identical to merged origin/main's reconcile-lock
release, unreachable within the `wx` writer protocol (no Mosaic writer removes a lock it doesn't own;
only external `rm`/a stale-lock reaper can vacate the inode mid-release), and its true fix is a
cross-cutting mechanism change out of scope for a projection-only recovery PR. Two independent human-agent
reviews (author≠reviewer) treated it as non-blocking. MS-LEAD adjudicates scope at exact-head review
(merge authority); recommendation = proceed to PR-open + spin the follow-up issue.
**Final gates (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest **1278**
(regen spec 26). No secret values in any snapshot/projection/report output (counts + paths only). Regen
NEVER issues a lifecycle/restart call (load-bearing recordingRunner gate). STOP at PR-open for MS-LEAD's
exact-head review + durable Reviewer-of-Record before any merge; do NOT self-merge.

View File

@@ -1,86 +0,0 @@
# Issue #804 — fail closed on unknown installer arguments
## Objective
Implement Part 1 of Gitea issue #804 only: `tools/install.sh` must reject every unrecognized flag or argument with an actionable STDERR error and nonzero exit before installation starts.
## Scope and constraints
- Preserve all currently recognized options and behavior, including `-y` and `--ref <branch>`.
- No positional arguments are currently accepted by the parser.
- Do not add `--next`, `MOSAIC_NEXT`, prerelease routing, or any Part 2 behavior.
- TDD is mandatory: add and observe a failing process-level regression test before changing `tools/install.sh`.
- Worker lifecycle ends after branch push, PR creation, and coordinator notification; do not merge or close #804.
- Existing launcher-owned changes in `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` are out of scope and must not be committed.
## Requirements and acceptance criteria
- Unknown input names the offending argument on STDERR.
- STDERR includes a short installer usage hint.
- Exit status is nonzero.
- The installer does not invoke npm or otherwise proceed into installation.
- Existing recognized flags remain unchanged.
## Plan
1. Add a process-level Vitest regression using the installer test location under `packages/mosaic/src/commands/`.
2. Run the focused test and record the expected RED failure.
3. Commit the RED test as `test(#804): ...`.
4. Replace the parser catch-all with a fail-closed STDERR error and usage hint.
5. Update concise installer-facing documentation without introducing prerelease behavior.
6. Run focused tests, shell syntax validation, package tests, lint, typecheck, and format checks.
7. Run independent review tooling and remediate findings.
8. Commit as `fix(#804): ...`, queue-guard, push, open a PR containing `Closes #804.`, notify the coordinator, and exit.
## Budget
- No explicit token cap supplied.
- Working estimate: 8K tokens; narrow two-file behavior/test change plus concise docs and delivery gates.
## Progress
- 2026-07-17: Loaded mission state, issue #804, delivery/QA/documentation rails, and relevant TDD/Vitest/pnpm/Gitea skills.
- 2026-07-17: Confirmed the parser has no legitimate positional arguments and currently drops all unmatched input via `*) shift ;;`.
- 2026-07-17: Installed locked workspace dependencies with a worktree-local pnpm store; no lockfile changes.
- 2026-07-17: Added the process-level unknown-argument regression with an isolated `$HOME` and npm shim.
- 2026-07-17: Replaced the silent catch-all with STDERR error + usage output and exit 2 before preflight or installation.
- 2026-07-17: Initial Codex code review found an unknown option could still be consumed as the `--ref` value. Added a second RED reproducer, then rejected option-shaped/missing `--ref` values without changing valid `--ref <branch>` behavior. The review's launcher-state note is handled by excluding both `.mosaic/orchestrator/` files from commits.
- 2026-07-17: Updated README, user guide, and packaged framework README with the fail-closed argument contract. No API, auth, admin, sitemap/navigation, or publishing surface changed.
## Verification
- RED: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/install-arguments.spec.ts` — expected failure: installer exited `0` instead of nonzero at the exit-status assertion; confirms the test reproduces the silent-drop defect before production changes.
- Remediation RED: the added `--cli --ref --bogus` case exited `0`, proving `--ref` could swallow an unknown option before the guard was added.
- GREEN: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/install-arguments.spec.ts src/commands/install-heading.spec.ts` — 2 files, 3 tests passed.
- Situational process check: unknown positional input exited 2, named the input on STDERR, printed usage, and did not call the npm shim.
- `bash -n tools/install.sh` — passed.
- Bare `--ref` process check — exited 2 with `Missing value for --ref` and usage.
- `pnpm --filter @mosaicstack/mosaic test` — 69 files, 1,287 tests passed; framework shell checks passed. The first attempt lacked generated `dist/cli.js`; `pnpm --filter @mosaicstack/mosaic build` restored the required test precondition and the full rerun passed.
- `pnpm lint` — 23/23 tasks passed.
- `pnpm typecheck` — 42/42 tasks passed.
- `pnpm format:check` — passed.
- Codex code re-review against `origin/main``approve`, 0 blockers/should-fix/suggestions.
- Codex security re-review against `origin/main` — risk `none`, 0 findings.
## Acceptance evidence
| Criterion | Evidence |
| --- | --- |
| Unknown input is named on STDERR | Process-level Vitest assertions for `--bogus`, including after `--ref` |
| Short usage hint is printed on STDERR | Vitest usage regex + manual process output |
| Exit is nonzero | Vitest status assertions and manual exit 2 |
| Installation does not proceed | Isolated npm shim marker remains absent |
| Recognized behavior is preserved | Parser cases are unchanged except validation of malformed `--ref`; full Mosaic package suite passed |
| Part 2 is excluded | No `--next`, `MOSAIC_NEXT`, dist-tag, or prerelease routing changes |
## Documentation checklist
- Current canonical `docs/PRD.md` remains unchanged; issue #804 and the coordinator brief supply this bounded defect's acceptance contract.
- Updated installer behavior in root README, user guide, and packaged framework README in the same logical change set.
- API/OpenAPI, auth/permissions, admin operations, developer architecture, sitemap/navigation, and external publishing are not affected.
- Scratchpad remains under `docs/scratchpads/`; no root-hygiene changes.
## Risks and blockers
- Part 2 remains owner-gated under #805 and is intentionally excluded.
- No implementation blocker remains. Independent coordinator RoR, CI, merge, and issue closure remain pending after worker handoff.

View File

@@ -1,64 +0,0 @@
# Issue #807 — GLPI list wrappers accept HTTP 206
- **Branch:** `fix/807-glpi-206`
- **Task:** Gitea issue #807
- **Role:** Author-only worker reporting to `mosaic-100`; no self-review or merge
- **Started:** 2026-07-16
## Objective
Fix the shipped GLPI ticket, computer, and user list wrappers so ranged responses with HTTP 206 Partial Content render successfully while genuine HTTP failures remain non-zero errors.
## Scope
- Modify only the three affected list wrappers and a focused shell regression test.
- Do not touch `session-init.sh`, `ticket-create.sh`, or `docs/TASKS.md`.
- Add task-local delivery evidence here as required by the mission protocol.
## Plan
1. Add a deterministic shell harness that copies each wrapper beside stubbed `session-init.sh`, credentials, and `curl` boundaries.
2. Prove RED against the current 200-only gates: 206 must fail before the implementation change.
3. Update all three status gates to accept exactly 200 or 206.
4. Prove GREEN for 206 rendering and genuine 401/500 failures, then run repository quality gates.
5. Commit with co-author attribution, run the push queue guard, push, and open a PR for independent review and merge by the team lead.
## Budget
- No explicit token cap supplied.
- Soft estimate: 8K tokens; narrow single-worker execution with no exploratory scope.
## Progress
- [x] Mission, task, PRD, QA, documentation, and code-review guidance loaded.
- [x] RED regression evidence captured: `test-list-http-status.sh` exited 1; all three wrappers rejected 206 while retaining 401 failures.
- [x] Implementation complete.
- [x] Relevant tests and repository gates green.
- [ ] Commit pushed and PR opened.
## Tests and evidence
- RED (before source fix): `packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → exit 1; ticket/computer/user 206 assertions failed, all 401 assertions passed.
- GREEN: `bash -n packages/mosaic/framework/tools/glpi/{ticket-list.sh,computer-list.sh,user-list.sh,test-list-http-status.sh}` → pass.
- GREEN: `shellcheck packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → pass.
- GREEN: `packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → 6 assertions pass (206 renders and 401 errors for all three wrappers).
- GREEN: `pnpm typecheck` → 42/42 tasks pass.
- GREEN: `pnpm lint` → 23/23 tasks pass.
- GREEN: `pnpm format:check` → all matched files pass.
- Setup note: initial gate attempts could not start because the fresh worktree lacked dependencies; `pnpm install --frozen-lockfile --store-dir /home/hermes/.local/share/pnpm/store` restored the locked workspace dependencies without lockfile changes.
## Acceptance criteria mapping
| Criterion | Evidence |
| --- | --- |
| HTTP 206 succeeds and renders each ranged list | Focused test's three 206 render assertions pass |
| Genuine HTTP failures remain non-zero with existing diagnostics | Focused test's three HTTP 401 assertions pass |
| Only affected list wrappers change | Diff contains the three status predicates plus focused test/evidence; session and create wrappers untouched |
## Documentation decision
No operator/API documentation change is needed: this restores documented list behavior for a healthy GLPI response without changing command syntax, output, configuration, or public contracts. This task scratchpad records delivery evidence.
## Risks / blockers
- Existing dirty `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` are runtime-owned and will not be edited or committed.

View File

@@ -1,37 +0,0 @@
# Issue #808 — agent-send sender identity
## Objective
Fix cross-socket `agent-send.sh` preambles so replies route to the real sender rather than a destination-socket holder session.
## Scope and acceptance criteria
- Prefer exported `MOSAIC_AGENT_NAME` as the authoritative sender session name.
- If it is unset, query the sender's local/default tmux socket for `#S` without destination `-L` arguments.
- Preserve `?` when sender identity cannot be determined.
- Do not alter destination socket dispatch.
- Add red-first regressions for all three identity paths.
## Plan
1. Extend `agent-send.test.sh` with deterministic fake-tmux coverage.
2. Run the test against the unpatched implementation and record RED evidence.
3. Apply the minimal sender lookup fix only.
4. Run the focused suite and repository quality gates.
5. Commit, queue-guard, push, and open an author-only PR for independent review.
## Constraints and risks
- Worker lane is author-only: no self-review or merge.
- `docs/TASKS.md` and mission state are orchestrator-owned and will not be modified.
- Pre-existing runtime changes under `.mosaic/orchestrator/` are excluded from this work.
- Budget: no explicit token cap; keep changes limited to the shell tool, sibling regression test, and this scratchpad.
## Evidence
- RED: `bash packages/mosaic/framework/tools/tmux/agent-send.test.sh` failed on the unpatched implementation with `PASS=12 FAIL=3`; it selected `destination-holder` instead of both `MOSAIC_AGENT_NAME=authoritative-agent` and local session `local-agent`. The genuinely unavailable sender case already exercised and preserved `?`.
- GREEN: `bash packages/mosaic/framework/tools/tmux/agent-send.test.sh` passed with `PASS=15 FAIL=0`; coverage includes env authority, local/default tmux fallback across a destination `-L`, explicit rejection of the destination holder, and `?` fallback.
- Syntax: `bash -n packages/mosaic/framework/tools/tmux/agent-send.sh packages/mosaic/framework/tools/tmux/agent-send.test.sh` passed.
- Quality gates: `pnpm typecheck` (42/42 tasks), `pnpm lint` (23/23 tasks), and `pnpm format:check` all passed after installing the frozen lockfile dependencies. The first install attempt failed because pnpm's configured store pointed at `/root`; retrying with the existing user-owned store (`--store-dir /home/hermes/.local/share/pnpm/store`) succeeded without changing tracked dependency files.
- Documentation: no public API or operator workflow changed; the source comment, regression-test contract, and this implementation record cover the internal bug fix.
- Independent review: intentionally pending for the reviewer assigned by `mosaic-100`; this author-only lane will not self-review or merge.

View File

@@ -1,112 +0,0 @@
# Issue #824 — Mosaic skill CLI and Claude bridge auto-sync
## Objective
Deliver `mosaic skill register|unregister|list` plus install/upgrade reconciliation of every canonical `~/.config/mosaic/skills/*` entry into `~/.claude/skills/`, without clobbering runtime-owned files or directories.
## Scope and constraints
- Issue: mosaicstack/stack#824
- Branch: `feat/824-mosaic-skill-cli`
- M1 runtime: Claude Code only.
- Pi/Codex parity is documentation-only; no non-Claude bridge implementation.
- Do not author the downstream `mosaic-context-refresh` skill.
- Workers do not modify `docs/TASKS.md`, merge, close #824, or touch `main`.
- TDD is mandatory and red-first; filesystem tests use temporary directories only.
- Budget: no explicit token cap supplied; use a focused single-worker implementation with no new dependencies.
## Requirements mapping
1. Register creates the canonical Claude symlink and is idempotent.
2. Names are untrusted: reject empty/escaping/absolute/separator/`..`/leading-dash names before filesystem mutation, with clear CLI stderr and nonzero status.
3. Register repairs only Mosaic-owned dangling symlinks and refuses foreign files, directories, and symlinks.
4. Unregister removes only symlinks pointing inside the canonical Mosaic skills root and is idempotent when absent.
5. List reports registered, dangling, foreign, and canonical-but-unregistered skills.
6. Install and upgrade generically reconcile all canonical skills after framework sync/re-seed, continuing past foreign conflicts without clobbering them.
7. User/developer documentation describes commands, status meanings, security boundaries, and Claude-only M1 scope.
## Plan
1. Add co-located failing Vitest coverage for all filesystem behaviors and auto-sync.
2. Run the focused spec and record the expected RED failure.
3. Commit the red contract as `test(#824): ...`.
4. Implement the skill bridge and Commander command registration.
5. Wire reconciliation into wizard finalize and `mosaic update` re-seed, preserving non-clobber behavior.
6. Update canonical docs and sitemap if navigation changes.
7. Run focused tests, package tests, typecheck, lint, and formatting.
8. Commit implementation/docs as `feat(#824): ...`, queue-guard, push, open PR with `Closes #824.`, fire completion event, and notify the coordinator.
## Progress
- 2026-07-17: Loaded mission/delivery/TDD/documentation rails, issue #824, active mission state, and relevant installer/update paths.
- 2026-07-17: Confirmed `mosaic update` invokes `framework/install.sh` with `MOSAIC_SYNC_ONLY=1`; that path exits before existing post-install skill linking, leaving newly present canonical skills unregistered.
- 2026-07-17: Coordinator addendum classified the user-supplied skill name and runtime symlink target as a path-traversal/symlink-injection surface. Expanded the initial red contract to reject traversal before mutation, preserve every foreign entry, and unregister Mosaic-owned links only.
- 2026-07-17: Implemented the Commander command group and secure generic bridge; wired wizard finalize and successful framework re-seed reconciliation; updated user/developer/installed/root docs and sitemap.
- 2026-07-17: Focused, package-wide, repository baseline, temp-home situational, and independent review gates completed. Ready for scoped feature commit, queue guard, push, and PR handoff.
## Tests and evidence
### TDD evidence
- RED environment attempt: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/skill.spec.ts` initially could not locate Vitest because this fresh worktree had no dependencies.
- Dependency setup: `pnpm install --frozen-lockfile --store-dir /home/hermes/.local/share/pnpm/store` succeeded. The explicit store was required because machine pnpm config incorrectly resolves the default store under `/root`.
- RED behavior: focused Vitest failed with `Failed to load url ./skill.js ... Does the file exist?`, proving the bridge API was absent.
- RED integration: finalize/update specs failed because no Claude links or `skillSync` result existed.
- RED symlink injection: symlinked Claude/canonical root tests failed because the initial implementation followed ancestor links.
- GREEN after review remediation: `skill.spec.ts` 36/36, `finalize-skills.spec.ts` 6/6, and `update-checker.reseed.spec.ts` 30/30.
### Baseline gates
- `pnpm --filter '@mosaicstack/mosaic...' run build` — pass (fresh-worktree dependency outputs built).
- `pnpm --filter @mosaicstack/mosaic run typecheck` — pass.
- `pnpm --filter @mosaicstack/mosaic run lint` — pass.
- `pnpm --filter @mosaicstack/mosaic test` — pass: 69 files, 1,325 Vitest tests plus framework shell suite.
- `pnpm typecheck` — pass: 42/42 Turbo tasks.
- `pnpm lint` — pass: 23/23 Turbo tasks.
- `pnpm format:check` — pass.
### Situational evidence
A built-CLI temp-home smoke test (no real `~/.claude` or Mosaic config touched) proved:
- register creates the exact link and a second run reports `already registered`;
- list reports registered and unregistered canonical skills;
- `../../etc` exits 1 with `Invalid skill name` and creates no escaped path;
- unregister removes the managed link and a second run reports `already unregistered`;
- a fake successful framework re-seed generically registered both `added-after-setup` and `second-skill` from runtime directory enumeration.
### Review evidence
- Initial uncommitted Codex code/security review described name validation/clobber protection as strong; its only finding was the harness-owned, unrelated `.mosaic/orchestrator/session.lock`, which is excluded from all commits and the PR.
- Exact branch review then identified two remediations: preserve successful framework re-seed status when bridge-wide reconciliation fails, and reject/escape control-character names to prevent terminal/log injection.
- Both findings were reproduced red-first and remediated. A subsequent exact review identified one finalize failure-isolation blocker; a root-wide bridge error now warns and allows wizard doctor/summary/next-steps completion, with a red-first regression.
- All remediations passed the full package and repository gates. Final exact-head review is rerun after amending the feature commit.
### Acceptance mapping
| Acceptance criterion | Evidence |
| --- | --- |
| register/unregister/list, idempotent | `skill.spec.ts` and built-CLI temp-home smoke |
| traversal/symlink-injection protection | invalid-name matrix, foreign file/dir/link tests, symlinked-root tests |
| list flags dangling and foreign entries | deterministic list status test |
| install and upgrade auto-sync every canonical directory | finalize + framework re-seed integration specs; two-skill built-module smoke |
| newly added skill becomes discoverable without manual link | `added-after-setup` auto-sync creates exact Claude link; Claude can rescan with `/reload-skills` or a new session |
| Pi/Codex parity captured as scope note | user guide, developer guide, installed framework README |
| documentation gate | root README, user guide, developer guide, framework README, sitemap |
## Risks
- Symlink replacement uses `lstat` semantics so dangling links are detectable without following them.
- Link ownership is determined lexically against the canonical skills root, and existing symlink ancestors in either managed root are rejected before mutation.
- Auto-sync continues across per-skill conflicts while never deleting real files/directories or foreign symlinks.
- Claude Code discovers filesystem skills at session launch/reload boundaries; bridge creation makes a later `/reload-skills` or new session able to discover the skill, but cannot mutate an already-cached in-process registry by itself.
- Pi does not need this Claude bridge because its Mosaic launcher can consume the canonical root. Codex lifecycle parity remains explicitly deferred.
- No deployment surface is affected.
## PR #826 review remediation
- 2026-07-17: Exact-head RoR requested changes for two ownership bugs: installer pruning deleted foreign-name links under `MOSAIC_HOME` outside canonical skills, and unregister deleted a same-root link targeting a different skill. It also requested trailing-dot rejection and executable coverage support.
- RED evidence: focused regression run failed 4 tests: register/unregister accepted `safe.`, misdirected unregister did not throw, and the install linker deleted the foreign-name link.
- GREEN evidence: `skill.spec.ts` passes 43/43, including live and dangling foreign-name links in a temp HOME/MOSAIC_HOME and the misdirected unregister invariant.
- Coverage: `vitest run src/commands/skill.spec.ts --coverage` passes configured 85% thresholds for `skill.ts`: 91.05% statements/lines, 86.27% branches, 95.23% functions.
- Full gates: package build passed; package tests passed 69 files / 1,332 tests plus framework shell suite; repository typecheck 42/42, lint 23/23, and format check passed.

View File

@@ -1,86 +0,0 @@
# WI-1 Scratchpad — Authenticated external lease broker
- **Issue:** Gitea #828
- **Milestone:** 188 — Compaction-Refresh Mechanism (M1: Claude + Pi)
- **Branch:** `feat/828-lease-broker`
- **Starting HEAD:** `d801d6c4c8a984d6a95033c49714210018d3d9a8`
- **Session role:** Orchestrator coordinating implementation; Mos retains merge authority.
## Objective
Implement the ratified WI-1 product lease broker under `packages/mosaic/`: Linux `SO_PEERCRED` identity, broker-minted logical session IDs, `(pid,starttime)` launcher anchors with per-hop `/proc` starttime revalidation, sibling-substitution rejection, same-PID runtime-generation revocation, crypto-RNG single-use token persistence, and protected Unix-socket posture.
## Authority verification
Verified before code on session start; all exact SHA-256 values matched:
- BUILD-BRIEF: `89fdbc27ed0e5050dc7b52f3ef2ddaea691edf17fd89d51b15e26fb5ed47171b`
- SPEC-v5: `a6d07ade835758e8488ca10d3b0631caf0beb93ea3a6733631f151b0c2f01433`
- Ratification: `bac58319c9c4028b5b40e1129e0033cdb5a6b7b02033c25f06f4cb77d7779c67`
- P6 planner ruling: `b7bbb6ea6e8d9a5c3366993642ab4e4f65b961af04936dcac20bfbcdcbaf1a09`
- WI-0 Gate0 evidence: `5d418306fcc597fd514e500bee40d1509f0bf467e46ee13fc5c280ed8274759d`
## Locked constraints
- Build against the ratified design; do not re-derive it.
- Product code only in `packages/mosaic`; Gate0 Python probes are reference prototypes and are not shipped.
- Caller-supplied/asserted `session_id` is refused.
- Tokens use the operating-system CSPRNG via Python `secrets`; never `Math.random` or model output.
- Socket parent directory mode `0700`, socket mode `0600` minimum; document distinct-principal deployment as the stronger T-C-closing posture.
- Red-first TDD for six named cases; new-code coverage >=85%.
- No merge. PR must say `closes #828`; exact 40-character head handed to Mos for Opus-SECREV and independent review.
## Plan
1. Load security/testing/docs guidance and inspect existing `packages/mosaic` architecture.
2. Write the six required tests first and capture RED evidence.
3. Implement minimal broker modules and CLI/runtime integration necessary for product use.
4. Run focused tests with coverage, package gates, then full repository gates/suite.
5. Run author-side review/remediation, commit `closes #828`, queue guard, push, and open PR through Mosaic wrappers.
6. Send PR number + exact head SHA to `web1:mosaic-100`; stop without merging.
## Risks / boundaries
- Same-UID counterfeit socket replacement remains the disclosed T-C residual unless broker runs under a distinct principal; filesystem modes alone are minimum hardening, not a complete authenticity proof.
- `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` were already modified at session start and must not be included in this PR.
- Repository Woodpecker pipelines exist; CI is the canonical build path. No manual image build/deploy is in scope.
## Progress / evidence
- 2026-07-18 session start: mandatory mission files and orchestration guides loaded.
- STEP 0: all four authority hashes matched; artifacts read in full.
- Branch/HEAD confirmed; issue #828 open; Gate0 evidence hash confirmed.
- Initial RED: focused Vitest acceptance suite failed 11/11 because the product daemon did not exist; the expected missing-product failure was observed before implementation.
- Review-remediation RED: partial/zero-progress state writes, nested corrupt state, symlink state, canonical starttime, and duplicate-anchor generation behavior failed before their fixes. Real socket RED/GREEN runs were executed by the unrestricted parent harness because the delegated worker sandbox denies `AF_UNIX.bind()`.
- Product implementation added at `packages/mosaic/framework/tools/lease-broker/daemon.py`; Gate0 probe scripts were read as references but not copied or shipped.
- Independent Codex code review round 1 found 2 blockers + 1 should-fix (connection stall/crash, partial writes, packet-dependent framing); all were remediated with tests.
- Independent Codex code review round 2 found 2 blockers + 1 relevant should-fix (half-close contract ambiguity, incomplete persisted-state validation, symlink/non-regular state); all were remediated with tests and documentation. Pre-existing `.mosaic/*` session dirt remains excluded from the PR.
- Unrestricted focused situational suite: `35/35` GREEN.
- New Python product module coverage: `90%` (`356` statements, `36` missed), above the user-required 85%.
- Root typecheck: `42/42` Turbo tasks GREEN.
- Root lint: `23/23` Turbo tasks GREEN.
- Root format check: GREEN.
- Package build + suite: `71/71` files and `1,369/1,369` tests GREEN, including framework shell tests.
- Full root suite: `43/43` Turbo tasks GREEN after the oversized-frame production race fix.
- Focused acceptance suite: `35/35` GREEN in three consecutive unrestricted runs; exact-head instrumented run also `35/35` GREEN.
- Exact-head Python product coverage: `90%` (`365` statements, `37` missed), above the required 85%.
- Review-triggered oversized-frame race was fixed in production by bounded drain-to-EOF; tests were not changed.
- Commits banked in red/green cadence: `d61c5441` (RED contract), `deb11df7` (GREEN implementation/docs), `57770e34` (oversized-frame production fix).
- Final-review blocker remediated: added a 256-token pending-state cap, deletion on consume/generation revocation, pre-open serialized-size enforcement, and request-wide in-memory rollback for every broker mutation/commit failure while retaining the v1 live-token schema.
- Distinct-principal docs now state built-in `0700`/`0600` is same-principal only; WI-1 does not provide the external identity-preserving proxy/ACL/service boundary needed for the stronger deployment.
- Exact Python unit suite: `8/8` GREEN. Unrestricted focused acceptance: `35/35` GREEN.
- Exact-head package build/suite: `71/71` files and `1,369/1,369` tests GREEN.
- Exact-head Python product coverage: `90%` (`376` statements, `36` missed), above required 85%.
- Root typecheck: `42/42` GREEN. Root lint: `23/23` GREEN. Root format check and `git diff --check`: GREEN.
- Final exact-head rereview found two persistence blockers: post-rename directory-fsync uncertainty and acceptance of impossible persisted token records. RED was captured as three invariant failures plus one missing fail-stop error; commits `a94b1220` (RED) and `d05465e5` (GREEN) remediate both without weakening tests.
- Post-remediation evidence: Python unit suite `10/10`, focused real-socket acceptance `35/35`, full root suite `43/43` Turbo tasks, broker coverage `90%` (`395` statements, `38` missed), lint `23/23`, typecheck `42/42`, format check and `git diff --check` GREEN.
- Independent Codex review of remediation commit `d05465e54736c4966294c4af8fbd6a4ad8fe81aa`: APPROVE, confidence `0.94`, zero findings. Reviewer sandbox could not allocate temp directories; unrestricted parent test evidence above is canonical.
- Remediation session: terra review comment `18072` reproduced a SERIAL-ACCEPT DoS; scope is RED regressions plus bounded concurrent connection handling on PR #836, preserving all existing broker security properties.
- RED evidence against reviewed daemon: four silent peers delayed registration `3920 ms` beyond the `1500 ms` bound; 16 silent peers were not reaped within `2500 ms`. The first bounded implementation then exposed slot exhaustion by rejecting the valid queued caller with `EPIPE`; admission was corrected to wait for a reclaimed bounded slot. GREEN evidence: queued-peer test `211 ms`; strengthened cap/reap/reclaim test `1118 ms`; complete real-socket acceptance `37/37` and Python persistence suite `10/10`.
## Coordinator handoff requirements
1. Mandatory Opus-SECREV on the exact PR head; no GPT/terra substitute.
2. Independent exact-head code review and exact-head RoR before Mos-authorized merge.
3. Mos retains merge authority; this WI author stops after PR + full 40-character head handoff.

View File

@@ -1,130 +0,0 @@
# WI-2 Scratchpad — Whole mutator-class gate
- **Issue:** Gitea #829
- **Branch:** `feat/829-mutator-gate`
- **Base HEAD:** `8ec67a1126adb0dcd4c3a2bf5525f3e239c0b201`
- **Role:** sol author/build lane only; terra code review and Opus security review are coordinator-owned.
## Mission prompt
Implement BUILD-BRIEF Deliverable 2 as a framework-native whole mutator-class gate under `packages/mosaic/`, building against the merged WI-1 lease broker. No consequential mutator may succeed while UNVERIFIED after a compaction observer fires or after TTL. Carry the T-B compromised-tool acceptance criteria. Enforce revoke-first and promote-last structurally. A receipt is only a promotion prerequisite; the mutator-class gate remains the safety mechanism. M1 is Claude + Pi only.
## Authority verification
Verified exact SHA-256 before design/code:
- BUILD-BRIEF: `89fdbc27ed0e5050dc7b52f3ef2ddaea691edf17fd89d51b15e26fb5ed47171b`
- SPEC-v5: `a6d07ade835758e8488ca10d3b0631caf0beb93ea3a6733631f151b0c2f01433`
- Ratification: `bac58319c9c4028b5b40e1129e0033cdb5a6b7b02033c25f06f4cb77d7779c67`
- sol final red-team: `3da326a4ea91767b731e128a93b13194e8002358101e30de3fcb8ca2f8f54faa`
Carried authority chain also verified/read for the locked T-B gate contract: SPEC-v4 `a5e9c261…`, v4 sol `1e76ee59…`, SPEC-v3 `e0830ba0…`, v3 sol `9f321ade…`.
## Plan
1. RED real-socket acceptance tests for default-deny whole classes, T-B raw-tool bypass, observer/TTL revocation, and structural revoke-first/promote-last.
2. Extend the merged WI-1 broker as the sole lease authority; authenticate every transition through existing peercred/ancestry/session logic and consume WI-1 single-use cycle tokens atomically before promotion.
3. Add one broker-backed runtime gate executable and wire it across all Claude `PreToolUse` tools and Pi `tool_call`; unknown/custom tools deny by default.
4. Add proportional protocol/security/operations documentation and requirements-to-evidence mapping.
5. Run focused coverage, package/full suites, lint/typecheck/format, then queue-guard, push, open an unmerged PR with `closes #829`, and hand off the exact head.
## Risks and bounds
- Receipt parsing/builders and compaction observers are later WIs; WI-2 exposes the promotion prerequisite boundary but does not treat a receipt as safety authority.
- Broker restart intentionally loses volatile VERIFIED leases and therefore restarts UNVERIFIED; persistent WI-1 identity/token state remains unchanged.
- The gate is whole-class and does not parse shell command strings. T-C extension/hook absence and same-UID broker replacement remain outside the client guarantee and server branch protection remains the backstop.
- Initial lease TTL is capped at ratified 300 seconds; callers may only shorten it.
- Working budget assumption: 35K tokens; reduce documentation/refactor breadth before touching locked scope if pressure rises.
## Progress and verification
- RED #1: all 5 initial real-socket contract tests failed on WI-1 with `UNKNOWN_ACTION` or missing adapter behavior.
- RED #2: register-before-exec runtime test failed because `launch-runtime.py` did not exist.
- GREEN: broker-owned volatile lease state, 300-second maximum monotonic TTL, WI-1 token-backed promotion, all-tools runtime gate, Claude/Pi wiring, and register-before-exec launcher delivered without changing WI-1 peercred/ancestry authority.
- Focused broker + gate acceptance: `43/43` GREEN.
- Instrumented Python coverage: `88%` total — daemon `89%`, register/exec launcher `86%`, runtime gate `86%`.
- Full repository suite: `43/43` Turbo tasks GREEN; `@mosaicstack/mosaic` `72/72` files and `1,377/1,377` tests GREEN.
- Root typecheck: `42/42`; lint: `23/23`; format check and `git diff --check`: GREEN.
- No author self-review was run. Exact-head terra CODE and Opus SECREV remain coordinator-owned gates.
## Acceptance mapping
| Acceptance criterion | Evidence |
| --- | --- |
| No consequential mutator succeeds while UNVERIFIED after observer revoke or TTL | `observer revocation and monotonic TTL expiry deny the next mutator` real-socket acceptance test |
| T-B compromised-tool bypass is covered by the whole gate | `T-B raw and custom mutator tools are default-denied without shell parsing` across Claude/Pi built-ins, raw Bash class, MCP/custom/unknown tools |
| Revoke-first / promote-last is structural | `revoke-first and promote-last structurally bracket mutator authority`; direct promotion rejected, pending remains denied, token consumption commits before VERIFIED |
| Consume WI-1 auth/lease substrate | All transitions and decisions traverse merged peercred/ancestry authentication; promotion consumes the exact WI-1 CSPRNG cycle token |
| M1 Claude + Pi | Claude `.*` PreToolUse and Pi `tool_call` invoke the same broker gate; register-before-exec test proves broker-minted parent identity reaches runtime descendants |
## Locked discipline
- Re-verify and read the four authority artifacts before design or code.
- RED-first tests must cover unverified mutation refusal, T-B compromised-tool refusal, and revoke-first/promote-last ordering.
- Consume WI-1 VERIFIED-lease state; do not re-derive kernel identity, ancestry, sessions, or token authentication.
- Minimum 85% new-code coverage; full suite, lint, typecheck, and format checks green.
- Build only: no self-review and no merge. Open a PR containing `closes #829`, report its exact 40-character head, then exit.
## Remediation — terra CODE comment 18091
- Coordinator correction: terra returned REQUEST CHANGES at head `77b137ccc04b5be035cac5ca21bbbf3df8b94f97`; Opus SECREV was GO and CI green, but no evidence transfers to the remediated head.
- BLOCKER 1 verified: first-class Claude/Pi route through `execLeaseGatedRuntime`, while the supported Claudex path preserves isolation but directly invokes `claude`; it therefore registers no anchor, injects no lease session, and the isolated config has no guaranteed all-tools gate hook.
- BLOCKER 2 accepted: prior 88% was aggregate evidence. Remediation must produce independently measured branch coverage of at least 85% for each new executable (`launch-runtime.py`, `mutator-gate.py`, and daemon delta evidence), including successful exec-boundary collection and validation/error branches.
- Remediation discipline: RED tests first; preserve the reviewed-good broker lock/state-transition ordering; update PR #837 on the same branch; no self-review or merge.
### Remediation evidence
- RED commit `046896c6`: both `mosaic claudex` and `mosaic yolo claudex` behavioral probes exited 1 because the direct path supplied neither a broker session nor the isolated all-tools hook; branch-focused Python tests failed on the absent injectable boundaries. Fresh WI-2 daemon-delta instrumentation also failed the ≥85% branch gate at 75%.
- GREEN: Claudex now exposes only `execLeaseGated`, passes the preserved isolated proxy environment through the shared register-before-exec wrapper, and merges the exact `.*` mutator hook into isolated `settings.json` with mode `0600`. Missing broker/identity, malformed or symlinked settings, and an unverified consequential tool all fail closed. The broker transition/lock implementation was not changed.
- Behavioral regression: normal and YOLO Claudex both receive a 64-hex broker session, retain their mode-specific arguments, observe the exact all-tools hook, and receive status 2 for unverified `Bash`.
- Independent branch coverage: `launch-runtime.py` 16/18 = **89%** (statements 98%); `mutator-gate.py` 21/22 = **95%** (statements 99%); `daemon.py` WI-2 delta 35/40 = **88%** (whole-file branch 80%, statements 90%).
- Fresh focused real-socket coverage run: WI-1 + WI-2 acceptance `46/46`; persistence `10/10`; branch unit suite `10/10`.
- Fresh full repository suite: `43/43` Turbo tasks; `@mosaicstack/mosaic` `72/72` files and `1,381/1,381` tests.
- Fresh root gates: typecheck `42/42`; lint `23/23`; format and `git diff --check` GREEN.
- PR #837 remains open and unmerged. Terra CODE and Opus SECREV must both rerun from zero on the exact remediated head before coordinator-owned merge authorization.
## Remediation round 3 — terra CODE comment 18099 + binding upgrade
- Locked-good surfaces: Claudex gating and B2 per-executable coverage are verified; do not regress them. Broker state-transition/lock ordering remains untouched.
- Mechanical repository sweep found direct executing Claude entries in PRDY init, PRDY update, QA remediation, and `@mosaicstack/coord` task launch. It also found a direct Claude command rendered into the QA report template and documentation examples. Existing Mosaic CLI Claude/Pi/Claudex, orchestrator session-run, and fleet starts already reach the gated boundary.
- Elevated hard requirements: ship a permanent suite/CI guard that scans production source and fails on any direct Claude/Pi launch; route every executing entry through one common gated wrapper; add real-broker RED/GREEN tests for PRDY init/update and QA; preserve each environment and denial behavior; independently measure all new executable coverage at ≥85%.
- Round-3 plan: first commit RED behavioral and scanner-contract tests; then add one framework `launch-runtime.sh` choke-point over `launch-runtime.py`, make Mosaic CLI and shell launchers use it, make coord route through `mosaic`, and wire the permanent guard into package tests. Update all discovered operator-facing direct-launch examples so the scanner inventory remains complete.
### Round-3 outcome
- RED commit `7f3418fa`: PRDY init, PRDY update, and QA remediation all reached the fake Claude binary without a broker session even when the configured socket did not exist; the permanent-guard contract initially failed because its executable was absent, then failed against the five discovered direct entries (four executing plus the QA command template).
- Choke-point decision: a new shell layer was unnecessary. Every executing repository entry now converges directly or through `mosaic`/`execLeaseGatedRuntime` on the existing single `launch-runtime.py` register-then-exec wrapper. PRDY and QA preserve their working directories, prompts, flags, logging pipe, and environment. Coord rewrites direct Claude commands to `mosaic claude` and rejects unknown custom Claude launchers fail-closed.
- Permanent guard: `packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py`, invoked by `packages/mosaic/package.json` `test:framework-shell` and therefore root `pnpm test`/CI. It scans production code under `packages/`, `apps/`, `plugins/`, and `tools/` and rejects literal, absolute-path, process-API, command-array, and dynamic Claude/Pi launch forms. Synthetic bypass tests are permanent at `runtime_launch_guard_unittest.py`.
- Mechanical inventory: **14 gated / 14 total** — coord 2, fleet 1, QA 2, orchestrator 3, PRDY 2, Mosaic Claude/Pi/Claudex adapter/boundary 4. No verification-layer fallback or follow-up issue is needed because the single code-level wrapper was achieved.
- Real-socket behavioral evidence: PRDY init, PRDY update, and QA remediation each fail before runtime execution when the broker is absent; with the broker present they receive a broker-minted 64-hex session and the unverified `Bash` authorization exits 2. Claudex normal/YOLO and the broker state machine remain GREEN.
- Fresh branch coverage: `launch-runtime.py` **18/18 = 100%**; `mutator-gate.py` **22/22 = 100%**; permanent guard **36/38 = 95%**; `daemon.py` WI-2 delta **35/40 = 87.5%**. All attributable executable statement coverage is at least 98%.
- Fresh focused suites: broker + mutator real-socket acceptance `49/49`; persistence `10/10`; launcher/gate branch suite `13/13`; permanent guard suite `7/7`; coord `19/19`.
- Fresh full repository suite: `43/43` Turbo tasks; `@mosaicstack/mosaic` `72/72` files and `1,384/1,384` tests. Root typecheck `42/42`, lint `23/23`, format, and diff checks GREEN.
- PR #837 remains open and unmerged. Terra CODE and Opus SECREV must both rerun from zero on the exact round-3 head before coordinator-owned merge authorization.
## Remediation round 4 — terra CODE comment 18104
- Locked-good surfaces: the 14/14 launch inventory, single `launch-runtime.py` choke-point, real-socket launcher behavior, coverage, Claudex gating, and broker state machine must not change.
- Reproduced RIDER E exactly at head `1792b7934dda7eff64a207b8b0edb9c460d4164b`: a temporary production file containing `exec claude --dangerously-skip-permissions "terra-r3" # launch-runtime.py` made the guard exit 0 and report `1 gated/1 total`.
- Root cause: classification searched the unparsed physical line, and the broad gated regex treated any `launch-runtime.py` substring—including comments and inert arguments—as an invocation before the direct-launch finding was evaluated.
- Round-4 plan: add permanent RED cases for the exact comment evasion plus string-argument, echo, and unrelated-variable marker evasions; tokenize/strip comments by launcher syntax; recognize only command-position wrapper invocations with `--runtime` and the gated command separator; retain 14/14 real inventory; rerun guard coverage and all gates fresh.
- Mos Rider A/B decision: adopt **both** defenses. Command-position parsing remains necessary because a normal `claude -p` launch is consequential even without the dangerous flag. The primitive-location invariant is more mechanically robust for dangerous mode because it does not need to recognize a wrapper marker at all. Move the sole raw `--dangerously-skip-permissions` literal into `launch-runtime.py`; any occurrence in another production file is independently RED.
- Rider-A RED matrix adds heredoc body, backslash continuation, non-first `;`/`&&`/pipe commands, command substitution, `eval`, and variable-execution indirection in addition to the six marker/comment evasions. Before the augmented implementation, primitive ownership, command substitution, `eval`, variable execution, and the preserved 14-site inventory all fail.
- Round-4 GREEN uses both defenses. Quote-aware comment stripping removes shell/Python `#` and JS/TS line/block comments; shell command prefixes are segmented with `shlex`; validated wrappers require `launch-runtime.py` in command position, `--runtime`, and the `--` command separator; multiline TypeScript wrapper calls are validated as complete invocations. Direct command syntax wins over markers, while tracked runtime assignments plus `eval`/variable execution, command substitution, chained commands, heredocs, continuations, and `env`/`command`/`nohup` prefixes are rejected.
- Primitive ownership is independently load-bearing: `launch-runtime.py` is the sole production owner of the raw Claude dangerous flag. Mosaic, Claudex, and PRDY request semantic `--dangerous`; the wrapper validates Claude and injects the primitive immediately before register/exec. This preserves actual YOLO argv behavior while making any raw primitive elsewhere fail without relying on wrapper-name recognition.
- Permanent guard suite now has 10 tests and 31 direct-launch forms, including 18 new round-4 marker/comment/indirection/prefix evasions plus harmless-marker and multiline-wrapper controls. Terra's exact add-ungated source is exercised through the CLI effectiveness test. Repository inventory remains exactly **14 gated / 14 total**.
- Fresh round-4 coverage: guard **97%** branch-aware aggregate (241 statements, 110 branches); `launch-runtime.py` **100%**; `mutator-gate.py` **100%**. The daemon is byte-unchanged from the round-3 head whose WI-2 delta is **87.5%**.
- Fresh round-4 gates: real-socket acceptance **49/49**; persistence **10/10**; launcher/gate **14/14**; guard **10/10**; coord **19/19**; Mosaic **1384/1384**; root **43/43**; typecheck **42/42**; lint **23/23**; format and diff checks green.
## Remediation round 5 — terra 18116 + Opus 18114
- Both independent gates converged on one guard-only completeness gap at round-4 head `1eb77c17f3147d4fa9944f77f1826243135b9cc0`; all round-4 primitive anchoring, command-position parsing, 14/14 inventory, broker ordering, and coverage remain locked-good.
- Reproduced exactly: a temporary production source containing `launcher=claude` followed by `exec "$launcher" -p x` exits 0 with `0 gated / 0 total`. The literal command resolver skips prefixes but cannot resolve a tracked variable; the variable resolver handles only bare/eval references and cannot skip prefixes.
- Round-5 plan: add 10 permanent RED forms (quoted/unquoted `exec`, `command`, `nohup`, and `env` with assignment, each multiline and same-line), then unify shell command-position resolution so literal and tracked-variable terminal tokens traverse the same prefix parser. Retain an independent variable-reference backstop, the 14/14 inventory, and every round-4 regression.
- Mos stopping-criterion augment: command parsing is explicitly best-effort rather than a complete shell interpreter. Add B1 proving a parser-exotic alias launch with the raw dangerous flag is still RED by primitive anchoring, and B2 proving a parser-missed non-dangerous alias launch reaches the global `.*` hook and fails closed with `GATE_UNAVAILABLE` when no lease session exists. Document A (realistic parser matrix) + B (robust residual backstops); fresh reviewers supply criterion C (no new non-overlapping finding).
- RED commit `91a4a983`: all 10 prefix×variable cases failed as expected before the fix—quoted/unquoted `exec`, `command`, `nohup`, and `env A=1`, each in multiline and same-line assignment shapes.
- GREEN structural resolution: `shell_command_tokens()` now owns command-position prefix skipping for both literal and variable callers, including nested `exec`/`command`/`nohup`/`env` ordering. `runtime_variables` is threaded into `is_shell_direct_invocation()` and the same terminal-token resolver backs `executes_runtime_variable()`; exact `$v` and `${v}` references are resolved after `shlex` removes quoting. Same-line runtime assignment delimiters include shell operators.
- Residual backstops: B1 proves alias-indirected dangerous mode is classified `dangerous-primitive` even though the parser does not resolve the alias. B2 proves a non-dangerous alias residual remains parser-missed, then verifies the shipped global `.*` Claude hook and status-2 `GATE_UNAVAILABLE` denial for representative read, mutator, and custom/MCP tools without a lease session.
- Stopping-criterion evidence A+B is committed in tests and architecture docs; C remains the fresh exact-head terra/Opus determination. Repository inventory remains exactly **14 gated / 14 total**.
- Fresh round-5 coverage: permanent guard remains **97%** branch-aware aggregate (251 statements, 112 branches). Locked-good launcher and mutator-gate executables remain unchanged at their round-4 **100% / 100%** evidence.
- Fresh round-5 gates: real-socket acceptance **50/50**; persistence **10/10**; launcher/gate **14/14**; guard **12/12**; coord **19/19**; Mosaic **1385/1385**; root **43/43**; typecheck **42/42**; lint **23/23**; format and diff checks green.

View File

@@ -1,44 +0,0 @@
# Issue #838 — Broker acceptance socket flake
## Objective
Eliminate high-contention uncaught JSON parse failures in lease-broker and mutator-gate acceptance helpers without laundering malformed/empty replies into passing assertions.
## Constraints
- Branch: `fix/838-broker-acceptance-flake` in `/home/hermes/agent-work/stack-838-flakefix`.
- Red-first TDD with a forced empty/truncated reply path and deterministic rejection or documented retry.
- Read newline-framed replies completely; reject malformed broker replies with byte length/content context.
- Determine RIDER2 branch before finalizing: test-harness-only `(a)` or daemon write truncation `(b)`.
- If product-side, prove the real Claude/Pi adapter read path fails closed; fix any allow-risk.
- Coverage >=85% per changed executable through real tests.
- Full suite and repository gates green; independent exact-head review required.
- Push and open a PR containing `closes #838`; do not merge.
## Progress
- 2026-07-17: Reclaimed from #824. Confirmed clean worktree on `fix/838-broker-acceptance-flake` at main base `abd2791f59b3f06f46dd08e55298ced72f6aa7c2`.
- RED evidence: after dependency setup, `broker-test-client.spec.ts` failed to load the intentionally absent shared client module. Its contract forces empty-close retry, repeated truncated-close rejection with byte context, and newline-terminated malformed-reply rejection.
- RIDER2 verdict: branch **(b)**. `daemon.py` starts one connection deadline before request reading, then may spend that budget waiting for `broker_lock`; after handling, `remaining <= 0` returns without writing. A timed `sendall` failure can likewise close after a partial write. A deterministic socketpair probe held the lock past `CONNECTION_DEADLINE_SECONDS` and observed `deadline_probe_reply_length=0`.
- Adapter tripwire: real subprocess executions of `mutator-gate.py` for both `--runtime claude` and `--runtime pi` against actual Unix servers returning empty and truncated replies all exited 2 with `GATE_UNAVAILABLE`. Adapter fail-close is proven; there is no ALLOW risk.
- Residual: production broker reply loss remains an availability-denial path under extreme contention, but cannot grant mutator authority. The acceptance-only client retries early closes and otherwise rejects with response byte length, escaped bytes, and hex; malformed newline-framed replies are never retried or converted to reply objects.
- Shared client now owns newline framing and parsing for both acceptance suites. Focused suites pass 60 tests, including the real adapter tripwire.
- Coverage gate: changed helper is 100% statements/lines/functions and 90% branches; existing `skill.ts` remains above 85% per-file thresholds.
## Scope corrections and bounded product repair
- Coordinator correction superseded the initial push/PR and retry language: this lane is BUILD-ONLY, and early-close retries are forbidden because they can mask a committed broker transaction. Nothing may be pushed or opened until explicitly cleared.
- Revised RED evidence: the no-retry empty/truncated tests failed because the first failed exchange was retried into `{ ok: true }`; the daemon regression failed because a slow completed `broker.handle()` produced `b''` instead of a newline-framed reply.
- Client repair: both former duplicated helpers use one shared reader. Empty, truncated, malformed, oversized, timed-out, and socket-error replies reject `BrokerTransportError` with a typed `kind`, attempt count fixed at one, response length, escaped bytes, and hex. No retry and no catch-to-reply conversion exists.
- Product repair: `daemon.py` now has independent bounded read, broker-lock queue, and send budgets. Lock queue exhaustion returns explicit `BROKER_BUSY` before `broker.handle()` can mutate state. Once handling starts it finishes atomically, and its reply always receives a fresh send timeout instead of being skipped because read/lock/fsync consumed a shared deadline.
- Product GREEN evidence: deterministic socketpair tests prove lock saturation returns framed `BROKER_BUSY` without invoking `handle()`, and a handle that completes after the former one-second shared deadline still returns its complete framed reply.
- RIDER2b remains **fail-closed**: real Claude and Pi `mutator-gate.py` subprocesses against empty and truncated Unix-socket replies exit 2 with `GATE_UNAVAILABLE`; no malformed/default ALLOW was observed.
- Residual/tripwire: an unavoidable peer disconnect or send failure can still lose acknowledgement after a valid transaction commits. The affected adapter call fails closed. A valid `promote_lease` may nevertheless remain VERIFIED after its acknowledgement is lost; that is authority-observability divergence requiring WI-3/Opus security review rather than expansion of #838. #838 does not add retries or attempt a protocol redesign.
- Final package evidence: recursive Mosaic dependency build passed; 73 Vitest files / 1,392 tests passed; deadline unit tests 2/2, real runtime tool tests 15/15, launch guard tests 12/12, inventory 14/14, and shell regressions passed.
- Final coverage: `broker-test-client.ts` 99.24% statements/lines, 86.11% branches, 100% functions under per-file >=85% thresholds. Repository typecheck 42/42, lint 23/23, and format check passed.
- Independent Codex exact-head review requested one framing fix: a valid frame followed by trailing bytes in a later socket chunk could resolve before the garbage arrived. Security review also flagged complete token-bearing reply bodies in diagnostic properties/logs.
- Review RED evidence: delayed cross-chunk garbage resolved `{ ok: true }` instead of rejecting, and a truncated `promotion_token` remained in the typed error. The tests use separate timed writes to prevent kernel/event-loop coalescing.
- Review remediation: the shared client now accumulates through EOF, requires exactly one terminal newline, then parses inside a rejecting error boundary. Diagnostic bodies are capped at 256 bytes, sensitive broker fields are fully redacted, and a SHA-256 digest preserves correlation without credential disclosure.
- Post-review coverage: `broker-test-client.ts` 99.35% statements/lines, 86.66% branches, 100% functions. Final full suite is 73 files / 1,394 tests plus all Python/shell gates; recursive build, typecheck, lint, and format are green.
- Fresh exact-head Codex security review: risk `none`, no findings. Fresh code review found only that the real-adapter subprocess proof lacked a timeout; it now has a five-second bound and fails with runtime/wire context while closing the fake server. The focused Python suite remains 15/15 green.
- Mandatory Opus SECREV remains coordinator-owned and pending before any push/PR decision; this build is intentionally local-only.

View File

@@ -0,0 +1,85 @@
# FCM-M5-001 — Fleet configuration operator documentation
- Task: `FCM-M5-001`
- Issue: `#758`
- Branch: `docs/758-fleet-config-operator-docs`
- Exact base: `9745bc3f29c26b021a478b7ad03cfb494f6c9de3` (tree `4da210da9a71b035130d4160a4a2e691bdfde2da`)
## Objective
Deliver the accepted fleet documentation information architecture, operator workflows, operations and migration references, comprehensive contract documentation, and deterministic link/example validation without live fleet action or product mutation.
## Scope and constraints
- Documentation, examples, documentation validation, and tracking only.
- `roster.yaml` remains the sole writable desired-state authority; generated state is derived/observed.
- No M4-002 implementation or execution; no canary, migration, rollback, deployment, systemd/tmux/session, generated projection, or product mutation.
- `mos-comms` is temporary and is not permanent architecture.
- Parent issue `#758` remains open through M5.
- No credentials, sensitive values, or privileged command content.
## Plan
1. Update tracking first with exact M4-001 evidence and mark M5-001 in progress.
2. Map the M0 checklist and current implementation behavior to documentation pages.
3. Author operator, operations, migration, schema/reference, recovery, troubleshooting, and security/authority docs.
4. Add or extend deterministic documentation/link/example validation if required, red-first.
5. Run repository documentation, link, example, and relevant package checks; review and remediate.
6. Commit, queue-guard, push one branch, and open one wrapper-created PR; stop for independent review.
## Budget
- Task estimate: `24K`.
- Working cap: stay within the card estimate by parallelizing read-only discovery and limiting edits to checklist-required artifacts.
## Progress checkpoints
- [x] Loaded repository/global delivery and documentation contracts.
- [x] Verified `origin/main` is exact required base and created isolated worktree.
- [x] Tracking updated first.
- [x] Checklist mapped and docs authored.
- [x] Validation green.
- [x] Review/remediation complete.
- [x] Commit, queue guard, push, PR #789.
- [x] Rejected exact-head RoR findings repaired on a new descendant commit candidate.
- [ ] New exact-head review and CI after repair push.
## Tests and verification
- Red-first documentation validator initially failed for the absent fleet entry point and canonical
example, then passed after the IA and example were added.
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/fleet/roster-v2.spec.ts src/fleet/example-profile-dispositions.spec.ts src/fleet/fleet-documentation.spec.ts src/fleet/v1-v2-migration.spec.ts src/fleet/generated-env-boundary.spec.ts src/fleet/fleet-agent-crud.spec.ts src/fleet/fleet-reconciler.spec.ts` — 7 files, 195 tests passed after building workspace dependencies.
- `pnpm format:check` — passed.
- `pnpm lint` — 23 tasks passed.
- `pnpm typecheck` — 42 tasks passed.
- `pnpm test` — 43 tasks passed; `@mosaicstack/mosaic` contributed 61 files and 1,045 tests.
- `bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh` — passed.
- `bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh` — passed.
- `git diff --check` — passed before final review.
- Independent staged-snapshot review identified four documentation/validation blockers: reboot safety,
heartbeat observation, migration failure envelope, and example-scan coverage. All were remediated;
focused rereview approved the staged remediations with no blockers. Exact committed-head review remains
a post-PR gate.
- Post-remediation `@mosaicstack/mosaic` lint/typecheck passed; package test passed 61 files / 1,045
tests; sanitization and resident-budget gates passed again.
- Post-PR exact-head RoR on rejected head `0aee2c09819fd06e28f927384ea56fa2ef374edf`
identified five blockers: update-lifecycle overclaim, missing explicit `fleet validate` gap,
fragment-blind link validation, unsupported checklist-evidence claim, and insufficient example safety
validation. Red-first regressions failed before implementation for missing-heading, privileged-command,
and credential-format fixtures. Repairs now preserve/document implementation truth, validate heading
fragments, narrow checklist claims, and scan fenced/canonical examples for common credential formats
and privileged commands without printing fixture values.
- Repair-focused fleet contracts: 7 files, 192 tests passed after review remediation; documentation
validator contributed 11 tests. Full gates passed: format; lint 23/23; typecheck 42/42; test 43/43
tasks with `@mosaicstack/mosaic` 61 files / 1,052 tests; sanitization; resident budget; and
`git diff --check`. New exact-head review/CI remain pending until the repair commit is pushed.
## Risks/blockers
- Checklist may include behavior intentionally deferred to M4-002/M5-002; such items must be recorded as approved-existing holds rather than claimed delivered.
- Commands/examples must remain non-live and avoid privileged/sensitive content.
## Final evidence
- Pending.

View File

@@ -1,38 +0,0 @@
# ms-792 — Fleet roster error handling and installer heading
## Objective
Make expected missing or malformed fleet roster configuration fail with an actionable message and nonzero exit instead of a raw Node stack trace. Ensure the installer preserves the `@mosaicstack/mosaic` heading.
## Plan
1. Add failing coverage for missing and malformed roster input.
2. Centralize roster-file read and parse error translation; add the CLI async error boundary.
3. Sweep fleet command read paths that bypass the roster loader.
4. Replace the installer heading output with format-safe rendering and test it.
5. Run focused and repository quality checks; request independent review.
## Progress
- 2026-07-16: Confirmed issue #792 and branch base `9745bc3f`.
- 2026-07-16: Installed locked workspace dependencies using a worktree-local pnpm store; no `.mosaic/` files were changed intentionally.
- 2026-07-16: Added a shared roster read/parse guard and routed v1 fleet commands plus v1/v2 selection through Commanders actionable nonzero error path. V2 command modules already return structured nonzero JSON errors for their guarded reads.
- 2026-07-16: Replaced installer heading `echo` with format-safe `printf`; added a regression check for the scoped package heading.
- 2026-07-16: Rebuilt CLI and manually verified `fleet ps` with no roster prints the initialization hint, exits 1, and has no stack trace.
- 2026-07-17: Rebased #818 onto `origin/main` at `9ddc6fbd` (#791 PR3). The added `fleet regen` command had a canonical roster read in its sibling module; it now uses the same missing-roster guard and Commander exit path. Internal NORTH_STAR, preset, and post-write invariant reads remain intentionally unguarded.
- 2026-07-17: RoR found that semantically invalid v1 documents still escaped as plain `Error` values. `normalizeFleetRosterV1` now preserves each validation message while converting it to `FleetRosterConfigurationError`, so its command callers use the actionable nonzero Commander path.
## Verification
- `pnpm --filter @mosaicstack/mosaic test` — PASS (61 files, 1,046 tests; executed outside sandbox because CLI smoke tests spawn Node)
- `pnpm typecheck` — PASS
- `pnpm lint` — PASS
- `pnpm format:check` — PASS
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts src/commands/install-heading.spec.ts` — PASS (209 tests)
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-regen-command.spec.ts` — PASS (27 tests, including missing canonical roster)
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts -t "semantically invalid v1 roster"` — RED then PASS; verifies duplicate agent names are reported as `fleet.roster` exit 1 without a stack trace.
- Instrumented Vitest coverage is unavailable because `@vitest/coverage-v8` is not declared in this repository. Each branch added in the roster guard has direct unit coverage.
## Risks / blockers
- Dependency installation is required before executing Vitest, TypeScript, lint, and formatting gates.

View File

@@ -1,41 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveLaunchCommand } from '../runner.js';
describe('coord consequential-runtime launch gate', () => {
it('routes default and direct configured Claude commands through mosaic', () => {
expect(resolveLaunchCommand('claude', 'continue', undefined)).toEqual([
'mosaic',
'claude',
'-p',
'continue',
]);
expect(resolveLaunchCommand('claude', 'continue', ['claude', '-p', '{prompt}'])).toEqual([
'mosaic',
'claude',
'-p',
'continue',
]);
});
it('preserves an already-gated Claude command and rejects unknown launchers', () => {
expect(
resolveLaunchCommand('claude', 'continue', ['mosaic', 'yolo', 'claude', '{prompt}']),
).toEqual(['mosaic', 'yolo', 'claude', 'continue']);
expect(() => resolveLaunchCommand('claude', 'continue', ['custom-launcher'])).toThrow(
/must use `mosaic claude`/,
);
});
it('does not change the out-of-scope Codex command contract', () => {
expect(resolveLaunchCommand('codex', 'continue', undefined)).toEqual([
'codex',
'-p',
'continue',
]);
expect(resolveLaunchCommand('codex', 'continue', ['codex', '{prompt}'])).toEqual([
'codex',
'continue',
]);
});
});

View File

@@ -179,41 +179,32 @@ function buildContinuationPrompt(params: {
`3. Read \`${mission.scratchpadFile}\` for session history and decisions`,
`4. Read \`${mission.tasksFile}\` for current task state`,
'5. `git pull --rebase` to sync latest changes',
`6. Launch runtime with \`mosaic ${runtime} -p\``,
`6. Launch runtime with \`${runtime} -p\``,
`7. Continue execution from task **${taskId}**`,
'8. Follow Two-Phase Completion Protocol',
`9. You are the SOLE writer of \`${mission.tasksFile}\``,
].join('\n');
}
export function resolveLaunchCommand(
function resolveLaunchCommand(
runtime: 'claude' | 'codex',
prompt: string,
configuredCommand: string[] | undefined,
): string[] {
if (configuredCommand === undefined || configuredCommand.length === 0) {
return runtime === 'claude' ? ['mosaic', 'claude', '-p', prompt] : [runtime, '-p', prompt];
return [runtime, '-p', prompt];
}
const hasPromptPlaceholder = configuredCommand.some((value) => value === '{prompt}');
const withInterpolation = configuredCommand.map((value) =>
value === '{prompt}' ? prompt : value,
);
const command = hasPromptPlaceholder ? withInterpolation : [...withInterpolation, prompt];
if (runtime !== 'claude') return command;
if (
command[0] === 'mosaic' &&
(command[1] === 'claude' || (command[1] === 'yolo' && command[2] === 'claude'))
) {
return command;
if (hasPromptPlaceholder) {
return withInterpolation;
}
if (command[0] === 'claude') {
return ['mosaic', 'claude', ...command.slice(1)];
}
throw new Error(
'Custom Claude task commands must use `mosaic claude` so lease registration cannot be bypassed.',
);
return [...withInterpolation, prompt];
}
async function writeAtomicJson(filePath: string, payload: unknown): Promise<void> {

View File

@@ -47,61 +47,6 @@ export MOSAIC_ADMIN_PASSWORD="securepass123"
mosaic gateway install
```
## Runtime launchers
```bash
mosaic claude # Launch Claude Code with Mosaic injection
mosaic yolo claude # …with --dangerously-skip-permissions
mosaic codex | opencode | pi
```
### `mosaic claudex` (EXPERIMENTAL)
Runs GPT models **inside the Claude Code harness** by pointing Claude Code at a
local [`claude-code-proxy`](https://github.com/raine/claude-code-proxy) that
translates the Anthropic Messages API to a ChatGPT-subscription (Codex OAuth)
backend. This is **not Anthropic Claude** — model behavior, tool use, and output
quality may differ. Intended for evaluation, not production delivery.
```bash
mosaic claudex # launch (prompts through the proxy readiness gate)
mosaic yolo claudex # …with --dangerously-skip-permissions
mosaic claudex --print "hello" # trailing args are forwarded to Claude Code
```
**Prerequisite:** the `claude-code-proxy` binary must be installed and
authenticated (`claude-code-proxy codex auth …`). `mosaic claudex` runs a
preflight that verifies the binary, the OAuth state (triggering a device re-auth
if needed), and a trusted local listener before launching; it **fails closed**
if the proxy cannot be brought up with a verified identity.
**Isolation (never touches your real Claude state).** claudex always launches
against an isolated `CLAUDE_CONFIG_DIR` (default `~/.config/mosaic/claudex/home`).
The ambient `CLAUDE_CONFIG_DIR` is deliberately ignored, and a guard proves the
resolved dir can never be — or live under — the real `~/.claude`. A claudex
session therefore cannot mutate your normal Claude Code config.
**No token leakage.** claudex never reads the proxy's credential file. Claude
Code is handed only `ANTHROPIC_AUTH_TOKEN=unused` pointed at the loopback proxy;
the entire credential-bearing env family (`ANTHROPIC_*`, `AWS_*`, `GOOGLE_CLOUD_*`,
`GOOGLE_APPLICATION_CREDENTIALS`, `*_TOKEN`, `*_KEY`, `*_SECRET`, …) is stripped
from the composed environment. The Bedrock/Vertex routing switches
(`CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_USE_VERTEX`, and the `_SKIP_*_AUTH`
pair) are force-removed regardless of value — otherwise their mere presence
would route Claude Code to the real Anthropic API via AWS/GCP and bypass the
proxy. The proxy holds the real OAuth credential.
**Model tiers (override via env).**
| Tier | Env var | Default |
| --------------------- | ---------------------------- | -------------- |
| primary (opus/sonnet) | `ANTHROPIC_MODEL` | `gpt-5.6-sol` |
| small/fast (haiku) | `ANTHROPIC_SMALL_FAST_MODEL` | `gpt-5.6-luna` |
Operator-provided values win over the defaults. Additional overrides:
`MOSAIC_CLAUDEX_CONFIG_DIR` (isolated config dir), `ANTHROPIC_BASE_URL` (proxy
endpoint).
## Hooks management
After running `mosaic wizard`, Claude hooks are installed in `~/.claude/hooks-config.json`.

View File

@@ -177,23 +177,15 @@ bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref
```
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
## Universal Skills
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`. Install, wizard finalization, and `mosaic update` automatically reconcile every canonical skill into Claude Code's `~/.claude/skills/` directory.
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`, then links each skill into runtime directories.
```bash
mosaic sync # Full canonical catalog sync
mosaic skill list # Show registered, missing, dangling, and foreign entries
mosaic skill register <name> # Register or repair one canonical Claude link
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
mosaic sync # Full sync (clone + link)
~/.config/mosaic/bin/mosaic-sync-skills --link-only # Re-link only
```
Skill names are direct children using `[A-Za-z0-9][A-Za-z0-9._-]*`, not paths. Registration rejects traversal/control characters and never replaces foreign files, directories, or symlinks; unregister removes only links that point inside the canonical Mosaic skill root. After registering during a running Claude Code session, use `/reload-skills` or start a new session.
M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosaic root through its launcher configuration. Codex parity remains follow-up scope and continues to use the existing full skill-sync linker.
## Health Audit
```bash

View File

@@ -1,85 +0,0 @@
# Mosaic framework path-ownership manifest — SSOT for the updater.
#
# This single file is the source of truth consumed by BOTH the bash installer
# (packages/mosaic/framework/install.sh) and the TypeScript config adapter
# (packages/mosaic/src/config/file-adapter.ts). A parity test asserts both
# paths resolve the same ownership from this file, so the two can never drift
# (the failure mode that #631 patched by hand in two places).
#
# Format: one glob per line, relative to the mosaic home (~/.config/mosaic).
# - Lines starting with '#' and blank lines are ignored.
# - '[framework]' / '[operator]' switch the active section.
# - '**' matches any depth; '*' matches within a single path segment.
#
# Ownership resolution for a path P (deny-wins / fail-safe):
# 1. P matches an [operator] glob -> operator-owned.
# 2. else P matches a [framework] glob -> framework-owned.
# 3. else (matches neither) -> OPERATOR-OWNED BY DEFAULT.
#
# Rule 3 is the root-cause fix for #791: a path the manifest authors never
# anticipated is protected because UNKNOWN defaults to operator. The updater
# may only ever create/overwrite framework-owned paths, and may only prune a
# framework-owned path that lives inside a shipped framework subtree and is
# absent from the current framework source (a genuinely retired file).
# Operator-owned and unknown paths are structurally unreachable by pruning.
[framework]
# Top-level framework contract files (also reconciled from defaults/ on upgrade).
CONSTITUTION.md
AGENTS.md
STANDARDS.md
# Shipped framework subtrees — pruning is scoped to these roots.
adapters/**
constitution/**
CONTRIBUTING.md
defaults/**
examples/**
guides/**
install.sh
install.ps1
LICENSE
profiles/**
runtime/**
systemd/**
templates/**
tools/**
# Fleet: only the framework-seeded fleet subtrees are framework-owned.
fleet/README.md
fleet/examples/**
fleet/profiles/**
fleet/roles/**
fleet/roster.schema.json
fleet/services/**
# The manifest itself is framework-owned.
framework-manifest.txt
[operator]
# Identity / user-seeded contract files — generated by the wizard or seeded
# once from defaults/, then owned by the operator. Never overwritten on upgrade.
SOUL.md
USER.md
TOOLS.md
# Local overlays (tighten-only) authored by the operator.
*.local.md
# Operator-owned trees the updater must never write over or prune.
agents/**
policy/**
memory/**
sources/**
credentials/**
# Secret-bearing operator file INSIDE the framework-owned tools/ subtree.
# Listed explicitly so the deny-wins rule carves it out of tools/**.
tools/_lib/credentials.json
# Operator-owned fleet state (roster SSOT, per-agent env, heartbeats, backlog,
# persona overrides). Losing these silently downgrades a running fleet (#791).
fleet/roster.yaml
fleet/roster.json
fleet/agents/**
# Runtime state, incl. the #797 Runtime Session Ledger at fleet/run/sessions/
# (events.ndjson journal + ledger.json projection). This carve-out is the
# mechanism that makes the ledger upgrade-safe: an upgrade that wiped it would
# defeat its reason to exist. The HARD GATE (test-upgrade-manifest-guard.sh)
# proves a populated ledger survives byte-identical + mtime-unchanged.
fleet/run/**
fleet/backlog/**
fleet/roles.local/**

View File

@@ -3,7 +3,7 @@
When spawning workers, include skill loading in the kickstart:
```bash
mosaic claude -p "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."codex exec "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."
claude -p "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."codex exec "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."
```
#### **MANDATORY**

View File

@@ -1,10 +1,5 @@
#!/usr/bin/env bash
# -E (errtrace): the ERR trap must propagate INTO functions and command
# substitutions. Without it the `trap restore_snapshot ERR` set below is dead
# code for any failure inside sync_framework_keep() (its whole body runs in a
# function) — a mid-sync failure would abort with a half-written target and NO
# rollback (#791 B1). Keep -E first so every later function inherits the trap.
set -Eeuo pipefail
set -euo pipefail
# ─── Mosaic Framework Installer ──────────────────────────────────────────────
#
@@ -24,19 +19,32 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# Shared framework path-ownership manifest reader (#791). Parity with
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
# shellcheck source=tools/_lib/manifest.sh
source "$SOURCE_DIR/tools/_lib/manifest.sh"
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
# denylist. It is derived from the shared framework-manifest.txt (#791): the
# updater only ever creates/overwrites framework-owned paths and only prunes a
# retired framework file inside a shipped framework subtree. Everything else —
# every operator file, and every path the manifest never anticipated — is
# operator-owned by default (fail-safe) and is never written or deleted. See
# sync_framework_keep() below and packages/mosaic/src/framework/manifest.ts.
# Files/dirs protected from rsync --delete during sync. NOTE: framework-owned
# entries (CONSTITUTION/AGENTS/STANDARDS) ARE re-applied afterward by
# reconcile_framework_files (overwrite + backup-once); the rest stay user-owned.
# User-created content in these paths survives rsync --delete.
#
# fleet/* — the framework SEEDS fleet/examples, fleet/roles, fleet/profiles, and
# fleet/roster.schema.json (synced normally — every fleet/roles/*.md role contract
# and fleet/profiles/*.yaml system-type profile lands automatically via this sync,
# so no per-file entry is needed; exact preserved roster paths are anchored to
# the top level only and do NOT shadow fleet/profiles/*.yaml). The user's
# own fleet files MUST
# survive `mosaic update` (which runs this sync automatically): the active
# rosters (`fleet/roster.yaml` and `fleet/roster.json`), per-agent env
# (`fleet/agents/`), heartbeat run dir (`fleet/run/`), and the Mosaic-native
# backlog-of-record store (`fleet/backlog/` — embedded PGlite data dir; see
# packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update
# wipes the operator's fleet AND their backlog. Glob entries are honored by
# both the rsync path (`--exclude`) and the glob-aware cp fallback below.
#
# fleet/roles.local — the persona OVERRIDE layer (H4). Baseline personas in
# fleet/roles/ are reseeded normally on every update (delivering new baseline
# personas), so any local edit there would be clobbered. User customizations
# and user-ADDED personas instead live in fleet/roles.local/ and MUST survive
# `mosaic update` — they win over the baseline on merge (AC-NS-7; see
# packages/mosaic/src/commands/fleet-personas.ts).
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/roster.yaml" "fleet/roster.json" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
# user must not edit them; a divergent copy is backed up once before overwrite).
@@ -67,267 +75,17 @@ step() { echo -e "\n${BOLD}$1${RESET}"; }
SNAPSHOT_DIR=""
make_snapshot() {
is_existing_install || return 0
# mktemp -d creates the dir 0700 — the snapshot (which mirrors operator config,
# possibly including secrets) is never world-readable.
SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-snapshot-XXXXXX")"
# The snapshot MUST be complete: restore rebuilds the target from it, so a
# partial capture (unreadable file, disk-full, I/O error) would silently
# discard whatever it missed. If cp -a cannot copy the whole tree, abort NOW —
# before the restore trap is armed and before anything is mutated. Fail closed
# rather than proceed with a snapshot we cannot trust (#791 blocker-2).
if ! cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/"; then
fail "Could not capture a complete pre-upgrade snapshot of $TARGET_DIR — aborting before any changes were made (fail-closed)."
rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""
exit 1
fi
cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/" 2>/dev/null || true
}
restore_snapshot() {
# Disarm the trap first: restore runs under `set -e`, and a non-zero step
# inside it must not re-enter this handler (errtrace makes ERR fire in
# functions now). One restore attempt, then let the script exit non-zero.
trap - ERR INT TERM
[[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] || return 0
fail "Install interrupted/failed — restoring previous state from snapshot"
# Reset the target before rebuilding from the snapshot — but CHECK it. Under
# `set -e` (trap already disarmed) a bare `rm -rf; mkdir -p` that fails would
# exit the whole script immediately, after `rm` may have deleted part of the
# target, WITHOUT ever printing the recovery pointer below — the operator would
# be left with a half-removed target and no idea the snapshot survives in /tmp.
# Test the reset explicitly (like the cp -a below), and on failure keep the
# snapshot and tell the operator where it is (#791 blocker-D2).
if ! rm -rf "$TARGET_DIR" || ! mkdir -p "$TARGET_DIR"; then
fail "Snapshot restore could not reset $TARGET_DIR. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually."
return 1
fi
# Surface an incomplete restore instead of swallowing it: the snapshot is the
# last good copy, so if cp cannot fully rebuild the target we must NOT delete
# the snapshot — point the operator at it for manual recovery (#791 blocker-2).
if ! cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/"; then
fail "Snapshot restore did not complete cleanly. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually."
return 1
fi
rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"
cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/" 2>/dev/null || true
}
cleanup_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] && rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""; }
# ─── durable operator-config snapshot (#791 PR2) ─────────────────────────────
# A SECOND, independent safety layer, distinct from SNAPSHOT_DIR above:
# • SNAPSHOT_DIR is ephemeral (/tmp, deleted on success) and mirrors the WHOLE
# target for CRASH rollback if the sync aborts mid-write.
# • DURABLE_SNAPSHOT_DIR is RETAINED, holds only the operator-owned surface, and
# lives OUTSIDE the framework tree and any repo. It exists for the failure the
# crash-rollback cannot see: a sync that finishes "successfully" yet a
# manifest/logic bug let it modify an operator file. verify_operator_surface()
# (post-sync) heals from it; `mosaic restore` recovers from it days later.
# Path convention is mirrored in packages/mosaic/src/commands/restore.ts — keep
# the two in sync (there is no shared code across the bash/TS boundary).
DURABLE_SNAPSHOT_DIR=""
backup_root() { printf '%s/mosaic/backups' "${XDG_STATE_HOME:-$HOME/.local/state}"; }
# Relative paths that a migration INTENTIONALLY removes from the target (e.g. the
# legacy bin/ tree). Such a path is operator-classified by the manifest (unknown⇒
# operator), so the durable snapshot captures it — but its post-migration absence
# is correct, NOT a manifest bug. run_migrations() records each removal here so
# verify_operator_surface() does not "heal" it back and silently undo the
# migration (which would then be skipped forever once the version is stamped).
MIGRATION_REMOVED_PATHS=()
# True (0) if $1 (a path relative to TARGET_DIR) equals or lives under a path a
# migration deliberately removed this run.
is_migration_removed() {
local rel="$1" removed
for removed in ${MIGRATION_REMOVED_PATHS[@]+"${MIGRATION_REMOVED_PATHS[@]}"}; do
[[ -n "$removed" ]] || continue
[[ "$rel" == "$removed" || "$rel" == "$removed"/* ]] && return 0
done
return 1
}
# True (0) if any parent directory of $1 (relative to TARGET_DIR) is a symlink.
# Restoring THROUGH a symlinked ancestor would let cp write snapshot contents —
# possibly secrets — outside the target (CWE-59), so the verify net refuses it.
has_symlinked_parent() {
local rel="$1" dir p seg
dir="$(dirname "$rel")"
[[ "$dir" == "." ]] && return 1
p="$TARGET_DIR"
local IFS='/'
for seg in $dir; do
[[ -n "$seg" ]] || continue
p="$p/$seg"
[[ -L "$p" ]] && return 0
done
return 1
}
# Emit (NUL-delimited, into file $1) the operator-owned relative paths that exist
# under TARGET_DIR, classified via the shared manifest (deny-wins; unknown⇒
# operator). Returns non-zero if the filesystem walk itself failed — we must
# NEVER snapshot from a truncated scan (a `< <(find …)` process substitution
# would hide that error; capture-then-check does not — cf. #791 blocker-D1).
enumerate_operator_files() {
local out="$1" scan abs rel
scan="$(mktemp)"
if ! find "$TARGET_DIR" -type f -print0 > "$scan"; then
rm -f "$scan"
return 1 # OP-SCAN-GUARD
fi
: > "$out"
while IFS= read -r -d '' abs; do
rel="${abs#"$TARGET_DIR"/}"
# Not operator config: version marker and any VCS metadata.
case "$rel" in .framework-version|.git|.git/*) continue ;; esac
manifest_is_framework "$rel" || printf '%s\0' "$rel" >> "$out"
done < "$scan"
rm -f "$scan"
}
# Retain only the newest MOSAIC_BACKUP_RETENTION (default 5) snapshots. The
# pre-update-<UTC-ts> names sort lexicographically = chronologically, so a
# reverse sort is newest-first. Pruning failures are non-fatal (they only leave
# extra old backups); the enclosing find's status is still honored, not swallowed.
prune_durable_snapshots() {
local root keep list d i=0
root="$(backup_root)"
keep="${MOSAIC_BACKUP_RETENTION:-5}"
[[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5
list="$(mktemp)"
if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then
rm -f "$list"; return 0
fi
# Newest-first ordering needs `sort` (`-o` writes back in place — no `mv`
# dependency); if it is somehow unavailable, leave the backups untouched rather
# than risk pruning in an undefined order.
if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then
rm -f "$list"; return 0
fi
while IFS= read -r d; do
[[ -n "$d" ]] || continue
i=$((i + 1))
(( i > keep )) && rm -rf "$d"
done < "$list"
rm -f "$list"
}
# Take the durable pre-update snapshot BEFORE any mutation. Fail-OPEN: the durable
# snapshot is a recovery bonus on top of the manifest (which already keeps the
# sync out of operator paths) and the crash-rollback — so an un-writable backup
# location warns and continues rather than blocking the upgrade. Everything it
# creates is private (umask 077 + explicit 0700 dirs / 0600 files): the snapshot
# mirrors operator config, which may hold secrets, and must never be world-readable.
make_durable_snapshot() {
is_existing_install || return 0
local root ts dir list rel src dst count=0 old_umask
root="$(backup_root)"
# Fail-open if we cannot even stamp a timestamp: the durable snapshot is a
# recovery bonus and must never be the thing that aborts an upgrade.
ts="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || true)"
if [[ -z "$ts" ]]; then
warn "Durable snapshot skipped: no UTC timestamp available (upgrade continues)."
return 0
fi
# umask 077 makes every dir/file the snapshot creates private from birth (it
# mirrors operator config, which may hold secrets). It is PROCESS-global, so we
# save and restore it around exactly this block — otherwise every later sync
# copy and new framework dir would inherit 0600/0700 instead of 0644/0755.
old_umask="$(umask)"
umask 077
if ! mkdir -p "$root"; then
umask "$old_umask"
warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)."
return 0
fi
chmod 700 "$root" 2>/dev/null || true
dir="$root/pre-update-$ts"
if [[ -e "$dir" ]]; then # same-second re-run: disambiguate
local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n"
fi
if ! mkdir -p "$dir"; then
umask "$old_umask"
warn "Durable snapshot skipped: cannot create $dir (upgrade continues)."
return 0
fi
chmod 700 "$dir"
list="$(mktemp)"
if ! enumerate_operator_files "$list"; then
umask "$old_umask"
warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)."
rm -f "$list"; rmdir "$dir" 2>/dev/null || true
return 0
fi
while IFS= read -r -d '' rel; do
src="$TARGET_DIR/$rel"; dst="$dir/$rel"
[[ -f "$src" ]] || continue
mkdir -p "$(dirname "$dst")"
if ! cp "$src" "$dst"; then
warn "Durable snapshot: could not copy operator file '$rel' (skipped)."
continue
fi
chmod 600 "$dst" 2>/dev/null || true
count=$((count + 1))
done < "$list"
rm -f "$list"
# Tighten every dir the copy created (mkdir -p honors umask, but be explicit).
find "$dir" -type d -exec chmod 700 {} + 2>/dev/null || true
umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above)
DURABLE_SNAPSHOT_DIR="$dir"
ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)"
prune_durable_snapshots
}
# Post-sync safety net: a keep-mode upgrade must NEVER modify an operator file.
# Compare every file in the durable snapshot to its current target counterpart;
# any that changed (or vanished) was touched by a framework bug — restore it from
# the snapshot and warn loudly. This does NOT abort: the framework itself synced
# correctly; we only heal the operator collateral. Runs after the restore trap is
# disarmed so its corrective copies can't spuriously trip a full rollback, and
# every step is guarded so `set -e` cannot exit silently mid-heal (cf. blocker-D2).
verify_operator_surface() {
[[ -n "$DURABLE_SNAPSHOT_DIR" && -d "$DURABLE_SNAPSHOT_DIR" ]] || return 0
local scan snap rel cur healed=0
scan="$(mktemp)"
if ! find "$DURABLE_SNAPSHOT_DIR" -type f -print0 > "$scan"; then
rm -f "$scan"
warn "Post-upgrade verify skipped: could not enumerate the pre-update snapshot at $DURABLE_SNAPSHOT_DIR."
return 0
fi
while IFS= read -r -d '' snap; do
rel="${snap#"$DURABLE_SNAPSHOT_DIR"/}"
cur="$TARGET_DIR/$rel"
# A migration may legitimately delete an operator-classified path (e.g. legacy
# bin/). Its absence is intended — do not heal it back, or the migration is
# silently undone and never re-runs once the version is stamped (#791 PR2).
is_migration_removed "$rel" && continue # MIGRATION-SKIP-GUARD
if [[ ! -e "$cur" ]] || ! cmp -s "$snap" "$cur"; then
# Never restore THROUGH a symlink: an operator path swapped for a link would
# otherwise let cp write snapshot contents (possibly secrets) outside the
# target (CWE-59). Refuse a symlinked parent; drop a symlinked leaf and write
# a real file in its place.
if has_symlinked_parent "$rel"; then
warn "Operator path '$rel' has a symlinked parent under $TARGET_DIR; refusing to restore through it (possible tampering) — recover it manually from $DURABLE_SNAPSHOT_DIR."
continue
fi
[[ -L "$cur" ]] && rm -f "$cur" # SYMLINK-LEAF-GUARD
# Guard mkdir too: under set -e (trap already disarmed) a bare failure would
# exit the whole installer before the recovery pointer below is emitted.
if ! mkdir -p "$(dirname "$cur")"; then
warn "Operator file '$rel' was modified by the upgrade but could NOT be auto-restored (parent dir unavailable) — recover it manually from $DURABLE_SNAPSHOT_DIR."
continue
fi
if cp "$snap" "$cur"; then
chmod 600 "$cur" 2>/dev/null || true
warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel"
healed=$((healed + 1))
else
warn "Operator file '$rel' was modified by the upgrade but could NOT be auto-restored — recover it manually from $DURABLE_SNAPSHOT_DIR."
fi
fi
done < "$scan"
rm -f "$scan"
if (( healed > 0 )); then
warn "$healed operator file(s) were unexpectedly changed by this upgrade and were restored from the pre-update snapshot. A keep-mode upgrade must never modify operator files — this indicates a framework manifest bug; please report it (#791)."
fi
}
# Reconcile contract files after sync: framework-owned overwrite (backup-once),
# user-seeded seed-if-absent.
reconcile_framework_files() {
@@ -426,105 +184,63 @@ sync_framework() {
return
fi
if [[ "$INSTALL_MODE" == "keep" ]]; then
# The `mosaic update` path. Manifest-driven, never-deleting-outside-framework:
# operator config is structurally protected (#791). No rsync --delete here.
# The manifest is already loaded+validated in main() BEFORE the snapshot/trap
# (a fail-closed manifest must abort without ever restoring over operator
# files — see the pre-flight in main, #791 blocker-1).
sync_framework_keep
return
fi
# overwrite mode — a full replace, chosen only for a fresh install or when the
# operator explicitly asks to replace everything. No operator state to protect.
sync_framework_overwrite
}
# Enumerate a NUL-delimited file list via `find` into the temp file $1, failing
# CLOSED if find errors. We capture to a checked file instead of consuming
# `< <(find …)` directly because a process substitution discards the producer's
# exit status: an EACCES/I/O failure partway through a scan would truncate the
# list yet leave the reading `while` loop exiting 0, so a partial upgrade would
# commit and report success and the ERR/restore trap would never fire. Running
# find to completion first, then checking its status, turns that silent
# truncation into a fail-closed abort that the restore trap can act on (#791
# blocker-D1). $1 after the shift is the scan root — named in the error.
_scan_or_die() {
local out="$1"; shift
if ! find "$@" -print0 > "$out"; then
fail "Could not enumerate framework files under '$1' — aborting before committing an incomplete sync (fail-closed)."
return 1 # D1-GUARD
fi
}
# Keep-mode sync: create/refresh framework-owned files and prune only retired
# framework files inside shipped framework subtrees. Operator-owned and unknown
# paths (fail-safe default) are never written and never deleted — the #791 HARD
# GATE. Single code path (no rsync) so it is byte-for-byte parity-testable.
sync_framework_keep() {
local src="$SOURCE_DIR" dst="$TARGET_DIR" abs rel root list
# 1) Overlay copy — every framework-owned source file, refreshed only when its
# bytes changed (no mtime churn on unchanged files, never on operator files).
# The source scan is captured fail-closed (#791 blocker-D1): a find failure
# aborts the sync (→ ERR trap → restore) rather than silently truncating it.
list="$(mktemp)"
_scan_or_die "$list" "$src" -type f || { rm -f "$list"; return 1; }
while IFS= read -r -d '' abs; do
rel="${abs#"$src"/}"
case "$rel" in
.git|.git/*|.framework-version|*.pre-constitution.bak) continue ;;
esac
manifest_is_framework "$rel" || continue
if [[ -f "$dst/$rel" ]] && cmp -s "$abs" "$dst/$rel"; then continue; fi
[[ "$rel" == */* ]] && mkdir -p "$dst/${rel%/*}"
cp "$abs" "$dst/$rel"
done < "$list"
rm -f "$list"
# 2) Scoped prune — within each shipped framework subtree root, remove
# framework-owned target files the current source no longer ships. Operator
# carve-outs (e.g. tools/_lib/credentials.json) resolve to operator and are
# skipped; unknown paths resolve to operator too — both are unreachable here.
# Each subtree scan is captured fail-closed for the same reason as the copy.
while IFS= read -r root; do
[[ -n "$root" && -d "$dst/$root" ]] || continue
list="$(mktemp)"
_scan_or_die "$list" "$dst/$root" -type f || { rm -f "$list"; return 1; }
while IFS= read -r -d '' abs; do
rel="${abs#"$dst"/}"
case "$rel" in *.pre-constitution.bak) continue ;; esac
[[ -f "$src/$rel" ]] && continue # still shipped
manifest_is_framework "$rel" || continue
rm -f "$abs"
done < "$list"
rm -f "$list"
# Drop framework dirs left empty by the prune (never touches a dir that still
# holds an operator file — those are never emptied). A genuine find failure
# (unreadable dir) is surfaced as a warning rather than silently swallowed;
# the "directory not empty" races we tolerate are ignored via -delete's own
# rc, not by hiding stderr — so a real error is still visible to the operator.
if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then
warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
fi
done < <(manifest_subtree_roots)
}
# Overwrite-mode sync: full replace. Only reached for a fresh install or an
# explicit operator "replace everything" choice, so nothing is preserved.
sync_framework_overwrite() {
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete \
--exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak" \
"$SOURCE_DIR/" "$TARGET_DIR/"
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak")
if [[ "$INSTALL_MODE" == "keep" ]]; then
# Anchor to the transfer root (leading /) so we preserve the TOP-LEVEL
# ~/.config/mosaic/<file> without also excluding defaults/<file> from sync
# (reconcile_framework_files needs the freshly-synced defaults/ copies).
for path in "${PRESERVE_PATHS[@]}"; do
rsync_args+=(--exclude "/$path")
done
fi
rsync "${rsync_args[@]}" "$SOURCE_DIR/" "$TARGET_DIR/"
return
fi
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 \
! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" \
-exec rm -rf {} +
# Fallback: cp-based sync. Exact top-level preserved paths mirror the
# root-anchored rsync excludes above.
local preserve_tmp=""
if [[ "$INSTALL_MODE" == "keep" ]]; then
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"
local match rel
for path in "${PRESERVE_PATHS[@]}"; do
# Unquoted $path lets the glob expand against TARGET_DIR; nullglob makes a
# non-matching pattern vanish instead of staying literal.
shopt -s nullglob
for match in "$TARGET_DIR/"$path; do
[[ -e "$match" ]] || continue
rel="${match#"$TARGET_DIR/"}"
mkdir -p "$preserve_tmp/$(dirname "$rel")"
cp -R "$match" "$preserve_tmp/$rel"
done
shopt -u nullglob
done
fi
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" -exec rm -rf {} +
cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/
rm -rf "$TARGET_DIR/.git"
if [[ -n "$preserve_tmp" ]]; then
# Restore by re-globbing the SAME patterns against preserve_tmp, so each
# preserved item is restored at its own relative path (e.g. only
# fleet/roster.yaml is replaced — the freshly-synced fleet/examples stays).
for path in "${PRESERVE_PATHS[@]}"; do
shopt -s nullglob
for match in "$preserve_tmp/"$path; do
[[ -e "$match" ]] || continue
rel="${match#"$preserve_tmp/"}"
rm -rf "$TARGET_DIR/$rel"
mkdir -p "$TARGET_DIR/$(dirname "$rel")"
cp -R "$match" "$TARGET_DIR/$rel"
done
shopt -u nullglob
done
rm -rf "$preserve_tmp"
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
@@ -545,10 +261,6 @@ run_migrations() {
# Remove bin/ directory — all executables now live in the npm CLI.
# Scripts that were in bin/ are now in tools/_scripts/.
if [[ "$from_version" -lt 2 ]]; then
# bin/ and the rails symlink are operator-classified by the manifest (unknown⇒
# operator) and thus captured in the durable snapshot; record them as
# intentional removals so the post-sync verify net does not restore them.
MIGRATION_REMOVED_PATHS+=("bin" "rails")
if [[ -d "$TARGET_DIR/bin" ]]; then
ok "Removing legacy bin/ directory (executables now in npm CLI)"
rm -rf "$TARGET_DIR/bin"
@@ -599,26 +311,9 @@ else
ok "Install mode: overwrite"
fi
# Pre-flight (keep mode): load + validate the framework manifest BEFORE taking a
# snapshot or arming the restore trap. A fail-closed manifest (missing / empty /
# malformed) must abort here WITHOUT deleting or restoring over operator files —
# the snapshot/restore path exists only for a genuine mid-sync mutation failure,
# not for a validation failure that has touched nothing yet (#791 blocker-1).
if [[ "$INSTALL_MODE" == "keep" ]]; then
manifest_load
# Durable, operator-scoped backup taken BEFORE any mutation (#791 PR2). Kept
# outside the framework tree; recovered later via `mosaic restore`. Fail-open.
make_durable_snapshot
fi
# Snapshot before any destructive file operation; restore on interrupt/failure.
# The trap MUST exit after restoring: a bash INT/TERM handler that merely returns
# does NOT terminate the script — execution would resume past the interrupt,
# clear the snapshot, and report success, leaving a partial post-interrupt update
# (#791 blocker-A). `restore_snapshot; exit 1` guarantees a non-zero exit for
# both the errtrace (ERR) and signal (INT/TERM) paths.
make_snapshot
trap 'restore_snapshot; exit 1' ERR INT TERM
trap 'restore_snapshot' ERR INT TERM
sync_framework
@@ -647,10 +342,6 @@ run_migrations
# File-system phase complete and consistent — clear the restore trap.
trap - ERR INT TERM
# Post-sync safety net: heal any operator file a manifest bug let the sync touch,
# using the durable pre-update snapshot (#791 PR2). Runs with the trap disarmed so
# a corrective copy can't spuriously trigger a full rollback.
verify_operator_surface # VERIFY-NET (#791 PR2)
cleanup_snapshot
# Testability / minimal-install hook: stop after the file-system phase, before any

View File

@@ -2,16 +2,6 @@
"model": "opus",
"hooks": {
"PreToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude",
"timeout": 3
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [

View File

@@ -28,7 +28,6 @@ import { execSync, spawnSync } from 'node:child_process';
// ---------------------------------------------------------------------------
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
const MUTATOR_GATE = join(MOSAIC_HOME, 'tools', 'lease-broker', 'mutator-gate.py');
// ---------------------------------------------------------------------------
// Helpers
@@ -107,23 +106,6 @@ function nowIso(): string {
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
}
function checkPiMutatorGate(toolName: string): { block: true; reason: string } | undefined {
const result = spawnSync('python3', [MUTATOR_GATE, '--runtime', 'pi'], {
input: `${JSON.stringify({ tool_name: toolName })}\n`,
encoding: 'utf8',
timeout: 2_000,
env: process.env,
});
if (result.status === 0) return undefined;
const detail = String(result.stderr ?? '')
.trim()
.split('\n')[0];
return {
block: true,
reason: detail || 'BLOCKED: Mosaic mutator gate is unavailable or the lease is UNVERIFIED.',
};
}
// ---------------------------------------------------------------------------
// Mission detection
// ---------------------------------------------------------------------------
@@ -268,11 +250,6 @@ export default function register(pi: ExtensionAPI) {
let hbModel: string | null = null;
let hbTimer: ReturnType<typeof setInterval> | null = null;
// ── Whole mutator-class authorization gate ────────────────────────────
// Every Pi tool, including unknown/custom tools, reaches the broker-backed
// class gate before execution. Broker/script failure blocks fail-closed.
pi.on('tool_call', async (event) => checkPiMutatorGate(event.toolName));
// ── Session Start ─────────────────────────────────────────────────────
pi.on('session_start', async (_event, ctx) => {
sessionCwd = process.cwd();

View File

@@ -1,253 +0,0 @@
#!/usr/bin/env bash
# Shared bash reader for framework-manifest.txt (#791).
#
# This is the bash half of the SSOT ownership resolver; the TypeScript half is
# packages/mosaic/src/framework/manifest.ts. BOTH read the same
# framework-manifest.txt and MUST resolve identical ownership for any path — the
# parity test (manifest-parity.spec.ts) invokes this file's `resolve` CLI and
# compares it against the TS resolver, so the two can never drift (the #631
# two-copies failure class this closes).
#
# Ownership resolution (deny-wins / fail-safe):
# 1. operator glob matches -> operator
# 2. else framework glob -> framework
# 3. else -> operator (UNKNOWN defaults to operator, #791)
#
# Globs are compiled once at load into exact-prefix checks or POSIX EREs, so the
# hot resolver (manifest_is_framework) forks no subprocesses — the installer
# calls it once per file across the whole tree.
#
# Usage as a library (source it, then):
# manifest_load [manifest-file] # populates + compiles the manifest
# manifest_is_framework <rel-path> # rc 0 = framework-owned, rc 1 = operator
# manifest_resolve <rel-path> # echoes: framework | operator
# manifest_subtree_roots # echoes shipped framework `dir/**` roots
#
# Usage as a CLI (parity harness):
# bash manifest.sh resolve <rel-path>
# bash manifest.sh subtree-roots
# bash manifest.sh classify # reads paths on stdin -> "<own>\t<path>"
MANIFEST_FRAMEWORK=()
MANIFEST_OPERATOR=()
# Compiled forms (parallel arrays). _*_KIND[i] is "exact" or "re".
_MF_KIND=(); _MF_EXACT=(); _MF_RE=()
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
_MF_ROOTS=()
_manifest_default_root() { cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd; }
# Normalize a path/glob: backslashes -> slashes, strip leading ./ and /, strip
# trailing / (mirrors normalizeRel in manifest.ts).
_manifest_norm() {
local p="$1"
p="${p//\\//}"
p="${p#./}"
while [[ "$p" == /* ]]; do p="${p#/}"; done
while [[ "$p" == */ ]]; do p="${p%/}"; done
printf '%s' "$p"
}
# Translate a normalized glob into a POSIX ERE body (mirrors globToRegExpBody).
_manifest_glob_to_ere() {
local pattern; pattern="$(_manifest_norm "$1")"
local out="" c n i len=${#pattern} trailing
for (( i = 0; i < len; i++ )); do
c="${pattern:i:1}"
if [[ "$c" == "*" ]]; then
n="${pattern:i+1:1}"
if [[ "$n" == "*" ]]; then
i=$((i + 1))
trailing=0
if [[ "${pattern:i+1:1}" == "/" ]]; then i=$((i + 1)); trailing=1; fi
if [[ "$out" == */ ]]; then
out="${out%/}(/.*)?"
elif [[ "$trailing" -eq 1 ]]; then
out="$out(.*/)?"
else
out="$out.*"
fi
else
out="$out[^/]*"
fi
else
case "$c" in
.|+|\?|^|\$|\{|\}|\(|\)|\||\[|\]|\\) out="$out\\$c" ;;
*) out="$out$c" ;;
esac
fi
done
printf '%s' "$out"
}
# Compile one raw glob into (kind, exact, re) appended to the given section.
# $1 = raw glob, $2 = section letter (F|O).
_manifest_compile_one() {
local norm; norm="$(_manifest_norm "$1")"
[[ -n "$norm" ]] || return 0
if [[ "$norm" == *"*"* ]]; then
local re="^$(_manifest_glob_to_ere "$norm")\$"
if [[ "$2" == F ]]; then
_MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re")
else
_MO_KIND+=(re); _MO_EXACT+=(""); _MO_RE+=("$re")
fi
else
if [[ "$2" == F ]]; then
_MF_KIND+=(exact); _MF_EXACT+=("$norm"); _MF_RE+=("")
else
_MO_KIND+=(exact); _MO_EXACT+=("$norm"); _MO_RE+=("")
fi
fi
[[ "$2" == F && "$norm" == */"**" ]] && _MF_ROOTS+=("${norm%/**}")
return 0
}
_manifest_compile() {
_MF_KIND=(); _MF_EXACT=(); _MF_RE=(); _MF_ROOTS=()
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
local g
for g in "${MANIFEST_FRAMEWORK[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" F; done
for g in "${MANIFEST_OPERATOR[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" O; done
# Explicit success: an empty operator array makes the final `[[ -n "" ]] && …`
# short-circuit to rc 1, which would otherwise become this function's (and
# manifest_load's) return code — a spurious failure (#791 B2). Never rely on
# the last loop's exit status here.
return 0
}
# Load + compile the manifest. Rejects a malformed file the same way
# parseManifest() does (entry before a section header / unknown header).
manifest_load() {
local file="${1:-}"
[[ -n "$file" ]] || file="$(_manifest_default_root)/framework-manifest.txt"
# Fail CLOSED on a missing/unreadable manifest. Without this, `done < "$file"`
# aborts on a raw redirection error with no explanation; downstream that reads
# as "no framework paths" and an upgrade could no-op silently (#791 B2/B3).
if [[ ! -r "$file" ]]; then
echo "manifest: cannot read manifest file: $file — refusing to sync (fail-closed)." >&2
return 1
fi
MANIFEST_FRAMEWORK=()
MANIFEST_OPERATOR=()
local section="" line
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line#"${line%%[![:space:]]*}"}" # ltrim
line="${line%"${line##*[![:space:]]}"}" # rtrim
[[ -z "$line" || "${line:0:1}" == "#" ]] && continue
case "$line" in
"[framework]") section=framework; continue ;;
"[operator]") section=operator; continue ;;
"["*) echo "manifest: unknown section header: $line" >&2; return 1 ;;
esac
if [[ -z "$section" ]]; then
echo "manifest: entry before any [section] header: $line" >&2
return 1
fi
if [[ "$section" == framework ]]; then
MANIFEST_FRAMEWORK+=("$line")
else
MANIFEST_OPERATOR+=("$line")
fi
done < "$file"
# An empty or comment-only manifest defines NO framework-owned paths. Treating
# that as valid would make every path resolve operator and an upgrade prune
# nothing / write nothing — a silent no-op indistinguishable from success.
# Fail loud instead, mirroring parseManifest()'s throw in manifest.ts (#791 B2).
if [[ ${#MANIFEST_FRAMEWORK[@]} -eq 0 ]]; then
echo "manifest: no [framework] entries in $file — refusing to sync (empty or malformed manifest)." >&2
return 1
fi
# An entry like `/` or `./` normalizes to nothing and compiles to a glob that
# matches no path — so a manifest whose only [framework] entries are degenerate
# passes the count guard above but leaves the framework matcher empty: every
# path resolves operator, the exact silent no-op we fail closed against. Require
# at least one entry with a real (non-slash, non-dot) character. Mirrors
# parseManifest()'s `isUsableFrameworkGlob` `/[^/.]/` test in manifest.ts (#791 blocker-B).
local _g _usable=0
for _g in "${MANIFEST_FRAMEWORK[@]:-}"; do
if [[ "$(_manifest_norm "$_g")" =~ [^/.] ]]; then _usable=1; break; fi
done
if [[ "$_usable" -eq 0 ]]; then
echo "manifest: no usable [framework] entries in $file (every entry is empty or a bare dot segment) — refusing to sync (malformed manifest)." >&2
return 1
fi
_manifest_compile
return 0
}
# Fork-free: does $1 (a mosaic-home-relative path) match an operator glob?
_mo_matches() {
local path="$1" i n=${#_MO_KIND[@]} re pat
for (( i = 0; i < n; i++ )); do
if [[ "${_MO_KIND[i]}" == exact ]]; then
pat="${_MO_EXACT[i]}"
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
else
re="${_MO_RE[i]}"
[[ "$path" =~ $re ]] && return 0
fi
done
return 1
}
# Fork-free: does $1 match a framework glob?
_mf_matches() {
local path="$1" i n=${#_MF_KIND[@]} re pat
for (( i = 0; i < n; i++ )); do
if [[ "${_MF_KIND[i]}" == exact ]]; then
pat="${_MF_EXACT[i]}"
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
else
re="${_MF_RE[i]}"
[[ "$path" =~ $re ]] && return 0
fi
done
return 1
}
# The installer hot path — no subshell. rc 0 = framework-owned, rc 1 = operator
# (deny-wins / fail-safe). Assumes an already-clean POSIX relative path.
manifest_is_framework() {
_mo_matches "$1" && return 1
_mf_matches "$1" && return 0
return 1
}
# Echo the ownership of a path: framework | operator. Normalizes first, so it is
# safe for CLI / test callers passing unnormalized input.
manifest_resolve() {
local path; path="$(_manifest_norm "$1")"
if manifest_is_framework "$path"; then echo framework; else echo operator; fi
}
# Echo each shipped framework subtree root (a `dir/**` entry, without the /**).
manifest_subtree_roots() {
local r
for r in "${_MF_ROOTS[@]:-}"; do [[ -n "$r" ]] && printf '%s\n' "$r"; done
}
# CLI dispatch — only when executed directly, never when sourced.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
set -o pipefail
# Propagate a fail-closed manifest_load (missing/empty/malformed) as a non-zero
# exit instead of continuing to resolve against empty compiled arrays — that is
# what lets the parity test assert bash and TS reject the same bad inputs (#791 B2).
manifest_load "${MANIFEST_FILE:-}" || exit 1
cmd="${1:-}"
case "$cmd" in
resolve) manifest_resolve "${2:?path required}" ;;
subtree-roots) manifest_subtree_roots ;;
classify)
while IFS= read -r p; do
[[ -z "$p" ]] && continue
printf '%s\t%s\n' "$(manifest_resolve "$p")" "$p"
done
;;
*)
echo "usage: manifest.sh {resolve <path>|subtree-roots|classify}" >&2
exit 2
;;
esac
fi

View File

@@ -161,7 +161,6 @@ link_targets=(
)
canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")"
local_real="$(readlink -f "$MOSAIC_LOCAL_SKILLS_DIR")"
# Build an associative array from the colon-separated whitelist for O(1) lookup.
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
@@ -204,14 +203,7 @@ link_skill_into_target() {
link_path="$target_dir/$name"
if [[ -L "$link_path" ]]; then
local raw_target resolved_target
raw_target="$(readlink "$link_path")"
resolved_target="$(node -e 'const p=require("node:path"); process.stdout.write(p.resolve(p.dirname(process.argv[1]), process.argv[2]));' "$link_path" "$raw_target")"
if [[ "$resolved_target" == "$canonical_real/"* || "$resolved_target" == "$local_real/"* ]]; then
ln -sfn "$skill_path" "$link_path"
else
echo "[mosaic-skills] Preserve foreign runtime symlink: $link_path"
fi
return
fi
@@ -242,10 +234,14 @@ prune_stale_links_in_target() {
continue
fi
# -m resolves lexical dangling targets too. If resolution fails, ownership
# is unproven and the link must be preserved.
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
if [[ -n "$resolved" && "$resolved" == "$canonical_real/"* ]]; then
resolved="$(readlink -f "$link_path" 2>/dev/null || true)"
if [[ -z "$resolved" ]]; then
rm -f "$link_path"
echo "[mosaic-skills] Removed stale broken skill link: $link_path"
continue
fi
if [[ "$resolved" == "$MOSAIC_HOME/"* ]]; then
rm -f "$link_path"
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
fi

View File

@@ -79,26 +79,9 @@ function Link-SkillIntoTarget {
$linkPath = Join-Path $TargetDir $name
# Recreate only Mosaic-owned junctions/symlinks. Foreign reparse points are
# runtime-owned and must never be clobbered by install/upgrade auto-sync.
# Already a junction/symlink — recreate
$existing = Get-Item $linkPath -Force -ErrorAction SilentlyContinue
if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
$rawTarget = @($existing.Target)[0]
$candidate = if ([System.IO.Path]::IsPathRooted($rawTarget)) {
$rawTarget
}
else {
Join-Path (Split-Path $linkPath -Parent) $rawTarget
}
$resolvedTarget = [System.IO.Path]::GetFullPath($candidate)
$canonicalRoot = [System.IO.Path]::GetFullPath($MosaicSkillsDir).TrimEnd('\') + '\'
$localRoot = [System.IO.Path]::GetFullPath($MosaicLocalSkillsDir).TrimEnd('\') + '\'
$owned = $resolvedTarget.StartsWith($canonicalRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
$resolvedTarget.StartsWith($localRoot, [System.StringComparison]::OrdinalIgnoreCase)
if (-not $owned) {
Write-Host "[mosaic-skills] Preserve foreign runtime symlink: $linkPath"
return
}
Remove-Item $linkPath -Force
}
elseif ($existing) {

View File

@@ -70,8 +70,6 @@ Security vulnerability review focusing on:
~/.config/mosaic/tools/codex/codex-security-review.sh -n 42
```
PR mode resolves the provider's PR diff rather than relying on the caller's checked-out branch. On Gitea, it fetches the base and `refs/pull/<number>/head` refs and diffs those explicit refs. If the refs cannot be fetched or the resulting diff is empty, the command exits nonzero before Codex runs or a review is posted.
### Review Against Base Branch
```bash
@@ -255,7 +253,7 @@ Run the script from inside a git repository.
### "No changes found to review"
The specified non-PR mode (`--uncommitted`, `--base`, etc.) found no changes to review. PR mode instead fails closed with an actionable error when it cannot construct a non-empty provider diff; verify the PR number, remote, provider login, and ref access before retrying.
The specified mode (--uncommitted, --base, etc.) found no changes to review.
### "Codex produced no output"

View File

@@ -44,47 +44,38 @@ build_diff_context() {
diff_text=$(git show "$value" 2>/dev/null)
;;
pr)
# Provider detection writes its result to stdout; suppress it so it cannot
# be mistaken for diff content when this function is used in a substitution.
detect_platform >/dev/null
# For PRs, we need to fetch the PR diff
detect_platform
if [[ "$PLATFORM" == "github" ]]; then
diff_text=$(gh pr diff "$value" 2>/dev/null) || {
echo "Error: Failed to fetch the diff for PR #${value}." >&2
return 1
}
diff_text=$(gh pr diff "$value" 2>/dev/null)
elif [[ "$PLATFORM" == "gitea" ]]; then
local pr_base base_ref pr_head_ref
pr_base=$(tea pr list --fields index,base --output simple 2>/dev/null | awk -v pr="$value" '$1 == pr { print $2; exit }')
if [[ -z "$pr_base" ]]; then
echo "Error: Could not resolve the base branch for Gitea PR #${value}." >&2
return 1
fi
base_ref="refs/remotes/origin/${pr_base}"
pr_head_ref="refs/remotes/origin/pr/${value}/head"
if ! git fetch --quiet origin \
"+refs/heads/${pr_base}:${base_ref}" \
"+refs/pull/${value}/head:${pr_head_ref}"; then
echo "Error: Failed to fetch the base and head refs for Gitea PR #${value}." >&2
return 1
fi
diff_text=$(git diff "${base_ref}...${pr_head_ref}") || {
echo "Error: Failed to diff the fetched refs for Gitea PR #${value}." >&2
return 1
}
# tea doesn't have a direct pr diff command, use git
local pr_base
pr_base=$(tea pr list --fields index,base --output simple 2>/dev/null | grep "^${value}" | awk '{print $2}')
if [[ -n "$pr_base" ]]; then
diff_text=$(git diff "${pr_base}...HEAD" 2>/dev/null)
else
echo "Error: Unsupported git platform while resolving PR #${value}." >&2
return 1
# Fallback: fetch PR info via API
local repo_info
repo_info=$(get_repo_info)
local remote_url
remote_url=$(git remote get-url origin 2>/dev/null)
local host
host=$(echo "$remote_url" | sed -E 's|.*://([^/]+).*|\1|; s|.*@([^:]+).*|\1|')
diff_text=$(curl -s "https://${host}/api/v1/repos/${repo_info}/pulls/${value}" \
-H "Authorization: token $(tea login list --output simple 2>/dev/null | head -1 | awk '{print $2}')" \
2>/dev/null | jq -r '.diff_url // empty')
if [[ -n "$diff_text" && "$diff_text" != "null" ]]; then
diff_text=$(curl -s "$diff_text" 2>/dev/null)
else
diff_text=$(git diff "main...HEAD" 2>/dev/null)
fi
fi
fi
;;
esac
if [[ "$mode" == "pr" && -z "${diff_text//[[:space:]]/}" ]]; then
echo "Error: Unable to construct a non-empty diff for PR #${value}; verify the PR refs and provider access." >&2
return 1
fi
printf '%s\n' "$diff_text"
echo "$diff_text"
}
# Format JSON findings as markdown for PR comments

View File

@@ -1,158 +0,0 @@
#!/bin/bash
# Hermetic regression coverage for Gitea PR diff construction and fail-closed reviews.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
fail() {
echo "not ok - $*" >&2
exit 1
}
assert_contains() {
local haystack="$1" needle="$2"
if [[ "$haystack" != *"$needle"* ]]; then
printf 'actual output:\n%s\n' "$haystack" >&2
fail "expected output to contain: $needle"
fi
}
# Prevent CI-provided repository context from leaking into the fixture repositories.
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \
GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR
export GIT_AUTHOR_NAME="Codex Fixture"
export GIT_AUTHOR_EMAIL="codex-fixture@example.test"
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
export GITEA_LOGIN="fixture"
export GITEA_TOKEN="fixture-token"
export GITEA_URL="file://$TMP_DIR"
create_pr_fixture() {
local fixture_root="$1" head_mode="$2"
local origin="$fixture_root/origin.git"
local seed="$fixture_root/seed"
local work="$fixture_root/work"
local base_sha head_sha
mkdir -p "$fixture_root"
git init --quiet --bare "$origin"
git init --quiet --initial-branch=release/next "$seed"
printf 'base\n' > "$seed/pr-change.ts"
git -C "$seed" add pr-change.ts
git -C "$seed" commit --quiet -m "fixture base"
base_sha=$(git -C "$seed" rev-parse HEAD)
git -C "$seed" remote add origin "$origin"
git -C "$seed" push --quiet origin release/next
git --git-dir="$origin" symbolic-ref HEAD refs/heads/release/next
if [[ "$head_mode" == "changed" ]]; then
git -C "$seed" switch --quiet -c feature/pr-795
printf 'actual-pr-change\n' > "$seed/pr-change.ts"
git -C "$seed" commit --quiet -am "fixture PR head"
head_sha=$(git -C "$seed" rev-parse HEAD)
git -C "$seed" push --quiet origin HEAD:refs/pull/795/head
else
head_sha="$base_sha"
git --git-dir="$origin" update-ref refs/pull/795/head "$head_sha"
fi
# Gitea's provider-owned PR head ref now exists in the local bare origin.
git clone --quiet "$origin" "$work"
printf '%s\n' "$work"
}
FAKE_BIN="$TMP_DIR/bin"
mkdir -p "$FAKE_BIN"
cat > "$FAKE_BIN/tea" <<'STUB'
#!/bin/bash
if [[ "$*" == "pr list --fields index,base --output simple" ]]; then
printf '795 release/next\n'
exit 0
fi
exit 1
STUB
cat > "$FAKE_BIN/codex" <<'STUB'
#!/bin/bash
printf 'CODEX %s\n' "$*" >> "$CODEX_LOG"
exit 99
STUB
chmod +x "$FAKE_BIN/tea" "$FAKE_BIN/codex"
export PATH="$FAKE_BIN:$PATH"
# The valid fixture is a fresh clone on the non-main base. The PR head exists only
# at refs/pull/795/head, so local HEAD cannot accidentally satisfy the assertion.
if [[ "${1:-all}" != "fail-closed" ]]; then
VALID_WORK=$(create_pr_fixture "$TMP_DIR/valid" changed)
(
cd "$VALID_WORK"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
diff_context=$(build_diff_context pr 795)
assert_contains "$diff_context" "actual-pr-change"
base_sha=$(git rev-parse refs/remotes/origin/release/next)
head_sha=$(git rev-parse refs/remotes/origin/pr/795/head)
local_sha=$(git rev-parse HEAD)
[[ "$local_sha" == "$base_sha" ]] || fail "fixture clone is not on the PR base"
[[ "$head_sha" != "$base_sha" ]] || fail "fixture PR head does not differ from its base"
git show-ref --verify --quiet refs/remotes/origin/pr/795/head || \
fail "fetched PR head ref is missing"
[[ "$(git diff --name-only "${base_sha}...${head_sha}")" == "pr-change.ts" ]] || \
fail "explicit PR refs do not contain the fixture change"
if git show-ref --verify --quiet refs/heads/main || \
git show-ref --verify --quiet refs/remotes/origin/main; then
fail "fixture unexpectedly contains a main ref"
fi
)
echo "ok - Gitea PR mode fetches and diffs explicit non-main base and PR head refs"
fi
# Build an empty PR entirely inside another local repository. Both review wrappers
# must emit the PR-numbered error before Codex or the stubbed post path can execute.
if [[ "${1:-all}" != "pr-head" ]]; then
EMPTY_WORK=$(create_pr_fixture "$TMP_DIR/empty" empty)
SANDBOX="$TMP_DIR/sandbox"
mkdir -p "$SANDBOX/tools/codex/schemas" "$SANDBOX/tools/git"
cp "$SCRIPT_DIR/common.sh" \
"$SCRIPT_DIR/codex-code-review.sh" \
"$SCRIPT_DIR/codex-security-review.sh" \
"$SANDBOX/tools/codex/"
cp "$SCRIPT_DIR/schemas/code-review-schema.json" \
"$SCRIPT_DIR/schemas/security-review-schema.json" \
"$SANDBOX/tools/codex/schemas/"
cp "$SCRIPT_DIR/../git/detect-platform.sh" "$SANDBOX/tools/git/"
cat > "$SANDBOX/tools/git/pr-review.sh" <<'STUB'
#!/bin/bash
printf 'POST %s\n' "$*" >> "$POST_LOG"
STUB
chmod +x "$SANDBOX/tools/git/pr-review.sh"
POST_LOG="$TMP_DIR/post.log"
CODEX_LOG="$TMP_DIR/codex.log"
export POST_LOG CODEX_LOG
for review_kind in code security; do
: > "$POST_LOG"
: > "$CODEX_LOG"
review_script="$SANDBOX/tools/codex/codex-${review_kind}-review.sh"
set +e
(
cd "$EMPTY_WORK"
"$review_script" -n 795
) >"$TMP_DIR/${review_kind}.stdout" 2>"$TMP_DIR/${review_kind}.stderr"
review_status=$?
set -e
stderr_text=$(cat "$TMP_DIR/${review_kind}.stderr")
[[ "$review_status" -ne 0 ]] || fail "${review_kind} review returned success for an empty PR diff"
[[ ! -s "$CODEX_LOG" ]] || fail "Codex ran for an empty ${review_kind} PR diff"
[[ ! -s "$POST_LOG" ]] || fail "${review_kind} review auto-post ran for an empty PR diff"
assert_contains "$stderr_text" "Error:"
assert_contains "$stderr_text" "PR #795"
echo "ok - empty ${review_kind} PR diff fails closed before Codex and auto-post"
done
fi

View File

@@ -37,7 +37,7 @@ response=$(curl -sk -w "\n%{http_code}" \
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" && "$http_code" != "206" ]]; then
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to list computers (HTTP $http_code)" >&2
exit 1
fi

View File

@@ -1,84 +0,0 @@
#!/usr/bin/env bash
# Regression harness for #807: ranged GLPI list requests may return HTTP 206.
#
# Each shipped list wrapper must render a healthy 206 response and must retain
# its non-zero error behavior for a genuine HTTP failure.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/glpi-list-http-status}"
TOOL_DIR="$WORK_DIR/tools/glpi"
BIN_DIR="$WORK_DIR/bin"
rm -rf "$WORK_DIR"
mkdir -p "$TOOL_DIR" "$BIN_DIR" "$WORK_DIR/tools/_lib"
trap 'rm -rf "$WORK_DIR"' EXIT
for wrapper in ticket-list.sh computer-list.sh user-list.sh; do
cp "$SCRIPT_DIR/$wrapper" "$TOOL_DIR/$wrapper"
done
cat > "$WORK_DIR/tools/_lib/credentials.sh" <<'SH'
load_credentials() {
export GLPI_URL="https://glpi.test/apirest.php"
export GLPI_APP_TOKEN="test-app-token"
}
SH
cat > "$TOOL_DIR/session-init.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' 'test-session-token'
SH
chmod +x "$TOOL_DIR/session-init.sh"
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '[{"id":42,"priority":3,"status":1,"name":"Regression fixture","date_mod":"2026-07-16 10:00:00","serial":"SER-42","states_id":1,"realname":"Fixture","firstname":"GLPI","is_active":1}]'
printf '%s\n' "${GLPI_TEST_HTTP_CODE:?GLPI_TEST_HTTP_CODE is required}"
SH
chmod +x "$BIN_DIR/curl"
export MOSAIC_HOME="$WORK_DIR"
export PATH="$BIN_DIR:$PATH"
wrappers=(ticket-list.sh computer-list.sh user-list.sh)
resources=(tickets computers users)
headings=(PRIORITY SERIAL USERNAME)
fail=0
for index in "${!wrappers[@]}"; do
wrapper="${wrappers[$index]}"
resource="${resources[$index]}"
heading="${headings[$index]}"
path="$TOOL_DIR/$wrapper"
if ! output=$(GLPI_TEST_HTTP_CODE=206 bash "$path" 2>&1); then
echo "FAIL: $wrapper rejected healthy HTTP 206" >&2
fail=1
elif [[ "$output" != *"$heading"* || "$output" != *"Regression fixture"* ]]; then
echo "FAIL: $wrapper did not render the HTTP 206 list response" >&2
fail=1
else
echo "PASS: $wrapper renders HTTP 206"
fi
rc=0
output=$(GLPI_TEST_HTTP_CODE=401 bash "$path" 2>&1) || rc=$?
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: $wrapper accepted HTTP 401" >&2
fail=1
elif [[ "$output" != *"Error: Failed to list $resource (HTTP 401)"* ]]; then
echo "FAIL: $wrapper changed the HTTP failure diagnostic" >&2
fail=1
else
echo "PASS: $wrapper rejects HTTP 401"
fi
done
if [[ "$fail" -eq 0 ]]; then
echo "ALL PASS: test-list-http-status.sh"
fi
exit "$fail"

View File

@@ -55,7 +55,7 @@ response=$(curl -sk -w "\n%{http_code}" \
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" && "$http_code" != "206" ]]; then
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to list tickets (HTTP $http_code)" >&2
exit 1
fi

View File

@@ -37,7 +37,7 @@ response=$(curl -sk -w "\n%{http_code}" \
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" != "200" && "$http_code" != "206" ]]; then
if [[ "$http_code" != "200" ]]; then
echo "Error: Failed to list users (HTTP $http_code)" >&2
exit 1
fi

View File

@@ -1,436 +0,0 @@
#!/usr/bin/env python3
"""Fail CI when production code launches Claude/Pi outside the lease gate."""
from __future__ import annotations
import argparse
import json
import re
import shlex
import sys
from pathlib import Path
from typing import Final, NamedTuple, Sequence
SCANNED_ROOTS: Final = ("packages", "apps", "plugins", "tools")
SCANNED_SUFFIXES: Final = {
".bash",
".cjs",
".js",
".json",
".mjs",
".py",
".sh",
".ts",
".tsx",
".yaml",
".yml",
".zsh",
}
SKIPPED_DIRECTORIES: Final = {
".git",
".next",
".turbo",
"coverage",
"dist",
"node_modules",
}
DANGEROUS_PRIMITIVE: Final = "--dangerously-" + "skip-permissions"
CHOKE_POINT_SUFFIX: Final = "framework/tools/lease-broker/launch-runtime.py"
SHELL_SUFFIXES: Final = {".bash", ".sh", ".zsh"}
# A launch line is gated only when it CALLS the common wrapper in command
# position, invokes the TypeScript adapter, or constructs/runs a `mosaic`
# runtime command. A marker in a comment, string argument, echo, or unrelated
# variable can never satisfy these invocation-shaped patterns.
GATED_PATTERNS: Final = (
re.compile(r"(?:^\s*|=>\s*)execLeaseGatedRuntime\s*\("),
re.compile(
r"\bexecRuntime\s*\(\s*[\"']python3[\"']\s*,\s*"
r"\[\s*launcher\s*,.*[\"']--runtime[\"']\s*,\s*runtime\s*,\s*[\"']--[\"']"
),
re.compile(
r"(?:^|[;&|]\s*|\bexec\s+|\b(?:LAUNCH_COMMAND|launch_cmd)\s*=\s*\(?|\becho\s+[\"'])"
r"mosaic\s+(?:yolo\s+)?(?:claude|pi|claudex|[\"']?\$\{?runtime\}?[\"']?|[\"']?\$MOSAIC_AGENT_RUNTIME[\"']?)\b"
),
re.compile(r"\[\s*[\"']mosaic[\"']\s*,\s*(?:[\"']yolo[\"']\s*,\s*)?(?:runtime|[\"'](?:claude|pi|claudex)[\"'])"),
)
DIRECT_PATTERNS: Final = (
# Shell/process command forms, including here-doc command examples that an
# operator could execute verbatim.
re.compile(r"^\s*(?:claude|pi)(?:\s|$)"),
re.compile(r"(?:^|[;&|]\s*|\bexec\s+|\bcommand\s+)(?:claude|pi)\s+(?:-p\b|--dangerously\b|--print\b)"),
re.compile(r"\bexec\s+(?:claude|pi)(?:\s|$)"),
re.compile(r"\bexec\s+(?:/[^\s/]+)+/(?:claude|pi)(?:\s|$)"),
re.compile(r"\bexec\s+[\"']?\$(?:\{?runtime\}?|MOSAIC_AGENT_RUNTIME)\b"),
# JS/TS and Python process APIs with a literal runtime binary.
re.compile(
r"\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync|execv|execvp|execvpe|Popen|run|call|system|check_call|check_output)\s*\(\s*(?:\[\s*)?[\"'](?:claude|pi)(?:[\"']|\s)"
),
re.compile(
r"\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync|execv|execvp|execvpe|Popen|run|call|system|check_call|check_output)\s*\(\s*(?:\[\s*)?[\"'](?:/[^\"'/]+)+/(?:claude|pi)[\"']"
),
# Launch-command arrays and the prior @mosaicstack/coord dynamic default.
re.compile(r"\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync)\s*\(\s*runtime\b"),
re.compile(r"\b(?:command|launchCommand|LAUNCH_COMMAND)\s*=\s*(?:\(|\[)\s*[\"']?(?:claude|pi)\b"),
re.compile(r"\b(?:command|launchCommand|LAUNCH_COMMAND)\s*=\s*(?:\(|\[)\s*[\"']?\$(?:\{?runtime\}?|MOSAIC_AGENT_RUNTIME)\b"),
re.compile(r"\breturn\s*\[\s*runtime\s*,\s*[\"'](?:-p|--dangerously)"),
re.compile(r"\$\(\s*(?:claude|pi)(?:\s|$)"),
re.compile(r"\beval\s+[\"'](?:claude|pi)(?:\s|[\"'])"),
)
RUNTIME_ASSIGNMENT: Final = re.compile(
r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*[\"']?(?:claude|pi)(?:\s|[\"']|[;&|]|$)"
)
TYPESCRIPT_WRAPPER_INVOCATION: Final = re.compile(
r"(?m)^\s*execRuntime\s*\(\s*[\"']python3[\"']\s*,\s*"
r"\[\s*launcher\s*,[^\]]*[\"']--runtime[\"']\s*,\s*runtime\s*,\s*"
r"[\"']--[\"']\s*,\s*runtime(?:\s*,|\s*\])",
re.DOTALL,
)
class LaunchSite(NamedTuple):
path: Path
line_number: int
line: str
classification: str
def is_test_path(path: Path) -> bool:
name = path.name.lower()
return (
"__tests__" in path.parts
or ".spec." in name
or ".test." in name
or name.endswith("_unittest.py")
or name.startswith("test-")
or name.startswith("test_")
)
def strip_comments(path: Path, line: str, in_block_comment: bool = False) -> tuple[str, bool]:
suffix = path.suffix.lower()
hash_comments = suffix in {".bash", ".py", ".sh", ".yaml", ".yml", ".zsh"}
slash_comments = suffix in {".cjs", ".js", ".mjs", ".ts", ".tsx"}
output: list[str] = []
quote: str | None = None
escaped = False
index = 0
while index < len(line):
if in_block_comment:
end = line.find("*/", index)
if end < 0:
return "".join(output), True
in_block_comment = False
index = end + 2
continue
character = line[index]
following = line[index + 1] if index + 1 < len(line) else ""
if quote is not None:
output.append(character)
if escaped:
escaped = False
elif character == "\\":
escaped = True
elif character == quote:
quote = None
index += 1
continue
if character in {"'", '"', "`"}:
quote = character
output.append(character)
index += 1
continue
if slash_comments and character == "/" and following == "/":
break
if slash_comments and character == "/" and following == "*":
in_block_comment = True
index += 2
continue
if hash_comments and character == "#":
if suffix == ".py" or index == 0 or line[index - 1].isspace():
break
output.append(character)
index += 1
return "".join(output), in_block_comment
def is_choke_point(path: Path) -> bool:
return path.as_posix().endswith(CHOKE_POINT_SUFFIX)
def shell_commands(line: str) -> list[list[str]]:
candidate = line.rstrip().removesuffix("\\").rstrip()
try:
lexer = shlex.shlex(candidate, posix=True, punctuation_chars=";&|")
lexer.whitespace_split = True
lexer.commenters = ""
tokens = list(lexer)
except ValueError:
return []
commands: list[list[str]] = []
current: list[str] = []
for token in tokens:
if token and all(character in ";&|" for character in token):
if current:
commands.append(current)
current = []
else:
current.append(token)
if current:
commands.append(current)
return commands
def is_wrapper_invocation(path: Path, line: str) -> bool:
if path.suffix.lower() not in SHELL_SUFFIXES:
return False
separator = re.search(r"\s--(?:\s|$)", line)
if separator is None:
return False
# Everything after the wrapper separator is opaque runtime argv and may
# contain an open quote continued on later physical lines. Parse only the
# complete command-position prefix through the separator.
wrapper_prefix = line[: separator.end()]
for command in shell_commands(wrapper_prefix):
if command and command[0] == "exec":
command = command[1:]
if not command:
continue
if command[0] == "python3":
if len(command) < 2 or not command[1].endswith("launch-runtime.py"):
continue
arguments = command[2:]
elif command[0].endswith(("launch-runtime.py", "launch-runtime.sh")):
arguments = command[1:]
else:
continue
try:
runtime_index = arguments.index("--runtime")
separator_index = arguments.index("--")
except ValueError:
continue
if runtime_index + 1 < len(arguments) and runtime_index < separator_index:
return True
return False
def is_gated_line(path: Path, line: str) -> bool:
return is_wrapper_invocation(path, line) or any(
pattern.search(line) for pattern in GATED_PATTERNS
)
def shell_command_tokens(path: Path, line: str) -> list[str]:
if path.suffix.lower() not in SHELL_SUFFIXES:
return []
assignment = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
case_arm = re.match(r"^\s*[^;&()]+\)\s*", line)
command_source = line[case_arm.end() :] if case_arm is not None else line
resolved: list[str] = []
for command in shell_commands(command_source):
index = 0
while index < len(command) and assignment.match(command[index]):
index += 1
# Resolve command position once for every literal and variable caller.
# Prefixes may be nested in either order (for example `exec env A=1`).
while index < len(command):
if command[index] in {"command", "exec", "nohup"}:
index += 1
continue
if command[index] == "env":
index += 1
while index < len(command) and (
command[index].startswith("-") or assignment.match(command[index])
):
index += 1
continue
break
if index < len(command):
resolved.append(command[index])
return resolved
def runtime_variable_reference(token: str, runtime_variables: set[str]) -> bool:
match = re.fullmatch(r"\$(?:([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)\})", token)
if match is None:
return False
return (match.group(1) or match.group(2)) in runtime_variables
def is_shell_direct_invocation(
path: Path, line: str, runtime_variables: set[str]
) -> bool:
return any(
Path(token).name in {"claude", "pi"}
or runtime_variable_reference(token, runtime_variables)
for token in shell_command_tokens(path, line)
)
def is_direct_line(path: Path, line: str, runtime_variables: set[str]) -> bool:
return is_shell_direct_invocation(path, line, runtime_variables) or any(
pattern.search(line) for pattern in DIRECT_PATTERNS
)
def executes_runtime_variable(
path: Path, line: str, runtime_variables: set[str]
) -> bool:
if any(
runtime_variable_reference(token, runtime_variables)
for token in shell_command_tokens(path, line)
):
return True
for variable in runtime_variables:
reference = rf"\$(?:{re.escape(variable)}|\{{{re.escape(variable)}\}})"
if re.search(rf"\beval\s+[\"']?{reference}", line):
return True
return False
def typescript_wrapper_lines(path: Path, source: str) -> set[int]:
if path.suffix.lower() not in {".js", ".mjs", ".ts", ".tsx"}:
return set()
return {
source.count("\n", 0, match.start()) + 1
for match in TYPESCRIPT_WRAPPER_INVOCATION.finditer(source)
}
def classify_text(path: Path, source: str) -> list[LaunchSite]:
sites: list[LaunchSite] = []
validated_typescript_wrappers = typescript_wrapper_lines(path, source)
runtime_variables: set[str] = set()
gated_continuation = False
in_block_comment = False
for line_number, physical_line in enumerate(source.splitlines(), start=1):
line, in_block_comment = strip_comments(path, physical_line, in_block_comment)
stripped = line.strip()
if not stripped:
gated_continuation = False
continue
assignment = RUNTIME_ASSIGNMENT.match(line)
if assignment is not None:
runtime_variables.add(assignment.group(1))
primitive_violation = DANGEROUS_PRIMITIVE in line and not is_choke_point(path)
line_is_direct = is_direct_line(path, line, runtime_variables) or executes_runtime_variable(
path, line, runtime_variables
)
line_is_gated = line_number in validated_typescript_wrappers or is_gated_line(path, line)
if primitive_violation:
sites.append(
LaunchSite(path, line_number, physical_line.rstrip(), "dangerous-primitive")
)
elif line_is_direct and not gated_continuation:
# Direct syntax always wins over a same-line marker. Only the command
# continuation of a previously validated wrapper may contain the raw
# runtime binary itself.
sites.append(LaunchSite(path, line_number, physical_line.rstrip(), "direct"))
elif line_is_gated:
sites.append(LaunchSite(path, line_number, physical_line.rstrip(), "gated"))
gated = line_is_gated or gated_continuation
gated_continuation = gated and line.rstrip().endswith("\\")
return sites
def scan_text(path: Path, source: str) -> list[LaunchSite]:
return [site for site in classify_text(path, source) if site.classification != "gated"]
def source_files(root: Path):
for relative_root in SCANNED_ROOTS:
search_root = root / relative_root
if not search_root.is_dir():
continue
for path in search_root.rglob("*"):
if not path.is_file() or path.suffix.lower() not in SCANNED_SUFFIXES:
continue
if any(part in SKIPPED_DIRECTORIES for part in path.parts):
continue
if is_test_path(path):
continue
yield path
def scan_repository(root: Path) -> list[LaunchSite]:
violations: list[LaunchSite] = []
for path in source_files(root):
try:
source = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
violations.append(LaunchSite(path, 0, "non-UTF-8 source", "unscannable"))
continue
violations.extend(scan_text(path.relative_to(root), source))
return violations
def inventory_repository(root: Path) -> list[LaunchSite]:
inventory: list[LaunchSite] = []
for path in source_files(root):
try:
source = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
inventory.append(LaunchSite(path.relative_to(root), 0, "non-UTF-8 source", "unscannable"))
continue
relative_path = path.relative_to(root)
inventory.extend(classify_text(relative_path, source))
return inventory
def format_violation(violation: LaunchSite) -> str:
return f"{violation.path}:{violation.line_number}: {violation.classification}: {violation.line.strip()}"
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--root", type=Path, default=Path.cwd())
parser.add_argument("--json", action="store_true")
arguments = parser.parse_args(argv)
root = arguments.root.resolve()
inventory = inventory_repository(root)
violations = [site for site in inventory if site.classification != "gated"]
if arguments.json:
print(
json.dumps(
{
"gated": sum(site.classification == "gated" for site in inventory),
"total": len(inventory),
"sites": [
{
"path": str(site.path),
"line": site.line_number,
"classification": site.classification,
"source": site.line.strip(),
}
for site in inventory
],
},
sort_keys=True,
)
)
else:
for site in inventory:
print(format_violation(site))
print(
f"runtime launch inventory: "
f"{len(inventory) - len(violations)} gated/{len(inventory)} total"
)
if violations:
print("ungated consequential runtime launch detected", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,704 +0,0 @@
#!/usr/bin/env python3
"""Mosaic external lease broker for Linux SO_PEERCRED authenticated clients."""
from __future__ import annotations
import argparse
import copy
import errno
from concurrent.futures import ThreadPoolExecutor
import json
import os
import secrets
import signal
import socket
import stat
import struct
import sys
import threading
import time
from pathlib import Path
from typing import Final
MAX_FRAME: Final = 64 * 1024
MAX_STATE: Final = 4 * 1024 * 1024
MAX_PENDING_TOKENS: Final = 256
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
MAX_LEASE_TTL_SECONDS: Final = 300
STATE_VERSION: Final = 1
READ_DEADLINE_SECONDS: Final = 1.0
HANDLE_QUEUE_TIMEOUT_SECONDS: Final = 1.0
SEND_TIMEOUT_SECONDS: Final = 1.0
HEX_256_LENGTH: Final = 64
LEASE_UNVERIFIED: Final = "UNVERIFIED"
LEASE_PENDING: Final = "PENDING_VERIFICATION"
LEASE_PENDING_PROMOTION: Final = "PENDING_PROMOTION"
LEASE_VERIFIED: Final = "VERIFIED"
READ_ONLY_TOOLS: Final = {
"claude": frozenset({"Read", "Grep", "Glob", "Ls", "Find"}),
"pi": frozenset({"read", "grep", "find", "ls"}),
}
RECOVERY_TOOL: Final = "mosaic_context_recover"
class BrokerFailure(Exception):
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
class StateCommitUncertain(RuntimeError):
def __init__(self) -> None:
super().__init__("STATE_COMMIT_UNCERTAIN")
def is_non_negative_integer(value: object) -> bool:
return type(value) is int and value >= 0
def is_hex_256(value: object) -> bool:
return (
isinstance(value, str)
and len(value) == HEX_256_LENGTH
and all(character in "0123456789abcdef" for character in value)
)
def is_positive_decimal(value: object) -> bool:
return (
isinstance(value, str)
and len(value) > 0
and value[0] in "123456789"
and all(character in "0123456789" for character in value)
)
def valid_binding(binding: object) -> bool:
if not isinstance(binding, dict):
return False
required = {"compaction_epoch", "request_epoch", "h_source", "h_payload", "schema_version"}
if set(binding) != required:
return False
if not all(
is_non_negative_integer(binding[field])
for field in ("compaction_epoch", "request_epoch", "schema_version")
):
return False
return all(
is_hex_256(binding[field])
for field in ("h_source", "h_payload")
)
def validate_state(value: object) -> dict[str, object]:
if not isinstance(value, dict) or set(value) != {"version", "sessions", "tokens"}:
raise BrokerFailure("STATE_INTEGRITY")
if type(value["version"]) is not int or value["version"] != STATE_VERSION:
raise BrokerFailure("STATE_INTEGRITY")
sessions = value["sessions"]
tokens = value["tokens"]
if not isinstance(sessions, dict) or not isinstance(tokens, dict):
raise BrokerFailure("STATE_INTEGRITY")
if len(tokens) > MAX_PENDING_TOKENS:
raise BrokerFailure("STATE_INTEGRITY")
anchors: set[tuple[int, str]] = set()
for session_id, session in sessions.items():
if not is_hex_256(session_id) or not isinstance(session, dict):
raise BrokerFailure("STATE_INTEGRITY")
if set(session) != {"anchor_pid", "anchor_starttime", "runtime_generation"}:
raise BrokerFailure("STATE_INTEGRITY")
anchor_pid = session["anchor_pid"]
anchor_starttime = session["anchor_starttime"]
if type(anchor_pid) is not int or anchor_pid <= 0:
raise BrokerFailure("STATE_INTEGRITY")
if not is_positive_decimal(anchor_starttime):
raise BrokerFailure("STATE_INTEGRITY")
if not is_non_negative_integer(session["runtime_generation"]):
raise BrokerFailure("STATE_INTEGRITY")
anchor = (anchor_pid, anchor_starttime)
if anchor in anchors:
raise BrokerFailure("STATE_INTEGRITY")
anchors.add(anchor)
for token_value, token in tokens.items():
if not is_hex_256(token_value) or not isinstance(token, dict):
raise BrokerFailure("STATE_INTEGRITY")
if set(token) != {"session_id", "runtime_generation", "binding", "consumed"}:
raise BrokerFailure("STATE_INTEGRITY")
session_id = token["session_id"]
generation = token["runtime_generation"]
session = sessions.get(session_id) if isinstance(session_id, str) else None
if not is_hex_256(session_id) or not isinstance(session, dict):
raise BrokerFailure("STATE_INTEGRITY")
if not is_non_negative_integer(generation):
raise BrokerFailure("STATE_INTEGRITY")
if generation != session["runtime_generation"]:
raise BrokerFailure("STATE_INTEGRITY")
if not valid_binding(token["binding"]) or token["consumed"] is not False:
raise BrokerFailure("STATE_INTEGRITY")
return value
def proc_node(pid: int) -> dict[str, int | str]:
try:
text = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
except (FileNotFoundError, PermissionError, ProcessLookupError) as exc:
raise BrokerFailure("PID_UNAVAILABLE") from exc
close = text.rfind(")")
fields = text[close + 2 :].split()
if close < 0 or len(fields) < 20:
raise BrokerFailure("PROC_STAT_INVALID")
return {"pid": pid, "ppid": int(fields[1]), "starttime": fields[19]}
def verified_ancestry(peer_pid: int, anchor_pid: int, anchor_starttime: str) -> bool:
chain: list[dict[str, int | str]] = []
seen: set[int] = set()
current = peer_pid
while current > 0 and current not in seen:
seen.add(current)
node = proc_node(current)
chain.append(node)
if current == anchor_pid:
if node["starttime"] != anchor_starttime:
return False
break
current = int(node["ppid"])
else:
return False
if int(chain[-1]["pid"]) != anchor_pid:
return False
for original in chain:
repeated = proc_node(int(original["pid"]))
if repeated["starttime"] != original["starttime"]:
raise BrokerFailure("PID_STARTTIME_RACE")
return True
def secure_parent(path: Path) -> None:
parent = path.parent
if not parent.is_dir() or stat.S_IMODE(parent.stat().st_mode) != 0o700:
raise BrokerFailure("INSECURE_PARENT_MODE")
class StateStore:
def __init__(self, path: Path) -> None:
self.path = path
secure_parent(path)
self.poisoned = False
self.value: dict[str, object] = {"version": STATE_VERSION, "sessions": {}, "tokens": {}}
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except FileNotFoundError:
return
except OSError as exc:
raise BrokerFailure("STATE_INTEGRITY") from exc
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode):
raise BrokerFailure("STATE_INTEGRITY")
if stat.S_IMODE(metadata.st_mode) != 0o600:
raise BrokerFailure("INSECURE_STATE_MODE")
if metadata.st_size > MAX_STATE:
raise BrokerFailure("STATE_INTEGRITY")
chunks = bytearray()
while len(chunks) <= MAX_STATE:
chunk = os.read(descriptor, min(64 * 1024, MAX_STATE + 1 - len(chunks)))
if not chunk:
break
chunks.extend(chunk)
if len(chunks) > MAX_STATE:
raise BrokerFailure("STATE_INTEGRITY")
try:
loaded = json.loads(chunks)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise BrokerFailure("STATE_INTEGRITY") from exc
self.value = validate_state(loaded)
except OSError as exc:
raise BrokerFailure("STATE_INTEGRITY") from exc
finally:
os.close(descriptor)
def sessions(self) -> dict[str, dict[str, object]]:
sessions = self.value.get("sessions")
if not isinstance(sessions, dict):
raise BrokerFailure("STATE_INTEGRITY")
return sessions
def tokens(self) -> dict[str, dict[str, object]]:
tokens = self.value.get("tokens")
if not isinstance(tokens, dict):
raise BrokerFailure("STATE_INTEGRITY")
return tokens
def commit(self) -> None:
if self.poisoned:
raise StateCommitUncertain()
payload = (
json.dumps(self.value, sort_keys=True, separators=(",", ":")) + "\n"
).encode()
if len(payload) > MAX_STATE:
raise BrokerFailure("STATE_TOO_LARGE")
temporary = self.path.with_name(f".{self.path.name}.{os.getpid()}.tmp")
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
replaced = False
try:
try:
remaining = memoryview(payload)
while remaining:
written = os.write(descriptor, remaining)
if written == 0:
raise OSError(errno.EIO, "state write made no progress")
remaining = remaining[written:]
os.fsync(descriptor)
finally:
os.close(descriptor)
os.replace(temporary, self.path)
replaced = True
try:
directory = os.open(self.path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
except OSError as exc:
self.poisoned = True
raise StateCommitUncertain() from exc
finally:
if not replaced:
try:
temporary.unlink()
except FileNotFoundError:
pass
class Broker:
def __init__(self, store: StateStore) -> None:
self.store = store
# VERIFIED authority is deliberately volatile: broker restart revokes all
# leases while preserving WI-1 identity and pending-token integrity.
self.leases: dict[str, dict[str, object]] = {}
def authenticate(self, peer_pid: int, request: dict[str, object]) -> tuple[str, dict[str, object]]:
session_id = request.get("session_id")
generation = request.get("runtime_generation")
if not isinstance(session_id, str) or not is_non_negative_integer(generation):
raise BrokerFailure("INVALID_IDENTITY")
session = self.store.sessions().get(session_id)
if not isinstance(session, dict):
raise BrokerFailure("UNKNOWN_SESSION")
anchor_pid = session.get("anchor_pid")
anchor_starttime = session.get("anchor_starttime")
current_generation = session.get("runtime_generation")
if (
type(anchor_pid) is not int
or not isinstance(anchor_starttime, str)
or not is_non_negative_integer(current_generation)
):
raise BrokerFailure("STATE_INTEGRITY")
if not verified_ancestry(peer_pid, anchor_pid, anchor_starttime):
raise BrokerFailure("ANCESTRY_MISMATCH")
if generation < current_generation:
raise BrokerFailure("STALE_GENERATION")
if generation > current_generation:
session["runtime_generation"] = generation
self.revoke_session_authority(session_id)
return session_id, session
def session_for_anchor(
self, anchor_pid: int, anchor_starttime: str
) -> tuple[str, dict[str, object]] | None:
for session_id, session in self.store.sessions().items():
if (
session["anchor_pid"] == anchor_pid
and session["anchor_starttime"] == anchor_starttime
):
return session_id, session
return None
def revoke_session_tokens(self, session_id: str) -> None:
tokens = self.store.tokens()
for token_value in [
value for value, token in tokens.items() if token["session_id"] == session_id
]:
del tokens[token_value]
def revoke_session_authority(self, session_id: str) -> None:
self.revoke_session_tokens(session_id)
session = self.store.sessions().get(session_id)
generation = session.get("runtime_generation") if isinstance(session, dict) else None
self.leases[session_id] = {
"state": LEASE_UNVERIFIED,
"runtime_generation": generation,
}
def mint_token(
self,
session_id: str,
generation: int,
binding: dict[str, object],
) -> str:
if len(self.store.tokens()) >= MAX_PENDING_TOKENS:
raise BrokerFailure("TOKEN_CAPACITY")
token = secrets.token_hex(32)
self.store.tokens()[token] = {
"session_id": session_id,
"runtime_generation": generation,
"binding": copy.deepcopy(binding),
"consumed": False,
}
return token
def finish_promotion(self, session_id: str) -> None:
lease = self.leases.get(session_id)
if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING_PROMOTION:
raise BrokerFailure("STATE_INTEGRITY")
lease["state"] = LEASE_VERIFIED
def handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]:
if self.store.poisoned:
raise StateCommitUncertain()
previous = copy.deepcopy(self.store.value)
previous_leases = copy.deepcopy(self.leases)
try:
response = self._handle(peer, request)
if self.store.value != previous:
self.store.commit()
if request.get("action") == "promote_lease":
session_id = request.get("session_id")
if not isinstance(session_id, str):
raise BrokerFailure("INVALID_IDENTITY")
self.finish_promotion(session_id)
response["state"] = LEASE_VERIFIED
return response
except StateCommitUncertain:
raise
except Exception:
self.store.value = previous
self.leases = previous_leases
raise
def _handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]:
peer_pid, peer_uid, peer_gid = peer
action = request.get("action")
if action == "register_anchor":
if "session_id" in request:
raise BrokerFailure("CALLER_SESSION_ID_REFUSED")
generation = request.get("runtime_generation")
if not is_non_negative_integer(generation):
raise BrokerFailure("INVALID_GENERATION")
anchor = proc_node(peer_pid)
anchor_starttime = str(anchor["starttime"])
existing = self.session_for_anchor(peer_pid, anchor_starttime)
if existing is None:
session_id = secrets.token_hex(32)
self.store.sessions()[session_id] = {
"anchor_pid": peer_pid,
"anchor_starttime": anchor_starttime,
"runtime_generation": generation,
}
else:
session_id, session = existing
current_generation = session["runtime_generation"]
if generation < current_generation:
raise BrokerFailure("STALE_GENERATION")
if generation > current_generation:
session["runtime_generation"] = generation
self.revoke_session_authority(session_id)
return {"ok": True, "session_id": session_id, "peer": {"pid": peer_pid, "uid": peer_uid, "gid": peer_gid, "starttime": anchor["starttime"]}}
if action == "authenticate":
self.authenticate(peer_pid, request)
return {"ok": True}
if action == "mint_token":
session_id, _ = self.authenticate(peer_pid, request)
binding = request.get("binding")
if not valid_binding(binding):
raise BrokerFailure("INVALID_BINDING")
token = self.mint_token(session_id, request["runtime_generation"], binding)
return {"ok": True, "token": token}
if action == "consume_token":
session_id, _ = self.authenticate(peer_pid, request)
token_value = request.get("token")
token = self.store.tokens().get(token_value) if isinstance(token_value, str) else None
if not isinstance(token, dict) or token.get("session_id") != session_id or token.get("runtime_generation") != request.get("runtime_generation") or token.get("consumed") is not False:
raise BrokerFailure("TOKEN_REPLAY")
del self.store.tokens()[token_value]
return {"ok": True}
if action == "begin_verification":
session_id, _ = self.authenticate(peer_pid, request)
runtime = request.get("runtime")
binding = request.get("binding")
ttl_seconds = request.get("ttl_seconds", MAX_LEASE_TTL_SECONDS)
if runtime not in READ_ONLY_TOOLS:
raise BrokerFailure("INVALID_RUNTIME")
if not valid_binding(binding):
raise BrokerFailure("INVALID_BINDING")
if (
type(ttl_seconds) is not int
or ttl_seconds <= 0
or ttl_seconds > MAX_LEASE_TTL_SECONDS
):
raise BrokerFailure("INVALID_LEASE_TTL")
# Revoke-first is a broker operation, not advisory adapter order.
self.revoke_session_authority(session_id)
token = self.mint_token(session_id, request["runtime_generation"], binding)
self.leases[session_id] = {
"state": LEASE_PENDING,
"runtime": runtime,
"runtime_generation": request["runtime_generation"],
"binding": copy.deepcopy(binding),
"promotion_token": token,
"ttl_seconds": ttl_seconds,
}
return {
"ok": True,
"state": LEASE_PENDING,
"promotion_token": token,
}
if action == "promote_lease":
session_id, _ = self.authenticate(peer_pid, request)
promotion_token = request.get("promotion_token")
lease = self.leases.get(session_id)
if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING:
raise BrokerFailure("INVALID_LEASE_TRANSITION")
expected_token = lease.get("promotion_token")
if (
not isinstance(promotion_token, str)
or not isinstance(expected_token, str)
or not secrets.compare_digest(promotion_token, expected_token)
):
raise BrokerFailure("PROMOTION_TOKEN_MISMATCH")
token = self.store.tokens().get(promotion_token)
if (
not isinstance(token, dict)
or token.get("session_id") != session_id
or token.get("runtime_generation") != request.get("runtime_generation")
or token.get("binding") != lease.get("binding")
or token.get("consumed") is not False
):
raise BrokerFailure("PROMOTION_TOKEN_INVALID")
del self.store.tokens()[promotion_token]
lease["state"] = LEASE_PENDING_PROMOTION
lease["expires_at"] = time.monotonic() + int(lease["ttl_seconds"])
# handle() commits token consumption before finish_promotion() makes
# VERIFIED externally visible: promote-last by construction.
return {"ok": True, "state": LEASE_PENDING_PROMOTION}
if action == "revoke_lease":
session_id, _ = self.authenticate(peer_pid, request)
self.revoke_session_authority(session_id)
return {"ok": True, "state": LEASE_UNVERIFIED}
if action == "authorize_tool":
session_id, _ = self.authenticate(peer_pid, request)
runtime = request.get("runtime")
tool_name = request.get("tool_name")
if runtime not in READ_ONLY_TOOLS:
raise BrokerFailure("INVALID_RUNTIME")
if not isinstance(tool_name, str) or not tool_name or len(tool_name) > 256:
raise BrokerFailure("INVALID_TOOL")
lease = self.leases.get(session_id)
state = lease.get("state") if isinstance(lease, dict) else LEASE_UNVERIFIED
if tool_name in READ_ONLY_TOOLS[runtime] or tool_name == RECOVERY_TOOL:
return {"ok": True, "decision": "allow", "state": state}
if not isinstance(lease, dict) or lease.get("state") != LEASE_VERIFIED:
return {
"ok": False,
"code": "MUTATOR_UNVERIFIED",
"decision": "deny",
"state": LEASE_UNVERIFIED,
}
if (
lease.get("runtime") != runtime
or lease.get("runtime_generation") != request.get("runtime_generation")
):
return {
"ok": False,
"code": "MUTATOR_UNVERIFIED",
"decision": "deny",
"state": LEASE_UNVERIFIED,
}
expires_at = lease.get("expires_at")
if not isinstance(expires_at, (int, float)) or time.monotonic() >= expires_at:
self.revoke_session_authority(session_id)
return {
"ok": False,
"code": "LEASE_EXPIRED",
"decision": "deny",
"state": LEASE_UNVERIFIED,
}
return {
"ok": True,
"decision": "allow",
"state": LEASE_VERIFIED,
}
raise BrokerFailure("UNKNOWN_ACTION")
def read_frame(connection: socket.socket, deadline: float) -> dict[str, object]:
data = bytearray()
while len(data) <= MAX_FRAME:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise BrokerFailure("MALFORMED_REQUEST")
connection.settimeout(remaining)
chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(data)))
if not chunk:
break
data.extend(chunk)
if len(data) > MAX_FRAME:
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise BrokerFailure("MALFORMED_REQUEST")
connection.settimeout(remaining)
if not connection.recv(4096):
break
raise BrokerFailure("MALFORMED_REQUEST")
if not data.endswith(b"\n") or data.count(b"\n") != 1:
raise BrokerFailure("MALFORMED_REQUEST")
try:
value = json.loads(data)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise BrokerFailure("MALFORMED_REQUEST") from exc
if not isinstance(value, dict):
raise BrokerFailure("MALFORMED_REQUEST")
return value
def handle_connection(
connection: socket.socket,
broker: Broker,
broker_lock: threading.Lock,
) -> None:
with connection:
read_deadline = time.monotonic() + READ_DEADLINE_SECONDS
try:
raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
peer = struct.unpack("3i", raw)
request = read_frame(connection, read_deadline)
except BrokerFailure as exc:
reply = {"ok": False, "code": exc.code}
except OSError:
return
else:
# Queueing is bounded and fails closed before broker.handle mutates
# state. Once handling starts it must finish atomically; its reply
# then receives an independent send budget so a slow fsync cannot
# consume the write opportunity and create client/broker ambiguity.
acquired = broker_lock.acquire(timeout=HANDLE_QUEUE_TIMEOUT_SECONDS)
if not acquired:
reply = {"ok": False, "code": "BROKER_BUSY"}
else:
try:
try:
reply = broker.handle(peer, request)
except BrokerFailure as exc:
reply = {"ok": False, "code": exc.code}
finally:
broker_lock.release()
try:
connection.settimeout(SEND_TIMEOUT_SECONDS)
connection.sendall((json.dumps(reply, separators=(",", ":")) + "\n").encode())
except OSError:
return
def serve(socket_path: Path, state_path: Path) -> None:
secure_parent(socket_path)
if socket_path.exists() or socket_path.is_symlink():
raise BrokerFailure("SOCKET_ALREADY_EXISTS")
store = StateStore(state_path)
broker = Broker(store)
broker_lock = threading.Lock()
slots = threading.BoundedSemaphore(MAX_IN_FLIGHT_CONNECTIONS)
fatal_lock = threading.Lock()
fatal_errors: list[Exception] = []
executor = ThreadPoolExecutor(
max_workers=MAX_IN_FLIGHT_CONNECTIONS,
thread_name_prefix="mosaic-lease-broker",
)
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(str(socket_path))
os.chmod(socket_path, 0o600)
owned = (socket_path.stat().st_dev, socket_path.stat().st_ino)
stopping = False
def stop(_signum: int, _frame: object) -> None:
nonlocal stopping
stopping = True
server.close()
def process_connection(connection: socket.socket) -> None:
try:
handle_connection(connection, broker, broker_lock)
except Exception as exc:
with fatal_lock:
if not fatal_errors:
fatal_errors.append(exc)
finally:
slots.release()
def fatal_error() -> Exception | None:
with fatal_lock:
return fatal_errors[0] if fatal_errors else None
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
server.listen(MAX_IN_FLIGHT_CONNECTIONS)
server.settimeout(0.1)
print("READY", flush=True)
try:
while not stopping:
failure = fatal_error()
if failure is not None:
raise failure
if not slots.acquire(timeout=0.1):
continue
try:
connection, _ = server.accept()
except socket.timeout:
slots.release()
continue
except OSError:
slots.release()
failure = fatal_error()
if failure is not None:
raise failure
if stopping:
break
raise
try:
executor.submit(process_connection, connection)
except Exception:
slots.release()
connection.close()
raise
finally:
server.close()
executor.shutdown(wait=True)
try:
current = socket_path.stat()
if (current.st_dev, current.st_ino) == owned:
socket_path.unlink()
except FileNotFoundError:
pass
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--socket", required=True, type=Path)
parser.add_argument("--state", required=True, type=Path)
arguments = parser.parse_args()
serve(arguments.socket, arguments.state)
if __name__ == "__main__":
try:
main()
except BrokerFailure as failure:
print(failure.code, file=sys.stderr)
raise SystemExit(1)
except StateCommitUncertain as failure:
print(str(failure), file=sys.stderr)
raise SystemExit(1)

View File

@@ -1,103 +0,0 @@
#!/usr/bin/env python3
"""Register a runtime parent with the lease broker, then exec without changing PID."""
from __future__ import annotations
import argparse
import json
import os
import socket
import sys
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import Final
MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 1.5
CLAUDE_DANGEROUS_FLAG: Final = "--dangerously-skip-permissions"
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
payload = (json.dumps(request, separators=(",", ":")) + "\n").encode()
response = bytearray()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(BROKER_TIMEOUT_SECONDS)
connection.connect(str(socket_path))
connection.sendall(payload)
connection.shutdown(socket.SHUT_WR)
while len(response) <= MAX_FRAME:
chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response)))
if not chunk:
break
response.extend(chunk)
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
raise ValueError("invalid broker reply")
value = json.loads(response)
if not isinstance(value, dict):
raise ValueError("invalid broker reply")
return value
def main(
argv: Sequence[str] | None = None,
*,
environ: Mapping[str, str] | None = None,
request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request,
execute: Callable[[str, list[str], dict[str, str]], object] = os.execvpe,
) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
parser.add_argument("--dangerous", action="store_true")
parser.add_argument("command", nargs=argparse.REMAINDER)
arguments = parser.parse_args(argv)
command = arguments.command
if command and command[0] == "--":
command = command[1:]
if not command:
print("lease-gated runtime command is required", file=sys.stderr)
return 64
if arguments.dangerous:
if arguments.runtime != "claude" or Path(command[0]).name != "claude":
print("dangerous mode is supported only for the Claude runtime", file=sys.stderr)
return 64
command = [command[0], CLAUDE_DANGEROUS_FLAG, *command[1:]]
source_environment = os.environ if environ is None else environ
try:
socket_path = Path(source_environment["MOSAIC_LEASE_BROKER_SOCKET"])
generation = int(source_environment.get("MOSAIC_RUNTIME_GENERATION", "1"))
if generation < 0:
raise ValueError("invalid generation")
reply = request(
socket_path,
{
"action": "register_anchor",
"runtime_generation": generation,
},
)
session_id = reply.get("session_id")
if (
reply.get("ok") is not True
or not isinstance(session_id, str)
or len(session_id) != 64
or any(character not in "0123456789abcdef" for character in session_id)
):
raise ValueError("registration refused")
except (KeyError, ValueError, OSError, json.JSONDecodeError):
print("Mosaic lease broker registration failed; runtime launch denied.", file=sys.stderr)
return 1
environment = dict(source_environment)
environment["MOSAIC_LEASE_SESSION_ID"] = session_id
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime
try:
execute(command[0], command, environment)
except OSError:
print("Mosaic lease-gated runtime exec failed.", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,100 +0,0 @@
#!/usr/bin/env python3
"""Runtime-neutral whole mutator-class gate backed by the Mosaic lease broker."""
from __future__ import annotations
import argparse
import json
import os
import socket
import sys
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import BinaryIO, Final
MAX_FRAME: Final = 64 * 1024
BROKER_TIMEOUT_SECONDS: Final = 1.5
def deny(code: str) -> int:
print(f"BLOCKED: Mosaic mutator gate denied this tool ({code}).", file=sys.stderr)
return 2
def read_tool_name(stream: BinaryIO | None = None) -> str:
source = sys.stdin.buffer if stream is None else stream
raw = source.read(MAX_FRAME + 1)
if len(raw) > MAX_FRAME:
raise ValueError("INVALID_GATE_INPUT")
value = json.loads(raw)
if not isinstance(value, dict):
raise ValueError("INVALID_GATE_INPUT")
tool_name = value.get("tool_name")
if not isinstance(tool_name, str) or not tool_name or len(tool_name) > 256:
raise ValueError("INVALID_GATE_INPUT")
return tool_name
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
payload = (json.dumps(request, separators=(",", ":")) + "\n").encode()
if len(payload) > MAX_FRAME:
raise ValueError("INVALID_GATE_INPUT")
response = bytearray()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(BROKER_TIMEOUT_SECONDS)
connection.connect(str(socket_path))
connection.sendall(payload)
connection.shutdown(socket.SHUT_WR)
while len(response) <= MAX_FRAME:
chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response)))
if not chunk:
break
response.extend(chunk)
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
raise ValueError("INVALID_BROKER_REPLY")
value = json.loads(response)
if not isinstance(value, dict):
raise ValueError("INVALID_BROKER_REPLY")
return value
def main(
argv: Sequence[str] | None = None,
*,
environ: Mapping[str, str] | None = None,
stream: BinaryIO | None = None,
request: Callable[[Path, dict[str, object]], dict[str, object]] = broker_request,
) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
arguments = parser.parse_args(argv)
source_environment = os.environ if environ is None else environ
try:
tool_name = read_tool_name(stream)
socket_value = source_environment["MOSAIC_LEASE_BROKER_SOCKET"]
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
generation = int(source_environment["MOSAIC_RUNTIME_GENERATION"])
if generation < 0:
raise ValueError("INVALID_GENERATION")
reply = request(
Path(socket_value),
{
"action": "authorize_tool",
"session_id": session_id,
"runtime_generation": generation,
"runtime": arguments.runtime,
"tool_name": tool_name,
},
)
except (KeyError, ValueError, OSError, json.JSONDecodeError):
return deny("GATE_UNAVAILABLE")
if reply.get("ok") is True and reply.get("decision") == "allow":
return 0
code = reply.get("code")
return deny(code if isinstance(code, str) else "MUTATOR_UNVERIFIED")
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -32,7 +32,7 @@ Claude:
```json
{
"worker": {
"command_template": "mosaic claude -p \"Execute task {task_id}: {task_title}\""
"command_template": "claude -p \"Execute task {task_id}: {task_title}\""
}
}
```

View File

@@ -86,8 +86,7 @@ echo ""
cd "$PROJECT"
if [[ "$RUNTIME_CMD" == "claude" ]]; then
exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --dangerous --runtime claude -- \
claude --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
exec claude --dangerously-skip-permissions --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
fi
if [[ "$RUNTIME_CMD" == "codex" ]]; then

View File

@@ -74,8 +74,7 @@ echo ""
cd "$PROJECT"
if [[ "$RUNTIME_CMD" == "claude" ]]; then
exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --dangerous --runtime claude -- \
claude --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
exec claude --dangerously-skip-permissions --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
fi
if [[ "$RUNTIME_CMD" == "codex" ]]; then

View File

@@ -190,7 +190,7 @@ Pending QA validation
This report was created by the QA automation hook.
To process this report, run:
\`\`\`bash
python3 ~/.config/mosaic/tools/lease-broker/launch-runtime.py --runtime claude -- claude -p "Use Task tool to launch universal-qa-agent for report: $REPORT_PATH"
claude -p "Use Task tool to launch universal-qa-agent for report: $REPORT_PATH"
\`\`\`
EOF

View File

@@ -58,8 +58,8 @@ ACTIONS_PATH="$IN_PROGRESS_DIR/$ACTIONS_FILE"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting remediation: $ACTIONS_PATH" | tee -a "$LOG_FILE"
# Trigger remediation agent through the authenticated lease-broker choke-point.
python3 "$(dirname "$0")/../lease-broker/launch-runtime.py" --runtime claude -- claude -p "Use Task tool to launch auto-remediation-agent for:
# Trigger remediation agent
claude -p "Use Task tool to launch auto-remediation-agent for:
- Remediation Report: $IN_PROGRESS_DIR/$(basename "$REPORT_FILE")
- Actions File: $ACTIONS_PATH
- Max Iterations: 5

View File

@@ -61,10 +61,8 @@ MOSAIC_HOME="$T5" MOSAIC_INSTALL_MODE=bogus MOSAIC_SYNC_ONLY=1 bash "$INSTALL" >
chk "F5 failure: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]"
chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
# F6 — keep-mode re-seed (the `mosaic update` path) MUST preserve ALL user-owned
# fleet state — including an unanticipated file the manifest never names, which
# resolves to operator-owned by the #791 fail-safe — while refreshing the
# framework-owned schema/examples.
# F6 — keep-mode re-seed (the `mosaic update` path) MUST preserve only the
# exact user-owned roster paths while refreshing framework-owned schema/examples.
T6=$(mktemp -d); mkdir -p "$T6/fleet/examples" "$T6/fleet/run" "$T6/fleet/agents"
printf '# persona\n' > "$T6/SOUL.md" # makes it a recognized existing install (→ keep mode)
printf 'version: 1\nagents:\n - name: coder0\n' > "$T6/fleet/roster.yaml"
@@ -77,14 +75,13 @@ printf '{"stale":true}\n' > "$T6/fleet/roster.schema.json"
E6=$(mktemp -d)
cp "$T6/fleet/roster.yaml" "$E6/roster-yaml.expected"
cp "$T6/fleet/roster.json" "$E6/roster-json.expected"
cp "$T6/fleet/my-fleet.yaml" "$E6/my-fleet.expected"
cp "$T6/fleet/run/coder0.hb" "$E6/run.expected"
cp "$T6/fleet/agents/coder0.env" "$E6/agent.expected"
echo 3 > "$T6/.framework-version"
run "$T6" keep
chk "F6 reseed: exact roster.yaml bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.yaml' '$E6/roster-yaml.expected'"
chk "F6 reseed: exact roster.json bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.json' '$E6/roster-json.expected'"
chk "F6 reseed: unanticipated operator fleet file survives (fail-safe, #791)" "cmp -s '$T6/fleet/my-fleet.yaml' '$E6/my-fleet.expected'"
chk "F6 reseed: unrelated fleet YAML is not preserved" "[ ! -f '$T6/fleet/my-fleet.yaml' ]"
chk "F6 reseed: per-agent env bytes survive" "cmp -s '$T6/fleet/agents/coder0.env' '$E6/agent.expected'"
chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'"
chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"

View File

@@ -1,313 +0,0 @@
#!/usr/bin/env bash
# test-upgrade-durable-snapshot.sh — the #791 PR2 regression gate.
#
# PR1 gave keep-mode upgrades two protections: the manifest (a keep-sync only
# ever writes framework-owned paths — operator config is structurally untouched)
# and an EPHEMERAL /tmp snapshot that rolls the whole target back if the sync
# CRASHES mid-write. PR2 adds a third, independent layer for the case neither
# covers: a "successful" upgrade that a manifest/logic bug silently let touch an
# operator file. That layer is a DURABLE, operator-scoped pre-update snapshot:
#
# Part 1 (scope): before any mutation, the installer copies exactly the
# operator-owned files that exist into a retained backup
# under $XDG_STATE_HOME/mosaic/backups/pre-update-<ts>/ —
# framework files are NOT captured.
# Part 2 (perms): the backup root, snapshot dir and every nested dir are
# 0700; every backed-up file is 0600 (never world-readable,
# even though operator config may hold secrets).
# Part 3 (no leak): a secret seeded into credentials.json is copied into the
# snapshot (proving coverage) but its value never appears
# on stdout/stderr — the snapshot reports counts/paths only.
# Part 4 (retention): only the newest MOSAIC_BACKUP_RETENTION snapshots survive;
# older ones are pruned.
# Part 5 (verify net): if the upgrade DID modify an operator file (injected here
# with a cp shim that scribbles on SOUL.md while a framework
# file is copied), the post-sync verify restores that file
# from the durable snapshot and warns loudly. The control —
# the same installer with the verify call stripped — leaves
# the corruption in place, proving the net is load-bearing.
#
# Usage: bash test-upgrade-durable-snapshot.sh
set -uo pipefail
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
INSTALL="$FW/install.sh"
ORIG_PATH="$PATH"
FRAMEWORK_VERSION="$(grep -m1 '^FRAMEWORK_VERSION=' "$INSTALL" | cut -d= -f2)"
# Control installers must live INSIDE $FW: install.sh derives SOURCE_DIR from its
# own path and sources tools/_lib/manifest.sh relative to it, so a copy anywhere
# else aborts before the sync. Each control is a shipped installer with one guard
# line stripped (keyed off a `# <MARKER>` anchor), proving that guard load-bearing.
# All controls share the .install-*.tmp.sh glob so one trap sweeps them on exit.
VERIFYCTRL="$FW/.install-verifynet-control.tmp.sh"
rm -f "$FW"/.install-*.tmp.sh
trap 'rm -f "$FW"/.install-*.tmp.sh' EXIT
# mk_control <marker-regex> <name> — echo a control installer path ($FW-local) that
# is $INSTALL with every line matching /<marker-regex>/ deleted.
mk_control() {
local path="$FW/.install-$2.tmp.sh"
sed "/$1/d" "$INSTALL" > "$path"
printf '%s' "$path"
}
pass=0; fail=0
chk() { if eval "$2"; then echo "$1"; pass=$((pass + 1)); else echo "$1"; fail=$((fail + 1)); fi; }
SECRET='SUPER-SECRET-TOKEN-do-not-log-pr2'
SOUL_ORIG='# persona'
# A framework file the sync copies (source ships it; the seeded target omits it,
# so the bytes differ and cp is attempted). The Part-5 shim keys off this path.
POISON_REL='guides/E2E-DELIVERY.md'
# Seed a recognized keep-mode install holding four operator-owned files across
# the identity file, an operator subtree, memory, and the credentials carve-out.
seed_home() {
local H="$1"
mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory"
printf '%s\n' "$SOUL_ORIG" > "$H/SOUL.md" # recognized install → keep mode
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
printf '# operator memory\n' > "$H/memory/note.md"
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
echo 3 > "$H/.framework-version"
# Deliberately NO guides/E2E-DELIVERY.md so the sync copies it (framework file,
# bytes differ) — that copy is where the Part-5 corruption shim fires.
}
# A pre-v2 (legacy) keep-mode install: SOUL.md marks it recognized, and a bin/
# tree with NO .framework-version makes installed_framework_version() report 1, so
# the v1→v2 migration (which deletes bin/) runs. bin/ is unknown⇒operator, so the
# durable snapshot captures it — the verify net must NOT heal the intended removal.
seed_home_v1() {
local H="$1"
mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory" "$H/bin"
printf '%s\n' "$SOUL_ORIG" > "$H/SOUL.md"
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
printf '#!/bin/sh\necho legacy\n' > "$H/bin/tool.sh"; chmod +x "$H/bin/tool.sh"
# Deliberately NO .framework-version and NO guides/E2E-DELIVERY.md (see seed_home).
}
# A cp shim that, while the framework POISON file is being copied during sync,
# swaps the operator credentials file for a symlink pointing at an attacker-
# readable file OUTSIDE the target — simulating post-snapshot tampering (CWE-59).
# The durable snapshot already holds the real credentials (it is taken before any
# sync), so the verify net must restore a REAL file in place WITHOUT following the
# link (which would write the snapshot's secret out through it). $EXFIL_TARGET is
# expanded at shim-write time from the caller's environment.
#
# PORTABILITY (why this shim, not the real `cp`): the CWE-59 leak this exercises is
# `cp` writing THROUGH a symlinked destination. GNU/BSD cp — what a real operator
# runs `mosaic update` under — follows the dest symlink and leaks. busybox cp (the
# Alpine CI image) REPLACES a symlinked dest instead of following it, so under the
# CI harness the leak vector simply does not exist and the negative control could
# never reproduce it. This shim therefore emulates the real-target GNU cp behavior
# PORTABLY: when the destination is a symlink it writes the source bytes through the
# link via redirection (which follows symlinks on every coreutils, busybox included);
# otherwise it delegates to the host's real cp unchanged. Both the shipped-case and
# the negative control run through this identical shim, so the ONLY difference
# between them remains the SYMLINK-LEAF-GUARD — the control stays load-bearing and
# non-tautological. It does NOT touch install.sh (approved) or the real assertions:
# with the guard present the symlinked leaf is dropped BEFORE this cp runs, so the
# dest is a real file and the delegate path is taken exactly as on a GNU host.
make_symlink_leaf_shim() {
local dir="$1" home="$2"
cat > "$dir/cp" <<SHIM
#!/usr/bin/env bash
dest="\${@: -1}"
src="\${@:(-2):1}"
case "\$dest" in
*/$POISON_REL)
rm -f "$home/tools/_lib/credentials.json"
ln -s "$EXFIL_TARGET" "$home/tools/_lib/credentials.json"
;;
esac
# Coreutils-agnostic emulation of GNU cp's follow-through-dest-symlink behavior.
if [[ -L "\$dest" && -f "\$src" ]]; then
cat "\$src" > "\$dest"
exit \$?
fi
exec env PATH="$ORIG_PATH" cp "\$@"
SHIM
chmod +x "$dir/cp"
}
# A cp shim that, while the framework POISON file is being copied during sync,
# also appends garbage to the operator SOUL.md — simulating a manifest bug that
# writes outside the framework lane. The framework copy itself still succeeds
# (real cp runs), so the sync completes 0 and the post-sync verify is what must
# catch and undo the operator-file damage. The snapshot's own cp only ever
# targets operator files (never guides/…), so it is never corrupted by this shim.
make_corrupt_shim() {
local dir="$1" home="$2"
cat > "$dir/cp" <<SHIM
#!/usr/bin/env bash
dest="\${@: -1}"
case "\$dest" in
*/$POISON_REL) printf 'CORRUPTION-mid-sync\n' >> "$home/SOUL.md" 2>/dev/null || true ;;
esac
exec env PATH="$ORIG_PATH" cp "\$@"
SHIM
chmod +x "$dir/cp"
}
# Run one keep-mode, sync-only upgrade with $XDG_STATE_HOME redirected to a
# throwaway dir (so the real ~/.local/state is never touched). Optional args:
# $2 shim-maker (default none), $3 MOSAIC_BACKUP_RETENTION (default unset).
# Echoes: "<exit>\t<out>\t<state-dir>\t<home>".
# $4 seeder (default seed_home) — swap in seed_home_v1 for the migration case.
run_snap() {
local installer="$1" shim_maker="${2:-}" retention="${3:-}" seeder="${4:-seed_home}" H STATE OUT SHIM rc pathpre
H=$(mktemp -d); STATE=$(mktemp -d); OUT=$(mktemp); pathpre="$ORIG_PATH"
"$seeder" "$H"
if [[ -n "$shim_maker" ]]; then
SHIM=$(mktemp -d); "$shim_maker" "$SHIM" "$H"; pathpre="$SHIM:$ORIG_PATH"
fi
set +e
env PATH="$pathpre" XDG_STATE_HOME="$STATE" \
${retention:+MOSAIC_BACKUP_RETENTION="$retention"} \
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 \
bash "$installer" >"$OUT" 2>&1
rc=$?
set -e 2>/dev/null || true
[[ -n "$shim_maker" ]] && rm -rf "$SHIM"
printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$STATE" "$H"
}
# Resolve the single pre-update-* snapshot dir under a state dir (newest if many).
snap_dir() {
find "$1/mosaic/backups" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null \
| LC_ALL=C sort -r | head -1
}
echo "── Part 1/2/3: durable snapshot scope, perms, no-leak ──────────────────"
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL")
SNAP="$(snap_dir "$STATE")"
chk "upgrade succeeds" "[ '$rc' -eq 0 ]"
chk "exactly one pre-update snapshot created" "[ \$(find '$STATE/mosaic/backups' -maxdepth 1 -type d -name 'pre-update-*' | wc -l) -eq 1 ]"
chk "snapshot: SOUL.md captured" "[ -f '$SNAP/SOUL.md' ]"
chk "snapshot: operator subtree captured" "[ -f '$SNAP/agents/coder0.conf' ]"
chk "snapshot: memory captured" "[ -f '$SNAP/memory/note.md' ]"
chk "snapshot: credentials carve-out captured" "[ -f '$SNAP/tools/_lib/credentials.json' ]"
chk "snapshot: SOUL.md bytes preserved" "[ \"\$(cat '$SNAP/SOUL.md')\" = '$SOUL_ORIG' ]"
chk "snapshot: framework file NOT captured" "[ ! -e '$SNAP/CONSTITUTION.md' ] && [ ! -e '$SNAP/$POISON_REL' ]"
# Part 2 — permissions (0700 dirs, 0600 files); never world-readable.
chk "perms: backup root is 0700" "[ \$(stat -c '%a' '$STATE/mosaic/backups') -eq 700 ]"
chk "perms: snapshot dir is 0700" "[ \$(stat -c '%a' '$SNAP') -eq 700 ]"
chk "perms: nested dir is 0700" "[ \$(stat -c '%a' '$SNAP/agents') -eq 700 ]"
chk "perms: credentials backup is 0600" "[ \$(stat -c '%a' '$SNAP/tools/_lib/credentials.json') -eq 600 ]"
chk "perms: SOUL.md backup is 0600" "[ \$(stat -c '%a' '$SNAP/SOUL.md') -eq 600 ]"
# Part 3 — the secret is backed up but never emitted to stdout/stderr.
chk "no-leak: secret IS in the backup file" "grep -q '$SECRET' '$SNAP/tools/_lib/credentials.json'"
chk "no-leak: secret NOT on stdout/stderr" "! grep -q '$SECRET' '$OUT'"
rm -rf "$STATE" "$H"; rm -f "$OUT"
echo "── Part 4: retention prune (MOSAIC_BACKUP_RETENTION) ───────────────────"
# Pre-seed four dated snapshots, then take one real snapshot with retention=2:
# only the two newest (the fresh real one + the newest pre-seeded) must survive.
IFS=$'\t' read -r rc OUT STATE H < <(
H=$(mktemp -d); STATE=$(mktemp -d); OUT=$(mktemp)
seed_home "$H"
mkdir -p "$STATE/mosaic/backups"
for ts in 20200101T000000Z 20210101T000000Z 20220101T000000Z 20230101T000000Z; do
mkdir -p "$STATE/mosaic/backups/pre-update-$ts"
done
set +e
env PATH="$ORIG_PATH" XDG_STATE_HOME="$STATE" MOSAIC_BACKUP_RETENTION=2 \
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 \
bash "$INSTALL" >"$OUT" 2>&1
rc=$?
set -e 2>/dev/null || true
printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$STATE" "$H"
)
chk "retention: upgrade succeeds" "[ '$rc' -eq 0 ]"
chk "retention: pruned to exactly 2 snapshots" "[ \$(find '$STATE/mosaic/backups' -maxdepth 1 -type d -name 'pre-update-*' | wc -l) -eq 2 ]"
chk "retention: newest pre-seeded survives" "[ -d '$STATE/mosaic/backups/pre-update-20230101T000000Z' ]"
chk "retention: oldest pre-seeded pruned" "[ ! -d '$STATE/mosaic/backups/pre-update-20200101T000000Z' ]"
rm -rf "$STATE" "$H"; rm -f "$OUT"
echo "── Part 5: post-sync verify restores an operator file (+ control) ──────"
# Shipped installer: the cp shim corrupts SOUL.md mid-sync; verify must restore it.
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" make_corrupt_shim)
chk "verify: upgrade still succeeds" "[ '$rc' -eq 0 ]"
chk "verify: SOUL.md restored to original" "[ \"\$(cat '$H/SOUL.md')\" = '$SOUL_ORIG' ]"
chk "verify: no corruption remains in SOUL.md" "! grep -q 'CORRUPTION-mid-sync' '$H/SOUL.md'"
chk "verify: loud restore warning emitted" "grep -qi 'restored from the pre-update snapshot' '$OUT'"
chk "verify: secret still not leaked" "! grep -q '$SECRET' '$OUT'"
rm -rf "$STATE" "$H"; rm -f "$OUT"
# Control: strip the verify call → the corruption must SURVIVE (net is load-bearing).
sed '/# VERIFY-NET/d' "$INSTALL" > "$VERIFYCTRL"
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$VERIFYCTRL" make_corrupt_shim)
chk "control: SOUL.md corruption survives" "grep -q 'CORRUPTION-mid-sync' '$H/SOUL.md'"
chk "control: no restore warning emitted" "! grep -qi 'restored from the pre-update snapshot' '$OUT'"
rm -rf "$STATE" "$H"; rm -f "$OUT"
echo "── Part 6: verify net honors an intentional migration removal (+ control) ─"
# BLOCKER regression: on a pre-v2 install, bin/ is operator-classified so the durable
# snapshot captures it — but the v1→v2 migration deletes bin/ ON PURPOSE. The verify
# net must SKIP that removal (is_migration_removed), or it heals bin/ back and the
# migration is silently undone forever once the version is stamped.
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" "" "" seed_home_v1)
chk "migration: upgrade succeeds" "[ '$rc' -eq 0 ]"
chk "migration: legacy bin/ stays removed" "[ ! -e '$H/bin' ]"
chk "migration: operator SOUL.md untouched" "[ \"\$(cat '$H/SOUL.md')\" = '$SOUL_ORIG' ]"
chk "migration: version stamped to $FRAMEWORK_VERSION" "[ \"\$(cat '$H/.framework-version')\" = '$FRAMEWORK_VERSION' ]"
rm -rf "$STATE" "$H"; rm -f "$OUT"
# Control: strip the MIGRATION-SKIP-GUARD → the verify net restores bin/ from the
# snapshot, silently undoing the migration (proves the guard is load-bearing).
MIGCTRL="$(mk_control 'MIGRATION-SKIP-GUARD' migration-control)"
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$MIGCTRL" "" "" seed_home_v1)
chk "control: bin/ wrongly restored by verify" "[ -e '$H/bin/tool.sh' ]"
rm -rf "$STATE" "$H"; rm -f "$OUT"
echo "── Part 7: verify net never restores a secret through a symlink (+ control) ─"
# HIGH (CWE-59) regression: an attacker who swaps an operator file for a symlink
# AFTER the durable snapshot must not cause the verify net's restore to write the
# snapshot's secret out THROUGH that link. The shipped net drops a symlinked leaf and
# writes a real file in its place, leaving the external target untouched.
EXFIL_DIR=$(mktemp -d); EXFIL_TARGET="$EXFIL_DIR/stolen"
printf 'ATTACKER-PLACEHOLDER\n' > "$EXFIL_TARGET"
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" make_symlink_leaf_shim)
chk "symlink-leaf: upgrade succeeds" "[ '$rc' -eq 0 ]"
chk "symlink-leaf: secret NOT written through link" "! grep -q '$SECRET' '$EXFIL_TARGET'"
chk "symlink-leaf: credentials.json is a real file" "[ -f '$H/tools/_lib/credentials.json' ] && [ ! -L '$H/tools/_lib/credentials.json' ]"
chk "symlink-leaf: credentials.json restored intact" "grep -q '$SECRET' '$H/tools/_lib/credentials.json'"
chk "symlink-leaf: secret not leaked to stdout/stderr" "! grep -q '$SECRET' '$OUT'"
rm -rf "$STATE" "$H" "$EXFIL_DIR"; rm -f "$OUT"
# Control: strip the SYMLINK-LEAF-GUARD → cp follows the swapped-in link and writes
# the snapshot secret out through it (proves the guard is load-bearing).
EXFIL_DIR=$(mktemp -d); EXFIL_TARGET="$EXFIL_DIR/stolen"
printf 'ATTACKER-PLACEHOLDER\n' > "$EXFIL_TARGET"
LEAFCTRL="$(mk_control 'SYMLINK-LEAF-GUARD' symlinkleaf-control)"
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$LEAFCTRL" make_symlink_leaf_shim)
chk "control: secret leaked through the symlink" "grep -q '$SECRET' '$EXFIL_TARGET'"
rm -rf "$STATE" "$H" "$EXFIL_DIR"; rm -f "$OUT"
echo "── Part 8: snapshot umask 077 does not leak into synced files (+ control) ──"
# SHOULD-FIX regression: umask 077 is process-global. Scoped to the snapshot it keeps
# backups 0600; leaked past it, every later cp/mkdir inherits 0600/0700. A freshly-
# synced framework file must be 0644 (per the ambient 022 umask) while the backup of
# a secret stays 0600.
IFS=$'\t' read -r rc OUT STATE H < <(umask 022; run_snap "$INSTALL")
SNAP="$(snap_dir "$STATE")"
chk "umask: upgrade succeeds" "[ '$rc' -eq 0 ]"
chk "umask: synced framework file is 0644" "[ \$(stat -c '%a' '$H/$POISON_REL') -eq 644 ]"
chk "umask: backup of a secret stays 0600" "[ \$(stat -c '%a' '$SNAP/tools/_lib/credentials.json') -eq 600 ]"
rm -rf "$STATE" "$H"; rm -f "$OUT"
# Control: strip the UMASK-RESTORE-NORMAL line → umask 077 leaks past the snapshot,
# so the newly-synced framework file is created 0600 (proves the restore matters).
UMASKCTRL="$(mk_control 'UMASK-RESTORE-NORMAL' umask-control)"
IFS=$'\t' read -r rc OUT STATE H < <(umask 022; run_snap "$UMASKCTRL")
chk "control: leaked umask makes synced file 0600" "[ \$(stat -c '%a' '$H/$POISON_REL') -eq 600 ]"
rm -rf "$STATE" "$H"; rm -f "$OUT"
echo ""
echo "RESULT: $pass passed, $fail failed"
[ "$fail" -eq 0 ]

View File

@@ -1,231 +0,0 @@
#!/usr/bin/env bash
# test-upgrade-manifest-guard.sh — the #791 HARD GATE.
#
# Proves that a keep-mode framework upgrade (the `mosaic update` path:
# install.sh with MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1) touches NO path
# outside the framework-owned manifest. Every operator-owned sentinel — including
# a deliberately UNANTICIPATED one the manifest never names — must survive
# byte-identical with an unchanged mtime (not even rewritten). Framework files
# must still update, and a retired framework file inside a shipped subtree must
# still be pruned. No operator secret value may appear in installer output.
#
# Keep mode is a SINGLE code path (sync_framework_keep, a manifest-driven cp
# overlay + scoped prune — no rsync). The matrix still runs twice, once with
# rsync on PATH and once with it hidden, to prove the keep path is genuinely
# rsync-independent: it must obey the manifest identically whether or not rsync
# happens to be installed (rsync --delete is only ever used by overwrite mode,
# which has no operator state to protect).
#
# It also runs a fail-closed matrix (#791 B2/B3): an empty, operator-only,
# malformed, or missing manifest must ABORT the upgrade loudly and leave every
# operator path untouched — never silently no-op to "complete".
#
# Usage: bash test-upgrade-manifest-guard.sh
set -uo pipefail
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
INSTALL="$FW/install.sh"
pass=0; fail=0
chk() { if eval "$2"; then echo "$1"; pass=$((pass + 1)); else echo "$1"; fail=$((fail + 1)); fi; }
# Redirect the #791 PR2 durable pre-update snapshot ($XDG_STATE_HOME/mosaic/backups)
# into a throwaway so a keep-mode upgrade under test never writes into the real
# ~/.local/state. This test asserts operator-surface fidelity, not backup content.
export XDG_STATE_HOME
XDG_STATE_HOME="$(mktemp -d)"
trap 'rm -rf "$XDG_STATE_HOME"' EXIT
SECRET='SUPER-SECRET-TOKEN-do-not-log-3f9a'
# Seed a throwaway MOSAIC_HOME with an operator sentinel per ownership class.
seed_home() {
local H="$1"
mkdir -p "$H/agents" "$H/policy" "$H/memory" "$H/tools/_lib" \
"$H/fleet/agents" "$H/fleet/run/sessions" "$H/harvester" \
"$H/unknown-operator-dir" "$H/guides"
printf '# persona\n' > "$H/SOUL.md" # marks a recognized existing install → keep mode
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
printf '# operator policy\n' > "$H/policy/custom.md"
printf '# soul overlay\n' > "$H/SOUL.local.md"
printf '# operator memory\n' > "$H/memory/note.md"
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
printf 'MOSAIC_AGENT_NAME=coder0\n' > "$H/fleet/agents/coder0.env"
printf 'version: 2\nagents:\n - name: coder0\n' > "$H/fleet/roster.yaml"
printf '# harvester SOP\n' > "$H/harvester/sop.md"
printf 'operator data the manifest never anticipated\n' > "$H/unknown-operator-dir/x"
printf 'version: 1\nagents:\n - name: mine\n' > "$H/fleet/my-fleet.yaml"
# #797 Runtime Session Ledger (Mos-elevated to a #791 PR1 merge-blocker): a
# populated ledger under fleet/run/sessions/ must survive the upgrade — a
# runtime ledger an upgrade rsync can wipe is worthless. Seed it exactly as
# #797 writes it: a non-empty append journal + a non-empty compacted
# projection, files 0600 under a 0700 dir.
printf '%s\n%s\n%s\n' \
'{"seq":1,"kind":"session.spawn","node":"sess-42","generation":7}' \
'{"seq":2,"kind":"lease.grant","node":"sess-42","lease":"web1"}' \
'{"seq":3,"kind":"dispatch.create","from":"sess-42","to":"disp-9"}' \
> "$H/fleet/run/sessions/events.ndjson"
printf '%s\n' \
'{"generation":7,"nodes":[{"id":"sess-42","kind":"session"}],"edges":[{"from":"sess-42","to":"disp-9","kind":"dispatch"}]}' \
> "$H/fleet/run/sessions/ledger.json"
chmod 0700 "$H/fleet/run" "$H/fleet/run/sessions"
chmod 0600 "$H/fleet/run/sessions/events.ndjson" "$H/fleet/run/sessions/ledger.json"
# A retired framework file inside a shipped subtree (absent from source) — must be pruned.
printf '# retired guide\n' > "$H/guides/RETIRED-OLD-GUIDE.md"
echo 3 > "$H/.framework-version"
}
OPERATOR_SENTINELS=(
"agents/coder0.conf"
"policy/custom.md"
"SOUL.local.md"
"memory/note.md"
"tools/_lib/credentials.json"
"fleet/agents/coder0.env"
"fleet/roster.yaml"
"harvester/sop.md"
"unknown-operator-dir/x"
"fleet/my-fleet.yaml"
# #797 Runtime Session Ledger — populated journal + projection must survive.
"fleet/run/sessions/events.ndjson"
"fleet/run/sessions/ledger.json"
)
run_matrix() {
local label="$1"; shift # extra env / PATH override applied to the run
local H E OUT rel before_hash after_hash before_mt after_mt
H=$(mktemp -d); E=$(mktemp -d); OUT=$(mktemp)
seed_home "$H"
# Snapshot hash + mtime of every operator sentinel before the upgrade.
for rel in "${OPERATOR_SENTINELS[@]}"; do
sha256sum "$H/$rel" | awk '{print $1}' > "$E/$(echo "$rel" | tr / _).hash"
stat -c %Y "$H/$rel" > "$E/$(echo "$rel" | tr / _).mt"
done
# Snapshot the ledger directory permission bits (#797 assert: perms unchanged).
local before_dirperm after_dirperm
before_dirperm=$(stat -c %a "$H/fleet/run/sessions")
# The upgrade under test (keep + sync-only = the `mosaic update` reseed path).
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 "$@" bash "$INSTALL" >"$OUT" 2>&1
# HARD GATE: every operator sentinel survives byte-identical AND mtime-unchanged.
for rel in "${OPERATOR_SENTINELS[@]}"; do
before_hash=$(cat "$E/$(echo "$rel" | tr / _).hash")
before_mt=$(cat "$E/$(echo "$rel" | tr / _).mt")
after_hash=$(sha256sum "$H/$rel" 2>/dev/null | awk '{print $1}')
after_mt=$(stat -c %Y "$H/$rel" 2>/dev/null || echo MISSING)
chk "[$label] operator sentinel survives byte-identical: $rel" \
"[ -n '$after_hash' ] && [ '$before_hash' = '$after_hash' ]"
chk "[$label] operator sentinel not rewritten (mtime unchanged): $rel" \
"[ '$before_mt' = '$after_mt' ]"
done
# #797 assert 7: the ledger directory's permission bits are unchanged.
after_dirperm=$(stat -c %a "$H/fleet/run/sessions" 2>/dev/null || echo MISSING)
chk "[$label] ledger dir perms unchanged (#797): $before_dirperm" \
"[ '$before_dirperm' = '$after_dirperm' ]"
# Positive controls / negative controls — prove the test discriminates: the
# upgrade DOES write and prune framework-owned paths, so the operator sentinels
# (incl. the #797 ledger) survive because of the manifest, not because the
# upgrade is a no-op.
chk "[$label] positive control: framework file present after upgrade (guides synced)" \
"[ -f '$H/guides/E2E-DELIVERY.md' ]"
chk "[$label] negative control: retired framework file inside a subtree IS pruned" \
"[ ! -f '$H/guides/RETIRED-OLD-GUIDE.md' ]"
chk "[$label] manifest itself is installed" "[ -f '$H/framework-manifest.txt' ]"
# Secret-safety: the operator secret value never appears in installer output.
chk "[$label] operator secret value absent from installer stdout/stderr" \
"! grep -q '$SECRET' '$OUT'"
rm -rf "$H" "$E" "$OUT"
}
# Fail-closed matrix (#791 B2/B3 + blocker-1): run install.sh from a COPY of the
# framework so the shipped manifest can be corrupted. Every corruption must abort
# the upgrade non-zero with a manifest error, leaving all operator sentinels
# byte-identical AND on the SAME inode. The inode check is the load-bearing part:
# manifest validation is hoisted BEFORE make_snapshot/the restore trap, so a bad
# manifest must abort without ever snapshotting, deleting, and restoring the
# target. Were validation still armed under the ERR trap, restore_snapshot would
# rm -rf + rebuild the target — same bytes but a NEW inode (broken hard links,
# changed ctime), which a content-only hash would miss (#791 blocker-1).
run_failclosed() {
local label="$1" mutate="$2"
local SRC H E OUT rc rel before_hash after_hash before_ino after_ino key
SRC=$(mktemp -d); H=$(mktemp -d); E=$(mktemp -d); OUT=$(mktemp)
cp -a "$FW/." "$SRC/"
case "$mutate" in
empty) : > "$SRC/framework-manifest.txt" ;;
operator-only) printf '[operator]\nSOUL.md\n*.local.md\n' > "$SRC/framework-manifest.txt" ;;
malformed) printf 'stray.md\n[framework]\nguides/**\n' > "$SRC/framework-manifest.txt" ;;
degenerate) printf '[framework]\n/\n./\n[operator]\nSOUL.md\n' > "$SRC/framework-manifest.txt" ;;
missing) rm -f "$SRC/framework-manifest.txt" ;;
esac
seed_home "$H"
for rel in "${OPERATOR_SENTINELS[@]}"; do
key=$(echo "$rel" | tr / _)
sha256sum "$H/$rel" | awk '{print $1}' > "$E/$key.hash"
stat -c '%i' "$H/$rel" > "$E/$key.ino"
done
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$SRC/install.sh" >"$OUT" 2>&1
rc=$?
chk "[fail-closed:$label] upgrade aborts non-zero" "[ '$rc' -ne 0 ]"
chk "[fail-closed:$label] refuses loudly with a manifest error" \
"grep -qi 'manifest' '$OUT'"
for rel in "${OPERATOR_SENTINELS[@]}"; do
key=$(echo "$rel" | tr / _)
before_hash=$(cat "$E/$key.hash")
after_hash=$(sha256sum "$H/$rel" 2>/dev/null | awk '{print $1}')
chk "[fail-closed:$label] operator sentinel untouched: $rel" \
"[ -n '$after_hash' ] && [ '$before_hash' = '$after_hash' ]"
before_ino=$(cat "$E/$key.ino")
after_ino=$(stat -c '%i' "$H/$rel" 2>/dev/null)
chk "[fail-closed:$label] operator sentinel not deleted/recreated (inode stable): $rel" \
"[ -n '$after_ino' ] && [ '$before_ino' = '$after_ino' ]"
done
chk "[fail-closed:$label] operator secret value absent from output" \
"! grep -q '$SECRET' '$OUT'"
chmod -R u+w "$SRC" "$H" 2>/dev/null || true
rm -rf "$SRC" "$H" "$E" "$OUT"
}
echo "#791 upgrade manifest guard (HARD GATE):"
# 1) rsync path (if available on this host).
if command -v rsync >/dev/null 2>&1; then
run_matrix "rsync"
else
echo " · rsync not installed — skipping rsync-path matrix"
fi
# 2) rsync-absent path — hide rsync behind a scratch PATH. Keep mode never calls
# rsync, so this must resolve identically to run (1); it proves the keep path
# does not silently depend on rsync being installed. (Provide the coreutils the
# installer needs on the stripped PATH.)
FBIN=$(mktemp -d)
for t in bash cp find mktemp rm mkdir chmod cmp sed grep cat dirname basename stat sha256sum awk tr date sort; do
p=$(command -v "$t" 2>/dev/null) && ln -s "$p" "$FBIN/$t"
done
run_matrix "rsync-absent" env "PATH=$FBIN"
rm -rf "$FBIN"
# 3) fail-closed matrix (#791 B2/B3) — corrupt the shipped manifest four ways.
run_failclosed "empty-manifest" empty
run_failclosed "operator-only" operator-only
run_failclosed "malformed-manifest" malformed
run_failclosed "degenerate-framework" degenerate
run_failclosed "missing-manifest" missing
echo
echo "RESULT: $pass passed, $fail failed"
[ "$fail" -eq 0 ]

View File

@@ -1,319 +0,0 @@
#!/usr/bin/env bash
# test-upgrade-rollback.sh — the #791 B1 regression gate.
#
# A keep-mode upgrade takes a pre-update snapshot and installs an ERR/INT/TERM
# trap that restores it if the sync aborts midway (install.sh: make_snapshot +
# `trap restore_snapshot`). That trap is only reached if `set -E` (errtrace) is
# active — otherwise a failure INSIDE sync_framework_keep() (which runs entirely
# in a function) never fires the trap, and the upgrade aborts leaving a
# half-written target with NO rollback. This test proves:
#
# Part A (the gate): the shipped installer rolls back a mid-sync failure —
# the restore message fires, the corrupted file is put
# back, AND the whole target is byte-identical to its
# pre-upgrade state.
# Part B (the control): the SAME installer with `-E` stripped does NOT roll back
# (dead trap) — the mid-sync corruption survives, proving
# errtrace is load-bearing. If anyone removes `set -E`,
# Part A goes red.
#
# The mid-sync failure is injected with a PATH-shadowing `cp` shim rather than
# file permissions. The earlier 0400/EACCES approach was NOT portable: Woodpecker
# runs steps as root (node:24-alpine has no USER directive), and root overwrites a
# 0400 file, so the failure never fired and this gate silently passed (#791
# blocker-3). The shim fails deterministically for one framework-owned
# destination regardless of uid, and — like a real interrupted cp (disk-full
# mid-write) — leaves a partially-written target behind, so rollback has real
# damage to undo and the control has real damage to expose.
#
# Usage: bash test-upgrade-rollback.sh
set -uo pipefail
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
INSTALL="$FW/install.sh"
ORIG_PATH="$PATH"
# The `-E`-stripped control installer must live INSIDE $FW: install.sh derives
# SOURCE_DIR from its own path and `source`s $SOURCE_DIR/tools/_lib/manifest.sh,
# so a copy anywhere else aborts at the source line before ever reaching the sync
# loop — which would make the control a false negative. A root dotfile is
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
STRIPPED="$FW/.install-rollback-control.tmp.sh"
NOEXIT="$FW/.install-noexit-control.tmp.sh"
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
pass=0; fail=0
chk() { if eval "$2"; then echo "$1"; pass=$((pass + 1)); else echo "$1"; fail=$((fail + 1)); fi; }
SECRET='SUPER-SECRET-TOKEN-do-not-log-b1'
# A framework-owned file the shim fails the copy of. The seeded target holds GOOD
# bytes; source ships different bytes, so sync_framework_keep() attempts the copy
# and the shim intercepts it. Root-level framework files sort before guides/, so
# several framework files are already refreshed when the copy reaches this one.
POISON_REL='guides/E2E-DELIVERY.md'
GOOD='GOOD-REFERENCE-CONTENT-pre-upgrade-b1'
GARBAGE='PARTIAL-WRITE-GARBAGE-mid-sync-b1'
# A `cp` shim: for the poisoned destination, simulate an interrupted copy — write
# partial garbage to the target, then fail — otherwise delegate to the real cp
# (resolved via the ORIGINAL PATH so make_snapshot/restore still work).
make_cp_shim() {
local dir="$1"
cat > "$dir/cp" <<SHIM
#!/usr/bin/env bash
dest="\${@: -1}"
case "\$dest" in
*/$POISON_REL)
printf '%s' '$GARBAGE' > "\$dest" 2>/dev/null || true
exit 1 ;;
esac
exec env PATH="$ORIG_PATH" cp "\$@"
SHIM
chmod +x "$dir/cp"
}
# A `find` shim that fails every enumeration scan (`-print0`) as if it hit an
# EACCES/I/O error partway — it emits the real (here: complete) list first, then
# exits non-zero, exactly the class of failure a `< <(find …)` process
# substitution silently swallows. All non-`-print0` finds (e.g. the -delete
# sweep) delegate to the real find on the original PATH. Used to prove #791
# blocker-D1: the shipped installer must honor find's exit status and roll back.
make_find_fail_shim() {
local dir="$1"
cat > "$dir/find" <<SHIM
#!/usr/bin/env bash
for a in "\$@"; do
if [ "\$a" = "-print0" ]; then
env PATH="$ORIG_PATH" find "\$@" # emit the real list…
exit 1 # …then fail as if the scan hit EACCES
fi
done
exec env PATH="$ORIG_PATH" find "\$@"
SHIM
chmod +x "$dir/find"
}
# An `rm` shim that fails ONLY `rm -rf <FAIL_RM_TARGET>` (the restore's target
# reset) and delegates every other rm to the real one. Used to prove #791
# blocker-D2: when the target reset inside restore_snapshot fails, the installer
# must emit the manual-recovery pointer (snapshot path) instead of exiting
# silently under `set -e`. FAIL_RM_TARGET is exported into the installer env.
make_rm_fail_shim() {
local dir="$1"
cat > "$dir/rm" <<'SHIM'
#!/usr/bin/env bash
last="${@: -1}"
if [ -n "${FAIL_RM_TARGET:-}" ] && [ "$last" = "$FAIL_RM_TARGET" ]; then
exit 1
fi
exec env PATH="$ORIG_PATH_FOR_RM" rm "$@"
SHIM
chmod +x "$dir/rm"
}
seed_home() {
local H="$1"
mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory" "$H/guides"
printf '# persona\n' > "$H/SOUL.md" # recognized install → keep mode + snapshot
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
printf '# operator memory\n' > "$H/memory/note.md"
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
echo 3 > "$H/.framework-version"
# Good pre-upgrade bytes; source ships different bytes, so cp is attempted.
printf '%s' "$GOOD" > "$H/$POISON_REL"
}
# Run one keep-mode upgrade against $1=installer, seeding a fresh home and a
# byte-for-byte reference of the pre-upgrade state, with the cp shim first on
# PATH. Echoes: "<exit>\t<out>\t<ref>\t<home>".
run_upgrade() {
local installer="$1" shim_maker="${2:-make_cp_shim}" H REF OUT SHIM rc
H=$(mktemp -d); REF=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
seed_home "$H"
env PATH="$ORIG_PATH" cp -a "$H/." "$REF/" # pre-upgrade reference (real cp)
"$shim_maker" "$SHIM"
set +e
PATH="$SHIM:$ORIG_PATH" \
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
rc=$?
set -e 2>/dev/null || true
rm -rf "$SHIM"
printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$REF" "$H"
}
echo "#791 upgrade rollback (B1 regression gate):"
# ── Part A: the shipped installer must roll back a mid-sync failure ───────────
IFS=$'\t' read -r rcA OUTA REFA HA < <(run_upgrade "$INSTALL")
chk "[shipped] upgrade aborts non-zero on the injected mid-sync failure" \
"[ '$rcA' -ne 0 ]"
chk "[shipped] restore_snapshot fires (rollback message present)" \
"grep -q 'restoring previous state from snapshot' '$OUTA'"
chk "[shipped] the corrupted file is restored to its pre-upgrade bytes" \
"[ \"\$(cat '$HA/$POISON_REL')\" = '$GOOD' ]"
chk "[shipped] target rolled back byte-identical to pre-upgrade state" \
"diff -r '$REFA' '$HA' >/dev/null 2>&1"
chk "[shipped] operator secret value absent from installer output" \
"! grep -q '$SECRET' '$OUTA'"
# ── Part B: control — strip `-E`, the trap is dead, no rollback happens ───────
# Proves errtrace is what makes the trap reachable. If `set -E` is ever removed
# from install.sh, Part A's rollback assertions fail exactly like this control.
sed 's/^set -Eeuo pipefail/set -euo pipefail/' "$INSTALL" > "$STRIPPED"
chk "[control] the -E strip actually changed the installer" \
"! cmp -s '$INSTALL' '$STRIPPED'"
IFS=$'\t' read -r rcB OUTB REFB HB < <(run_upgrade "$STRIPPED")
chk "[control] without -E the upgrade still aborts non-zero" \
"[ '$rcB' -ne 0 ]"
# The load-bearing, deterministic proof of B1: without errtrace the ERR trap
# never fires for a failure inside sync_framework_keep(), so no rollback runs.
chk "[control] without -E the rollback message does NOT fire (dead trap)" \
"! grep -q 'restoring previous state from snapshot' '$OUTB'"
chk "[control] without -E the mid-sync corruption survives (no rollback)" \
"[ \"\$(cat '$HB/$POISON_REL')\" = '$GARBAGE' ]"
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
# A bash signal trap that merely returns lets the script continue past the
# interrupt — restoring the snapshot, then resuming the sync and reporting
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
# fires and ONLY the signal path governs), and assert the shipped installer
# restores AND exits without reporting success. The control strips `exit 1` from
# the trap and shows the buggy resume-to-success.
make_term_shim() {
local dir="$1"
cat > "$dir/cp" <<SHIM
#!/usr/bin/env bash
dest="\${@: -1}"
case "\$dest" in
*/$POISON_REL)
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
exec env PATH="$ORIG_PATH" cp "\$@" ;;
esac
exec env PATH="$ORIG_PATH" cp "\$@"
SHIM
chmod +x "$dir/cp"
}
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
run_signal_upgrade() {
local installer="$1" H OUT SHIM rc
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
seed_home "$H"
make_term_shim "$SHIM"
set +e
PATH="$SHIM:$ORIG_PATH" \
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
rc=$?
set -e 2>/dev/null || true
rm -rf "$SHIM"
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
}
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
"[ '$rcC' -ne 0 ]"
chk "[signal] restore_snapshot fires on the interrupt" \
"grep -q 'restoring previous state from snapshot' '$OUTC'"
chk "[signal] does NOT resume to report sync success after the interrupt" \
"! grep -q 'file phase complete' '$OUTC'"
# Control: strip `exit 1` from the signal trap → the handler returns, the script
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
"$INSTALL" > "$NOEXIT"
chk "[control] the exit-strip actually changed the installer" \
"! cmp -s '$INSTALL' '$NOEXIT'"
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
"grep -q 'file phase complete' '$OUTD'"
# ── Part D: a failed source/prune `find` scan must abort + roll back (D1) ─────
# A `< <(find …)` process substitution discards find's exit status, so an
# EACCES/I/O failure mid-scan would truncate the file list yet leave the reading
# loop exiting 0 — a partial upgrade committed and reported as success, with the
# ERR/restore trap never firing. The shipped installer captures the scan into a
# checked temp file (_scan_or_die) and aborts on failure. We inject a `find` that
# fails every `-print0` scan and assert the shipped installer rolls back.
IFS=$'\t' read -r rcE OUTE REFE HE < <(run_upgrade "$INSTALL" make_find_fail_shim)
chk "[find-fail] a failing framework scan aborts the upgrade non-zero" \
"[ '$rcE' -ne 0 ]"
chk "[find-fail] restore_snapshot fires on the aborted scan" \
"grep -q 'restoring previous state from snapshot' '$OUTE'"
chk "[find-fail] the abort is a fail-closed enumeration error (not a silent truncation)" \
"grep -q 'Could not enumerate framework files' '$OUTE'"
chk "[find-fail] target rolled back byte-identical to pre-upgrade state" \
"diff -r '$REFE' '$HE' >/dev/null 2>&1"
# Control: neuter the D1 guard (turn its `return 1` into a no-op) so a find
# failure is swallowed exactly as `< <(find …)` would — the scan appears to
# succeed and the upgrade reports completion with NO rollback.
sed 's/return 1 # D1-GUARD/: # D1-GUARD-DISABLED/' "$INSTALL" > "$D1CTRL"
chk "[control] the D1-guard strip actually changed the installer" \
"! cmp -s '$INSTALL' '$D1CTRL'"
IFS=$'\t' read -r _rcF OUTF REFF HF < <(run_upgrade "$D1CTRL" make_find_fail_shim)
chk "[control] with the D1 guard disabled the find failure is swallowed (no rollback)" \
"! grep -q 'restoring previous state from snapshot' '$OUTF'"
chk "[control] with the D1 guard disabled the upgrade wrongly reports success" \
"grep -q 'file phase complete' '$OUTF'"
# ── Part E: a failed target reset inside restore must not exit silently (D2) ──
# restore_snapshot resets the target (`rm -rf; mkdir -p`) before rebuilding from
# the snapshot. Under `set -e` (trap disarmed) a bare reset that fails would exit
# the whole script immediately — after `rm` may have deleted part of the target —
# WITHOUT printing where the snapshot lives. We trigger a rollback (cp poison) AND
# fail the target reset (rm shim), then assert the shipped installer emits the
# manual-recovery pointer and preserves the snapshot.
run_rmfail_upgrade() {
local installer="$1" H OUT SHIM rc
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
seed_home "$H"
make_cp_shim "$SHIM" # poison cp → triggers the abort + restore
make_rm_fail_shim "$SHIM" # rm -rf <H> fails → exercises the D2 reset guard
set +e
PATH="$SHIM:$ORIG_PATH" ORIG_PATH_FOR_RM="$ORIG_PATH" FAIL_RM_TARGET="$H" \
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
rc=$?
set -e 2>/dev/null || true
rm -rf "$SHIM"
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
}
IFS=$'\t' read -r rcG OUTG HG < <(run_rmfail_upgrade "$INSTALL")
chk "[reset-fail] a failed target reset still aborts non-zero" \
"[ '$rcG' -ne 0 ]"
chk "[reset-fail] the manual-recovery pointer is emitted (not a silent set -e exit)" \
"grep -q 'Snapshot restore could not reset' '$OUTG'"
chk "[reset-fail] the recovery message points at a preserved snapshot dir" \
"grep -q 'preserved at: .*mosaic-snapshot' '$OUTG'"
SNAP_E="$(grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTG" | head -1)"
chk "[reset-fail] the named snapshot directory actually survives for recovery" \
"[ -n '$SNAP_E' ] && [ -d '$SNAP_E' ]"
chk "[reset-fail] operator secret value never appears in installer output" \
"! grep -q '$SECRET' '$OUTG'"
# Control: delete the D2 recovery line so a failed reset returns non-zero with NO
# operator pointer — the observable defect (half-reset target, snapshot orphaned
# in /tmp with no path told to the operator). Proves the message is load-bearing.
sed '/Snapshot restore could not reset/d' "$INSTALL" > "$D2CTRL"
chk "[control] the D2-recovery strip actually changed the installer" \
"! cmp -s '$INSTALL' '$D2CTRL'"
IFS=$'\t' read -r _rcH OUTH HH < <(run_rmfail_upgrade "$D2CTRL")
chk "[control] without the D2 recovery line the operator gets no snapshot pointer" \
"! grep -q 'Snapshot restore could not reset' '$OUTH'"
[ -n "${SNAP_E:-}" ] && rm -rf "$SNAP_E"
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap).
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
echo
echo "RESULT: $pass passed, $fail failed"
[ "$fail" -eq 0 ]

View File

@@ -122,11 +122,12 @@ fi
# Source label: this agent's host:session (auto-detected, overridable).
if [ -z "$SRC_LABEL" ]; then
src_host=$(hostname -s 2>/dev/null || echo "?")
src_sess=${MOSAIC_AGENT_NAME:-}
if [ -z "$src_sess" ]; then
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
tmux_cmd=(tmux)
if [ -n "$SOCKET_NAME" ]; then
tmux_cmd+=(-L "$SOCKET_NAME")
fi
src_host=$(hostname -s 2>/dev/null || echo "?")
src_sess=$("${tmux_cmd[@]}" display-message -p '#S' 2>/dev/null || echo "?")
SRC_LABEL="${src_host}:${src_sess}"
fi

View File

@@ -14,9 +14,6 @@
# 5. invalid class => exit 3, nothing sent.
# 6. --class with no value => exit 3.
# 7. the documented consumer regex parses producer output for every class.
# 8. MOSAIC_AGENT_NAME is authoritative for sender identity.
# 9. sender fallback queries local tmux, never the destination -L socket.
# 10. an undeterminable sender is stamped as "?".
set -uo pipefail
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
@@ -24,45 +21,22 @@ TOOL="$HERE/agent-send.sh"
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
STUB=$(mktemp)
FAKE_BIN=$(mktemp -d)
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN"' EXIT
trap 'rm -f "$STUB"' EXIT
cat >"$STUB" <<'STUB_EOF'
#!/usr/bin/env bash
set -uo pipefail
b64=""
while getopts "L:t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
while getopts "t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
printf '%s' "$b64" | base64 -d
STUB_EOF
chmod +x "$STUB"
# Fake tmux distinguishes the sender's default socket from a destination socket.
cat >"$FAKE_BIN/tmux" <<'TMUX_EOF'
#!/usr/bin/env bash
set -uo pipefail
case "${FAKE_TMUX_MODE:-sessions}" in
unavailable) exit 1 ;;
sessions)
if [ "${1:-}" = "-L" ]; then
printf '%s\n' 'destination-holder'
else
printf '%s\n' 'local-agent'
fi
;;
esac
TMUX_EOF
chmod +x "$FAKE_BIN/tmux"
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
# Run the tool with the stub injected; echoes captured payload on stdout.
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
run_auto() {
env -u MOSAIC_AGENT_NAME \
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
bash "$TOOL" -n dsthost "$@"
}
# Documented consumer grammar — the daemon will mirror exactly this.
GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction)\] (.*)$'
@@ -118,30 +92,6 @@ classic=$(run -s mos -m "plain body")
[[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \
&& ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]"
# 8. Exported pane identity wins even when dispatch targets another tmux socket.
src_host=$(hostname -s)
got=$(MOSAIC_AGENT_NAME=authoritative-agent FAKE_TMUX_MODE=sessions \
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
bash "$TOOL" -L destination-socket -n dsthost -s mos -m "env identity")
want="[$src_host:authoritative-agent -> dsthost:mos] env identity"
[ "$got" = "$want" ] && ok "MOSAIC_AGENT_NAME is authoritative across sockets" \
|| no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]"
# 9. Without the env identity, self-lookup uses local tmux, not destination -L.
got=$(FAKE_TMUX_MODE=sessions run_auto -L destination-socket -s mos -m "local fallback")
want="[$src_host:local-agent -> dsthost:mos] local fallback"
[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \
|| no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]"
[[ "$got" != *":destination-holder ->"* ]] \
&& ok "cross-socket fallback rejects destination holder identity" \
|| no "cross-socket fallback rejects destination holder identity" "got=[$got]"
# 10. If neither env nor local tmux identifies the sender, preserve '?'.
got=$(FAKE_TMUX_MODE=unavailable run_auto -L destination-socket -s mos -m "unknown fallback")
want="[$src_host:? -> dsthost:mos] unknown fallback"
[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \
|| no "unknown sender falls back to ?" "got=[$got] want=[$want]"
echo "---"
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]

View File

@@ -24,8 +24,7 @@
"build": "tsc",
"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/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"
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -53,7 +52,6 @@
},
"devDependencies": {
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^2.0.0",
"@types/react": "^18.3.0",
"tsx": "^4.0.0",
"typescript": "^5.8.0",

View File

@@ -17,8 +17,6 @@ import { registerConfigCommand } from './commands/config.js';
import { registerFleetCommand } from './commands/fleet.js';
import { registerMissionCommand } from './commands/mission.js';
import { registerUninstallCommand } from './commands/uninstall.js';
import { registerRestoreCommand } from './commands/restore.js';
import { registerSkillCommand } from './commands/skill.js';
// prdy is registered via launch.ts
import { registerLaunchCommands } from './commands/launch.js';
import { registerAuthCommand } from './commands/auth.js';
@@ -68,7 +66,7 @@ Command Groups:
Runtime: tui, login, sessions
Gateway: gateway
Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, skill, sync, upgrade, wizard, yolo
Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, sync, upgrade, wizard, yolo
Platform: update
Runtimes: claude, codex, opencode, pi
`,
@@ -408,14 +406,6 @@ registerStorageCommand(program);
registerUninstallCommand(program);
// ─── restore ─────────────────────────────────────────────────────────────────
registerRestoreCommand(program);
// ─── skill ───────────────────────────────────────────────────────────────────
registerSkillCommand(program);
// ─── telemetry ───────────────────────────────────────────────────────────────
registerTelemetryCommand(program);
@@ -476,18 +466,6 @@ program
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();

View File

@@ -1,862 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
CLAUDEX_PROXY_HOST,
CLAUDEX_PROXY_PORT,
CLAUDEX_PROXY_URL,
CLAUDEX_PROXY_BINARY,
CLAUDEX_HEALTH_PATH,
CLAUDEX_HEALTH_URL,
buildAuthStatusArgs,
buildDeviceAuthArgs,
buildServeArgs,
parseAuthStatus,
checkProxyBinary,
checkAuthStatus,
runDeviceReauth,
probeLiveness,
buildSystemdUnitContent,
systemdUnitPath,
installSystemdUnit,
startNohupProxy,
verifyListenerIdentity,
runProxyPreflight,
ensureProxyRunning,
type AuthStatus,
type ProxyRunResult,
type SpawnedChild,
type ListenerIdentity,
} from './claudex-proxy.js';
/**
* P1 — Proxy preflight + lifecycle helpers for `mosaic yolo claudex`.
*
* Security-relevant invariants exercised here:
* - Liveness probe hits the proxy's dedicated `GET /healthz` and treats only a
* 2xx as "alive" — a *proxy-specific* health contract, not arbitrary HTTP on
* the port (CWE-345: a local port-squatter must not be trusted as the proxy).
* This also honors spec gotcha #1 (never `curl -f` the root, which returns
* non-2xx): `/healthz` returns 2xx when the proxy is up, so a healthy proxy is
* never mistaken for dead and no duplicate proxy is spawned.
* - Auth-status parsing NEVER surfaces OAuth token material — only a coarse
* state + optional expiry — even if a token-shaped string appears in output.
* - The systemd unit's ExecStart never interpolates an unvalidated path
* (CWE-74: a CR/LF in the path could inject arbitrary systemd directives).
* - The nohup fallback captures spawn's *async* error event instead of crashing.
*/
describe('claudex-proxy constants', () => {
it('pins the proxy endpoint to loopback :18765 (spec table)', () => {
expect(CLAUDEX_PROXY_HOST).toBe('127.0.0.1');
expect(CLAUDEX_PROXY_PORT).toBe(18765);
expect(CLAUDEX_PROXY_URL).toBe('http://127.0.0.1:18765');
expect(CLAUDEX_PROXY_BINARY).toBe('claude-code-proxy');
});
it('exposes the dedicated /healthz liveness endpoint (not the root path)', () => {
expect(CLAUDEX_HEALTH_PATH).toBe('/healthz');
expect(CLAUDEX_HEALTH_URL).toBe('http://127.0.0.1:18765/healthz');
});
it('builds the documented codex subcommand argv', () => {
expect(buildAuthStatusArgs()).toEqual(['codex', 'auth', 'status']);
expect(buildDeviceAuthArgs()).toEqual(['codex', 'auth', 'device']);
expect(buildServeArgs()).toEqual(['serve', '--no-monitor']);
});
});
describe('parseAuthStatus', () => {
it('reports valid on exit 0 with an authenticated marker', () => {
const s = parseAuthStatus({
status: 0,
stdout: 'Authenticated as user; token valid',
stderr: '',
});
expect(s.state).toBe('valid');
});
it('reports expired when output mentions expiry', () => {
const s = parseAuthStatus({ status: 0, stdout: 'Token expired 2 days ago', stderr: '' });
expect(s.state).toBe('expired');
});
it('reports unauthenticated when output says not logged in', () => {
const s = parseAuthStatus({
status: 1,
stdout: '',
stderr: 'not authenticated: run codex auth device',
});
expect(s.state).toBe('unauthenticated');
});
it('reports unknown on an unrecognized non-zero exit', () => {
const s = parseAuthStatus({ status: 2, stdout: 'weird', stderr: '' });
expect(s.state).toBe('unknown');
});
it('does NOT trust a signal-terminated check (status null) even with an auth-looking line', () => {
// status: null means the process was killed by a signal — an INCOMPLETE
// check. An auth-looking line that happened to be flushed must not be read
// as valid, or preflight passes on a check that never finished.
const s = parseAuthStatus({ status: null, stdout: 'Authenticated', stderr: '' });
expect(s.state).toBe('unknown');
});
it('extracts a best-effort expiry in days when present', () => {
const s = parseAuthStatus({
status: 0,
stdout: 'Authenticated; expires in 9 days',
stderr: '',
});
expect(s.state).toBe('valid');
expect(s.expiresInDays).toBe(9);
});
it('treats a clean exit 0 with no explicit markers as valid', () => {
const s = parseAuthStatus({ status: 0, stdout: 'Session active for account foo', stderr: '' });
expect(s.state).toBe('valid');
expect(s.expiresInDays).toBeUndefined();
});
it('NEVER retains token-shaped material from output', () => {
const leaky = 'Authenticated. access_token=sk-abc123SECRETdeadbeef refresh_token=rt-9999';
const s: AuthStatus = parseAuthStatus({ status: 0, stdout: leaky, stderr: '' });
const serialized = JSON.stringify(s);
expect(serialized).not.toContain('sk-abc123SECRETdeadbeef');
expect(serialized).not.toContain('rt-9999');
expect(serialized).not.toContain('access_token');
expect(serialized).not.toContain('refresh_token');
});
});
describe('checkAuthStatus', () => {
it('runs the status subcommand and parses the result', () => {
const run = vi.fn(
(_cmd: string, _args: string[]): ProxyRunResult => ({
status: 0,
stdout: 'Authenticated; expires in 7 days',
stderr: '',
}),
);
const s = checkAuthStatus(run);
expect(run).toHaveBeenCalledWith(CLAUDEX_PROXY_BINARY, ['codex', 'auth', 'status']);
expect(s.state).toBe('valid');
expect(s.expiresInDays).toBe(7);
});
it('surfaces unknown when the default runner cannot find the binary', () => {
// Exercises the default spawnSync path against an absent binary: no throw,
// status is non-zero/null → unknown. Deterministic on a box without the proxy.
const s = checkAuthStatus();
expect(['unknown', 'unauthenticated', 'valid', 'expired']).toContain(s.state);
});
});
describe('runDeviceReauth', () => {
it('spawns the device flow with inherited stdio (never captures the code/token)', () => {
const calls: Array<{ cmd: string; args: string[]; opts: { stdio: string } }> = [];
const status = runDeviceReauth((cmd, args, opts) => {
calls.push({ cmd, args, opts });
return { status: 0 };
});
expect(status).toBe(0);
expect(calls).toHaveLength(1);
expect(calls[0]!.cmd).toBe(CLAUDEX_PROXY_BINARY);
expect(calls[0]!.args).toEqual(['codex', 'auth', 'device']);
// stdio 'inherit' is the security-critical bit: the device code streams to
// the user's TTY; the launcher never pipes/captures it.
expect(calls[0]!.opts.stdio).toBe('inherit');
});
it('returns 1 when the child yields no status (absent binary)', () => {
const status = runDeviceReauth(() => ({ status: null }));
expect(status).toBe(1);
});
});
describe('checkProxyBinary', () => {
it('resolves via the default `which` path (proxy absent → null)', () => {
// Covers the default resolver; on CI/dev the proxy is not installed.
const r = checkProxyBinary();
expect(typeof r.present).toBe('boolean');
if (!r.present) expect(r.path).toBeNull();
});
it('reports present with the resolved path', () => {
const r = checkProxyBinary(() => '/home/u/.local/bin/claude-code-proxy');
expect(r.present).toBe(true);
expect(r.path).toBe('/home/u/.local/bin/claude-code-proxy');
});
it('reports absent when the resolver finds nothing', () => {
const r = checkProxyBinary(() => null);
expect(r.present).toBe(false);
expect(r.path).toBeNull();
});
});
describe('probeLiveness (proxy-specific /healthz, not arbitrary HTTP)', () => {
it('defaults to probing the /healthz endpoint, never the root path', async () => {
const seen: string[] = [];
await probeLiveness(undefined, async (u) => {
seen.push(u);
return { status: 200 };
});
expect(seen[0]).toBe(CLAUDEX_HEALTH_URL);
expect(seen[0]).toContain('/healthz');
});
it('treats a 200 on /healthz as alive', async () => {
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 200 }));
expect(live).toBe(true);
});
it('treats a 204 on /healthz as alive', async () => {
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 204 }));
expect(live).toBe(true);
});
it('treats a 404 as DEAD — does not trust an arbitrary responder on the port (CWE-345)', async () => {
// The whole point: a random local process squatting :18765 will not honor the
// proxy's /healthz contract, so a non-2xx there must not be mistaken for the proxy.
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 404 }));
expect(live).toBe(false);
});
it('treats a 500 as DEAD (unhealthy / not the proxy health contract)', async () => {
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 500 }));
expect(live).toBe(false);
});
it('treats a missing status as dead', async () => {
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({}));
expect(live).toBe(false);
});
it('treats a connection failure (reject) as dead', async () => {
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => {
throw new Error('ECONNREFUSED');
});
expect(live).toBe(false);
});
it('treats a timeout as dead', async () => {
const never = () => new Promise<{ status?: number }>(() => {});
const live = await probeLiveness(CLAUDEX_HEALTH_URL, never, 20);
expect(live).toBe(false);
});
});
describe('buildSystemdUnitContent', () => {
it('emits a user unit that execs the given binary with serve args', () => {
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
expect(unit).toContain('[Unit]');
expect(unit).toContain('[Service]');
expect(unit).toContain('[Install]');
expect(unit).toContain('/home/u/.local/bin/claude-code-proxy serve --no-monitor');
expect(unit).toContain('WantedBy=default.target');
});
it('never embeds credential material', () => {
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
expect(unit).not.toMatch(/token/i);
expect(unit).not.toMatch(/auth\.json/i);
});
it('rejects a path containing a newline (CWE-74 systemd directive injection)', () => {
// A raw newline in ExecStart would let an attacker append arbitrary unit
// directives — e.g. `ExecStartPost=curl evil`. Must be rejected outright.
expect(() =>
buildSystemdUnitContent('/bin/claude-code-proxy\nExecStartPost=/bin/rm -rf /'),
).toThrow();
});
it('rejects a path containing a carriage return', () => {
expect(() => buildSystemdUnitContent('/bin/claude-code-proxy\rmalicious')).toThrow();
});
it('rejects a path with other control characters', () => {
expect(() => buildSystemdUnitContent('/bin/claude-code-proxy\x00nul')).toThrow();
});
it('rejects a non-absolute path', () => {
expect(() => buildSystemdUnitContent('claude-code-proxy')).toThrow();
expect(() => buildSystemdUnitContent('')).toThrow();
});
it('systemd-quotes a path that contains spaces', () => {
const unit = buildSystemdUnitContent('/home/u/my apps/claude-code-proxy');
expect(unit).toContain('ExecStart="/home/u/my apps/claude-code-proxy" serve --no-monitor');
});
it('escapes embedded quotes and backslashes when quoting', () => {
const unit = buildSystemdUnitContent('/home/u/we"ird\\dir/claude-code-proxy');
// No unescaped closing quote can terminate the token early.
expect(unit).toContain('ExecStart="/home/u/we\\"ird\\\\dir/claude-code-proxy" serve');
});
it('leaves a clean absolute path unquoted (no needless churn)', () => {
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
expect(unit).toContain('ExecStart=/home/u/.local/bin/claude-code-proxy serve --no-monitor');
});
});
describe('systemdUnitPath', () => {
it('targets the systemd --user unit dir', () => {
expect(systemdUnitPath('/home/u')).toBe(
'/home/u/.config/systemd/user/claude-code-proxy.service',
);
});
});
describe('installSystemdUnit', () => {
it('writes the unit and returns true when daemon-reload succeeds', () => {
let written: { path: string; content: string } | null = null;
const ok = installSystemdUnit('/bin/claude-code-proxy', {
home: '/home/u',
writeUnit: (path, content) => {
written = { path, content };
},
run: () => ({ status: 0, stdout: '', stderr: '' }),
});
expect(ok).toBe(true);
expect(written).not.toBeNull();
expect(written!.path).toBe('/home/u/.config/systemd/user/claude-code-proxy.service');
expect(written!.content).toContain('ExecStart=/bin/claude-code-proxy serve --no-monitor');
});
it('returns false when daemon-reload fails (systemd --user unavailable)', () => {
const ok = installSystemdUnit('/bin/claude-code-proxy', {
home: '/home/u',
writeUnit: () => {},
run: () => ({ status: 1, stdout: '', stderr: 'Failed to connect to bus' }),
});
expect(ok).toBe(false);
});
it('returns false when writing the unit throws', () => {
const ok = installSystemdUnit('/bin/claude-code-proxy', {
home: '/home/u',
writeUnit: () => {
throw new Error('EACCES');
},
run: () => ({ status: 0, stdout: '', stderr: '' }),
});
expect(ok).toBe(false);
});
it('refuses to write a unit for an injection-bearing path (never writes a poisoned unit)', () => {
const writeUnit = vi.fn();
const ok = installSystemdUnit('/bin/claude-code-proxy\nExecStartPost=/bin/rm -rf /', {
home: '/home/u',
writeUnit,
run: () => ({ status: 0, stdout: '', stderr: '' }),
});
expect(ok).toBe(false);
// The poisoned unit content is never even produced, so nothing is written.
expect(writeUnit).not.toHaveBeenCalled();
});
it('writes to a real temp dir via the default writer', () => {
const home = mkdtempSync(join(tmpdir(), 'claudex-unit-'));
try {
const ok = installSystemdUnit('/bin/claude-code-proxy', {
home,
run: () => ({ status: 0, stdout: '', stderr: '' }),
});
expect(ok).toBe(true);
const written = readFileSync(systemdUnitPath(home), 'utf8');
expect(written).toContain('[Service]');
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
describe('runProxyPreflight', () => {
const trustedListener = () => 'ok' as const;
it('is ok when binary present, auth valid, proxy live, and listener identity-verified', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'valid' }),
probe: async () => true,
verifyListener: trustedListener,
});
expect(report.ok).toBe(true);
expect(report.listenerVerdict).toBe('ok');
expect(report.problems).toEqual([]);
});
it('flags a missing binary', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: false, path: null }),
checkAuth: () => ({ state: 'valid' }),
probe: async () => true,
verifyListener: trustedListener,
});
expect(report.ok).toBe(false);
expect(report.problems.some((p) => /binary/i.test(p))).toBe(true);
});
it('flags expired auth (re-auth needed)', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'expired' }),
probe: async () => true,
verifyListener: trustedListener,
});
expect(report.ok).toBe(false);
expect(report.needsReauth).toBe(true);
expect(report.problems.some((p) => /auth/i.test(p))).toBe(true);
});
it('flags a dead proxy', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'valid' }),
probe: async () => false,
verifyListener: trustedListener,
});
expect(report.ok).toBe(false);
expect(report.live).toBe(false);
});
it('flags an unknown auth state without marking it for re-auth', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'unknown' }),
probe: async () => true,
verifyListener: trustedListener,
});
expect(report.ok).toBe(false);
expect(report.needsReauth).toBe(false);
expect(report.problems.some((p) => /could not determine/i.test(p))).toBe(true);
});
it('does NOT pass preflight when the live responder fails identity verification (F2b)', async () => {
// A squatter answering /healthz-2xx must not yield ok:true just because the
// binary is installed and OAuth is valid — the identity gate holds here too.
for (const verdict of ['foreign-user', 'wrong-exe', 'unknown'] as const) {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'valid' }),
probe: async () => true,
verifyListener: () => verdict,
});
expect(report.ok).toBe(false);
expect(report.live).toBe(true);
expect(report.listenerVerdict).toBe(verdict);
expect(report.problems.some((p) => /identity could not be verified/i.test(p))).toBe(true);
// The identity problem is non-sensitive: port + verdict only, no token.
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
}
});
it('does not verify listener identity when the proxy is dead (no listener to trust)', async () => {
const verifyListener = vi.fn(() => 'ok' as const);
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'valid' }),
probe: async () => false,
verifyListener,
});
expect(verifyListener).not.toHaveBeenCalled();
expect(report.listenerVerdict).toBe('unknown');
expect(report.ok).toBe(false);
});
it('does not leak token material for any auth state', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'expired' }),
probe: async () => false,
verifyListener: trustedListener,
});
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
});
it('runs end-to-end with all real defaults (no proxy installed → not ok)', async () => {
// Exercises the default checkBinary/checkAuth/probe closures against a box
// with no proxy: absent binary, spawnSync status, real loopback probe that
// fast-fails with ECONNREFUSED. Asserts shape only (never token material).
const report = await runProxyPreflight();
expect(typeof report.ok).toBe('boolean');
expect(Array.isArray(report.problems)).toBe(true);
expect(['valid', 'expired', 'unauthenticated', 'unknown']).toContain(report.auth.state);
expect(JSON.stringify(report)).not.toMatch(/access_token|refresh_token|sk-/i);
});
});
/**
* A minimal fake ChildProcess for the nohup-fallback tests: records once()
* handlers so a test can drive the async 'spawn'/'error' events, and tracks
* whether the 'error' listener was already attached at the moment unref() ran
* (the security-critical ordering from finding #1).
*/
function fakeChild() {
const handlers: Record<string, (arg?: unknown) => void> = {};
const state = { unreffed: false, errorHandlerAtUnref: false };
const child = {
once(event: string, listener: (arg?: unknown) => void) {
handlers[event] = listener;
return child;
},
unref() {
state.unreffed = true;
state.errorHandlerAtUnref = typeof handlers.error === 'function';
},
emit(event: string, arg?: unknown) {
handlers[event]?.(arg);
},
};
return {
child: child as unknown as SpawnedChild & { emit(e: string, a?: unknown): void },
state,
};
}
describe('startNohupProxy (finding #1 — async spawn error must not crash)', () => {
it('resolves status 0 only after a confirmed spawn, and unrefs the child', async () => {
const { child, state } = fakeChild();
const spawnImpl = vi.fn((_cmd: string, _args: string[]) => {
queueMicrotask(() => child.emit('spawn'));
return child;
});
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
expect(r.status).toBe(0);
expect(state.unreffed).toBe(true);
// The error listener MUST be registered before unref(), so an ENOENT that
// arrives asynchronously can never become an unhandled 'error' crash.
expect(state.errorHandlerAtUnref).toBe(true);
expect(spawnImpl).toHaveBeenCalledWith('/bin/claude-code-proxy', ['serve', '--no-monitor'], {
detached: true,
stdio: 'ignore',
});
});
it('captures an async spawn error (ENOENT) as a failed start instead of crashing', async () => {
const { child, state } = fakeChild();
const spawnImpl = () => {
queueMicrotask(() => child.emit('error', new Error('spawn claude-code-proxy ENOENT')));
return child;
};
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
expect(r.status).toBe(1);
expect(r.stderr).toContain('ENOENT');
expect(state.unreffed).toBe(false); // never unref a child that failed to start
});
it('captures a synchronous spawn throw as a failed start', async () => {
const spawnImpl = () => {
throw new Error('EACCES');
};
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
expect(r.status).toBe(1);
expect(r.stderr).toContain('EACCES');
});
it('ignores a late error after a successful spawn (settles once)', async () => {
const { child } = fakeChild();
const spawnImpl = () => {
queueMicrotask(() => {
child.emit('spawn');
child.emit('error', new Error('late boom'));
});
return child;
};
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
expect(r.status).toBe(0); // first settle wins; the late error cannot flip it
});
});
describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE-345)', () => {
const me: ListenerIdentity = {
pid: 4242,
uid: 1000,
exePath: '/home/me/.local/bin/claude-code-proxy',
};
// Identity canonicalize for tests: fake paths don't exist on disk, so we map
// each path to itself and exercise symlink resolution explicitly where needed.
const idc = (p: string) => p;
it('accepts a listener owned by the current uid whose exe is the expected proxy path', () => {
const verdict = verifyListenerIdentity({
identify: () => me,
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('ok');
});
it('resolves symlinks on BOTH sides before comparing (canonical match → ok)', () => {
// The listener exe and our resolved binary reach the same real file via
// different symlink paths — a canonical comparison must accept it.
const canon: Record<string, string> = {
'/var/run/proxy.link': '/opt/proxy/bin/claude-code-proxy',
'/home/me/.local/bin/claude-code-proxy': '/opt/proxy/bin/claude-code-proxy',
};
const verdict = verifyListenerIdentity({
identify: () => ({ ...me, exePath: '/var/run/proxy.link' }),
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: (p) => canon[p] ?? null,
});
expect(verdict).toBe('ok');
});
it('does NOT trust a same-uid process at the WRONG path with the right basename (F2a)', () => {
// The squatter vector on a shared-uid host: right basename, wrong path. The
// basename must NEVER be a trust signal when an expected exact path resolved.
const verdict = verifyListenerIdentity({
identify: () => ({ ...me, exePath: '/tmp/claude-code-proxy' }),
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('wrong-exe');
});
it('fails closed (unknown) when our own proxy binary path cannot be resolved (F2a)', () => {
// No expected path → we cannot assert identity → refuse to trust (no basename
// acceptance). Previously this returned `ok` by basename; that was a bypass.
const verdict = verifyListenerIdentity({
identify: () => me,
currentUid: () => 1000,
expectedExe: () => null,
canonicalize: idc,
});
expect(verdict).toBe('unknown');
});
it('fails closed (unknown) when a path cannot be canonicalized (F2a)', () => {
const verdict = verifyListenerIdentity({
identify: () => me,
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: () => null, // e.g. binary deleted out from under the listener
});
expect(verdict).toBe('unknown');
});
it('rejects a listener owned by a DIFFERENT uid (foreign-user) — fail closed', () => {
const verdict = verifyListenerIdentity({
identify: () => ({ ...me, uid: 0 }),
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('foreign-user');
});
it('rejects a same-user listener whose exe is NOT the proxy (wrong-exe)', () => {
const verdict = verifyListenerIdentity({
identify: () => ({ ...me, exePath: '/usr/bin/nc' }),
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('wrong-exe');
});
it('returns unknown (fail closed) when the listener cannot be identified', () => {
const verdict = verifyListenerIdentity({
identify: () => null,
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('unknown');
});
it('returns unknown when the current uid is unavailable (non-posix)', () => {
const verdict = verifyListenerIdentity({
identify: () => me,
currentUid: () => -1,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('unknown');
});
it('returns unknown when the listener exe path cannot be read', () => {
const verdict = verifyListenerIdentity({
identify: () => ({ ...me, exePath: null }),
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('unknown');
});
it('runs with real defaults without throwing (identity may be unresolved → verdict)', () => {
const verdict = verifyListenerIdentity();
expect(['ok', 'foreign-user', 'wrong-exe', 'unknown']).toContain(verdict);
});
});
describe('ensureProxyRunning', () => {
const ok: ProxyRunResult = { status: 0, stdout: '', stderr: '' };
const nohupOk = async (): Promise<ProxyRunResult> => ok;
const trusted = () => 'ok' as const;
it('is a no-op when the proxy is already live AND identity-verified', async () => {
const startSystemd = vi.fn(() => ok);
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
probe: async () => true,
verifyListener: trusted,
startSystemd,
startNohup,
waitMs: async () => {},
});
expect(r.method).toBe('already');
expect(r.live).toBe(true);
expect(startSystemd).not.toHaveBeenCalled();
expect(startNohup).not.toHaveBeenCalled();
});
it('fails closed as untrusted when a responder holds :18765 but identity is NOT ours', async () => {
// A foreign process answers /healthz but the listener is not our proxy
// (foreign uid / wrong exe / unidentifiable). We must NOT trust it and must
// NOT start a second proxy (the port is already taken) — fail closed.
const startSystemd = vi.fn(() => ok);
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
probe: async () => true,
verifyListener: () => 'foreign-user',
startSystemd,
startNohup,
waitMs: async () => {},
});
expect(r.method).toBe('untrusted');
expect(r.live).toBe(false);
expect(startSystemd).not.toHaveBeenCalled();
expect(startNohup).not.toHaveBeenCalled();
});
it('starts via systemd when available and then becomes trusted-live', async () => {
let calls = 0;
const r = await ensureProxyRunning({
probe: async () => calls++ > 0, // dead first, live after start
verifyListener: trusted,
startSystemd: () => ok,
startNohup: async () => {
throw new Error('should not fall back');
},
waitMs: async () => {},
});
expect(r.method).toBe('systemd');
expect(r.live).toBe(true);
});
it('waits past a slow systemd bind before giving up (finding #2 — no duplicate proxy)', async () => {
// systemd `start` returns 0 (job accepted) but the socket only binds on the
// 4th probe — still well within the startup deadline. nohup must NOT run,
// or two proxies would contend for :18765.
let probes = 0;
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
probe: async () => probes++ >= 3,
verifyListener: trusted,
startSystemd: () => ok,
startNohup,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 200,
});
expect(r.method).toBe('systemd');
expect(r.live).toBe(true);
expect(startNohup).not.toHaveBeenCalled();
});
it('does NOT fall back to nohup after systemd accepts but never binds (finding #1 — dup-proxy race)', async () => {
// systemctl start exit 0 means the job was ACCEPTED, not bound. If it binds
// just after our deadline (or systemd restarts it), a nohup fallback would
// create a SECOND proxy contending for :18765. Once systemd has accepted the
// job we never spawn nohup — we report a managed-service startup failure.
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
probe: async () => false, // never becomes live within the deadline
verifyListener: trusted,
startSystemd: () => ok,
startNohup,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(startNohup).not.toHaveBeenCalled();
expect(r.method).toBe('failed');
expect(r.live).toBe(false);
});
it('does NOT trust a systemd-started responder whose identity cannot be verified', async () => {
// Dead at first (so we reach the systemd start), then the socket binds — but
// identity never verifies (e.g. a squatter beat systemd to the port). A live
// responder that fails identity must never be reported as a successful start.
let calls = 0;
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
probe: async () => calls++ > 0,
verifyListener: () => 'wrong-exe',
startSystemd: () => ok,
startNohup,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(startNohup).not.toHaveBeenCalled();
expect(r.method).toBe('failed');
expect(r.live).toBe(false);
});
it('falls back to nohup only when systemd start FAILS outright (not accepted)', async () => {
let calls = 0;
const r = await ensureProxyRunning({
// A failed systemd start skips its post-start poll, so probes are:
// #0 initial (dead), #1 after nohup (live). nohup fallback is reachable
// ONLY because systemd never accepted the job (status 1).
probe: async () => calls++ > 0,
verifyListener: trusted,
startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }),
startNohup: nohupOk,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(r.method).toBe('nohup');
expect(r.live).toBe(true);
});
it('does NOT trust a nohup-started responder whose identity cannot be verified', async () => {
let calls = 0;
const r = await ensureProxyRunning({
probe: async () => calls++ > 0,
verifyListener: () => 'unknown',
startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }),
startNohup: nohupOk,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(r.method).toBe('failed');
expect(r.live).toBe(false);
});
it('reports failed when nothing brings the proxy up', async () => {
const r = await ensureProxyRunning({
probe: async () => false,
verifyListener: trusted,
startSystemd: () => ({ status: 1, stdout: '', stderr: '' }),
startNohup: async () => ({ status: 1, stdout: '', stderr: '' }),
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(r.method).toBe('failed');
expect(r.live).toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show More