Compare commits
11 Commits
feat/791-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dfcf1903e | |||
| abd2791f59 | |||
| 8ec67a1126 | |||
| d801d6c4c8 | |||
| d3bf52898b | |||
| 3f77229e88 | |||
| 686c881fe4 | |||
| fe7a468c9d | |||
| cabf02e7b9 | |||
| 9ddc6fbda8 | |||
| 31607a4af6 |
@@ -22,10 +22,10 @@
|
|||||||
FROM node:24-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
# Native toolchain required to compile node-gyp deps on musl, plus the
|
# 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`
|
# 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
|
# `git`, and `jq` are baked here too — framework shell tests and the shipped
|
||||||
# `apk add bash`.
|
# Codex review wrappers require them without per-run installation in ci.yml.
|
||||||
RUN apk add --no-cache python3 make g++ postgresql-client bash
|
RUN apk add --no-cache python3 make g++ postgresql-client bash git jq
|
||||||
|
|
||||||
# Pin pnpm to the repo's packageManager version via corepack.
|
# Pin pnpm to the repo's packageManager version via corepack.
|
||||||
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate
|
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate
|
||||||
|
|||||||
@@ -97,7 +97,10 @@ mosaic config path # Print config file path
|
|||||||
```bash
|
```bash
|
||||||
mosaic doctor # Health audit — detect drift and missing files
|
mosaic doctor # Health audit — detect drift and missing files
|
||||||
mosaic sync # Sync skills from canonical source
|
mosaic sync # Sync skills from canonical source
|
||||||
mosaic update # Check for and install CLI updates
|
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 wizard # Full guided setup wizard
|
mosaic wizard # Full guided setup wizard
|
||||||
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
|
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
|
||||||
mosaic coord init # Initialize a new orchestration mission
|
mosaic coord init # Initialize a new orchestration mission
|
||||||
@@ -349,6 +352,8 @@ bash tools/install.sh --yes # Non-interactive, accept all defaults
|
|||||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
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
|
## Contributing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Documentation Sitemap
|
# 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
|
## 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.
|
- [Generated environment boundary](fleet/reference/generated-env-boundary.md) — roster-derived launch projection, strict local data, legacy quarantine, and downstream interface evidence.
|
||||||
|
|||||||
24
docs/architecture/lease-broker-protocol.md
Normal file
24
docs/architecture/lease-broker-protocol.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 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.
|
||||||
14
docs/architecture/lease-broker-security.md
Normal file
14
docs/architecture/lease-broker-security.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# 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.
|
||||||
72
docs/architecture/mutator-class-gate.md
Normal file
72
docs/architecture/mutator-class-gate.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# 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.
|
||||||
@@ -8,8 +8,9 @@
|
|||||||
4. [Adding New Agent Tools](#adding-new-agent-tools)
|
4. [Adding New Agent Tools](#adding-new-agent-tools)
|
||||||
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
||||||
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
||||||
7. [API Endpoint Reference](#api-endpoint-reference)
|
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
|
||||||
8. [Local Fleet Canary](./fleet-local-canary.md)
|
8. [API Endpoint Reference](#api-endpoint-reference)
|
||||||
|
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -353,6 +354,37 @@ 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
|
## API Endpoint Reference
|
||||||
|
|
||||||
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
||||||
|
|||||||
@@ -98,6 +98,39 @@ Expected results:
|
|||||||
that means the unit ran, not that an agent pane is live. Treat tmux
|
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.
|
`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
|
## Release Preflight
|
||||||
|
|
||||||
Run this checklist before cutting or dogfooding a fleet release:
|
Run this checklist before cutting or dogfooding a fleet release:
|
||||||
|
|||||||
36
docs/guides/lease-broker-operations.md
Normal file
36
docs/guides/lease-broker-operations.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# 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.
|
||||||
147
docs/guides/upgrade-safety-and-recovery.md
Normal file
147
docs/guides/upgrade-safety-and-recovery.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# 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)
|
||||||
@@ -183,6 +183,8 @@ non-interactive use:
|
|||||||
--no-auto-launch # Skip auto-launch of wizard after install
|
--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:
|
Or if installed globally:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -307,6 +309,39 @@ 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
|
## Sub-package Commands
|
||||||
|
|
||||||
Each Mosaic sub-package exposes its full API surface through the `mosaic` CLI.
|
Each Mosaic sub-package exposes its full API surface through the `mosaic` CLI.
|
||||||
|
|||||||
@@ -296,3 +296,234 @@ vitest **1252** · restore.spec **30** · durable-snapshot **41** · manifest-gu
|
|||||||
migration 21. shellcheck clean on all new lines; new test markers mirror the existing `# VERIFY-NET`
|
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
|
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.
|
(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 3–6 (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.
|
||||||
|
|||||||
86
docs/scratchpads/804-install-unknown-flags.md
Normal file
86
docs/scratchpads/804-install-unknown-flags.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# 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.
|
||||||
112
docs/scratchpads/824-mosaic-skill-cli.md
Normal file
112
docs/scratchpads/824-mosaic-skill-cli.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# 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.
|
||||||
86
docs/scratchpads/828-lease-broker.md
Normal file
86
docs/scratchpads/828-lease-broker.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# 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.
|
||||||
130
docs/scratchpads/829-mutator-gate.md
Normal file
130
docs/scratchpads/829-mutator-gate.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# 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.
|
||||||
44
docs/scratchpads/838-broker-acceptance-flake.md
Normal file
44
docs/scratchpads/838-broker-acceptance-flake.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# 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.
|
||||||
38
docs/scratchpads/ms-792-fleet-enoent-installer.md
Normal file
38
docs/scratchpads/ms-792-fleet-enoent-installer.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# 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 Commander’s 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.
|
||||||
41
packages/coord/src/__tests__/runtime-launch-gate.test.ts
Normal file
41
packages/coord/src/__tests__/runtime-launch-gate.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -179,32 +179,41 @@ function buildContinuationPrompt(params: {
|
|||||||
`3. Read \`${mission.scratchpadFile}\` for session history and decisions`,
|
`3. Read \`${mission.scratchpadFile}\` for session history and decisions`,
|
||||||
`4. Read \`${mission.tasksFile}\` for current task state`,
|
`4. Read \`${mission.tasksFile}\` for current task state`,
|
||||||
'5. `git pull --rebase` to sync latest changes',
|
'5. `git pull --rebase` to sync latest changes',
|
||||||
`6. Launch runtime with \`${runtime} -p\``,
|
`6. Launch runtime with \`mosaic ${runtime} -p\``,
|
||||||
`7. Continue execution from task **${taskId}**`,
|
`7. Continue execution from task **${taskId}**`,
|
||||||
'8. Follow Two-Phase Completion Protocol',
|
'8. Follow Two-Phase Completion Protocol',
|
||||||
`9. You are the SOLE writer of \`${mission.tasksFile}\``,
|
`9. You are the SOLE writer of \`${mission.tasksFile}\``,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLaunchCommand(
|
export function resolveLaunchCommand(
|
||||||
runtime: 'claude' | 'codex',
|
runtime: 'claude' | 'codex',
|
||||||
prompt: string,
|
prompt: string,
|
||||||
configuredCommand: string[] | undefined,
|
configuredCommand: string[] | undefined,
|
||||||
): string[] {
|
): string[] {
|
||||||
if (configuredCommand === undefined || configuredCommand.length === 0) {
|
if (configuredCommand === undefined || configuredCommand.length === 0) {
|
||||||
return [runtime, '-p', prompt];
|
return runtime === 'claude' ? ['mosaic', 'claude', '-p', prompt] : [runtime, '-p', prompt];
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPromptPlaceholder = configuredCommand.some((value) => value === '{prompt}');
|
const hasPromptPlaceholder = configuredCommand.some((value) => value === '{prompt}');
|
||||||
const withInterpolation = configuredCommand.map((value) =>
|
const withInterpolation = configuredCommand.map((value) =>
|
||||||
value === '{prompt}' ? prompt : value,
|
value === '{prompt}' ? prompt : value,
|
||||||
);
|
);
|
||||||
|
const command = hasPromptPlaceholder ? withInterpolation : [...withInterpolation, prompt];
|
||||||
|
|
||||||
if (hasPromptPlaceholder) {
|
if (runtime !== 'claude') return command;
|
||||||
return withInterpolation;
|
if (
|
||||||
|
command[0] === 'mosaic' &&
|
||||||
|
(command[1] === 'claude' || (command[1] === 'yolo' && command[2] === 'claude'))
|
||||||
|
) {
|
||||||
|
return command;
|
||||||
}
|
}
|
||||||
|
if (command[0] === 'claude') {
|
||||||
return [...withInterpolation, prompt];
|
return ['mosaic', 'claude', ...command.slice(1)];
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
'Custom Claude task commands must use `mosaic claude` so lease registration cannot be bypassed.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeAtomicJson(filePath: string, payload: unknown): Promise<void> {
|
async function writeAtomicJson(filePath: string, payload: unknown): Promise<void> {
|
||||||
|
|||||||
@@ -177,15 +177,23 @@ bash tools/install.sh --cli # npm CLI only (skip framework)
|
|||||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
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
|
## Universal Skills
|
||||||
|
|
||||||
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`, then links each skill into runtime directories.
|
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.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic sync # Full sync (clone + link)
|
mosaic sync # Full canonical catalog sync
|
||||||
~/.config/mosaic/bin/mosaic-sync-skills --link-only # Re-link only
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## Health Audit
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
When spawning workers, include skill loading in the kickstart:
|
When spawning workers, include skill loading in the kickstart:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
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..."
|
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..."
|
||||||
```
|
```
|
||||||
|
|
||||||
#### **MANDATORY**
|
#### **MANDATORY**
|
||||||
|
|||||||
@@ -2,6 +2,16 @@
|
|||||||
"model": "opus",
|
"model": "opus",
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"PreToolUse": [
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": ".*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude",
|
||||||
|
"timeout": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matcher": "Write|Edit|MultiEdit",
|
"matcher": "Write|Edit|MultiEdit",
|
||||||
"hooks": [
|
"hooks": [
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { execSync, spawnSync } from 'node:child_process';
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||||
|
const MUTATOR_GATE = join(MOSAIC_HOME, 'tools', 'lease-broker', 'mutator-gate.py');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -106,6 +107,23 @@ function nowIso(): string {
|
|||||||
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
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
|
// Mission detection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -250,6 +268,11 @@ export default function register(pi: ExtensionAPI) {
|
|||||||
let hbModel: string | null = null;
|
let hbModel: string | null = null;
|
||||||
let hbTimer: ReturnType<typeof setInterval> | 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 ─────────────────────────────────────────────────────
|
// ── Session Start ─────────────────────────────────────────────────────
|
||||||
pi.on('session_start', async (_event, ctx) => {
|
pi.on('session_start', async (_event, ctx) => {
|
||||||
sessionCwd = process.cwd();
|
sessionCwd = process.cwd();
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ link_targets=(
|
|||||||
)
|
)
|
||||||
|
|
||||||
canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")"
|
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.
|
# Build an associative array from the colon-separated whitelist for O(1) lookup.
|
||||||
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
|
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
|
||||||
@@ -203,7 +204,14 @@ link_skill_into_target() {
|
|||||||
link_path="$target_dir/$name"
|
link_path="$target_dir/$name"
|
||||||
|
|
||||||
if [[ -L "$link_path" ]]; then
|
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"
|
ln -sfn "$skill_path" "$link_path"
|
||||||
|
else
|
||||||
|
echo "[mosaic-skills] Preserve foreign runtime symlink: $link_path"
|
||||||
|
fi
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -234,14 +242,10 @@ prune_stale_links_in_target() {
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
resolved="$(readlink -f "$link_path" 2>/dev/null || true)"
|
# -m resolves lexical dangling targets too. If resolution fails, ownership
|
||||||
if [[ -z "$resolved" ]]; then
|
# is unproven and the link must be preserved.
|
||||||
rm -f "$link_path"
|
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
||||||
echo "[mosaic-skills] Removed stale broken skill link: $link_path"
|
if [[ -n "$resolved" && "$resolved" == "$canonical_real/"* ]]; then
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$resolved" == "$MOSAIC_HOME/"* ]]; then
|
|
||||||
rm -f "$link_path"
|
rm -f "$link_path"
|
||||||
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
|
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -79,9 +79,26 @@ function Link-SkillIntoTarget {
|
|||||||
|
|
||||||
$linkPath = Join-Path $TargetDir $name
|
$linkPath = Join-Path $TargetDir $name
|
||||||
|
|
||||||
# Already a junction/symlink — recreate
|
# Recreate only Mosaic-owned junctions/symlinks. Foreign reparse points are
|
||||||
|
# runtime-owned and must never be clobbered by install/upgrade auto-sync.
|
||||||
$existing = Get-Item $linkPath -Force -ErrorAction SilentlyContinue
|
$existing = Get-Item $linkPath -Force -ErrorAction SilentlyContinue
|
||||||
if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
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
|
Remove-Item $linkPath -Force
|
||||||
}
|
}
|
||||||
elseif ($existing) {
|
elseif ($existing) {
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ Security vulnerability review focusing on:
|
|||||||
~/.config/mosaic/tools/codex/codex-security-review.sh -n 42
|
~/.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
|
### Review Against Base Branch
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -253,7 +255,7 @@ Run the script from inside a git repository.
|
|||||||
|
|
||||||
### "No changes found to review"
|
### "No changes found to review"
|
||||||
|
|
||||||
The specified mode (--uncommitted, --base, etc.) found no changes 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.
|
||||||
|
|
||||||
### "Codex produced no output"
|
### "Codex produced no output"
|
||||||
|
|
||||||
|
|||||||
@@ -44,38 +44,47 @@ build_diff_context() {
|
|||||||
diff_text=$(git show "$value" 2>/dev/null)
|
diff_text=$(git show "$value" 2>/dev/null)
|
||||||
;;
|
;;
|
||||||
pr)
|
pr)
|
||||||
# For PRs, we need to fetch the PR diff
|
# Provider detection writes its result to stdout; suppress it so it cannot
|
||||||
detect_platform
|
# be mistaken for diff content when this function is used in a substitution.
|
||||||
|
detect_platform >/dev/null
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
diff_text=$(gh pr diff "$value" 2>/dev/null)
|
diff_text=$(gh pr diff "$value" 2>/dev/null) || {
|
||||||
|
echo "Error: Failed to fetch the diff for PR #${value}." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
# tea doesn't have a direct pr diff command, use git
|
local pr_base base_ref pr_head_ref
|
||||||
local pr_base
|
pr_base=$(tea pr list --fields index,base --output simple 2>/dev/null | awk -v pr="$value" '$1 == pr { print $2; exit }')
|
||||||
pr_base=$(tea pr list --fields index,base --output simple 2>/dev/null | grep "^${value}" | awk '{print $2}')
|
if [[ -z "$pr_base" ]]; then
|
||||||
if [[ -n "$pr_base" ]]; then
|
echo "Error: Could not resolve the base branch for Gitea PR #${value}." >&2
|
||||||
diff_text=$(git diff "${pr_base}...HEAD" 2>/dev/null)
|
return 1
|
||||||
else
|
|
||||||
# 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
|
||||||
|
|
||||||
|
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
|
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
|
||||||
|
}
|
||||||
|
else
|
||||||
|
echo "Error: Unsupported git platform while resolving PR #${value}." >&2
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
echo "$diff_text"
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Format JSON findings as markdown for PR comments
|
# Format JSON findings as markdown for PR comments
|
||||||
|
|||||||
158
packages/mosaic/framework/tools/codex/test-pr-diff-context.sh
Normal file
158
packages/mosaic/framework/tools/codex/test-pr-diff-context.sh
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
#!/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
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
#!/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())
|
||||||
704
packages/mosaic/framework/tools/lease-broker/daemon.py
Normal file
704
packages/mosaic/framework/tools/lease-broker/daemon.py
Normal file
@@ -0,0 +1,704 @@
|
|||||||
|
#!/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)
|
||||||
103
packages/mosaic/framework/tools/lease-broker/launch-runtime.py
Normal file
103
packages/mosaic/framework/tools/lease-broker/launch-runtime.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
#!/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())
|
||||||
100
packages/mosaic/framework/tools/lease-broker/mutator-gate.py
Normal file
100
packages/mosaic/framework/tools/lease-broker/mutator-gate.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
#!/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())
|
||||||
@@ -32,7 +32,7 @@ Claude:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"worker": {
|
"worker": {
|
||||||
"command_template": "claude -p \"Execute task {task_id}: {task_title}\""
|
"command_template": "mosaic claude -p \"Execute task {task_id}: {task_title}\""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ echo ""
|
|||||||
|
|
||||||
cd "$PROJECT"
|
cd "$PROJECT"
|
||||||
if [[ "$RUNTIME_CMD" == "claude" ]]; then
|
if [[ "$RUNTIME_CMD" == "claude" ]]; then
|
||||||
exec claude --dangerously-skip-permissions --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
|
exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --dangerous --runtime claude -- \
|
||||||
|
claude --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$RUNTIME_CMD" == "codex" ]]; then
|
if [[ "$RUNTIME_CMD" == "codex" ]]; then
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ echo ""
|
|||||||
|
|
||||||
cd "$PROJECT"
|
cd "$PROJECT"
|
||||||
if [[ "$RUNTIME_CMD" == "claude" ]]; then
|
if [[ "$RUNTIME_CMD" == "claude" ]]; then
|
||||||
exec claude --dangerously-skip-permissions --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
|
exec python3 "$SCRIPT_DIR/../lease-broker/launch-runtime.py" --dangerous --runtime claude -- \
|
||||||
|
claude --append-system-prompt "$SYSTEM_PROMPT" "$KICKOFF"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$RUNTIME_CMD" == "codex" ]]; then
|
if [[ "$RUNTIME_CMD" == "codex" ]]; then
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ Pending QA validation
|
|||||||
This report was created by the QA automation hook.
|
This report was created by the QA automation hook.
|
||||||
To process this report, run:
|
To process this report, run:
|
||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
claude -p "Use Task tool to launch universal-qa-agent for report: $REPORT_PATH"
|
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"
|
||||||
\`\`\`
|
\`\`\`
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting remediation: $ACTIONS_PATH" | tee -a "$LOG_FILE"
|
||||||
|
|
||||||
# Trigger remediation agent
|
# Trigger remediation agent through the authenticated lease-broker choke-point.
|
||||||
claude -p "Use Task tool to launch auto-remediation-agent for:
|
python3 "$(dirname "$0")/../lease-broker/launch-runtime.py" --runtime claude -- claude -p "Use Task tool to launch auto-remediation-agent for:
|
||||||
- Remediation Report: $IN_PROGRESS_DIR/$(basename "$REPORT_FILE")
|
- Remediation Report: $IN_PROGRESS_DIR/$(basename "$REPORT_FILE")
|
||||||
- Actions File: $ACTIONS_PATH
|
- Actions File: $ACTIONS_PATH
|
||||||
- Max Iterations: 5
|
- Max Iterations: 5
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests"
|
"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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@vitest/coverage-v8": "^2.0.0",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"tsx": "^4.0.0",
|
"tsx": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { registerFleetCommand } from './commands/fleet.js';
|
|||||||
import { registerMissionCommand } from './commands/mission.js';
|
import { registerMissionCommand } from './commands/mission.js';
|
||||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||||
import { registerRestoreCommand } from './commands/restore.js';
|
import { registerRestoreCommand } from './commands/restore.js';
|
||||||
|
import { registerSkillCommand } from './commands/skill.js';
|
||||||
// prdy is registered via launch.ts
|
// prdy is registered via launch.ts
|
||||||
import { registerLaunchCommands } from './commands/launch.js';
|
import { registerLaunchCommands } from './commands/launch.js';
|
||||||
import { registerAuthCommand } from './commands/auth.js';
|
import { registerAuthCommand } from './commands/auth.js';
|
||||||
@@ -67,7 +68,7 @@ Command Groups:
|
|||||||
|
|
||||||
Runtime: tui, login, sessions
|
Runtime: tui, login, sessions
|
||||||
Gateway: gateway
|
Gateway: gateway
|
||||||
Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, sync, upgrade, wizard, yolo
|
Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, skill, sync, upgrade, wizard, yolo
|
||||||
Platform: update
|
Platform: update
|
||||||
Runtimes: claude, codex, opencode, pi
|
Runtimes: claude, codex, opencode, pi
|
||||||
`,
|
`,
|
||||||
@@ -411,6 +412,10 @@ registerUninstallCommand(program);
|
|||||||
|
|
||||||
registerRestoreCommand(program);
|
registerRestoreCommand(program);
|
||||||
|
|
||||||
|
// ─── skill ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerSkillCommand(program);
|
||||||
|
|
||||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerTelemetryCommand(program);
|
registerTelemetryCommand(program);
|
||||||
@@ -471,6 +476,18 @@ program
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('✔ Framework re-seeded.');
|
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
|
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
|
||||||
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
|
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
|
||||||
const units = refreshActiveFleetUnits();
|
const units = refreshActiveFleetUnits();
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { mkdtempSync, mkdirSync, symlinkSync, rmSync, lstatSync, writeFileSync } from 'node:fs';
|
import {
|
||||||
|
lstatSync,
|
||||||
|
mkdtempSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
symlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
import { tmpdir, homedir } from 'node:os';
|
import { tmpdir, homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +22,7 @@ import {
|
|||||||
buildClaudexEnv,
|
buildClaudexEnv,
|
||||||
buildClaudexBanner,
|
buildClaudexBanner,
|
||||||
buildClaudexContractNote,
|
buildClaudexContractNote,
|
||||||
|
ensureClaudexMutatorGateSettings,
|
||||||
runClaudexProxyGate,
|
runClaudexProxyGate,
|
||||||
launchClaudex,
|
launchClaudex,
|
||||||
type ClaudexHarnessAdapter,
|
type ClaudexHarnessAdapter,
|
||||||
@@ -36,12 +45,20 @@ function makeReport(overrides: Partial<PreflightReport> = {}): PreflightReport {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function okAdapter(overrides: Partial<ClaudexHarnessAdapter> = {}): ClaudexHarnessAdapter {
|
type TestAdapterOverrides = Partial<ClaudexHarnessAdapter> & {
|
||||||
|
exec?: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function okAdapter(overrides: TestAdapterOverrides = {}): ClaudexHarnessAdapter {
|
||||||
|
const { exec, ...adapterOverrides } = overrides;
|
||||||
return {
|
return {
|
||||||
harnessPreflight: () => {},
|
harnessPreflight: () => {},
|
||||||
composePrompt: () => '# Composed Claude contract',
|
composePrompt: () => '# Composed Claude contract',
|
||||||
exec: () => {},
|
execLeaseGated:
|
||||||
...overrides,
|
adapterOverrides.execLeaseGated ??
|
||||||
|
((args, env, dangerous) =>
|
||||||
|
exec?.('claude', dangerous ? ['--dangerously-skip-permissions', ...args] : args, env)),
|
||||||
|
...adapterOverrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,11 +578,51 @@ describe('runClaudexProxyGate', () => {
|
|||||||
|
|
||||||
// ─── launch orchestration (fail-closed ordering) ──────────────────────────────
|
// ─── launch orchestration (fail-closed ordering) ──────────────────────────────
|
||||||
|
|
||||||
|
describe('ensureClaudexMutatorGateSettings', () => {
|
||||||
|
it('does not accept a lookalike hook and preserves isolated settings', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'claudex-gate-settings-'));
|
||||||
|
try {
|
||||||
|
writeFileSync(
|
||||||
|
join(root, 'settings.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
preserved: true,
|
||||||
|
hooks: {
|
||||||
|
PreToolUse: [
|
||||||
|
{
|
||||||
|
matcher: '.*',
|
||||||
|
hooks: [{ type: 'command', command: 'echo lease-broker/mutator-gate.py' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
ensureClaudexMutatorGateSettings(root);
|
||||||
|
|
||||||
|
const settings = JSON.parse(readFileSync(join(root, 'settings.json'), 'utf8')) as {
|
||||||
|
preserved: boolean;
|
||||||
|
hooks: { PreToolUse: Array<{ hooks: Array<{ command: string }> }> };
|
||||||
|
};
|
||||||
|
const commands = settings.hooks.PreToolUse.flatMap((entry) =>
|
||||||
|
entry.hooks.map((hook) => hook.command),
|
||||||
|
);
|
||||||
|
expect(settings.preserved).toBe(true);
|
||||||
|
expect(commands).toContain(
|
||||||
|
'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude',
|
||||||
|
);
|
||||||
|
expect(lstatSync(join(root, 'settings.json')).mode & 0o777).toBe(0o600);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('launchClaudex', () => {
|
describe('launchClaudex', () => {
|
||||||
const baseDeps = {
|
const baseDeps = {
|
||||||
baseEnv: {},
|
baseEnv: {},
|
||||||
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
|
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
|
||||||
resolveConfigDir: () => '/home/agent/.config/mosaic/claudex/home',
|
resolveConfigDir: () => '/home/agent/.config/mosaic/claudex/home',
|
||||||
|
prepareConfig: () => {},
|
||||||
log: () => {},
|
log: () => {},
|
||||||
errorLog: () => {},
|
errorLog: () => {},
|
||||||
fail: (() => {
|
fail: (() => {
|
||||||
|
|||||||
@@ -27,7 +27,14 @@
|
|||||||
* unit-testable without spawning Claude Code or touching a real config dir.
|
* unit-testable without spawning Claude Code or touching a real config dir.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { lstatSync, mkdirSync, realpathSync } from 'node:fs';
|
import {
|
||||||
|
chmodSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
realpathSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
import {
|
import {
|
||||||
@@ -278,6 +285,77 @@ export function buildClaudexEnv(
|
|||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CLAUDEX_MUTATOR_GATE_COMMAND =
|
||||||
|
'python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude';
|
||||||
|
|
||||||
|
const CLAUDEX_MUTATOR_GATE_HOOK = {
|
||||||
|
matcher: '.*',
|
||||||
|
hooks: [
|
||||||
|
{
|
||||||
|
type: 'command',
|
||||||
|
command: CLAUDEX_MUTATOR_GATE_COMMAND,
|
||||||
|
timeout: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the mandatory all-tools gate in Claudex's isolated config without
|
||||||
|
* discarding any existing isolated settings. Malformed settings and symlinked
|
||||||
|
* settings files fail closed rather than launching with uncertain hook state.
|
||||||
|
*/
|
||||||
|
export function ensureClaudexMutatorGateSettings(configDir: string): void {
|
||||||
|
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
||||||
|
const settingsPath = join(configDir, 'settings.json');
|
||||||
|
let settings: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (lstatSync(settingsPath).isSymbolicLink()) {
|
||||||
|
throw new Error('claudex: isolated settings.json must not be a symlink (fail closed).');
|
||||||
|
}
|
||||||
|
const parsed: unknown = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
||||||
|
if (!isRecord(parsed)) {
|
||||||
|
throw new Error('claudex: isolated settings.json must contain a JSON object (fail closed).');
|
||||||
|
}
|
||||||
|
settings = parsed;
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hooksValue = settings['hooks'];
|
||||||
|
if (hooksValue !== undefined && !isRecord(hooksValue)) {
|
||||||
|
throw new Error('claudex: isolated settings hooks must be an object (fail closed).');
|
||||||
|
}
|
||||||
|
const hooks = hooksValue ?? {};
|
||||||
|
const preToolUseValue = hooks['PreToolUse'];
|
||||||
|
if (preToolUseValue !== undefined && !Array.isArray(preToolUseValue)) {
|
||||||
|
throw new Error('claudex: isolated PreToolUse hooks must be an array (fail closed).');
|
||||||
|
}
|
||||||
|
const preToolUse = preToolUseValue ?? [];
|
||||||
|
const gatePresent = preToolUse.some(
|
||||||
|
(entry) =>
|
||||||
|
isRecord(entry) &&
|
||||||
|
entry['matcher'] === '.*' &&
|
||||||
|
Array.isArray(entry['hooks']) &&
|
||||||
|
entry['hooks'].some(
|
||||||
|
(hook) =>
|
||||||
|
isRecord(hook) &&
|
||||||
|
hook['type'] === 'command' &&
|
||||||
|
hook['command'] === CLAUDEX_MUTATOR_GATE_COMMAND,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!gatePresent) preToolUse.unshift(CLAUDEX_MUTATOR_GATE_HOOK);
|
||||||
|
|
||||||
|
hooks['PreToolUse'] = preToolUse;
|
||||||
|
settings['hooks'] = hooks;
|
||||||
|
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
|
||||||
|
chmodSync(settingsPath, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
|
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
|
||||||
|
|
||||||
/** Console banner shown at launch. Contains no token material by construction. */
|
/** Console banner shown at launch. Contains no token material by construction. */
|
||||||
@@ -396,8 +474,8 @@ export interface ClaudexHarnessAdapter {
|
|||||||
harnessPreflight: () => void;
|
harnessPreflight: () => void;
|
||||||
/** Compose the full Claude runtime contract (== `composeContract('claude')`). */
|
/** Compose the full Claude runtime contract (== `composeContract('claude')`). */
|
||||||
composePrompt: () => string;
|
composePrompt: () => string;
|
||||||
/** Replace the current process with `claude` using the composed env. */
|
/** Register the Claude parent with the broker, then exec with the same PID. */
|
||||||
exec: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
|
execLeaseGated: (args: string[], env: NodeJS.ProcessEnv, dangerous: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LaunchClaudexDeps {
|
export interface LaunchClaudexDeps {
|
||||||
@@ -406,6 +484,7 @@ export interface LaunchClaudexDeps {
|
|||||||
resolveConfigDir?: () => string;
|
resolveConfigDir?: () => string;
|
||||||
models?: () => ClaudexModels;
|
models?: () => ClaudexModels;
|
||||||
buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv;
|
buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv;
|
||||||
|
prepareConfig?: (configDir: string) => void;
|
||||||
log?: (message: string) => void;
|
log?: (message: string) => void;
|
||||||
errorLog?: (message: string) => void;
|
errorLog?: (message: string) => void;
|
||||||
fail?: (code: number) => never;
|
fail?: (code: number) => never;
|
||||||
@@ -431,6 +510,7 @@ export async function launchClaudex(
|
|||||||
const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv));
|
const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv));
|
||||||
const models = deps.models ?? (() => resolveClaudexModels(baseEnv));
|
const models = deps.models ?? (() => resolveClaudexModels(baseEnv));
|
||||||
const buildEnv = deps.buildEnv ?? buildClaudexEnv;
|
const buildEnv = deps.buildEnv ?? buildClaudexEnv;
|
||||||
|
const prepareConfig = deps.prepareConfig ?? ensureClaudexMutatorGateSettings;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Harness readiness first (claude on PATH, mosaic home, sequential-thinking).
|
// Harness readiness first (claude on PATH, mosaic home, sequential-thinking).
|
||||||
@@ -447,14 +527,14 @@ export async function launchClaudex(
|
|||||||
// Compose the isolated launch env (guard throws → caught below, fail closed).
|
// Compose the isolated launch env (guard throws → caught below, fail closed).
|
||||||
const resolvedModels = models();
|
const resolvedModels = models();
|
||||||
const configDir = resolveConfigDir();
|
const configDir = resolveConfigDir();
|
||||||
|
prepareConfig(configDir);
|
||||||
const env = buildEnv(baseEnv, { configDir, models: resolvedModels });
|
const env = buildEnv(baseEnv, { configDir, models: resolvedModels });
|
||||||
const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`;
|
const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`;
|
||||||
|
|
||||||
log(buildClaudexBanner(resolvedModels));
|
log(buildClaudexBanner(resolvedModels));
|
||||||
|
|
||||||
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
const cliArgs = ['--append-system-prompt', prompt, ...args];
|
||||||
cliArgs.push('--append-system-prompt', prompt, ...args);
|
adapter.execLeaseGated(cliArgs, env, yolo);
|
||||||
adapter.exec('claude', cliArgs, env);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorLog(
|
errorLog(
|
||||||
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,
|
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
|||||||
896
packages/mosaic/src/commands/fleet-regen-command.spec.ts
Normal file
896
packages/mosaic/src/commands/fleet-regen-command.spec.ts
Normal file
@@ -0,0 +1,896 @@
|
|||||||
|
import { chmod, mkdir, mkdtemp, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
|
||||||
|
import { executeFleetRegen, formatFleetRegenReport } from './fleet-regen-command.js';
|
||||||
|
import {
|
||||||
|
acquirePrivateRosterMutationLock,
|
||||||
|
projectRosterV2AgentGeneratedEnv,
|
||||||
|
} from '../fleet/fleet-reconciler.js';
|
||||||
|
import { applyPreparedGeneratedAgentEnvironmentProjection } from '../fleet/generated-env-boundary.js';
|
||||||
|
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
||||||
|
|
||||||
|
// A two-agent roster-v2 SSOT. `mosaic fleet regen` must rebuild each agent's
|
||||||
|
// `fleet/agents/<name>.env.generated` projection from exactly this source and
|
||||||
|
// nothing else — deterministically, without ever touching agent lifecycle.
|
||||||
|
const rosterYaml = `
|
||||||
|
version: 2
|
||||||
|
generation: 7
|
||||||
|
transport: tmux
|
||||||
|
tmux:
|
||||||
|
socket_name: mosaic-fleet
|
||||||
|
holder_session: _holder
|
||||||
|
defaults:
|
||||||
|
working_directory: /srv/mosaic
|
||||||
|
runtime: pi
|
||||||
|
runtimes:
|
||||||
|
pi:
|
||||||
|
reset_command: /new
|
||||||
|
agents:
|
||||||
|
- name: coder0
|
||||||
|
alias: Coder 0
|
||||||
|
class: code
|
||||||
|
runtime: pi
|
||||||
|
provider: openai
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
reasoning: high
|
||||||
|
tool_policy: code
|
||||||
|
working_directory: /srv/mosaic
|
||||||
|
persistent_persona: false
|
||||||
|
reset_between_tasks: true
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
desired_state: stopped
|
||||||
|
launch:
|
||||||
|
yolo: true
|
||||||
|
- name: coder1
|
||||||
|
alias: Coder 1
|
||||||
|
class: code
|
||||||
|
runtime: pi
|
||||||
|
provider: openai
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
reasoning: medium
|
||||||
|
tool_policy: code
|
||||||
|
working_directory: /srv/other
|
||||||
|
persistent_persona: false
|
||||||
|
reset_between_tasks: true
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
desired_state: stopped
|
||||||
|
launch:
|
||||||
|
yolo: true
|
||||||
|
`;
|
||||||
|
|
||||||
|
let cleanup: string | undefined;
|
||||||
|
|
||||||
|
afterEach(async (): Promise<void> => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.exitCode = undefined;
|
||||||
|
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||||
|
cleanup = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fleetHome(withRoster = rosterYaml): Promise<string> {
|
||||||
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-regen-command-'));
|
||||||
|
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
||||||
|
await mkdir(join(cleanup, directory), { recursive: true, mode: 0o700 });
|
||||||
|
await chmod(join(cleanup, directory), 0o700);
|
||||||
|
}
|
||||||
|
await chmod(cleanup, 0o700);
|
||||||
|
await writeFile(join(cleanup, 'fleet', 'roster.yaml'), withRoster, { mode: 0o600 });
|
||||||
|
// Semantic roster validation (matching `fleet reconcile`) resolves each agent
|
||||||
|
// class to a persona; seed the classes the fixtures reference.
|
||||||
|
for (const klass of ['code', 'merge-gate']) {
|
||||||
|
await writeFile(
|
||||||
|
join(cleanup, 'fleet', 'roles', `${klass}.md`),
|
||||||
|
`\`class: ${klass}\`\n\n# ${klass} persona\n`,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A runner spy that records EVERY invocation. `regen` is projection-only and
|
||||||
|
* must never issue a lifecycle/restart call, so a non-empty call log is a
|
||||||
|
* hard failure — this is the load-bearing "never restarts" gate.
|
||||||
|
*/
|
||||||
|
function recordingRunner(
|
||||||
|
calls: string[][],
|
||||||
|
): (command: string, args: string[]) => Promise<CommandResult> {
|
||||||
|
return async (command: string, args: string[]): Promise<CommandResult> => {
|
||||||
|
calls.push([command, ...args]);
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function program(mosaicHome: string, runner: FleetCommandDeps['runner']): Command {
|
||||||
|
const result = new Command();
|
||||||
|
result.exitOverride();
|
||||||
|
registerFleetCommand(result, { mosaicHome, runner });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capture(): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||||
|
lines.push(value);
|
||||||
|
});
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await stat(path);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('projectRosterV2AgentGeneratedEnv', (): void => {
|
||||||
|
it('maps a roster-v2 agent to exactly the eight generated projection keys', (): void => {
|
||||||
|
const roster = parseRosterV2(rosterYaml, 'yaml');
|
||||||
|
const agent = roster.agents.find((candidate) => candidate.name === 'coder0');
|
||||||
|
expect(agent).toBeDefined();
|
||||||
|
const values = projectRosterV2AgentGeneratedEnv(roster, agent!);
|
||||||
|
expect(values).toEqual({
|
||||||
|
MOSAIC_AGENT_NAME: 'coder0',
|
||||||
|
MOSAIC_AGENT_CLASS: 'code',
|
||||||
|
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||||
|
MOSAIC_AGENT_MODEL: 'gpt-5.6-sol',
|
||||||
|
MOSAIC_AGENT_REASONING: 'high',
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY: 'code',
|
||||||
|
MOSAIC_AGENT_WORKDIR: '/srv/mosaic',
|
||||||
|
MOSAIC_TMUX_SOCKET: 'mosaic-fleet',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mosaic fleet regen', (): void => {
|
||||||
|
it('reports a missing canonical roster with the shared initialization hint', async (): Promise<void> => {
|
||||||
|
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-regen-missing-roster-'));
|
||||||
|
cleanup = home;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program(home, recordingRunner([])).parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']),
|
||||||
|
).rejects.toThrow(
|
||||||
|
`No fleet roster found at ${join(home, 'fleet', 'roster.yaml')}. Run \`mosaic fleet init\``,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is dry-run by default: reports the plan and writes nothing', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await program(home, recordingRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = JSON.parse(lines.pop() ?? '{}');
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
mode: 'dry-run',
|
||||||
|
generation: 7,
|
||||||
|
agentCount: 2,
|
||||||
|
written: 0,
|
||||||
|
});
|
||||||
|
expect(result.agents.map((agent: { name: string }) => agent.name)).toEqual([
|
||||||
|
'coder0',
|
||||||
|
'coder1',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
result.agents.every((agent: { disposition: string }) => agent.disposition === 'create'),
|
||||||
|
).toBe(true);
|
||||||
|
// Nothing on disk.
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(false);
|
||||||
|
// No lifecycle/restart call — ever.
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards configured persona roots (rolesDir/overrideDir) from reconcileDeps into regen', async (): Promise<void> => {
|
||||||
|
// Codex r6: regen must resolve personas the SAME way reconcile does. If the
|
||||||
|
// top-level registration drops reconcileDeps.rolesDir/overrideDir, a deployment
|
||||||
|
// with custom persona roots gets reconcile ACCEPTING a roster while regen
|
||||||
|
// REJECTS it (validating against the wrong default `<home>/fleet/roles`),
|
||||||
|
// blocking the recovery command. Pin the wiring: seed personas ONLY under the
|
||||||
|
// custom roots, leave the default roles dir empty, require regen to succeed.
|
||||||
|
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-regen-wiring-'));
|
||||||
|
cleanup = home;
|
||||||
|
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
||||||
|
await mkdir(join(home, directory), { recursive: true, mode: 0o700 });
|
||||||
|
await chmod(join(home, directory), 0o700);
|
||||||
|
}
|
||||||
|
await chmod(home, 0o700);
|
||||||
|
await writeFile(join(home, 'fleet', 'roster.yaml'), rosterYaml, { mode: 0o600 });
|
||||||
|
// Personas live ONLY under the configured roots — the default `fleet/roles`
|
||||||
|
// stays empty, so broken wiring fails persona resolution.
|
||||||
|
const customRoles = join(home, 'custom-roles');
|
||||||
|
const customOverride = join(home, 'custom-roles.local');
|
||||||
|
await mkdir(customRoles, { recursive: true, mode: 0o700 });
|
||||||
|
await mkdir(customOverride, { recursive: true, mode: 0o700 });
|
||||||
|
for (const klass of ['code', 'merge-gate']) {
|
||||||
|
await writeFile(join(customRoles, `${klass}.md`), `\`class: ${klass}\`\n\n# ${klass}\n`, {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new Command();
|
||||||
|
command.exitOverride();
|
||||||
|
registerFleetCommand(command, {
|
||||||
|
mosaicHome: home,
|
||||||
|
runner: recordingRunner([]),
|
||||||
|
reconcileDeps: { rolesDir: customRoles, overrideDir: customOverride },
|
||||||
|
});
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await command.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']);
|
||||||
|
|
||||||
|
// Regen validated against the CONFIGURED roots → success. Broken wiring
|
||||||
|
// resolves against the empty default and fails (exitCode 1, no JSON result).
|
||||||
|
expect(process.exitCode).not.toBe(1);
|
||||||
|
const result = JSON.parse(lines.pop() ?? '{}');
|
||||||
|
expect(result).toMatchObject({ mode: 'dry-run', agentCount: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('--write rebuilds each generated projection from the roster SSOT', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await program(home, recordingRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = JSON.parse(lines.pop() ?? '{}');
|
||||||
|
expect(result).toMatchObject({ mode: 'write', written: 2, agentCount: 2 });
|
||||||
|
|
||||||
|
const coder0 = await readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8');
|
||||||
|
expect(coder0).toContain('MOSAIC_AGENT_NAME=coder0');
|
||||||
|
expect(coder0).toContain('MOSAIC_AGENT_WORKDIR=/srv/mosaic');
|
||||||
|
expect(coder0).toContain('MOSAIC_TMUX_SOCKET=mosaic-fleet');
|
||||||
|
const coder1 = await readFile(join(home, 'fleet', 'agents', 'coder1.env.generated'), 'utf8');
|
||||||
|
expect(coder1).toContain('MOSAIC_AGENT_NAME=coder1');
|
||||||
|
expect(coder1).toContain('MOSAIC_AGENT_WORKDIR=/srv/other');
|
||||||
|
// No lifecycle/restart call — ever.
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic and idempotent across repeated --write runs', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const path = join(home, 'fleet', 'agents', 'coder0.env.generated');
|
||||||
|
|
||||||
|
const cli = program(home, recordingRunner(calls));
|
||||||
|
capture();
|
||||||
|
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
|
||||||
|
const first = await readFile(path, 'utf8');
|
||||||
|
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
|
||||||
|
const second = await readFile(path, 'utf8');
|
||||||
|
|
||||||
|
expect(second).toBe(first);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports rebuild disposition once the generated projection already exists', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const cli = program(home, recordingRunner(calls));
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
|
||||||
|
lines.length = 0;
|
||||||
|
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']);
|
||||||
|
|
||||||
|
const result = JSON.parse(lines.pop() ?? '{}');
|
||||||
|
expect(
|
||||||
|
result.agents.every((agent: { disposition: string }) => agent.disposition === 'rebuild'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(result.written).toBe(0);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never issues a lifecycle/restart call in either mode', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const cli = program(home, recordingRunner(calls));
|
||||||
|
capture();
|
||||||
|
|
||||||
|
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']);
|
||||||
|
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
|
||||||
|
|
||||||
|
// Structural guarantee: regen has no path to systemctl/tmux at all.
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
const systemctlCalls = calls.filter(([command]) => command === 'systemctl');
|
||||||
|
expect(systemctlCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('human-readable --write output prints the do-not-restart recovery runbook', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await program(home, recordingRunner([])).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const output = lines.join('\n');
|
||||||
|
expect(output).toMatch(/do not restart|before.*restart/i);
|
||||||
|
expect(output).toMatch(/env\.generated/);
|
||||||
|
// The unit has no EnvironmentFile= directive, so the runbook must NOT tell the
|
||||||
|
// operator to verify one — verify the launcher/generated file instead.
|
||||||
|
expect(output).not.toContain('EnvironmentFile');
|
||||||
|
expect(output).toMatch(/systemctl --user cat mosaic-agent@<name>/);
|
||||||
|
expect(output).toMatch(/restart/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits paths and counts only — never the projected env body (secrev)', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await program(home, recordingRunner([])).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const output = lines.join('\n');
|
||||||
|
// Relative paths + counts are fine; the rendered projection body (KEY=value
|
||||||
|
// lines) must never be echoed to stdout.
|
||||||
|
expect(output).toContain('coder0.env.generated');
|
||||||
|
expect(output).not.toMatch(/^MOSAIC_AGENT_\w+=/m);
|
||||||
|
expect(output).not.toContain('MOSAIC_TMUX_SOCKET=mosaic-fleet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is projection-only: leaves legacy .env untouched and writes no .env.local/.env.quarantine', async (): Promise<void> => {
|
||||||
|
// Recovery contract: regen rebuilds ONLY <name>.env.generated. It must never
|
||||||
|
// relocate, quarantine, or unlink the operator-owned legacy .env surface — the
|
||||||
|
// full reconciler apply path does, so regen must NOT use it.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const legacyPath = join(home, 'fleet', 'agents', 'coder0.env');
|
||||||
|
await writeFile(legacyPath, 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', { mode: 0o600 });
|
||||||
|
capture();
|
||||||
|
|
||||||
|
await program(home, recordingRunner([])).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Generated projection rebuilt...
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(true);
|
||||||
|
// ...but the operator's legacy .env is preserved verbatim, and no local/quarantine
|
||||||
|
// files were fabricated from it.
|
||||||
|
expect(await readFile(legacyPath, 'utf8')).toBe('MOSAIC_RUNTIME_BIN=/usr/bin/pi\n');
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.local'))).toBe(false);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.quarantine'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepares every agent before writing any: a later prepare failure leaves nothing written', async (): Promise<void> => {
|
||||||
|
// Fail-closed across agents. Pre-seed coder1's projection with world-readable
|
||||||
|
// perms so its prepare rejects; coder0 (valid) must NOT be written because
|
||||||
|
// preparation is fully completed before the first apply.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const coder1Path = join(home, 'fleet', 'agents', 'coder1.env.generated');
|
||||||
|
await writeFile(coder1Path, 'MOSAIC_AGENT_NAME=coder1\n', { mode: 0o644 });
|
||||||
|
const calls: string[][] = [];
|
||||||
|
capture();
|
||||||
|
const errors: string[] = [];
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||||
|
errors.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await program(home, recordingRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(errors.join('')).toMatch(/regen failed/i);
|
||||||
|
// coder0 is valid but must remain unwritten — no partial rebuild.
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed on a semantically invalid roster (protected-class tool-policy mismatch)', async (): Promise<void> => {
|
||||||
|
// A hand-edited roster giving a protected class a weaker tool_policy is rejected
|
||||||
|
// by reconcile/plan/verify; regen must enforce the SAME gate, not silently
|
||||||
|
// project the downgraded policy into <name>.env.generated.
|
||||||
|
const home = await fleetHome(`
|
||||||
|
version: 2
|
||||||
|
generation: 3
|
||||||
|
transport: tmux
|
||||||
|
tmux:
|
||||||
|
socket_name: mosaic-fleet
|
||||||
|
holder_session: _holder
|
||||||
|
defaults:
|
||||||
|
working_directory: /srv/mosaic
|
||||||
|
runtime: pi
|
||||||
|
runtimes:
|
||||||
|
pi:
|
||||||
|
reset_command: /new
|
||||||
|
agents:
|
||||||
|
- name: gate0
|
||||||
|
alias: Gate 0
|
||||||
|
class: merge-gate
|
||||||
|
runtime: pi
|
||||||
|
provider: openai
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
reasoning: high
|
||||||
|
tool_policy: code
|
||||||
|
working_directory: /srv/mosaic
|
||||||
|
persistent_persona: false
|
||||||
|
reset_between_tasks: true
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
desired_state: stopped
|
||||||
|
launch:
|
||||||
|
yolo: true
|
||||||
|
`);
|
||||||
|
const calls: string[][] = [];
|
||||||
|
capture();
|
||||||
|
const errors: string[] = [];
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||||
|
errors.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await program(home, recordingRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(errors.join('')).toMatch(/regen failed/i);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'gate0.env.generated'))).toBe(false);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to write while a concurrent reconcile holds the mutation lock', async (): Promise<void> => {
|
||||||
|
// regen --write mutates the same projections as reconcile; it must take the
|
||||||
|
// shared reconcile lock so a concurrent reconcile cannot race a stale write.
|
||||||
|
const home = await fleetHome();
|
||||||
|
await writeFile(join(home, 'fleet', 'roster.yaml.reconcile.lock'), 'held\n', { mode: 0o600 });
|
||||||
|
const calls: string[][] = [];
|
||||||
|
capture();
|
||||||
|
const errors: string[] = [];
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||||
|
errors.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await program(home, recordingRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(errors.join('')).toMatch(/regen failed/i);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed (non-zero, no writes) on an invalid roster', async (): Promise<void> => {
|
||||||
|
// roster-v2 requires >= 1 agent; a malformed roster must abort regen without
|
||||||
|
// writing any projection and without touching lifecycle.
|
||||||
|
const home = await fleetHome('version: 2\ngeneration: 1\n');
|
||||||
|
const calls: string[][] = [];
|
||||||
|
capture();
|
||||||
|
const errors: string[] = [];
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||||
|
errors.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await program(home, recordingRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(errors.join('')).toMatch(/regen failed/i);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-canonical --roster path (canonical roster only)', async (): Promise<void> => {
|
||||||
|
// `fleet` exposes a global `--roster`; reconcile rejects a non-canonical value
|
||||||
|
// rather than silently reading the canonical roster. regen must enforce the
|
||||||
|
// SAME guard so `--roster /elsewhere regen --write` cannot mislead the operator.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const calls: string[][] = [];
|
||||||
|
capture();
|
||||||
|
const errors: string[] = [];
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||||
|
errors.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await program(home, recordingRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'--roster',
|
||||||
|
join(home, 'other', 'roster.yaml'),
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(errors.join('')).toMatch(/regen failed/i);
|
||||||
|
expect(errors.join('')).toMatch(/canonical roster/i);
|
||||||
|
// No projection written against a misdirected roster path.
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an incomplete rebuild when a later projection write fails mid-loop', async (): Promise<void> => {
|
||||||
|
// Fault injected on the SECOND apply: coder0 is already replaced on disk, so a
|
||||||
|
// bare throw would hide the partial state. regen must surface which agents were
|
||||||
|
// rebuilt and which failed, with a verify-before-restart recovery instruction.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const realApply = applyPreparedGeneratedAgentEnvironmentProjection;
|
||||||
|
const result = await executeFleetRegen(
|
||||||
|
{
|
||||||
|
runner: recordingRunner([]),
|
||||||
|
mosaicHome: home,
|
||||||
|
applyProjection: async (prepared): Promise<string> => {
|
||||||
|
if (prepared.generatedPath.endsWith('coder1.env.generated')) {
|
||||||
|
throw new Error('simulated ENOSPC on coder1');
|
||||||
|
}
|
||||||
|
return realApply(prepared);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ write: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// coder0 written before the fault; coder1 not.
|
||||||
|
expect(result.written).toBe(1);
|
||||||
|
expect(result.incomplete).toMatchObject({
|
||||||
|
code: 'projection-apply-failed',
|
||||||
|
failedAgent: 'coder1',
|
||||||
|
writtenAgents: ['coder0'],
|
||||||
|
});
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(true);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(false);
|
||||||
|
|
||||||
|
// The human report names the failed agent and directs a verify-before-restart
|
||||||
|
// recovery — not a bare success runbook.
|
||||||
|
const report = formatFleetRegenReport(result).join('\n');
|
||||||
|
expect(report).toMatch(/incomplete/i);
|
||||||
|
expect(report).toContain('coder1');
|
||||||
|
expect(report).toMatch(/verify|re-run|retry/i);
|
||||||
|
// Structural: still no lifecycle call anywhere on the failure path.
|
||||||
|
expect(report).not.toMatch(/systemctl --user restart mosaic-agent@<name>\n.*\n/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to write while agent CRUD holds the roster mutation lock', async (): Promise<void> => {
|
||||||
|
// regen --write overwrites the SAME generated projections that `fleet agent
|
||||||
|
// create/update/delete` writes under roster.yaml.mutation.lock. If regen only
|
||||||
|
// took the reconcile lock it could read a stale roster and overwrite a just-
|
||||||
|
// committed projection. It must also take the mutation lock and fail closed.
|
||||||
|
const home = await fleetHome();
|
||||||
|
await writeFile(join(home, 'fleet', 'roster.yaml.mutation.lock'), 'crud\n', { mode: 0o600 });
|
||||||
|
const calls: string[][] = [];
|
||||||
|
capture();
|
||||||
|
const errors: string[] = [];
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||||
|
errors.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await program(home, recordingRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(errors.join('')).toMatch(/regen failed/i);
|
||||||
|
// No projection written against a roster a concurrent CRUD is mutating.
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(false);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces BOTH the roster failure and a stale-lock warning on a double fault', async (): Promise<void> => {
|
||||||
|
// Worst case with nothing written: run() fails (invalid roster, fail-closed) AND
|
||||||
|
// a lock release faults. The operator must be told the lock may be stale, not
|
||||||
|
// just the roster error — otherwise the next regen/reconcile is silently blocked.
|
||||||
|
const home = await fleetHome('version: 2\ngeneration: 1\n');
|
||||||
|
const throwingRelease =
|
||||||
|
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||||
|
throw new Error('simulated lock unlink fault');
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
executeFleetRegen(
|
||||||
|
{
|
||||||
|
runner: recordingRunner([]),
|
||||||
|
mosaicHome: home,
|
||||||
|
acquireReconcileLock: throwingRelease,
|
||||||
|
acquireRosterMutationLock: throwingRelease,
|
||||||
|
},
|
||||||
|
{ write: true },
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/stale|inspect|lock/i);
|
||||||
|
// Nothing written on the fail-closed path.
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates a roster-mutation-lock release failure instead of silently keeping a stale lock', async (): Promise<void> => {
|
||||||
|
// Finding L: the real `acquirePrivateRosterMutationLock` release must SURFACE an
|
||||||
|
// unlink failure, not swallow it. If it swallowed (like the CRUD-internal
|
||||||
|
// acquireMutationLock does), a stale roster.yaml.mutation.lock left after a
|
||||||
|
// successful regen would block later regen/agent CRUD while the command reported
|
||||||
|
// success — and the finding-J stale-lock warning would never fire for this lock.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const release = await acquirePrivateRosterMutationLock(home)();
|
||||||
|
// Simulate the lock vanishing (or being unremovable) before release runs: the
|
||||||
|
// underlying unlink then faults. A swallowing release would resolve and hide it.
|
||||||
|
await rm(join(home, 'fleet', 'roster.yaml.mutation.lock'));
|
||||||
|
await expect(release()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a complete rebuild result and flags lock-cleanup when release faults', async (): Promise<void> => {
|
||||||
|
// A lock-release fault after a successful write must NOT discard the fact that
|
||||||
|
// projections are now live — the operator needs to know the write happened and
|
||||||
|
// that the shared lock may be stale, not just a bare "regen failed".
|
||||||
|
const home = await fleetHome();
|
||||||
|
const result = await executeFleetRegen(
|
||||||
|
{
|
||||||
|
runner: recordingRunner([]),
|
||||||
|
mosaicHome: home,
|
||||||
|
acquireReconcileLock:
|
||||||
|
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||||
|
throw new Error('simulated lock unlink fault');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ write: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.written).toBe(2);
|
||||||
|
expect(result.incomplete).toBeUndefined();
|
||||||
|
expect(result.cleanup).toMatchObject({
|
||||||
|
code: 'lock-cleanup-failed',
|
||||||
|
action: 'inspect-lock-before-retry',
|
||||||
|
});
|
||||||
|
// Projections are genuinely on disk despite the release fault.
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(true);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(true);
|
||||||
|
|
||||||
|
const report = formatFleetRegenReport(result).join('\n');
|
||||||
|
expect(report).toMatch(/lock/i);
|
||||||
|
expect(report).toMatch(/stale|inspect|clear/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a partial rebuild AND flags lock-cleanup when both apply and release fault', async (): Promise<void> => {
|
||||||
|
// Worst case: a mid-loop apply fault leaves a partial rebuild, then the release
|
||||||
|
// also faults. Both recovery states must survive so the operator sees exactly
|
||||||
|
// which projections are live and that the lock may be stale.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const realApply = applyPreparedGeneratedAgentEnvironmentProjection;
|
||||||
|
const result = await executeFleetRegen(
|
||||||
|
{
|
||||||
|
runner: recordingRunner([]),
|
||||||
|
mosaicHome: home,
|
||||||
|
applyProjection: async (prepared): Promise<string> => {
|
||||||
|
if (prepared.generatedPath.endsWith('coder1.env.generated')) {
|
||||||
|
throw new Error('simulated ENOSPC on coder1');
|
||||||
|
}
|
||||||
|
return realApply(prepared);
|
||||||
|
},
|
||||||
|
acquireReconcileLock:
|
||||||
|
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||||
|
throw new Error('simulated lock unlink fault');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ write: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.written).toBe(1);
|
||||||
|
expect(result.incomplete).toMatchObject({
|
||||||
|
code: 'projection-apply-failed',
|
||||||
|
failedAgent: 'coder1',
|
||||||
|
writtenAgents: ['coder0'],
|
||||||
|
});
|
||||||
|
expect(result.cleanup).toMatchObject({ code: 'lock-cleanup-failed' });
|
||||||
|
|
||||||
|
const report = formatFleetRegenReport(result).join('\n');
|
||||||
|
expect(report).toMatch(/incomplete/i);
|
||||||
|
expect(report).toMatch(/lock/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed without deleting a REPLACEMENT roster-mutation lock it no longer owns', async (): Promise<void> => {
|
||||||
|
// Finding M1 (replacement-lock race): after this invocation created its lock, the
|
||||||
|
// lock is cleared and re-created by ANOTHER writer (new inode + foreign token)
|
||||||
|
// BEFORE release runs. The release must PROVE ownership (device/inode + token) and
|
||||||
|
// refuse to unlink the stranger's live lock — otherwise it deletes it, a third
|
||||||
|
// writer enters concurrently, and mutual exclusion over the projections is broken.
|
||||||
|
// This pins the REACHABLE window (replaced-before-release); the residual sub-window
|
||||||
|
// between the final check and the path `unlink` is unreachable within the `wx`
|
||||||
|
// writer protocol and matches the merged reconcile lock — see the acquirer doc.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const lockPath = join(home, 'fleet', 'roster.yaml.mutation.lock');
|
||||||
|
const release = await acquirePrivateRosterMutationLock(home)();
|
||||||
|
// A different writer replaces the lock: same path, NEW inode, foreign content.
|
||||||
|
await rm(lockPath);
|
||||||
|
await writeFile(lockPath, 'a-different-writer\n', { mode: 0o600 });
|
||||||
|
const replacement = await stat(lockPath);
|
||||||
|
// Release must reject (it no longer owns this file) AND the diagnosis must name
|
||||||
|
// the MUTATION lock specifically — a fault on this shared helper must not be
|
||||||
|
// mislabeled as the reconcile lock (that would defeat the M3 accurate-diagnosis
|
||||||
|
// goal, since regen serializes on both) ...
|
||||||
|
await expect(release()).rejects.toThrow(/roster\.yaml\.mutation\.lock/);
|
||||||
|
// ... and MUST NOT have unlinked the stranger's live lock.
|
||||||
|
expect(await exists(lockPath)).toBe(true);
|
||||||
|
expect((await stat(lockPath)).ino).toBe(replacement.ino);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not strand the lock file when initialization fails after creating it', async (): Promise<void> => {
|
||||||
|
// Codex r4: if writeFile/stat/close/ownership-check throws AFTER the `wx` create
|
||||||
|
// succeeds, the just-created lock must NOT be left behind — a stranded lock would
|
||||||
|
// permanently block future regen AND agent CRUD (both contend on this path). The
|
||||||
|
// cleanup is dev/ino-guarded (never deletes a replacement) and best-effort (never
|
||||||
|
// masks the initialization error).
|
||||||
|
const home = await fleetHome();
|
||||||
|
const lockPath = join(home, 'fleet', 'roster.yaml.mutation.lock');
|
||||||
|
// Inject an opener that performs the REAL `wx` create (so the lock file really
|
||||||
|
// lands on disk) but whose handle faults on the token write — a post-create
|
||||||
|
// initialization failure.
|
||||||
|
const faultingOpen = (async (
|
||||||
|
path: Parameters<typeof open>[0],
|
||||||
|
flags: Parameters<typeof open>[1],
|
||||||
|
mode: Parameters<typeof open>[2],
|
||||||
|
) => {
|
||||||
|
const handle = await open(path, flags, mode);
|
||||||
|
return new Proxy(handle, {
|
||||||
|
get(target, prop, receiver): unknown {
|
||||||
|
if (prop === 'writeFile') {
|
||||||
|
return async (): Promise<never> => {
|
||||||
|
throw new Error('simulated ENOSPC on token write');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const value = Reflect.get(target, prop, receiver);
|
||||||
|
return typeof value === 'function' ? value.bind(target) : value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}) as typeof open;
|
||||||
|
|
||||||
|
await expect(acquirePrivateRosterMutationLock(home, faultingOpen)()).rejects.toThrow(
|
||||||
|
/mutation\.lock/,
|
||||||
|
);
|
||||||
|
// The lock we created must have been cleaned up — not stranded on disk.
|
||||||
|
expect(await exists(lockPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not strand the lock file when the post-create stat itself fails', async (): Promise<void> => {
|
||||||
|
// Codex r5: the narrower sub-path where `handle.stat()` ITSELF rejects right after
|
||||||
|
// the `wx` create succeeds (transient EIO/EBADF). device/inode identity is then
|
||||||
|
// unavailable, so the init-failure cleanup cannot prove ownership by dev/ino — yet
|
||||||
|
// the lock must STILL not be stranded, because a stranded lock permanently blocks
|
||||||
|
// future regen AND agent CRUD. The token we persisted before the failure uniquely
|
||||||
|
// identifies OUR lock, so cleanup falls back to matching it.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const lockPath = join(home, 'fleet', 'roster.yaml.mutation.lock');
|
||||||
|
// Real `wx` create (lock really lands on disk); the token write SUCCEEDS so our
|
||||||
|
// random token is persisted, then `stat()` faults — a post-create init failure.
|
||||||
|
const faultingStatOpen = (async (
|
||||||
|
path: Parameters<typeof open>[0],
|
||||||
|
flags: Parameters<typeof open>[1],
|
||||||
|
mode: Parameters<typeof open>[2],
|
||||||
|
) => {
|
||||||
|
const handle = await open(path, flags, mode);
|
||||||
|
return new Proxy(handle, {
|
||||||
|
get(target, prop, receiver): unknown {
|
||||||
|
if (prop === 'stat') {
|
||||||
|
return async (): Promise<never> => {
|
||||||
|
throw new Error('simulated EIO on post-create stat');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const value = Reflect.get(target, prop, receiver);
|
||||||
|
return typeof value === 'function' ? value.bind(target) : value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}) as typeof open;
|
||||||
|
|
||||||
|
await expect(acquirePrivateRosterMutationLock(home, faultingStatOpen)()).rejects.toThrow(
|
||||||
|
/mutation\.lock/,
|
||||||
|
);
|
||||||
|
// Even without dev/ino, the token-based fallback must have removed our lock.
|
||||||
|
expect(await exists(lockPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a lock-cleanup fault when unwinding after a later lock acquire fails', async (): Promise<void> => {
|
||||||
|
// Finding M2: the FIRST lock is taken, the SECOND lock's acquire throws, and
|
||||||
|
// unwinding the first lock's release ALSO faults. The acquire-unwind path must
|
||||||
|
// surface both — the already-taken lock may now be stale and silently block the
|
||||||
|
// next regen/reconcile — not just re-throw the acquire error and drop the fault.
|
||||||
|
const home = await fleetHome();
|
||||||
|
await expect(
|
||||||
|
executeFleetRegen(
|
||||||
|
{
|
||||||
|
runner: recordingRunner([]),
|
||||||
|
mosaicHome: home,
|
||||||
|
// Mutation lock (acquired first) succeeds, but its RELEASE faults on unwind.
|
||||||
|
acquireRosterMutationLock:
|
||||||
|
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||||
|
throw new Error('simulated mutation-lock unlink fault');
|
||||||
|
},
|
||||||
|
// Reconcile lock (acquired second) ACQUIRE fails, triggering the unwind.
|
||||||
|
acquireReconcileLock: () => async (): Promise<() => Promise<void>> => {
|
||||||
|
throw new Error('simulated reconcile acquire failure');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ write: true },
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/stale|inspect|lock/i);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names BOTH fleet locks in the stale-lock warning (cleanup can come from either)', async (): Promise<void> => {
|
||||||
|
// Finding M3: finding L made the roster MUTATION lock's release fault reachable, so
|
||||||
|
// the cleanup marker can now originate from either lock. The human report must not
|
||||||
|
// claim only the reconcile lock is stale — it must name both candidate lock files
|
||||||
|
// so the operator inspects the right one.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const result = await executeFleetRegen(
|
||||||
|
{
|
||||||
|
runner: recordingRunner([]),
|
||||||
|
mosaicHome: home,
|
||||||
|
// Fault the MUTATION lock's release specifically (reconcile lock is real).
|
||||||
|
acquireRosterMutationLock:
|
||||||
|
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||||
|
throw new Error('simulated mutation-lock unlink fault');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ write: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.written).toBe(2);
|
||||||
|
expect(result.cleanup).toMatchObject({ code: 'lock-cleanup-failed' });
|
||||||
|
const report = formatFleetRegenReport(result).join('\n');
|
||||||
|
expect(report).toContain('roster.yaml.mutation.lock');
|
||||||
|
expect(report).toContain('roster.yaml.reconcile.lock');
|
||||||
|
});
|
||||||
|
});
|
||||||
446
packages/mosaic/src/commands/fleet-regen-command.ts
Normal file
446
packages/mosaic/src/commands/fleet-regen-command.ts
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import { stat } from 'node:fs/promises';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join, relative, resolve } from 'node:path';
|
||||||
|
import type { Command } from 'commander';
|
||||||
|
import type { CommandRunner } from './fleet.js';
|
||||||
|
import {
|
||||||
|
applyPreparedGeneratedAgentEnvironmentProjection,
|
||||||
|
prepareGeneratedAgentEnvironmentProjection,
|
||||||
|
type PreparedGeneratedAgentEnvironmentProjection,
|
||||||
|
} from '../fleet/generated-env-boundary.js';
|
||||||
|
import {
|
||||||
|
acquirePrivateReconcileLock,
|
||||||
|
acquirePrivateRosterMutationLock,
|
||||||
|
projectRosterV2AgentGeneratedEnv,
|
||||||
|
} from '../fleet/fleet-reconciler.js';
|
||||||
|
import {
|
||||||
|
parseRosterV2,
|
||||||
|
validateRosterV2Semantics,
|
||||||
|
type FleetRosterV2,
|
||||||
|
} from '../fleet/roster-v2.js';
|
||||||
|
import { FleetRosterConfigurationError, readFleetRosterText } from '../fleet/fleet-roster-v1.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `mosaic fleet regen` — recovery-framed regeneration of the roster-derived
|
||||||
|
* agent env projections (`fleet/agents/<name>.env.generated`) from the roster
|
||||||
|
* SSOT. It is the recovery layer of #791: when a wiped/diverged operator surface
|
||||||
|
* has cost an agent its generated projection, regen rebuilds it deterministically
|
||||||
|
* from `roster.yaml` so the launcher can source the intended identity again.
|
||||||
|
*
|
||||||
|
* It is intentionally a THIN projection-only wrapper over the same mapping the
|
||||||
|
* reconciler apply path uses ({@link projectRosterV2AgentGeneratedEnv}), and it
|
||||||
|
* has NO code path to systemd lifecycle: regen never starts, stops, or restarts
|
||||||
|
* an agent. Recovery order forbids restart-before-verify, so the operator must
|
||||||
|
* verify each rebuilt projection resolves before restarting anything.
|
||||||
|
*/
|
||||||
|
export interface FleetRegenAgentPlan {
|
||||||
|
readonly name: string;
|
||||||
|
/** Generated-projection path, relative to the Mosaic home (never absolute in output). */
|
||||||
|
readonly path: string;
|
||||||
|
/** `create` when the projection is currently absent, `rebuild` when it already exists. */
|
||||||
|
readonly disposition: 'create' | 'rebuild';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Present ONLY when a `--write` projection apply failed partway through the
|
||||||
|
* fleet. Every prior agent was already validated and prepared, so `writtenAgents`
|
||||||
|
* are byte-complete on disk; `failedAgent` is where the write faulted. The
|
||||||
|
* command surfaces this instead of a bare throw so the operator can finish the
|
||||||
|
* rebuild and verify every projection before restarting any unit.
|
||||||
|
*/
|
||||||
|
export interface FleetRegenIncomplete {
|
||||||
|
readonly code: 'projection-apply-failed';
|
||||||
|
/** The agent whose generated-projection write faulted. */
|
||||||
|
readonly failedAgent: string;
|
||||||
|
/** Agents whose projections were fully written before the fault (in apply order). */
|
||||||
|
readonly writtenAgents: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetRegenResult {
|
||||||
|
readonly mode: 'dry-run' | 'write';
|
||||||
|
/** Roster path, relative to the Mosaic home. */
|
||||||
|
readonly rosterPath: string;
|
||||||
|
readonly generation: number;
|
||||||
|
readonly agentCount: number;
|
||||||
|
/** Count of projections written to disk (always 0 in dry-run). */
|
||||||
|
readonly written: number;
|
||||||
|
readonly agents: readonly FleetRegenAgentPlan[];
|
||||||
|
/** Set ONLY when a `--write` apply faulted mid-fleet — a partial rebuild. */
|
||||||
|
readonly incomplete?: FleetRegenIncomplete;
|
||||||
|
/**
|
||||||
|
* Set ONLY when a shared fleet lock could not be released after a write completed
|
||||||
|
* (or partially completed) — EITHER `roster.yaml.mutation.lock` or
|
||||||
|
* `roster.yaml.reconcile.lock`, since regen holds both. The projections in this
|
||||||
|
* result are real; the lock file may be stale and must be inspected before the
|
||||||
|
* next mutation. Independent of {@link incomplete} — both can be present at once.
|
||||||
|
*/
|
||||||
|
readonly cleanup?: {
|
||||||
|
readonly code: 'lock-cleanup-failed';
|
||||||
|
readonly action: 'inspect-lock-before-retry';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetRegenDeps {
|
||||||
|
/**
|
||||||
|
* Present ONLY so an accidental future lifecycle wiring is caught by the
|
||||||
|
* "never restarts" test — regen is contractually forbidden to invoke it.
|
||||||
|
*/
|
||||||
|
readonly runner: CommandRunner;
|
||||||
|
readonly mosaicHome?: string;
|
||||||
|
/** Persona roots for semantic roster validation (defaults mirror the reconciler). */
|
||||||
|
readonly rolesDir?: string;
|
||||||
|
readonly overrideDir?: string;
|
||||||
|
/** Test seam: canonical roster reader. Defaults to parse + semantic validation. */
|
||||||
|
readonly readRoster?: (rosterPath: string) => Promise<FleetRosterV2>;
|
||||||
|
/**
|
||||||
|
* Test seam: generated-only projection validator. Defaults to the audited
|
||||||
|
* boundary helper that validates ONLY `<name>.env.generated`.
|
||||||
|
*/
|
||||||
|
readonly prepareProjection?: typeof prepareGeneratedAgentEnvironmentProjection;
|
||||||
|
/** Test seam: generated-only projection writer. Defaults to the audited boundary helper. */
|
||||||
|
readonly applyProjection?: typeof applyPreparedGeneratedAgentEnvironmentProjection;
|
||||||
|
/**
|
||||||
|
* Test seam: acquire the shared reconcile lock before a `--write`. Defaults to
|
||||||
|
* the reconciler's private lock so regen and reconcile are mutually exclusive
|
||||||
|
* and a concurrent reconcile cannot race a stale projection write.
|
||||||
|
*/
|
||||||
|
readonly acquireReconcileLock?: (mosaicHome: string) => () => Promise<() => Promise<void>>;
|
||||||
|
/**
|
||||||
|
* Test seam: acquire the shared roster mutation lock before a `--write`.
|
||||||
|
* Defaults to the reconciler's hardened, ownership-proving acquirer for the
|
||||||
|
* same `fleet/roster.yaml.mutation.lock` path that CRUD contends on, so regen
|
||||||
|
* serializes against agent create/update/delete — both rewrite the same
|
||||||
|
* generated projections, so without this a concurrent CRUD could race a stale
|
||||||
|
* projection write.
|
||||||
|
*/
|
||||||
|
readonly acquireRosterMutationLock?: (mosaicHome: string) => () => Promise<() => Promise<void>>;
|
||||||
|
/** Test seam: existence probe for create/rebuild disposition. */
|
||||||
|
readonly fileExists?: (path: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FleetRegenOptions {
|
||||||
|
readonly write?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultMosaicHome(deps: FleetRegenDeps): string {
|
||||||
|
return deps.mosaicHome ?? process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultFileExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await stat(path);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projection-only regeneration core. Deterministic and side-effect-free in
|
||||||
|
* dry-run; in `--write` it rebuilds each generated projection through the
|
||||||
|
* audited generated-only boundary writer and NOTHING else — it never writes
|
||||||
|
* `.env.local`/`.env.quarantine`, never unlinks the legacy `.env`, and never
|
||||||
|
* touches `deps.runner`.
|
||||||
|
*
|
||||||
|
* Fail-closed by construction: the roster is validated (structural + semantic)
|
||||||
|
* and EVERY agent's projection is prepared before ANY is written, so a single
|
||||||
|
* invalid agent (or a semantically rejected roster) leaves the whole surface
|
||||||
|
* untouched. In `--write` mode the read-prepare-apply sequence runs under the
|
||||||
|
* shared reconcile lock so a concurrent reconcile cannot race a stale write.
|
||||||
|
*/
|
||||||
|
export async function executeFleetRegen(
|
||||||
|
deps: FleetRegenDeps,
|
||||||
|
options: FleetRegenOptions,
|
||||||
|
): Promise<FleetRegenResult> {
|
||||||
|
const mosaicHome = defaultMosaicHome(deps);
|
||||||
|
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||||
|
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||||
|
const readRoster = deps.readRoster ?? defaultReadRoster(deps, mosaicHome);
|
||||||
|
const prepare = deps.prepareProjection ?? prepareGeneratedAgentEnvironmentProjection;
|
||||||
|
const apply = deps.applyProjection ?? applyPreparedGeneratedAgentEnvironmentProjection;
|
||||||
|
const fileExists = deps.fileExists ?? defaultFileExists;
|
||||||
|
const acquireReconcileLock = deps.acquireReconcileLock ?? acquirePrivateReconcileLock;
|
||||||
|
const acquireRosterMutationLock =
|
||||||
|
deps.acquireRosterMutationLock ?? acquirePrivateRosterMutationLock;
|
||||||
|
const write = options.write === true;
|
||||||
|
|
||||||
|
const run = async (): Promise<FleetRegenResult> => {
|
||||||
|
const roster = await readRoster(rosterPath);
|
||||||
|
|
||||||
|
// Prepare (validate) every agent BEFORE any write, so a later failure never
|
||||||
|
// leaves an earlier projection partially rebuilt.
|
||||||
|
const prepared: (PreparedGeneratedAgentEnvironmentProjection & {
|
||||||
|
readonly name: string;
|
||||||
|
readonly disposition: 'create' | 'rebuild';
|
||||||
|
})[] = [];
|
||||||
|
for (const agent of roster.agents) {
|
||||||
|
const projection = await prepare({
|
||||||
|
mosaicHome,
|
||||||
|
agentEnvDir,
|
||||||
|
agentName: agent.name,
|
||||||
|
generated: projectRosterV2AgentGeneratedEnv(roster, agent),
|
||||||
|
});
|
||||||
|
const disposition = (await fileExists(projection.generatedPath)) ? 'rebuild' : 'create';
|
||||||
|
prepared.push({ ...projection, name: agent.name, disposition });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every projection is already validated (prepared); an apply here can only
|
||||||
|
// fault on the underlying I/O (ENOSPC, EIO, a race outside the lock). If a
|
||||||
|
// later write faults, earlier agents are already replaced on disk, so we
|
||||||
|
// stop and report the partial state rather than throwing a bare error that
|
||||||
|
// hides which projections are now live.
|
||||||
|
let written = 0;
|
||||||
|
let incomplete: FleetRegenIncomplete | undefined;
|
||||||
|
if (write) {
|
||||||
|
for (const projection of prepared) {
|
||||||
|
try {
|
||||||
|
await apply(projection);
|
||||||
|
} catch {
|
||||||
|
incomplete = {
|
||||||
|
code: 'projection-apply-failed',
|
||||||
|
failedAgent: projection.name,
|
||||||
|
writtenAgents: prepared.slice(0, written).map((entry) => entry.name),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
written += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: write ? 'write' : 'dry-run',
|
||||||
|
rosterPath: relative(mosaicHome, rosterPath),
|
||||||
|
generation: roster.generation,
|
||||||
|
agentCount: roster.agents.length,
|
||||||
|
written,
|
||||||
|
agents: prepared.map((projection) => ({
|
||||||
|
name: projection.name,
|
||||||
|
path: relative(mosaicHome, projection.generatedPath),
|
||||||
|
disposition: projection.disposition,
|
||||||
|
})),
|
||||||
|
...(incomplete ? { incomplete } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dry-run is preview-only and never mutates, so it stays lock-free (usable even
|
||||||
|
// while a reconcile or CRUD holds a lock). A `--write` must serialize against
|
||||||
|
// BOTH roster writers of the same generated projections: agent CRUD (roster
|
||||||
|
// mutation lock) and reconcile (reconcile lock). Acquire in a fixed order — the
|
||||||
|
// locks are non-blocking (they throw on contention rather than wait), so no
|
||||||
|
// deadlock is possible — and fail closed if either is already held.
|
||||||
|
if (!write) return run();
|
||||||
|
|
||||||
|
const releases: (() => Promise<void>)[] = [];
|
||||||
|
try {
|
||||||
|
releases.push(await acquireRosterMutationLock(mosaicHome)());
|
||||||
|
releases.push(await acquireReconcileLock(mosaicHome)());
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// A later acquire failed; unwind any lock already taken (reverse order). If
|
||||||
|
// that unwind ALSO faults, surface both — the already-taken lock may now be
|
||||||
|
// stale and silently block the next regen/reconcile — rather than dropping it.
|
||||||
|
const releaseFault = await releaseFleetLocks(releases);
|
||||||
|
throw augmentWithLockCleanupFault(error, releaseFault);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: FleetRegenResult | undefined;
|
||||||
|
let primaryError: unknown;
|
||||||
|
try {
|
||||||
|
result = await run();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
primaryError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release both locks (reverse acquire order), continuing past a fault so neither
|
||||||
|
// leaks. A release fault must never discard a completed (or partial) rebuild —
|
||||||
|
// the projections are already on disk — and must not be silently hidden even
|
||||||
|
// when the run itself failed with nothing written.
|
||||||
|
const releaseFault = await releaseFleetLocks(releases);
|
||||||
|
if (result) {
|
||||||
|
return releaseFault === undefined
|
||||||
|
? result
|
||||||
|
: {
|
||||||
|
...result,
|
||||||
|
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// result is undefined ⇒ run() threw ⇒ primaryError is set.
|
||||||
|
throw augmentWithLockCleanupFault(primaryError, releaseFault);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases held locks in reverse acquire order, continuing past a fault so a
|
||||||
|
* single failed release never leaks the others. Returns the first fault seen (or
|
||||||
|
* undefined when every release succeeded).
|
||||||
|
*/
|
||||||
|
async function releaseFleetLocks(releases: readonly (() => Promise<void>)[]): Promise<unknown> {
|
||||||
|
let firstFault: unknown;
|
||||||
|
for (const release of [...releases].reverse()) {
|
||||||
|
try {
|
||||||
|
await release();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (firstFault === undefined) firstFault = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstFault;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a run failed with nothing written AND lock cleanup also faulted, surface
|
||||||
|
* both: the operator needs to know the lock may be stale (else the next
|
||||||
|
* regen/reconcile is silently blocked), not just the original run error.
|
||||||
|
*/
|
||||||
|
function augmentWithLockCleanupFault(primaryError: unknown, releaseFault: unknown): unknown {
|
||||||
|
if (releaseFault === undefined) return primaryError;
|
||||||
|
const base = primaryError instanceof Error ? primaryError.message : String(primaryError);
|
||||||
|
return new Error(
|
||||||
|
`${base} (additionally, a fleet lock could not be released — ` +
|
||||||
|
'fleet/roster.yaml.mutation.lock or roster.yaml.reconcile.lock may be stale; ' +
|
||||||
|
'inspect and clear it before retry)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultReadRoster(
|
||||||
|
deps: FleetRegenDeps,
|
||||||
|
mosaicHome: string,
|
||||||
|
): (rosterPath: string) => Promise<FleetRosterV2> {
|
||||||
|
return async (rosterPath: string): Promise<FleetRosterV2> => {
|
||||||
|
const roster = parseRosterV2(await readFleetRosterText(rosterPath), 'yaml');
|
||||||
|
// Enforce the SAME semantic gate as reconcile/plan/verify (persona resolution
|
||||||
|
// + protected-class tool-policy match) so regen cannot project a roster the
|
||||||
|
// rest of the fleet surface would reject.
|
||||||
|
await validateRosterV2Semantics(roster, {
|
||||||
|
rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
|
||||||
|
overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
|
||||||
|
});
|
||||||
|
return roster;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable report — paths and counts ONLY. The rendered projection body
|
||||||
|
* (KEY=value lines) is never echoed (secrev). In `--write` mode it prints the
|
||||||
|
* recovery runbook: verify each projection resolves BEFORE any restart.
|
||||||
|
*/
|
||||||
|
export function formatFleetRegenReport(result: FleetRegenResult): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const header =
|
||||||
|
result.mode === 'dry-run'
|
||||||
|
? 'mosaic fleet regen — dry run (no changes written)'
|
||||||
|
: 'mosaic fleet regen — wrote roster-derived projections';
|
||||||
|
lines.push(header);
|
||||||
|
lines.push(
|
||||||
|
`roster: ${result.rosterPath} (generation ${result.generation}, ${result.agentCount} agent(s))`,
|
||||||
|
);
|
||||||
|
for (const agent of result.agents) {
|
||||||
|
lines.push(` ${agent.name} ${agent.path} [${agent.disposition}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.mode === 'dry-run') {
|
||||||
|
lines.push(
|
||||||
|
`Plan: would write ${result.agentCount} generated projection(s). ` +
|
||||||
|
`Re-run with --write to apply. regen never restarts agents.`,
|
||||||
|
);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.incomplete) {
|
||||||
|
const { failedAgent, writtenAgents } = result.incomplete;
|
||||||
|
lines.push(
|
||||||
|
`INCOMPLETE: rebuild stopped at agent ${failedAgent} — its projection write faulted.`,
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
`Rebuilt before the fault: ${writtenAgents.length} projection(s)` +
|
||||||
|
(writtenAgents.length > 0 ? ` (${writtenAgents.join(', ')})` : '') +
|
||||||
|
`; ${result.agentCount - writtenAgents.length} not written.`,
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
'Recover: re-run `mosaic fleet regen --write` to finish, then VERIFY every ' +
|
||||||
|
"agent's fleet/agents/<name>.env.generated resolves BEFORE restarting any " +
|
||||||
|
'unit. regen never restarts agents.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
lines.push(`Wrote ${result.written} generated projection(s).`);
|
||||||
|
lines.push('Next steps — DO NOT restart agents before verifying:');
|
||||||
|
lines.push(
|
||||||
|
" 1. Confirm each agent's fleet/agents/<name>.env.generated exists and carries " +
|
||||||
|
'the intended MOSAIC_AGENT_* values.',
|
||||||
|
);
|
||||||
|
// The unit sets NO EnvironmentFile= — it launches from a minimal env and the
|
||||||
|
// launcher (start-agent-session.sh) sources .env.generated itself. Verify the
|
||||||
|
// launcher path, not a nonexistent EnvironmentFile property.
|
||||||
|
lines.push(
|
||||||
|
' 2. Confirm the unit launches from it: `systemctl --user cat mosaic-agent@<name>` ' +
|
||||||
|
'shows ExecStart running start-agent-session.sh, which reads .env.generated.',
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
' 3. Only then restart one unit at a time: systemctl --user restart mosaic-agent@<name>.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.cleanup) {
|
||||||
|
lines.push(
|
||||||
|
'WARNING: a fleet lock could not be released — ' +
|
||||||
|
'fleet/roster.yaml.mutation.lock or fleet/roster.yaml.reconcile.lock may be ' +
|
||||||
|
'stale; inspect and clear it before the next reconcile or regen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRegenMosaicHome(fleetCommand: Command, deps: FleetRegenDeps): string {
|
||||||
|
const options = fleetCommand.optsWithGlobals<{ mosaicHome?: string }>();
|
||||||
|
return options.mosaicHome ?? defaultMosaicHome(deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* regen reads the roster SSOT at `<mosaicHome>/fleet/roster.yaml` and ignores the
|
||||||
|
* `fleet --roster <path>` global. If an operator passes a non-canonical `--roster`
|
||||||
|
* they must NOT be silently served the canonical file — mirror reconcile's guard
|
||||||
|
* and reject, so `mosaic fleet --roster /elsewhere regen --write` fails closed.
|
||||||
|
*/
|
||||||
|
function assertRegenCanonicalRoster(fleetCommand: Command, mosaicHome: string): void {
|
||||||
|
const options = fleetCommand.optsWithGlobals<{ roster?: string }>();
|
||||||
|
const canonical = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||||
|
if (options.roster !== undefined && resolve(options.roster) !== resolve(canonical)) {
|
||||||
|
throw new Error(
|
||||||
|
'Roster-v2 regeneration requires the canonical roster path (fleet/roster.yaml).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registers the recovery-framed `mosaic fleet regen` command. */
|
||||||
|
export function registerFleetRegenCommand(fleetCommand: Command, deps: FleetRegenDeps): void {
|
||||||
|
fleetCommand
|
||||||
|
.command('regen')
|
||||||
|
.description(
|
||||||
|
'Regenerate roster-derived agent env projections (dry-run default, --write to apply; never restarts)',
|
||||||
|
)
|
||||||
|
.option('--write', 'Write the regenerated projections to disk (default: dry-run)')
|
||||||
|
.option('--json', 'Emit the plan/result as JSON')
|
||||||
|
.action(async (opts: { write?: boolean; json?: boolean }): Promise<void> => {
|
||||||
|
const mosaicHome = resolveRegenMosaicHome(fleetCommand, deps);
|
||||||
|
try {
|
||||||
|
assertRegenCanonicalRoster(fleetCommand, mosaicHome);
|
||||||
|
const result = await executeFleetRegen(
|
||||||
|
{ ...deps, mosaicHome },
|
||||||
|
{ write: opts.write === true },
|
||||||
|
);
|
||||||
|
if (opts.json === true) {
|
||||||
|
console.log(JSON.stringify(result));
|
||||||
|
} else {
|
||||||
|
for (const line of formatFleetRegenReport(result)) console.log(line);
|
||||||
|
}
|
||||||
|
// A partial rebuild or a stale-lock cleanup state is a non-zero outcome:
|
||||||
|
// the operator must finish/verify (and clear the lock) before restarting.
|
||||||
|
if (result.incomplete || result.cleanup) process.exitCode = 1;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof FleetRosterConfigurationError) {
|
||||||
|
fleetCommand.error(error.message, { code: 'fleet.roster', exitCode: 1 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.exitCode = 1;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
process.stderr.write(`mosaic fleet regen failed: ${message}\n`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
type SleepFn,
|
type SleepFn,
|
||||||
} from './fleet.js';
|
} from './fleet.js';
|
||||||
import { registerAgentCommand } from './agent.js';
|
import { registerAgentCommand } from './agent.js';
|
||||||
|
import { parseFleetRosterDocument, readFleetRosterText } from '../fleet/fleet-roster-v1.js';
|
||||||
|
|
||||||
function buildProgram(): Command {
|
function buildProgram(): Command {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -97,6 +98,7 @@ describe('registerFleetCommand', () => {
|
|||||||
'provision',
|
'provision',
|
||||||
'ps',
|
'ps',
|
||||||
'reconcile',
|
'reconcile',
|
||||||
|
'regen',
|
||||||
'remove',
|
'remove',
|
||||||
'restart',
|
'restart',
|
||||||
'start',
|
'start',
|
||||||
@@ -165,6 +167,91 @@ describe('registerFleetCommand', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fleet roster configuration failures', () => {
|
||||||
|
it('reports a missing roster with an actionable initialization hint', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
try {
|
||||||
|
const program = buildProgram();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program.parseAsync(['node', 'mosaic', 'fleet', '--mosaic-home', home, 'ps']),
|
||||||
|
).rejects.toThrow(
|
||||||
|
`No fleet roster found at ${join(home, 'fleet', 'roster.json')}. Run \`mosaic fleet init\``,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a malformed roster with the path and repair hint', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
const rosterPath = join(home, 'fleet', 'roster.json');
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(rosterPath), { recursive: true });
|
||||||
|
await writeFile(rosterPath, '{not valid json');
|
||||||
|
|
||||||
|
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||||||
|
`Fleet roster at ${rosterPath} is invalid. Fix the file or run \`mosaic fleet init --force\``,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an unreadable roster without exposing the filesystem error', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
try {
|
||||||
|
await expect(readFleetRosterText(home)).rejects.toThrow(
|
||||||
|
`Could not read fleet roster at ${home}. Check the file exists and is readable.`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a malformed roster while selecting the v1/v2 command path', () => {
|
||||||
|
expect(() => parseFleetRosterDocument('version: [', '/srv/mosaic/fleet/roster.yaml')).toThrow(
|
||||||
|
'Fleet roster at /srv/mosaic/fleet/roster.yaml is invalid. Fix the file or run `mosaic fleet init --force`.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a semantically invalid v1 roster as an actionable nonzero command error', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||||
|
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(rosterPath), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
rosterPath,
|
||||||
|
[
|
||||||
|
'version: 1',
|
||||||
|
'transport: tmux',
|
||||||
|
'agents:',
|
||||||
|
' - name: canary-pi',
|
||||||
|
' runtime: pi',
|
||||||
|
' - name: canary-pi',
|
||||||
|
' runtime: codex',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
buildProgram().parseAsync(['node', 'mosaic', 'fleet', '--mosaic-home', home, 'ps']),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'fleet.roster',
|
||||||
|
exitCode: 1,
|
||||||
|
message: 'Fleet roster has duplicate agent name: canary-pi.',
|
||||||
|
});
|
||||||
|
expect(stderrSpy.mock.calls.flat().join('')).toContain(
|
||||||
|
'Fleet roster has duplicate agent name: canary-pi.',
|
||||||
|
);
|
||||||
|
expect(stderrSpy.mock.calls.flat().join('')).not.toMatch(/\n\s+at\s/);
|
||||||
|
} finally {
|
||||||
|
stderrSpy.mockRestore();
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('fleet roster parsing', () => {
|
describe('fleet roster parsing', () => {
|
||||||
let cleanup: string | undefined;
|
let cleanup: string | undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ import * as readline from 'node:readline';
|
|||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
import {
|
import {
|
||||||
|
FleetRosterConfigurationError,
|
||||||
getRosterAgent,
|
getRosterAgent,
|
||||||
loadFleetRoster,
|
loadFleetRoster,
|
||||||
|
parseFleetRosterDocument,
|
||||||
|
readFleetRosterText,
|
||||||
resolveInstalledFleetRosterPath,
|
resolveInstalledFleetRosterPath,
|
||||||
type FleetAgent,
|
type FleetAgent,
|
||||||
type FleetRoster,
|
type FleetRoster,
|
||||||
@@ -44,6 +47,7 @@ import {
|
|||||||
registerFleetReconcilerCommands,
|
registerFleetReconcilerCommands,
|
||||||
type FleetReconcilerCommandDeps,
|
type FleetReconcilerCommandDeps,
|
||||||
} from './fleet-reconciler-command.js';
|
} from './fleet-reconciler-command.js';
|
||||||
|
import { registerFleetRegenCommand } from './fleet-regen-command.js';
|
||||||
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
|
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
|
||||||
import {
|
import {
|
||||||
applyPreparedAgentEnvironmentProjection,
|
applyPreparedAgentEnvironmentProjection,
|
||||||
@@ -1912,7 +1916,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
||||||
const roster = await loadFleetRoster(rosterPath);
|
const roster = await loadRosterAtPath(cmd, rosterPath);
|
||||||
|
|
||||||
const newAgent: FleetAgent = {
|
const newAgent: FleetAgent = {
|
||||||
name,
|
name,
|
||||||
@@ -1972,7 +1976,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
||||||
const roster = await loadFleetRoster(rosterPath);
|
const roster = await loadRosterAtPath(cmd, rosterPath);
|
||||||
|
|
||||||
// Guard: throws if removing leaves 0 orchestrators or agent not in roster
|
// Guard: throws if removing leaves 0 orchestrators or agent not in roster
|
||||||
const updatedRoster = removeAgentFromRoster(roster, name);
|
const updatedRoster = removeAgentFromRoster(roster, name);
|
||||||
@@ -2063,6 +2067,18 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
reconcileDeps: deps.reconcileDeps,
|
reconcileDeps: deps.reconcileDeps,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Recovery (#791 PR3): rebuild roster-derived env projections from the roster
|
||||||
|
// SSOT. Projection-only and preview-first — never issues a lifecycle restart.
|
||||||
|
registerFleetRegenCommand(cmd, {
|
||||||
|
runner,
|
||||||
|
mosaicHome: deps.mosaicHome,
|
||||||
|
// Resolve personas the SAME way reconcile does: forward any configured roots
|
||||||
|
// so a custom-persona-root deployment cannot have reconcile accept a roster
|
||||||
|
// that regen then rejects against the default `<mosaicHome>/fleet/roles`.
|
||||||
|
rolesDir: deps.reconcileDeps?.rolesDir,
|
||||||
|
overrideDir: deps.reconcileDeps?.overrideDir,
|
||||||
|
});
|
||||||
|
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2389,14 +2405,19 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
|
|
||||||
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
||||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
|
return loadRosterAtPath(cmd, await resolveRosterPath(opts.mosaicHome, opts.roster));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */
|
/** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */
|
||||||
async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
|
async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
|
||||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
|
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
|
||||||
const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = parseFleetRosterDocument(await readFleetRosterText(path), path);
|
||||||
|
} catch (error) {
|
||||||
|
reportFleetRosterConfigurationError(cmd, error);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
typeof parsed === 'object' &&
|
typeof parsed === 'object' &&
|
||||||
parsed !== null &&
|
parsed !== null &&
|
||||||
@@ -2412,7 +2433,22 @@ async function loadRosterFromAgentCommand(
|
|||||||
): Promise<FleetRoster> {
|
): Promise<FleetRoster> {
|
||||||
const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>();
|
const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>();
|
||||||
const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome();
|
const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome();
|
||||||
return loadFleetRoster(await resolveRosterPath(mosaicHome, opts.roster));
|
return loadRosterAtPath(command, await resolveRosterPath(mosaicHome, opts.roster));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRosterAtPath(command: Command, path: string): Promise<FleetRoster> {
|
||||||
|
try {
|
||||||
|
return await loadFleetRoster(path);
|
||||||
|
} catch (error) {
|
||||||
|
reportFleetRosterConfigurationError(command, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportFleetRosterConfigurationError(command: Command, error: unknown): never {
|
||||||
|
if (error instanceof FleetRosterConfigurationError) {
|
||||||
|
command.error(error.message, { code: 'fleet.roster', exitCode: 1 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMosaicHomeFromCommand(command: Command, override?: string): string {
|
function resolveMosaicHomeFromCommand(command: Command, override?: string): string {
|
||||||
|
|||||||
61
packages/mosaic/src/commands/install-arguments.spec.ts
Normal file
61
packages/mosaic/src/commands/install-arguments.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const INSTALLER_PATH = fileURLToPath(new URL('../../../../tools/install.sh', import.meta.url));
|
||||||
|
|
||||||
|
async function runInstaller(args: string[]): Promise<{
|
||||||
|
status: number | null;
|
||||||
|
stderr: string;
|
||||||
|
npmCalled: boolean;
|
||||||
|
}> {
|
||||||
|
const home = await mkdtemp(join(tmpdir(), 'mosaic-installer-args-'));
|
||||||
|
const bin = join(home, 'bin');
|
||||||
|
const npmMarker = join(home, 'npm-called');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(bin);
|
||||||
|
const npmShim = join(bin, 'npm');
|
||||||
|
await writeFile(npmShim, `#!/bin/sh\n: > "${npmMarker}"\n`);
|
||||||
|
await chmod(npmShim, 0o755);
|
||||||
|
|
||||||
|
const result = spawnSync('bash', [INSTALLER_PATH, ...args], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
HOME: home,
|
||||||
|
MOSAIC_NO_COLOR: '1',
|
||||||
|
PATH: `${bin}:${process.env.PATH ?? ''}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: result.status,
|
||||||
|
stderr: result.stderr,
|
||||||
|
npmCalled: existsSync(npmMarker),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectUnknownArgument(result: Awaited<ReturnType<typeof runInstaller>>): void {
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain('Unknown argument: --bogus');
|
||||||
|
expect(result.stderr).toMatch(/Usage: .*install\.sh/);
|
||||||
|
expect(result.npmCalled).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('installer arguments', () => {
|
||||||
|
it('rejects an unknown argument before installation starts', async () => {
|
||||||
|
expectUnknownArgument(await runInstaller(['--cli', '--bogus']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let --ref consume an unknown option', async () => {
|
||||||
|
expectUnknownArgument(await runInstaller(['--cli', '--ref', '--bogus']));
|
||||||
|
});
|
||||||
|
});
|
||||||
17
packages/mosaic/src/commands/install-heading.spec.ts
Normal file
17
packages/mosaic/src/commands/install-heading.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const INSTALLER_PATH = fileURLToPath(new URL('../../../../tools/install.sh', import.meta.url));
|
||||||
|
|
||||||
|
describe('installer CLI package heading', () => {
|
||||||
|
it('renders the scoped package name intact', async () => {
|
||||||
|
const installer = await readFile(INSTALLER_PATH, 'utf8');
|
||||||
|
const step = installer.match(/^step\(\)\s*\{.*\}$/m)?.[0];
|
||||||
|
|
||||||
|
expect(step).toBeDefined();
|
||||||
|
|
||||||
|
expect(step).toContain('printf \'\\n%s%s%s\\n\' "$BOLD" "$*" "$RESET"');
|
||||||
|
expect(installer).toContain('step "@mosaicstack/mosaic (npm package)"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -115,7 +115,7 @@ function auditClaudeSettings(): SettingsAudit {
|
|||||||
// Check required hooks
|
// Check required hooks
|
||||||
const hooks = settings['hooks'] as Record<string, unknown[]> | undefined;
|
const hooks = settings['hooks'] as Record<string, unknown[]> | undefined;
|
||||||
|
|
||||||
const requiredPreToolUse = ['prevent-memory-write.sh'];
|
const requiredPreToolUse = ['mutator-gate.py', 'prevent-memory-write.sh'];
|
||||||
const requiredPostToolUse = ['qa-hook-stdin.sh', 'typecheck-hook.sh'];
|
const requiredPostToolUse = ['qa-hook-stdin.sh', 'typecheck-hook.sh'];
|
||||||
|
|
||||||
const preHooks = (hooks?.['PreToolUse'] ?? []) as Array<Record<string, unknown>>;
|
const preHooks = (hooks?.['PreToolUse'] ?? []) as Array<Record<string, unknown>>;
|
||||||
@@ -755,7 +755,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
printSettingsWarnings(settingsAudit);
|
printSettingsWarnings(settingsAudit);
|
||||||
|
|
||||||
const prompt = buildRuntimePrompt('claude');
|
const prompt = buildRuntimePrompt('claude');
|
||||||
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
const cliArgs: string[] = [];
|
||||||
cliArgs.push('--append-system-prompt', prompt);
|
cliArgs.push('--append-system-prompt', prompt);
|
||||||
if (hasMissionNoArgs) {
|
if (hasMissionNoArgs) {
|
||||||
cliArgs.push(missionPrompt);
|
cliArgs.push(missionPrompt);
|
||||||
@@ -763,7 +763,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
execRuntime('claude', cliArgs);
|
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,7 +798,7 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
execRuntime('pi', cliArgs);
|
execLeaseGatedRuntime('pi', cliArgs);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -806,6 +806,33 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
process.exit(0); // Unreachable but satisfies never
|
process.exit(0); // Unreachable but satisfies never
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultLeaseBrokerSocket(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
if (env['MOSAIC_LEASE_BROKER_SOCKET']) return env['MOSAIC_LEASE_BROKER_SOCKET'];
|
||||||
|
const runtimeDir = env['XDG_RUNTIME_DIR'];
|
||||||
|
if (runtimeDir) return join(runtimeDir, 'mosaic-lease', 'broker.sock');
|
||||||
|
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
||||||
|
return join('/run/user', String(uid), 'mosaic-lease', 'broker.sock');
|
||||||
|
}
|
||||||
|
|
||||||
|
function execLeaseGatedRuntime(
|
||||||
|
runtime: 'claude' | 'pi',
|
||||||
|
args: string[],
|
||||||
|
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||||
|
dangerous = false,
|
||||||
|
): void {
|
||||||
|
const launcher = resolveTool('lease-broker', 'launch-runtime.py');
|
||||||
|
const dangerousArgs = dangerous ? ['--dangerous'] : [];
|
||||||
|
execRuntime(
|
||||||
|
'python3',
|
||||||
|
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
||||||
|
{
|
||||||
|
...baseEnv,
|
||||||
|
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
|
||||||
|
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** exec into the runtime, replacing the current process. */
|
/** exec into the runtime, replacing the current process. */
|
||||||
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
||||||
try {
|
try {
|
||||||
@@ -839,7 +866,8 @@ function launchClaudexProduction(args: string[], yolo: boolean): void {
|
|||||||
checkSequentialThinking('claude');
|
checkSequentialThinking('claude');
|
||||||
},
|
},
|
||||||
composePrompt: () => buildRuntimePrompt('claude'),
|
composePrompt: () => buildRuntimePrompt('claude'),
|
||||||
exec: (cmd, cmdArgs, env) => execRuntime(cmd, cmdArgs, env),
|
execLeaseGated: (cmdArgs, env, dangerous) =>
|
||||||
|
execLeaseGatedRuntime('claude', cmdArgs, env, dangerous),
|
||||||
};
|
};
|
||||||
void launchClaudex(args, yolo, adapter);
|
void launchClaudex(args, yolo, adapter);
|
||||||
}
|
}
|
||||||
|
|||||||
421
packages/mosaic/src/commands/skill.spec.ts
Normal file
421
packages/mosaic/src/commands/skill.spec.ts
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readlinkSync,
|
||||||
|
rmSync,
|
||||||
|
symlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
listSkills,
|
||||||
|
registerSkill,
|
||||||
|
registerSkillCommand,
|
||||||
|
syncClaudeSkills,
|
||||||
|
unregisterSkill,
|
||||||
|
type SkillPaths,
|
||||||
|
} from './skill.js';
|
||||||
|
|
||||||
|
const LEGACY_SYNC_SCRIPT = fileURLToPath(
|
||||||
|
new URL('../../framework/tools/_scripts/mosaic-sync-skills', import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('Claude skill bridge', () => {
|
||||||
|
let root: string;
|
||||||
|
let paths: SkillPaths;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
root = mkdtempSync(join(tmpdir(), 'mosaic-skill-cli-'));
|
||||||
|
paths = {
|
||||||
|
mosaicSkillsDir: join(root, '.config', 'mosaic', 'skills'),
|
||||||
|
claudeSkillsDir: join(root, '.claude', 'skills'),
|
||||||
|
};
|
||||||
|
mkdirSync(paths.mosaicSkillsDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function createSkill(name: string): string {
|
||||||
|
const skillDir = join(paths.mosaicSkillsDir, name);
|
||||||
|
mkdirSync(skillDir, { recursive: true });
|
||||||
|
writeFileSync(join(skillDir, 'SKILL.md'), `# ${name}\n`);
|
||||||
|
return skillDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectCorrectLink(name: string): void {
|
||||||
|
const linkPath = join(paths.claudeSkillsDir, name);
|
||||||
|
expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
||||||
|
expect(readlinkSync(linkPath)).toBe(join(paths.mosaicSkillsDir, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('name validation', () => {
|
||||||
|
const invalidNames = [
|
||||||
|
'../../etc',
|
||||||
|
'/abs/path',
|
||||||
|
'a/b',
|
||||||
|
String.raw`a\b`,
|
||||||
|
'-rf',
|
||||||
|
'..',
|
||||||
|
'safe.',
|
||||||
|
'space name',
|
||||||
|
'line\nbreak',
|
||||||
|
'escape\u001B[31m',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of invalidNames) {
|
||||||
|
it(`rejects ${JSON.stringify(name)} before register can escape its roots`, () => {
|
||||||
|
expect(() => registerSkill(name, paths)).toThrow(/invalid skill name/i);
|
||||||
|
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`rejects ${JSON.stringify(name)} before unregister can escape its roots`, () => {
|
||||||
|
expect(() => unregisterSkill(name, paths)).toThrow(/invalid skill name/i);
|
||||||
|
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CLI validation errors', () => {
|
||||||
|
let previousExitCode: number | string | null | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
previousExitCode = process.exitCode;
|
||||||
|
process.exitCode = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.exitCode = previousExitCode;
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['register', 'unregister'])(
|
||||||
|
'reports invalid %s names on stderr and sets a nonzero exit status',
|
||||||
|
async (subcommand) => {
|
||||||
|
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
const program = new Command().exitOverride();
|
||||||
|
registerSkillCommand(program, paths);
|
||||||
|
|
||||||
|
await program.parseAsync(['node', 'mosaic', 'skill', subcommand, '../../etc']);
|
||||||
|
|
||||||
|
expect(error).toHaveBeenCalledWith(expect.stringMatching(/invalid skill name/i));
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
|
||||||
|
error.mockRestore();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CLI status output', () => {
|
||||||
|
let previousExitCode: number | string | null | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
previousExitCode = process.exitCode;
|
||||||
|
process.exitCode = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.exitCode = previousExitCode;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function run(...args: string[]): Promise<void> {
|
||||||
|
const program = new Command().exitOverride();
|
||||||
|
registerSkillCommand(program, paths);
|
||||||
|
await program.parseAsync(['node', 'mosaic', 'skill', ...args]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reports register repair/idempotency and unregister idempotency statuses', async () => {
|
||||||
|
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
createSkill('status-skill');
|
||||||
|
|
||||||
|
await run('register', 'status-skill');
|
||||||
|
await run('register', 'status-skill');
|
||||||
|
rmSync(join(paths.claudeSkillsDir, 'status-skill'));
|
||||||
|
symlinkSync(
|
||||||
|
join(paths.mosaicSkillsDir, 'retired'),
|
||||||
|
join(paths.claudeSkillsDir, 'status-skill'),
|
||||||
|
);
|
||||||
|
await run('register', 'status-skill');
|
||||||
|
await run('unregister', 'status-skill');
|
||||||
|
await run('unregister', 'status-skill');
|
||||||
|
|
||||||
|
expect(log.mock.calls.flat()).toEqual([
|
||||||
|
'status-skill: registered',
|
||||||
|
'status-skill: already registered',
|
||||||
|
'status-skill: repaired dangling registration',
|
||||||
|
'status-skill: unregistered',
|
||||||
|
'status-skill: already unregistered',
|
||||||
|
]);
|
||||||
|
log.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports empty and populated skill lists', async () => {
|
||||||
|
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
await run('list');
|
||||||
|
createSkill('listed');
|
||||||
|
await run('list');
|
||||||
|
|
||||||
|
expect(log).toHaveBeenCalledWith('No Mosaic or Claude Code skills found.');
|
||||||
|
expect(log).toHaveBeenCalledWith(expect.stringMatching(/^unregistered\s+listed$/));
|
||||||
|
log.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerSkill', () => {
|
||||||
|
it('creates the exact canonical symlink and is idempotent', () => {
|
||||||
|
createSkill('new-skill');
|
||||||
|
|
||||||
|
expect(registerSkill('new-skill', paths).status).toBe('registered');
|
||||||
|
expectCorrectLink('new-skill');
|
||||||
|
|
||||||
|
expect(registerSkill('new-skill', paths).status).toBe('already-registered');
|
||||||
|
expectCorrectLink('new-skill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repairs a dangling Mosaic-owned symlink', () => {
|
||||||
|
createSkill('new-skill');
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
symlinkSync(
|
||||||
|
join(paths.mosaicSkillsDir, 'retired-skill'),
|
||||||
|
join(paths.claudeSkillsDir, 'new-skill'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(registerSkill('new-skill', paths).status).toBe('repaired');
|
||||||
|
expectCorrectLink('new-skill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['file', 'directory', 'symlink'] as const)(
|
||||||
|
'refuses to clobber a foreign %s at the target',
|
||||||
|
(kind) => {
|
||||||
|
createSkill('protected');
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
const target = join(paths.claudeSkillsDir, 'protected');
|
||||||
|
const foreign = join(root, 'foreign');
|
||||||
|
|
||||||
|
if (kind === 'file') writeFileSync(target, 'keep me\n');
|
||||||
|
if (kind === 'directory') mkdirSync(target);
|
||||||
|
if (kind === 'symlink') {
|
||||||
|
writeFileSync(foreign, 'keep me\n');
|
||||||
|
symlinkSync(foreign, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => registerSkill('protected', paths)).toThrow(/foreign|refus/i);
|
||||||
|
if (kind === 'file') expect(lstatSync(target).isFile()).toBe(true);
|
||||||
|
if (kind === 'directory') expect(lstatSync(target).isDirectory()).toBe(true);
|
||||||
|
if (kind === 'symlink') expect(readlinkSync(target)).toBe(foreign);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('refuses a symlinked Claude skills ancestor instead of writing outside the bridge root', () => {
|
||||||
|
createSkill('protected');
|
||||||
|
const externalClaude = join(root, 'external-claude');
|
||||||
|
mkdirSync(externalClaude);
|
||||||
|
symlinkSync(externalClaude, join(root, '.claude'));
|
||||||
|
|
||||||
|
expect(() => registerSkill('protected', paths)).toThrow(
|
||||||
|
/symlink.*ancestor|ancestor.*symlink/i,
|
||||||
|
);
|
||||||
|
expect(existsSync(join(externalClaude, 'skills', 'protected'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a symlinked canonical skills root instead of registering an external source', () => {
|
||||||
|
rmSync(paths.mosaicSkillsDir, { recursive: true });
|
||||||
|
const externalSkills = join(root, 'external-skills');
|
||||||
|
mkdirSync(join(externalSkills, 'protected'), { recursive: true });
|
||||||
|
symlinkSync(externalSkills, paths.mosaicSkillsDir);
|
||||||
|
|
||||||
|
expect(() => registerSkill('protected', paths)).toThrow(
|
||||||
|
/symlink.*ancestor|ancestor.*symlink/i,
|
||||||
|
);
|
||||||
|
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a dangling foreign symlink rather than treating it as repairable', () => {
|
||||||
|
createSkill('protected');
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
const foreignMissing = join(root, 'foreign-missing');
|
||||||
|
const target = join(paths.claudeSkillsDir, 'protected');
|
||||||
|
symlinkSync(foreignMissing, target);
|
||||||
|
|
||||||
|
expect(() => registerSkill('protected', paths)).toThrow(/foreign|refus/i);
|
||||||
|
expect(readlinkSync(target)).toBe(foreignMissing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unregisterSkill', () => {
|
||||||
|
it('removes a Mosaic-owned symlink and is idempotent when absent', () => {
|
||||||
|
createSkill('removable');
|
||||||
|
registerSkill('removable', paths);
|
||||||
|
|
||||||
|
expect(unregisterSkill('removable', paths).status).toBe('unregistered');
|
||||||
|
expect(existsSync(join(paths.claudeSkillsDir, 'removable'))).toBe(false);
|
||||||
|
|
||||||
|
expect(unregisterSkill('removable', paths).status).toBe('already-unregistered');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to remove a misdirected Mosaic-root symlink', () => {
|
||||||
|
createSkill('other');
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
const requested = join(paths.claudeSkillsDir, 'requested');
|
||||||
|
symlinkSync(join(paths.mosaicSkillsDir, 'other'), requested);
|
||||||
|
|
||||||
|
expect(() => unregisterSkill('requested', paths)).toThrow(/misdirected/i);
|
||||||
|
expect(readlinkSync(requested)).toBe(join(paths.mosaicSkillsDir, 'other'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['file', 'directory', 'symlink'] as const)('refuses to remove a foreign %s', (kind) => {
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
const target = join(paths.claudeSkillsDir, 'protected');
|
||||||
|
const foreign = join(root, 'foreign');
|
||||||
|
|
||||||
|
if (kind === 'file') writeFileSync(target, 'keep me\n');
|
||||||
|
if (kind === 'directory') mkdirSync(target);
|
||||||
|
if (kind === 'symlink') {
|
||||||
|
writeFileSync(foreign, 'keep me\n');
|
||||||
|
symlinkSync(foreign, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => unregisterSkill('protected', paths)).toThrow(/foreign|refus/i);
|
||||||
|
expect(lstatSync(target)).toBeDefined();
|
||||||
|
if (kind === 'symlink') expect(readlinkSync(target)).toBe(foreign);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listSkills', () => {
|
||||||
|
it('flags registered, unregistered, Mosaic-owned dangling, and foreign entries', () => {
|
||||||
|
createSkill('registered');
|
||||||
|
createSkill('unregistered');
|
||||||
|
registerSkill('registered', paths);
|
||||||
|
symlinkSync(join(paths.mosaicSkillsDir, 'retired'), join(paths.claudeSkillsDir, 'dangling'));
|
||||||
|
writeFileSync(join(paths.claudeSkillsDir, 'foreign-file'), 'keep me\n');
|
||||||
|
symlinkSync(join(root, 'missing-foreign'), join(paths.claudeSkillsDir, 'foreign-link'));
|
||||||
|
|
||||||
|
expect(listSkills(paths)).toEqual([
|
||||||
|
expect.objectContaining({ name: 'dangling', status: 'dangling' }),
|
||||||
|
expect.objectContaining({ name: 'foreign-file', status: 'foreign' }),
|
||||||
|
expect.objectContaining({ name: 'foreign-link', status: 'foreign-dangling' }),
|
||||||
|
expect.objectContaining({ name: 'registered', status: 'registered' }),
|
||||||
|
expect.objectContaining({ name: 'unregistered', status: 'unregistered' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('install linker compatibility', () => {
|
||||||
|
it('preserves foreign-name links into Mosaic home but outside canonical skills', () => {
|
||||||
|
createSkill('missing');
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
const mosaicHome = join(root, '.config', 'mosaic');
|
||||||
|
const liveForeignTarget = join(mosaicHome, 'foreign-non-skill-target');
|
||||||
|
mkdirSync(liveForeignTarget);
|
||||||
|
const liveForeignLink = join(paths.claudeSkillsDir, 'foreign-tool');
|
||||||
|
const danglingForeignLink = join(paths.claudeSkillsDir, 'unresolvable-foreign');
|
||||||
|
symlinkSync(liveForeignTarget, liveForeignLink);
|
||||||
|
symlinkSync(join(mosaicHome, 'foreign-missing'), danglingForeignLink);
|
||||||
|
|
||||||
|
const result = spawnSync('bash', [LEGACY_SYNC_SCRIPT, '--link-only'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: { ...process.env, HOME: root, MOSAIC_HOME: mosaicHome },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status, result.stderr).toBe(0);
|
||||||
|
expect(readlinkSync(liveForeignLink)).toBe(liveForeignTarget);
|
||||||
|
expect(readlinkSync(danglingForeignLink)).toBe(join(mosaicHome, 'foreign-missing'));
|
||||||
|
expect(readlinkSync(join(paths.claudeSkillsDir, 'missing'))).toBe(
|
||||||
|
join(paths.mosaicSkillsDir, 'missing'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves live and dangling foreign Claude symlinks while linking missing skills', () => {
|
||||||
|
createSkill('dangling-foreign');
|
||||||
|
createSkill('live-foreign');
|
||||||
|
createSkill('missing');
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
const external = join(root, 'external');
|
||||||
|
mkdirSync(external);
|
||||||
|
const liveLink = join(paths.claudeSkillsDir, 'live-foreign');
|
||||||
|
const danglingLink = join(paths.claudeSkillsDir, 'dangling-foreign');
|
||||||
|
symlinkSync(external, liveLink);
|
||||||
|
symlinkSync(join(root, 'external-missing'), danglingLink);
|
||||||
|
|
||||||
|
const result = spawnSync('bash', [LEGACY_SYNC_SCRIPT, '--link-only'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: { ...process.env, HOME: root, MOSAIC_HOME: join(root, '.config', 'mosaic') },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status, result.stderr).toBe(0);
|
||||||
|
expect(readlinkSync(liveLink)).toBe(external);
|
||||||
|
expect(readlinkSync(danglingLink)).toBe(join(root, 'external-missing'));
|
||||||
|
expect(readlinkSync(join(paths.claudeSkillsDir, 'missing'))).toBe(
|
||||||
|
join(paths.mosaicSkillsDir, 'missing'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('syncClaudeSkills', () => {
|
||||||
|
it('generically creates every missing canonical link and repairs managed broken links', () => {
|
||||||
|
createSkill('added-after-setup');
|
||||||
|
createSkill('another-new-skill');
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
symlinkSync(
|
||||||
|
join(paths.mosaicSkillsDir, 'retired'),
|
||||||
|
join(paths.claudeSkillsDir, 'added-after-setup'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = syncClaudeSkills(paths);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
registered: ['another-new-skill'],
|
||||||
|
repaired: ['added-after-setup'],
|
||||||
|
unchanged: [],
|
||||||
|
conflicts: [],
|
||||||
|
});
|
||||||
|
expectCorrectLink('added-after-setup');
|
||||||
|
expectCorrectLink('another-new-skill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes an invalid filesystem-derived name in conflict output', () => {
|
||||||
|
createSkill('line\nbreak');
|
||||||
|
|
||||||
|
const result = syncClaudeSkills(paths);
|
||||||
|
|
||||||
|
expect(result.registered).toEqual([]);
|
||||||
|
expect(result.conflicts).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: '"line\\nbreak"',
|
||||||
|
reason: expect.stringMatching(/invalid/i),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(existsSync(paths.claudeSkillsDir)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues syncing other skills without clobbering foreign entries', () => {
|
||||||
|
createSkill('blocked');
|
||||||
|
createSkill('link-me');
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
const blocked = join(paths.claudeSkillsDir, 'blocked');
|
||||||
|
writeFileSync(blocked, 'keep me\n');
|
||||||
|
|
||||||
|
const result = syncClaudeSkills(paths);
|
||||||
|
|
||||||
|
expect(result.registered).toEqual(['link-me']);
|
||||||
|
expect(result.conflicts).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'blocked',
|
||||||
|
reason: expect.stringMatching(/foreign|refus/i),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(readlinkSync(join(paths.claudeSkillsDir, 'link-me'))).toBe(
|
||||||
|
join(paths.mosaicSkillsDir, 'link-me'),
|
||||||
|
);
|
||||||
|
expect(lstatSync(blocked).isFile()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
419
packages/mosaic/src/commands/skill.ts
Normal file
419
packages/mosaic/src/commands/skill.ts
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
readdirSync,
|
||||||
|
readlinkSync,
|
||||||
|
symlinkSync,
|
||||||
|
unlinkSync,
|
||||||
|
type Stats,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
|
||||||
|
import type { Command } from 'commander';
|
||||||
|
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||||
|
|
||||||
|
export interface SkillPaths {
|
||||||
|
mosaicSkillsDir: string;
|
||||||
|
claudeSkillsDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SkillRegistrationStatus = 'registered' | 'already-registered' | 'repaired';
|
||||||
|
export type SkillUnregistrationStatus = 'unregistered' | 'already-unregistered';
|
||||||
|
export type SkillListStatus =
|
||||||
|
| 'registered'
|
||||||
|
| 'unregistered'
|
||||||
|
| 'dangling'
|
||||||
|
| 'foreign'
|
||||||
|
| 'foreign-dangling'
|
||||||
|
| 'misdirected';
|
||||||
|
|
||||||
|
export interface SkillRegistrationResult {
|
||||||
|
name: string;
|
||||||
|
status: SkillRegistrationStatus;
|
||||||
|
sourcePath: string;
|
||||||
|
linkPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillUnregistrationResult {
|
||||||
|
name: string;
|
||||||
|
status: SkillUnregistrationStatus;
|
||||||
|
linkPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillListEntry {
|
||||||
|
name: string;
|
||||||
|
status: SkillListStatus;
|
||||||
|
sourcePath?: string;
|
||||||
|
linkPath: string;
|
||||||
|
targetPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillSyncConflict {
|
||||||
|
name: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillSyncResult {
|
||||||
|
registered: string[];
|
||||||
|
repaired: string[];
|
||||||
|
unchanged: string[];
|
||||||
|
conflicts: SkillSyncConflict[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE_SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||||
|
|
||||||
|
export class SkillBridgeError extends Error {
|
||||||
|
public constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SkillBridgeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the production bridge paths while keeping tests injectable. */
|
||||||
|
export function getDefaultSkillPaths(): SkillPaths {
|
||||||
|
const mosaicHome = process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME;
|
||||||
|
const claudeHome = process.env['CLAUDE_HOME'] ?? join(homedir(), '.claude');
|
||||||
|
return {
|
||||||
|
mosaicSkillsDir: join(mosaicHome, 'skills'),
|
||||||
|
claudeSkillsDir: join(claudeHome, 'skills'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a user-supplied name before any filesystem operation.
|
||||||
|
* A skill name must identify one direct child in both managed roots.
|
||||||
|
*/
|
||||||
|
export function validateSkillName(name: string): void {
|
||||||
|
if (
|
||||||
|
name.length === 0 ||
|
||||||
|
name.startsWith('-') ||
|
||||||
|
name.endsWith('.') ||
|
||||||
|
name.includes('..') ||
|
||||||
|
name.includes('/') ||
|
||||||
|
name.includes('\\') ||
|
||||||
|
isAbsolute(name) ||
|
||||||
|
!SAFE_SKILL_NAME.test(name)
|
||||||
|
) {
|
||||||
|
throw new SkillBridgeError(
|
||||||
|
`Invalid skill name ${JSON.stringify(name)}: use letters, numbers, dots, underscores, or hyphens; start with a letter or number; and do not use paths, "..", or a leading "-".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displaySkillName(name: string): string {
|
||||||
|
return SAFE_SKILL_NAME.test(name) ? name : JSON.stringify(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lstatIfPresent(path: string): Stats | undefined {
|
||||||
|
try {
|
||||||
|
return lstatSync(path);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoSymlinkAncestors(path: string): void {
|
||||||
|
const absolute = resolve(path);
|
||||||
|
const pathRoot = parse(absolute).root;
|
||||||
|
let current = pathRoot;
|
||||||
|
|
||||||
|
for (const segment of relative(pathRoot, absolute).split(sep)) {
|
||||||
|
if (segment.length === 0) continue;
|
||||||
|
current = join(current, segment);
|
||||||
|
const entry = lstatIfPresent(current);
|
||||||
|
if (!entry) break;
|
||||||
|
if (entry.isSymbolicLink()) {
|
||||||
|
throw new SkillBridgeError(
|
||||||
|
`Refusing symlink ancestor at ${current}; managed skill roots must resolve without symlink traversal.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertManagedRoots(paths: SkillPaths): void {
|
||||||
|
assertNoSymlinkAncestors(paths.mosaicSkillsDir);
|
||||||
|
assertNoSymlinkAncestors(paths.claudeSkillsDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function directChild(root: string, name: string): string {
|
||||||
|
const resolvedRoot = resolve(root);
|
||||||
|
const child = resolve(resolvedRoot, name);
|
||||||
|
if (dirname(child) !== resolvedRoot) {
|
||||||
|
throw new SkillBridgeError(`Invalid skill name "${name}": resolved path escapes its root.`);
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLinkTarget(linkPath: string): string {
|
||||||
|
return resolve(dirname(linkPath), readlinkSync(linkPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideSkillsRoot(targetPath: string, skillsRoot: string): boolean {
|
||||||
|
const rel = relative(resolve(skillsRoot), resolve(targetPath));
|
||||||
|
return rel.length > 0 && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDangling(linkPath: string): boolean {
|
||||||
|
return !existsSync(linkPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSourceSkill(name: string, paths: SkillPaths): string {
|
||||||
|
const sourcePath = directChild(paths.mosaicSkillsDir, name);
|
||||||
|
const source = lstatIfPresent(sourcePath);
|
||||||
|
if (!source?.isDirectory()) {
|
||||||
|
throw new SkillBridgeError(
|
||||||
|
`Canonical skill directory not found: ${sourcePath}. Add the skill under the Mosaic skills directory before registering it.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sourcePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function foreignTargetError(linkPath: string): SkillBridgeError {
|
||||||
|
return new SkillBridgeError(
|
||||||
|
`Refusing to modify foreign entry at ${linkPath}; only symlinks pointing inside the Mosaic skills directory are managed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register one canonical skill with Claude Code without clobbering foreign entries. */
|
||||||
|
export function registerSkill(
|
||||||
|
name: string,
|
||||||
|
paths: SkillPaths = getDefaultSkillPaths(),
|
||||||
|
): SkillRegistrationResult {
|
||||||
|
validateSkillName(name);
|
||||||
|
assertManagedRoots(paths);
|
||||||
|
const sourcePath = assertSourceSkill(name, paths);
|
||||||
|
const linkPath = directChild(paths.claudeSkillsDir, name);
|
||||||
|
const existing = lstatIfPresent(linkPath);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
mkdirSync(paths.claudeSkillsDir, { recursive: true });
|
||||||
|
symlinkSync(sourcePath, linkPath);
|
||||||
|
return { name, status: 'registered', sourcePath, linkPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing.isSymbolicLink()) throw foreignTargetError(linkPath);
|
||||||
|
|
||||||
|
const existingTarget = resolveLinkTarget(linkPath);
|
||||||
|
if (!isInsideSkillsRoot(existingTarget, paths.mosaicSkillsDir)) {
|
||||||
|
throw foreignTargetError(linkPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingTarget === resolve(sourcePath) && !isDangling(linkPath)) {
|
||||||
|
return { name, status: 'already-registered', sourcePath, linkPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDangling(linkPath)) {
|
||||||
|
throw new SkillBridgeError(
|
||||||
|
`Refusing to replace live Mosaic skill symlink at ${linkPath}; it points to ${existingTarget}, not ${sourcePath}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
unlinkSync(linkPath);
|
||||||
|
symlinkSync(sourcePath, linkPath);
|
||||||
|
return { name, status: 'repaired', sourcePath, linkPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unregister only a symlink owned by the canonical Mosaic skills root. */
|
||||||
|
export function unregisterSkill(
|
||||||
|
name: string,
|
||||||
|
paths: SkillPaths = getDefaultSkillPaths(),
|
||||||
|
): SkillUnregistrationResult {
|
||||||
|
validateSkillName(name);
|
||||||
|
assertManagedRoots(paths);
|
||||||
|
const linkPath = directChild(paths.claudeSkillsDir, name);
|
||||||
|
const existing = lstatIfPresent(linkPath);
|
||||||
|
|
||||||
|
if (!existing) return { name, status: 'already-unregistered', linkPath };
|
||||||
|
if (!existing.isSymbolicLink()) throw foreignTargetError(linkPath);
|
||||||
|
|
||||||
|
const targetPath = resolveLinkTarget(linkPath);
|
||||||
|
if (!isInsideSkillsRoot(targetPath, paths.mosaicSkillsDir)) throw foreignTargetError(linkPath);
|
||||||
|
|
||||||
|
const expectedTarget = resolve(directChild(paths.mosaicSkillsDir, name));
|
||||||
|
if (targetPath !== expectedTarget) {
|
||||||
|
throw new SkillBridgeError(
|
||||||
|
`Refusing to unregister misdirected Mosaic skill symlink at ${linkPath}; it points to ${targetPath}, not ${expectedTarget}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
unlinkSync(linkPath);
|
||||||
|
return { name, status: 'unregistered', linkPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalSkillNames(paths: SkillPaths): string[] {
|
||||||
|
assertNoSymlinkAncestors(paths.mosaicSkillsDir);
|
||||||
|
const root = lstatIfPresent(paths.mosaicSkillsDir);
|
||||||
|
if (!root) return [];
|
||||||
|
if (!root.isDirectory()) {
|
||||||
|
throw new SkillBridgeError(
|
||||||
|
`Canonical skills path is not a directory: ${paths.mosaicSkillsDir}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return readdirSync(paths.mosaicSkillsDir, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function claudeEntryNames(paths: SkillPaths): string[] {
|
||||||
|
assertNoSymlinkAncestors(paths.claudeSkillsDir);
|
||||||
|
const root = lstatIfPresent(paths.claudeSkillsDir);
|
||||||
|
if (!root) return [];
|
||||||
|
if (!root.isDirectory()) {
|
||||||
|
throw new SkillBridgeError(`Claude skills path is not a directory: ${paths.claudeSkillsDir}`);
|
||||||
|
}
|
||||||
|
return readdirSync(paths.claudeSkillsDir)
|
||||||
|
.filter((name) => name.length > 0)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a deterministic union of canonical skills and Claude bridge entries. */
|
||||||
|
export function listSkills(paths: SkillPaths = getDefaultSkillPaths()): SkillListEntry[] {
|
||||||
|
const canonicalNames = new Set(canonicalSkillNames(paths));
|
||||||
|
const names = new Set([...canonicalNames, ...claudeEntryNames(paths)]);
|
||||||
|
const entries: SkillListEntry[] = [];
|
||||||
|
|
||||||
|
for (const name of [...names].sort()) {
|
||||||
|
const sourcePath = canonicalNames.has(name)
|
||||||
|
? directChild(paths.mosaicSkillsDir, name)
|
||||||
|
: undefined;
|
||||||
|
const linkPath = directChild(paths.claudeSkillsDir, name);
|
||||||
|
const installed = lstatIfPresent(linkPath);
|
||||||
|
|
||||||
|
if (!installed) {
|
||||||
|
if (sourcePath) entries.push({ name, status: 'unregistered', sourcePath, linkPath });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!installed.isSymbolicLink()) {
|
||||||
|
entries.push({ name, status: 'foreign', sourcePath, linkPath });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = resolveLinkTarget(linkPath);
|
||||||
|
const owned = isInsideSkillsRoot(targetPath, paths.mosaicSkillsDir);
|
||||||
|
const dangling = isDangling(linkPath);
|
||||||
|
|
||||||
|
if (!owned) {
|
||||||
|
entries.push({
|
||||||
|
name,
|
||||||
|
status: dangling ? 'foreign-dangling' : 'foreign',
|
||||||
|
sourcePath,
|
||||||
|
linkPath,
|
||||||
|
targetPath,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dangling) {
|
||||||
|
entries.push({ name, status: 'dangling', sourcePath, linkPath, targetPath });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
name,
|
||||||
|
status: sourcePath && targetPath === resolve(sourcePath) ? 'registered' : 'misdirected',
|
||||||
|
sourcePath,
|
||||||
|
linkPath,
|
||||||
|
targetPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile every canonical skill directory while preserving all foreign entries. */
|
||||||
|
export function syncClaudeSkills(paths: SkillPaths = getDefaultSkillPaths()): SkillSyncResult {
|
||||||
|
const result: SkillSyncResult = {
|
||||||
|
registered: [],
|
||||||
|
repaired: [],
|
||||||
|
unchanged: [],
|
||||||
|
conflicts: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const name of canonicalSkillNames(paths)) {
|
||||||
|
try {
|
||||||
|
const registration = registerSkill(name, paths);
|
||||||
|
if (registration.status === 'registered') result.registered.push(name);
|
||||||
|
if (registration.status === 'repaired') result.repaired.push(name);
|
||||||
|
if (registration.status === 'already-registered') result.unchanged.push(name);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
result.conflicts.push({
|
||||||
|
name: displaySkillName(name),
|
||||||
|
reason: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportCommandError(error: unknown): void {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register the `mosaic skill` command group. */
|
||||||
|
export function registerSkillCommand(
|
||||||
|
program: Command,
|
||||||
|
paths: SkillPaths = getDefaultSkillPaths(),
|
||||||
|
): void {
|
||||||
|
const skill = program
|
||||||
|
.command('skill')
|
||||||
|
.description('Manage Claude Code skill registrations')
|
||||||
|
.configureHelp({ sortSubcommands: true });
|
||||||
|
|
||||||
|
skill
|
||||||
|
.command('register <name>')
|
||||||
|
.description('Register a Mosaic skill with Claude Code')
|
||||||
|
.action((name: string) => {
|
||||||
|
try {
|
||||||
|
const result = registerSkill(name, paths);
|
||||||
|
if (result.status === 'already-registered') {
|
||||||
|
console.log(`${name}: already registered`);
|
||||||
|
} else if (result.status === 'repaired') {
|
||||||
|
console.log(`${name}: repaired dangling registration`);
|
||||||
|
} else {
|
||||||
|
console.log(`${name}: registered`);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
reportCommandError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
skill
|
||||||
|
.command('unregister <name>')
|
||||||
|
.description('Unregister a Mosaic skill from Claude Code')
|
||||||
|
.action((name: string) => {
|
||||||
|
try {
|
||||||
|
const result = unregisterSkill(name, paths);
|
||||||
|
console.log(
|
||||||
|
result.status === 'already-unregistered'
|
||||||
|
? `${name}: already unregistered`
|
||||||
|
: `${name}: unregistered`,
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
reportCommandError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
skill
|
||||||
|
.command('list')
|
||||||
|
.description('List registered, dangling, foreign, and unregistered skills')
|
||||||
|
.action(() => {
|
||||||
|
try {
|
||||||
|
const entries = listSkills(paths);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
console.log('No Mosaic or Claude Code skills found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
console.log(`${entry.status.padEnd(17)} ${displaySkillName(entry.name)}`);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
reportCommandError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -381,6 +381,22 @@ function isMissingFile(error: unknown): boolean {
|
|||||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-blocking acquire of the roster mutation lock: an exclusive `wx` create that
|
||||||
|
* throws `concurrent-mutation` (never waits) if the lock is already held. The
|
||||||
|
* on-disk format is an empty private file. CRUD releases in a plain `finally` with
|
||||||
|
* no result-preservation logic, so a release fault here must not override the
|
||||||
|
* mutation result — the release swallows unlink failures by design.
|
||||||
|
*
|
||||||
|
* The recovery-framed `fleet regen` contends on this exact same lock file, but it
|
||||||
|
* does so through the hardened, ownership-proving acquirer in the reconciler
|
||||||
|
* (`acquirePrivateRosterMutationLock`), not this one: whoever wins the `wx` create
|
||||||
|
* owns the file (the loser always gets `concurrent-mutation`), so the reconciler's
|
||||||
|
* ownership token is only ever written and read back by the same regen invocation,
|
||||||
|
* never by this empty-file writer. The two acquirers stay mutually compatible at
|
||||||
|
* the `wx`-contention layer while regen additionally proves ownership before it
|
||||||
|
* unlinks — a guarantee CRUD does not need because it releases unconditionally.
|
||||||
|
*/
|
||||||
async function acquireMutationLock(lockPath: string): Promise<() => Promise<void>> {
|
async function acquireMutationLock(lockPath: string): Promise<() => Promise<void>> {
|
||||||
try {
|
try {
|
||||||
const handle = await open(lockPath, 'wx', 0o600);
|
const handle = await open(lockPath, 'wx', 0o600);
|
||||||
|
|||||||
@@ -584,6 +584,29 @@ function defaultValidateRoster(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single roster-v2 → generated-projection mapping. Both the reconciler's
|
||||||
|
* apply path ({@link defaultPrepareProjections}) and the recovery-framed
|
||||||
|
* `mosaic fleet regen` command derive their generated env from THIS one
|
||||||
|
* function, so the two paths can never drift (the #791 single-SSOT invariant).
|
||||||
|
* Pure: no IO, no clock — a deterministic function of the roster alone.
|
||||||
|
*/
|
||||||
|
export function projectRosterV2AgentGeneratedEnv(
|
||||||
|
roster: FleetRosterV2,
|
||||||
|
agent: FleetRosterV2Agent,
|
||||||
|
): Readonly<Record<string, string>> {
|
||||||
|
return {
|
||||||
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
|
MOSAIC_AGENT_CLASS: agent.className,
|
||||||
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
|
MOSAIC_AGENT_MODEL: agent.model,
|
||||||
|
MOSAIC_AGENT_REASONING: agent.reasoning,
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
|
||||||
|
MOSAIC_AGENT_WORKDIR: agent.workingDirectory,
|
||||||
|
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function defaultPrepareProjections(
|
function defaultPrepareProjections(
|
||||||
request: FleetReconcileRequest,
|
request: FleetReconcileRequest,
|
||||||
): (roster: FleetRosterV2) => Promise<readonly PreparedAgentEnvironmentProjection[]> {
|
): (roster: FleetRosterV2) => Promise<readonly PreparedAgentEnvironmentProjection[]> {
|
||||||
@@ -596,16 +619,7 @@ function defaultPrepareProjections(
|
|||||||
mosaicHome,
|
mosaicHome,
|
||||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||||
agentName: agent.name,
|
agentName: agent.name,
|
||||||
generated: {
|
generated: projectRosterV2AgentGeneratedEnv(roster, agent),
|
||||||
MOSAIC_AGENT_NAME: agent.name,
|
|
||||||
MOSAIC_AGENT_CLASS: agent.className,
|
|
||||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
|
||||||
MOSAIC_AGENT_MODEL: agent.model,
|
|
||||||
MOSAIC_AGENT_REASONING: agent.reasoning,
|
|
||||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
|
|
||||||
MOSAIC_AGENT_WORKDIR: agent.workingDirectory,
|
|
||||||
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -620,62 +634,193 @@ function mosaicHomeFor(deps: FleetReconcileDeps): string {
|
|||||||
return deps.mosaicHome ?? join(homedir(), '.config', 'mosaic');
|
return deps.mosaicHome ?? join(homedir(), '.config', 'mosaic');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Acquires a private lock only after proving the canonical managed path. */
|
/** Acquires the private reconcile lock only after proving the canonical managed path. */
|
||||||
export function acquirePrivateReconcileLock(
|
export function acquirePrivateReconcileLock(
|
||||||
mosaicHome: string,
|
mosaicHome: string,
|
||||||
openLock: typeof open = open,
|
openLock: typeof open = open,
|
||||||
|
): () => Promise<() => Promise<void>> {
|
||||||
|
return acquirePrivateManagedRosterLock(
|
||||||
|
mosaicHome,
|
||||||
|
'roster.yaml.reconcile.lock',
|
||||||
|
'Another roster reconciliation is in progress.',
|
||||||
|
openLock,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquires the private roster MUTATION lock (`roster.yaml.mutation.lock`) with the
|
||||||
|
* exact same ownership-proof discipline as the reconcile lock. Exposed so the
|
||||||
|
* recovery-framed `mosaic fleet regen` can serialize its projection rewrites
|
||||||
|
* against agent CRUD — which holds this very lock while it rewrites the roster and
|
||||||
|
* the same derived projections — reusing this hardened acquirer rather than
|
||||||
|
* reimplementing it.
|
||||||
|
*
|
||||||
|
* The release PROVES ownership (device/inode + token) before unlinking, so a lock
|
||||||
|
* that was already cleared and replaced by the time release runs is never deleted
|
||||||
|
* out from under the new owner (it fails closed as `lock-cleanup-failed` instead),
|
||||||
|
* and that cleanup fault is propagated to the caller rather than silently dropped.
|
||||||
|
* CRUD contends on the identical path with a plain empty-file `wx` create; the two
|
||||||
|
* never co-own it (whoever wins `wx` owns it; the loser gets `concurrent-mutation`),
|
||||||
|
* so the token this writes is only ever read back by the same regen invocation that
|
||||||
|
* wrote it.
|
||||||
|
*
|
||||||
|
* SCOPE of the guarantee (matches the merged reconcile lock exactly — see
|
||||||
|
* {@link acquirePrivateManagedRosterLock}): the ownership proof closes the
|
||||||
|
* *reachable* window — a stale-lock reaper or operator cleared our lock and another
|
||||||
|
* writer took it BEFORE our release began — not the residual sub-instruction window
|
||||||
|
* between the final check and the path-based `unlink`. Closing that residual fully
|
||||||
|
* requires an fd-held advisory lock adopted by every fleet writer (CRUD, reconcile,
|
||||||
|
* regen), which is a cross-cutting mechanism change out of scope for this
|
||||||
|
* projection-only recovery command; it is unreachable within the `wx` writer
|
||||||
|
* protocol regardless (no Mosaic writer removes a lock it does not own, so only
|
||||||
|
* external interference can vacate our inode mid-release).
|
||||||
|
*/
|
||||||
|
export function acquirePrivateRosterMutationLock(
|
||||||
|
mosaicHome: string,
|
||||||
|
openLock: typeof open = open,
|
||||||
|
): () => Promise<() => Promise<void>> {
|
||||||
|
return acquirePrivateManagedRosterLock(
|
||||||
|
mosaicHome,
|
||||||
|
'roster.yaml.mutation.lock',
|
||||||
|
'Another roster mutation is in progress.',
|
||||||
|
openLock,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared body for the ownership-proving managed roster locks. Acquires `lockLeaf`
|
||||||
|
* under `<mosaicHome>/fleet` with a private `wx` create, writes an ownership token,
|
||||||
|
* and returns a release that re-proves device/inode + token before unlinking so it
|
||||||
|
* can never remove a lock it no longer owns. Non-blocking: throws `concurrent-mutation`
|
||||||
|
* (with `busyMessage`) on contention rather than waiting.
|
||||||
|
*/
|
||||||
|
function acquirePrivateManagedRosterLock(
|
||||||
|
mosaicHome: string,
|
||||||
|
lockLeaf: string,
|
||||||
|
busyMessage: string,
|
||||||
|
openLock: typeof open,
|
||||||
): () => Promise<() => Promise<void>> {
|
): () => Promise<() => Promise<void>> {
|
||||||
const fleetDir = join(mosaicHome, 'fleet');
|
const fleetDir = join(mosaicHome, 'fleet');
|
||||||
const lockPath = join(fleetDir, 'roster.yaml.reconcile.lock');
|
const lockPath = join(fleetDir, lockLeaf);
|
||||||
|
// The message-producing helpers below serve BOTH managed locks, so every fault
|
||||||
|
// names the actual lock file (`fleet/<leaf>`) — an operator needs to know WHICH
|
||||||
|
// lock is stale, not a hardcoded "reconciliation lock".
|
||||||
|
const lockLabel = `fleet/${lockLeaf}`;
|
||||||
return async (): Promise<() => Promise<void>> => {
|
return async (): Promise<() => Promise<void>> => {
|
||||||
await assertPrivateManagedDirectory(mosaicHome);
|
await assertPrivateManagedDirectory(mosaicHome);
|
||||||
await assertPrivateManagedDirectory(fleetDir);
|
await assertPrivateManagedDirectory(fleetDir);
|
||||||
await assertSafeLockLeafIfPresent(lockPath);
|
await assertSafeLockLeafIfPresent(lockPath, lockLabel);
|
||||||
|
|
||||||
let handle: FileHandle;
|
let handle: FileHandle;
|
||||||
try {
|
try {
|
||||||
handle = await openLock(lockPath, 'wx', 0o600);
|
handle = await openLock(lockPath, 'wx', 0o600);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (isCode(error, 'EEXIST')) {
|
if (isCode(error, 'EEXIST')) {
|
||||||
await assertSafeLockLeafIfPresent(lockPath);
|
await assertSafeLockLeafIfPresent(lockPath, lockLabel);
|
||||||
throw new FleetReconcileError(
|
throw new FleetReconcileError('concurrent-mutation', busyMessage);
|
||||||
'concurrent-mutation',
|
|
||||||
'Another roster reconciliation is in progress.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
throw new FleetReconcileError('lock-io-failed', 'The reconciliation lock cannot be created.');
|
throw new FleetReconcileError('lock-io-failed', `The ${lockLabel} lock cannot be created.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Identity of the lock WE exclusively created (the `wx` create guaranteed it is
|
||||||
|
// ours). Captured up front so both the release closure and the init-failure
|
||||||
|
// cleanup below can prove ownership by device/inode before touching the file.
|
||||||
|
const created = await handle.stat().catch((): undefined => undefined);
|
||||||
|
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
|
let tokenPersisted = false;
|
||||||
try {
|
try {
|
||||||
await handle.writeFile(`${token}\n`, 'utf8');
|
await handle.writeFile(`${token}\n`, 'utf8');
|
||||||
const opened = await handle.stat();
|
tokenPersisted = true;
|
||||||
await handle.close();
|
await handle.close();
|
||||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'unsafe-lock');
|
if (!created)
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'lock-io-failed',
|
||||||
|
`The ${lockLabel} lock cannot be initialized.`,
|
||||||
|
);
|
||||||
|
await assertLockOwnership(
|
||||||
|
lockPath,
|
||||||
|
created.dev,
|
||||||
|
created.ino,
|
||||||
|
token,
|
||||||
|
'unsafe-lock',
|
||||||
|
lockLabel,
|
||||||
|
);
|
||||||
return async (): Promise<void> => {
|
return async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
|
await assertLockOwnership(
|
||||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
|
lockPath,
|
||||||
|
created.dev,
|
||||||
|
created.ino,
|
||||||
|
token,
|
||||||
|
'lock-cleanup-failed',
|
||||||
|
lockLabel,
|
||||||
|
);
|
||||||
await unlink(lockPath);
|
await unlink(lockPath);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof FleetReconcileError) throw error;
|
if (error instanceof FleetReconcileError) throw error;
|
||||||
throw new FleetReconcileError(
|
throw new FleetReconcileError('lock-cleanup-failed', `The ${lockLabel} cleanup failed.`);
|
||||||
'lock-cleanup-failed',
|
|
||||||
'The reconciliation lock cleanup failed.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
await handle.close().catch((): void => {});
|
await handle.close().catch((): void => {});
|
||||||
|
// Best-effort: remove the lock THIS invocation created so a transient init
|
||||||
|
// fault does not strand a lock that would block future regen and agent CRUD.
|
||||||
|
// dev/ino-guarded so it can never delete a replacement; swallowed so cleanup
|
||||||
|
// failure never masks the initialization error being surfaced. The token is
|
||||||
|
// passed only once persisted, so the fallback path (when the post-create stat
|
||||||
|
// failed and dev/ino is unavailable) can still prove ownership by content.
|
||||||
|
await removeOwnedLockLeafBestEffort(lockPath, created, tokenPersisted ? token : undefined);
|
||||||
if (error instanceof FleetReconcileError) throw error;
|
if (error instanceof FleetReconcileError) throw error;
|
||||||
throw new FleetReconcileError(
|
throw new FleetReconcileError(
|
||||||
'lock-io-failed',
|
'lock-io-failed',
|
||||||
'The reconciliation lock cannot be initialized.',
|
`The ${lockLabel} lock cannot be initialized.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort removal of a managed lock leaf THIS process created, used only on the
|
||||||
|
* initialization-failure path. Two independent ownership proofs, so a lock that was
|
||||||
|
* already replaced is never deleted:
|
||||||
|
*
|
||||||
|
* - primary: the captured device/inode of the file we exclusively `wx`-created; or
|
||||||
|
* - fallback: when the post-create stat itself failed (dev/ino unavailable), the
|
||||||
|
* random `ownershipToken` we persisted — only OUR lock carries it, so a CRUD
|
||||||
|
* (empty) or a differently-tokened replacement is never removed.
|
||||||
|
*
|
||||||
|
* Every fault is swallowed because the caller is already surfacing the initialization
|
||||||
|
* error and a leftover lock is recoverable via the documented runbook. If BOTH proofs
|
||||||
|
* are unavailable (stat failed AND the token write never landed) the lock is left in
|
||||||
|
* place rather than risk deleting another writer's file — a doubly-degenerate case
|
||||||
|
* requiring two independent fs faults on a just-created fd.
|
||||||
|
*/
|
||||||
|
async function removeOwnedLockLeafBestEffort(
|
||||||
|
lockPath: string,
|
||||||
|
created: { dev: number; ino: number } | undefined,
|
||||||
|
ownershipToken: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const current = await lstat(lockPath);
|
||||||
|
if (!current.isFile() || current.isSymbolicLink()) return;
|
||||||
|
if (created) {
|
||||||
|
if (current.dev === created.dev && current.ino === created.ino) {
|
||||||
|
await unlink(lockPath);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ownershipToken !== undefined) {
|
||||||
|
const contents = await readFile(lockPath, 'utf8');
|
||||||
|
if (contents.trim() === ownershipToken) {
|
||||||
|
await unlink(lockPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Swallowed: leftover lock is recoverable; do not mask the init error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function assertPrivateManagedDirectory(path: string): Promise<void> {
|
async function assertPrivateManagedDirectory(path: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const metadata = await lstat(path);
|
const metadata = await lstat(path);
|
||||||
@@ -691,16 +836,16 @@ async function assertPrivateManagedDirectory(path: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertSafeLockLeafIfPresent(lockPath: string): Promise<void> {
|
async function assertSafeLockLeafIfPresent(lockPath: string, lockLabel: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const metadata = await lstat(lockPath);
|
const metadata = await lstat(lockPath);
|
||||||
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||||
throw new FleetReconcileError('unsafe-lock', 'The reconciliation lock path is unsafe.');
|
throw new FleetReconcileError('unsafe-lock', `The ${lockLabel} lock path is unsafe.`);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (isCode(error, 'ENOENT')) return;
|
if (isCode(error, 'ENOENT')) return;
|
||||||
if (error instanceof FleetReconcileError) throw error;
|
if (error instanceof FleetReconcileError) throw error;
|
||||||
throw new FleetReconcileError('unsafe-lock', 'The reconciliation lock path is unavailable.');
|
throw new FleetReconcileError('unsafe-lock', `The ${lockLabel} lock path is unavailable.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,6 +855,7 @@ async function assertLockOwnership(
|
|||||||
inode: number,
|
inode: number,
|
||||||
token: string,
|
token: string,
|
||||||
failureCode: 'unsafe-lock' | 'lock-cleanup-failed',
|
failureCode: 'unsafe-lock' | 'lock-cleanup-failed',
|
||||||
|
lockLabel: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const metadata = await lstat(lockPath);
|
const metadata = await lstat(lockPath);
|
||||||
@@ -720,24 +866,21 @@ async function assertLockOwnership(
|
|||||||
metadata.dev !== device ||
|
metadata.dev !== device ||
|
||||||
metadata.ino !== inode
|
metadata.ino !== inode
|
||||||
) {
|
) {
|
||||||
throw new FleetReconcileError(failureCode, 'The reconciliation lock ownership changed.');
|
throw new FleetReconcileError(failureCode, `The ${lockLabel} ownership changed.`);
|
||||||
}
|
}
|
||||||
const handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
const handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||||
try {
|
try {
|
||||||
const opened = await handle.stat();
|
const opened = await handle.stat();
|
||||||
const contents = await handle.readFile({ encoding: 'utf8' });
|
const contents = await handle.readFile({ encoding: 'utf8' });
|
||||||
if (opened.dev !== device || opened.ino !== inode || contents !== `${token}\n`) {
|
if (opened.dev !== device || opened.ino !== inode || contents !== `${token}\n`) {
|
||||||
throw new FleetReconcileError(failureCode, 'The reconciliation lock ownership changed.');
|
throw new FleetReconcileError(failureCode, `The ${lockLabel} ownership changed.`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await handle.close();
|
await handle.close();
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof FleetReconcileError) throw error;
|
if (error instanceof FleetReconcileError) throw error;
|
||||||
throw new FleetReconcileError(
|
throw new FleetReconcileError(failureCode, `The ${lockLabel} ownership cannot be proven.`);
|
||||||
failureCode,
|
|
||||||
'The reconciliation lock ownership cannot be proven.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ export interface FleetRoster {
|
|||||||
|
|
||||||
export type FleetRosterInputFormat = 'yaml' | 'json';
|
export type FleetRosterInputFormat = 'yaml' | 'json';
|
||||||
|
|
||||||
|
export class FleetRosterConfigurationError extends Error {
|
||||||
|
override name = 'FleetRosterConfigurationError';
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveInstalledFleetRosterPath(mosaicHome: string): string {
|
export function resolveInstalledFleetRosterPath(mosaicHome: string): string {
|
||||||
const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||||
try {
|
try {
|
||||||
@@ -138,8 +142,52 @@ export function parseFleetRosterV1(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
||||||
const source = await readFile(path, 'utf8');
|
const source = await readFleetRosterText(path);
|
||||||
|
try {
|
||||||
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
||||||
|
} catch (error) {
|
||||||
|
if (isRosterParserError(error)) throw invalidFleetRosterError(path);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read an operator-owned roster with errors that say how to recover. */
|
||||||
|
export async function readFleetRosterText(path: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await readFile(path, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeErrorCode(error, 'ENOENT')) {
|
||||||
|
throw new FleetRosterConfigurationError(
|
||||||
|
`No fleet roster found at ${path}. Run \`mosaic fleet init\` to create one.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new FleetRosterConfigurationError(
|
||||||
|
`Could not read fleet roster at ${path}. Check the file exists and is readable.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a roster document needed only to select the v1/v2 command path. */
|
||||||
|
export function parseFleetRosterDocument(source: string, path: string): unknown {
|
||||||
|
try {
|
||||||
|
return YAML.parse(source);
|
||||||
|
} catch (error) {
|
||||||
|
if (isRosterParserError(error)) throw invalidFleetRosterError(path);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidFleetRosterError(path: string): FleetRosterConfigurationError {
|
||||||
|
return new FleetRosterConfigurationError(
|
||||||
|
`Fleet roster at ${path} is invalid. Fix the file or run \`mosaic fleet init --force\`.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRosterParserError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof SyntaxError ||
|
||||||
|
(error instanceof Error && (error.name === 'YAMLParseError' || error.name === 'YAMLWarning'))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
||||||
@@ -149,6 +197,16 @@ export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeFleetRosterV1(raw: RawFleetRoster): FleetRoster {
|
export function normalizeFleetRosterV1(raw: RawFleetRoster): FleetRoster {
|
||||||
|
try {
|
||||||
|
return normalizeFleetRosterV1Unchecked(raw);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FleetRosterConfigurationError) throw error;
|
||||||
|
if (error instanceof Error) throw new FleetRosterConfigurationError(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster {
|
||||||
assertObject(raw, 'Fleet roster');
|
assertObject(raw, 'Fleet roster');
|
||||||
assertKnownKeys(raw, 'Fleet roster', [
|
assertKnownKeys(raw, 'Fleet roster', [
|
||||||
'version',
|
'version',
|
||||||
|
|||||||
@@ -204,6 +204,53 @@ export async function prepareAgentEnvironmentProjection(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A generated-only projection validated without touching any legacy/local/quarantine file. */
|
||||||
|
export interface PreparedGeneratedAgentEnvironmentProjection {
|
||||||
|
readonly mosaicHome: string;
|
||||||
|
readonly agentEnvDir: string;
|
||||||
|
readonly generatedPath: string;
|
||||||
|
readonly generated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates ONLY the roster-derived generated projection for a recovery rebuild.
|
||||||
|
* Unlike {@link prepareAgentEnvironmentProjection}, this never reads, classifies,
|
||||||
|
* relocates, or quarantines the legacy `.env` / `.env.local` operator surface — it
|
||||||
|
* exists so `mosaic fleet regen` has no code path that can mutate anything except
|
||||||
|
* `<name>.env.generated`. The existing generated file, if present, must already be
|
||||||
|
* a private regular file.
|
||||||
|
*/
|
||||||
|
export async function prepareGeneratedAgentEnvironmentProjection(
|
||||||
|
options: AgentEnvironmentProjectionOptions,
|
||||||
|
): Promise<PreparedGeneratedAgentEnvironmentProjection> {
|
||||||
|
if (!AGENT_NAME.test(options.agentName)) {
|
||||||
|
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
||||||
|
}
|
||||||
|
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
||||||
|
const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`);
|
||||||
|
const generated = renderGeneratedAgentEnvironment(options.generated);
|
||||||
|
await assertPrivateRegularFileIfPresent(generatedPath);
|
||||||
|
return {
|
||||||
|
mosaicHome: options.mosaicHome,
|
||||||
|
agentEnvDir: options.agentEnvDir,
|
||||||
|
generatedPath,
|
||||||
|
generated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a generated-only projection: writes ONLY `<name>.env.generated` atomically
|
||||||
|
* and touches nothing else. It never writes `.env.local`/`.env.quarantine` and never
|
||||||
|
* unlinks the legacy `.env` — the projection-only recovery guarantee is structural.
|
||||||
|
*/
|
||||||
|
export async function applyPreparedGeneratedAgentEnvironmentProjection(
|
||||||
|
prepared: PreparedGeneratedAgentEnvironmentProjection,
|
||||||
|
): Promise<string> {
|
||||||
|
await ensurePrivateProjectionDirectory(prepared.mosaicHome, prepared.agentEnvDir);
|
||||||
|
await writePrivateAtomically(prepared.generatedPath, prepared.generated);
|
||||||
|
return prepared.generatedPath;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates only the exact generated projection eligible for a roster delete.
|
* Validates only the exact generated projection eligible for a roster delete.
|
||||||
* Local overrides, legacy input, and quarantine records are operator-retained and
|
* Local overrides, legacy input, and quarantine records are operator-retained and
|
||||||
|
|||||||
181
packages/mosaic/src/lease-broker/broker-test-client.spec.ts
Normal file
181
packages/mosaic/src/lease-broker/broker-test-client.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { createServer, type Server, type Socket } from 'node:net';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { BrokerTransportError, readBrokerReply, requestBrokerReply } from './broker-test-client.js';
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
const servers: Server[] = [];
|
||||||
|
const sockets: Socket[] = [];
|
||||||
|
|
||||||
|
async function scriptedBroker(
|
||||||
|
replies: ReadonlyArray<ReadonlyArray<Buffer> | 'hang'>,
|
||||||
|
): Promise<{ socketPath: string; connections: () => number }> {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'mosaic-broker-client-'));
|
||||||
|
roots.push(root);
|
||||||
|
const socketPath = join(root, 'broker.sock');
|
||||||
|
let connections = 0;
|
||||||
|
const server = createServer({ allowHalfOpen: true }, (socket) => {
|
||||||
|
sockets.push(socket);
|
||||||
|
const chunks = replies[connections] ?? replies.at(-1) ?? [];
|
||||||
|
connections += 1;
|
||||||
|
socket.once('end', () => {
|
||||||
|
if (chunks === 'hang') return;
|
||||||
|
void (async () => {
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
socket.write(chunk);
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
socket.end();
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
socket.resume();
|
||||||
|
});
|
||||||
|
servers.push(server);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(socketPath, resolve);
|
||||||
|
});
|
||||||
|
return { socketPath, connections: () => connections };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const socket of sockets.splice(0)) socket.destroy();
|
||||||
|
await Promise.all(
|
||||||
|
servers
|
||||||
|
.splice(0)
|
||||||
|
.map(
|
||||||
|
(server) =>
|
||||||
|
new Promise<void>((resolve, reject) =>
|
||||||
|
server.close((error) => (error ? reject(error) : resolve())),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('newline-framed broker test client', () => {
|
||||||
|
test('rejects an empty early close without retrying into a false green', async () => {
|
||||||
|
const broker = await scriptedBroker([[], [Buffer.from('{"ok":true}\n')]]);
|
||||||
|
|
||||||
|
const failure = await requestBrokerReply(broker.socketPath, { action: 'probe' }).catch(
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(failure).toBeInstanceOf(BrokerTransportError);
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
kind: 'early-close',
|
||||||
|
attempts: 1,
|
||||||
|
responseLength: 0,
|
||||||
|
responseHex: '',
|
||||||
|
});
|
||||||
|
expect(failure).toHaveProperty(
|
||||||
|
'message',
|
||||||
|
expect.stringMatching(/closed before newline.*length=0.*hex=<empty>/i),
|
||||||
|
);
|
||||||
|
expect(broker.connections()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects repeated truncated early closes with response bytes and length', async () => {
|
||||||
|
const truncated = Buffer.from('{"ok":');
|
||||||
|
const broker = await scriptedBroker([[truncated], [Buffer.from('{"ok":true}\n')]]);
|
||||||
|
|
||||||
|
const failure = await requestBrokerReply(broker.socketPath, { action: 'probe' }).catch(
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(failure).toBeInstanceOf(BrokerTransportError);
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
kind: 'early-close',
|
||||||
|
attempts: 1,
|
||||||
|
responseLength: 6,
|
||||||
|
responseHex: '7b226f6b223a',
|
||||||
|
});
|
||||||
|
expect(failure).toHaveProperty(
|
||||||
|
'message',
|
||||||
|
expect.stringMatching(/closed before newline.*length=6.*bytes=.*ok/i),
|
||||||
|
);
|
||||||
|
expect(broker.connections()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a newline-terminated malformed broker reply without retrying', async () => {
|
||||||
|
const broker = await scriptedBroker([[Buffer.from('{bad}\n')]]);
|
||||||
|
|
||||||
|
await expect(requestBrokerReply(broker.socketPath, { action: 'probe' })).rejects.toThrow(
|
||||||
|
/malformed broker reply.*length=6.*bytes="\{bad\}\\n"/i,
|
||||||
|
);
|
||||||
|
expect(broker.connections()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects trailing bytes delivered after a complete frame in a later data event', async () => {
|
||||||
|
const broker = await scriptedBroker([[Buffer.from('{"ok":true}\n'), Buffer.from('extra')]]);
|
||||||
|
|
||||||
|
const failure = await requestBrokerReply(broker.socketPath, { action: 'probe' }).catch(
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(failure).toBeInstanceOf(BrokerTransportError);
|
||||||
|
expect(failure).toMatchObject({ kind: 'malformed-reply', responseLength: 17 });
|
||||||
|
expect(failure).toHaveProperty(
|
||||||
|
'message',
|
||||||
|
expect.stringMatching(/bytes after the newline terminator/i),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redacts security tokens from typed transport diagnostics', async () => {
|
||||||
|
const token = 'a'.repeat(64);
|
||||||
|
const reply = Buffer.from(`{"ok":true,"promotion_token":"${token}`);
|
||||||
|
const broker = await scriptedBroker([[reply]]);
|
||||||
|
|
||||||
|
const failure = await requestBrokerReply(broker.socketPath, { action: 'probe' }).catch(
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(failure).toBeInstanceOf(BrokerTransportError);
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
kind: 'early-close',
|
||||||
|
responseLength: reply.length,
|
||||||
|
responsePreview: '<redacted-sensitive-reply>',
|
||||||
|
});
|
||||||
|
expect(String((failure as Error).message)).not.toContain(token);
|
||||||
|
expect(JSON.stringify(failure)).not.toContain(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['trailing bytes', Buffer.from('{"ok":true}\nextra'), /bytes after the newline/i],
|
||||||
|
['non-object JSON', Buffer.from('[]\n'), /JSON value is not an object/i],
|
||||||
|
['oversized frame', Buffer.alloc(64 * 1024 + 1, 0x78), /exceeds 65536 bytes/i],
|
||||||
|
])('rejects %s with deterministic framing context', async (_label, reply, message) => {
|
||||||
|
const broker = await scriptedBroker([[reply as Buffer]]);
|
||||||
|
|
||||||
|
await expect(requestBrokerReply(broker.socketPath, { action: 'probe' })).rejects.toThrow(
|
||||||
|
message as RegExp,
|
||||||
|
);
|
||||||
|
expect(broker.connections()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports timeout, connection, and writer failures as promise rejections', async () => {
|
||||||
|
const hanging = await scriptedBroker(['hang']);
|
||||||
|
await expect(
|
||||||
|
requestBrokerReply(hanging.socketPath, { action: 'probe' }, { timeoutMs: 10 }),
|
||||||
|
).rejects.toThrow(/timed out before newline.*length=0.*hex=<empty>/i);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requestBrokerReply(join(rootForMissingSocket(), 'missing.sock'), {}),
|
||||||
|
).rejects.toThrow(/socket error before newline.*length=0/i);
|
||||||
|
|
||||||
|
const writerFailure = await scriptedBroker(['hang']);
|
||||||
|
await expect(
|
||||||
|
readBrokerReply(writerFailure.socketPath, () => {
|
||||||
|
throw new Error('writer failed');
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('writer failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function rootForMissingSocket(): string {
|
||||||
|
return join(tmpdir(), `mosaic-missing-broker-${process.pid}-${Date.now()}`);
|
||||||
|
}
|
||||||
187
packages/mosaic/src/lease-broker/broker-test-client.ts
Normal file
187
packages/mosaic/src/lease-broker/broker-test-client.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { createConnection, type Socket } from 'node:net';
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 3_000;
|
||||||
|
const MAX_REPLY_BYTES = 64 * 1024;
|
||||||
|
const MAX_DIAGNOSTIC_BYTES = 256;
|
||||||
|
const SENSITIVE_REPLY_FIELD = /"(?:promotion_token|session_id|token)"\s*:/;
|
||||||
|
|
||||||
|
export interface BrokerTestClientOptions {
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BrokerTransportFailureKind =
|
||||||
|
| 'early-close'
|
||||||
|
| 'malformed-reply'
|
||||||
|
| 'socket-error'
|
||||||
|
| 'timeout';
|
||||||
|
|
||||||
|
export class BrokerTransportError extends Error {
|
||||||
|
public readonly responseLength: number;
|
||||||
|
public readonly responseBytes: string;
|
||||||
|
public readonly responseHex: string;
|
||||||
|
public readonly responsePreview: string;
|
||||||
|
public readonly responseSha256: string;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
public readonly kind: BrokerTransportFailureKind,
|
||||||
|
description: string,
|
||||||
|
response: Buffer,
|
||||||
|
public readonly attempts = 1,
|
||||||
|
) {
|
||||||
|
const diagnostics = responseDiagnostics(response);
|
||||||
|
super(`${description}; attempts=${attempts}; ${responseContext(response, diagnostics)}`);
|
||||||
|
this.name = 'BrokerTransportError';
|
||||||
|
this.responseLength = response.length;
|
||||||
|
this.responseBytes = diagnostics.preview;
|
||||||
|
this.responseHex = diagnostics.hex;
|
||||||
|
this.responsePreview = diagnostics.preview;
|
||||||
|
this.responseSha256 = diagnostics.sha256;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponseDiagnostics {
|
||||||
|
preview: string;
|
||||||
|
hex: string;
|
||||||
|
sha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseDiagnostics(response: Buffer): ResponseDiagnostics {
|
||||||
|
const fullText = response.toString('utf8');
|
||||||
|
const sensitive = SENSITIVE_REPLY_FIELD.test(fullText);
|
||||||
|
const bounded = response.subarray(0, MAX_DIAGNOSTIC_BYTES);
|
||||||
|
const suffix = response.length > MAX_DIAGNOSTIC_BYTES ? '…' : '';
|
||||||
|
return {
|
||||||
|
preview:
|
||||||
|
response.length === 0
|
||||||
|
? '<empty>'
|
||||||
|
: sensitive
|
||||||
|
? '<redacted-sensitive-reply>'
|
||||||
|
: `${bounded.toString('utf8')}${suffix}`,
|
||||||
|
hex: sensitive ? '<redacted>' : `${bounded.toString('hex')}${suffix}`,
|
||||||
|
sha256: createHash('sha256').update(response).digest('hex'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseContext(response: Buffer, diagnostics = responseDiagnostics(response)): string {
|
||||||
|
const hex = diagnostics.hex.length === 0 ? '<empty>' : diagnostics.hex;
|
||||||
|
return `length=${response.length}; bytes=${JSON.stringify(diagnostics.preview)}; hex=${hex}; sha256=${diagnostics.sha256}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function malformedReply(response: Buffer, reason: string): BrokerTransportError {
|
||||||
|
return new BrokerTransportError('malformed-reply', `Malformed broker reply: ${reason}`, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBrokerReplyAttempt<T extends object>(
|
||||||
|
socketPath: string,
|
||||||
|
write: (socket: Socket) => void,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const socket = createConnection(socketPath);
|
||||||
|
let response = Buffer.alloc(0);
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const settle = (callback: () => void): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
callback();
|
||||||
|
socket.destroy();
|
||||||
|
};
|
||||||
|
const fail = (error: Error): void => settle(() => reject(error));
|
||||||
|
const timer = setTimeout(
|
||||||
|
() =>
|
||||||
|
fail(
|
||||||
|
new BrokerTransportError(
|
||||||
|
'timeout',
|
||||||
|
`Broker reply timed out before newline after ${timeoutMs}ms`,
|
||||||
|
response,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.once('error', (error) =>
|
||||||
|
fail(
|
||||||
|
new BrokerTransportError(
|
||||||
|
'socket-error',
|
||||||
|
`Broker reply socket error before newline: ${error.message}`,
|
||||||
|
response,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
socket.on('data', (chunk: Buffer) => {
|
||||||
|
if (settled) return;
|
||||||
|
response = Buffer.concat([response, chunk]);
|
||||||
|
if (response.length > MAX_REPLY_BYTES) {
|
||||||
|
fail(malformedReply(response, `exceeds ${MAX_REPLY_BYTES} bytes`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.once('end', () => {
|
||||||
|
if (settled) return;
|
||||||
|
const newline = response.indexOf(0x0a);
|
||||||
|
if (response.length === 0 || newline < 0) {
|
||||||
|
fail(
|
||||||
|
new BrokerTransportError(
|
||||||
|
'early-close',
|
||||||
|
'Broker reply socket closed before newline',
|
||||||
|
response,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newline !== response.length - 1) {
|
||||||
|
fail(malformedReply(response, 'contains bytes after the newline terminator'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(response.subarray(0, newline).toString('utf8'));
|
||||||
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||||
|
fail(malformedReply(response, 'JSON value is not an object'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settle(() => resolve(parsed as T));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
fail(
|
||||||
|
malformedReply(response, error instanceof Error ? error.message : 'JSON parsing failed'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.once('connect', () => {
|
||||||
|
try {
|
||||||
|
write(socket);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
fail(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read exactly one complete newline-framed JSON object; transport failures reject. */
|
||||||
|
export async function readBrokerReply<T extends object>(
|
||||||
|
socketPath: string,
|
||||||
|
write: (socket: Socket) => void,
|
||||||
|
options: BrokerTestClientOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
return await readBrokerReplyAttempt<T>(
|
||||||
|
socketPath,
|
||||||
|
write,
|
||||||
|
options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send one newline-framed request and read its complete broker reply. */
|
||||||
|
export async function requestBrokerReply<T extends object>(
|
||||||
|
socketPath: string,
|
||||||
|
requestValue: object,
|
||||||
|
options?: BrokerTestClientOptions,
|
||||||
|
): Promise<T> {
|
||||||
|
return await readBrokerReply<T>(
|
||||||
|
socketPath,
|
||||||
|
(socket) => socket.end(`${JSON.stringify(requestValue)}\n`),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
103
packages/mosaic/src/lease-broker/daemon_deadline_unittest.py
Normal file
103
packages/mosaic/src/lease-broker/daemon_deadline_unittest.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regression tests for bounded lease-broker read/handle/send deadlines."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DAEMON_PATH = Path(__file__).parents[2] / "framework/tools/lease-broker/daemon.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("lease_broker_deadline_daemon", DAEMON_PATH)
|
||||||
|
if SPEC is None or SPEC.loader is None:
|
||||||
|
raise RuntimeError("unable to load lease broker daemon")
|
||||||
|
DAEMON = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(DAEMON)
|
||||||
|
|
||||||
|
|
||||||
|
def original_connection_budget() -> float:
|
||||||
|
read_budget = getattr(DAEMON, "READ_DEADLINE_SECONDS", None)
|
||||||
|
if isinstance(read_budget, (int, float)):
|
||||||
|
return float(read_budget)
|
||||||
|
return float(DAEMON.CONNECTION_DEADLINE_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
class SlowBroker:
|
||||||
|
def __init__(self, delay: float) -> None:
|
||||||
|
self.delay = delay
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def handle(self, _peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]:
|
||||||
|
self.calls += 1
|
||||||
|
time.sleep(self.delay)
|
||||||
|
return {"ok": True, "echo": request.get("action")}
|
||||||
|
|
||||||
|
|
||||||
|
class BrokerDeadlineTest(unittest.TestCase):
|
||||||
|
def test_lock_queue_timeout_returns_explicit_fail_closed_reply_without_handling(self) -> None:
|
||||||
|
broker = SlowBroker(0)
|
||||||
|
broker_lock = threading.Lock()
|
||||||
|
broker_lock.acquire()
|
||||||
|
server, client = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
client.settimeout(DAEMON.HANDLE_QUEUE_TIMEOUT_SECONDS + 2.0)
|
||||||
|
worker = threading.Thread(
|
||||||
|
target=DAEMON.handle_connection,
|
||||||
|
args=(server, broker, broker_lock),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
worker.start()
|
||||||
|
client.sendall(b'{"action":"probe"}\n')
|
||||||
|
client.shutdown(socket.SHUT_WR)
|
||||||
|
|
||||||
|
reply = bytearray()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = client.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
reply.extend(chunk)
|
||||||
|
finally:
|
||||||
|
broker_lock.release()
|
||||||
|
worker.join(timeout=2.0)
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
self.assertFalse(worker.is_alive())
|
||||||
|
self.assertEqual(broker.calls, 0)
|
||||||
|
self.assertEqual(json.loads(reply), {"ok": False, "code": "BROKER_BUSY"})
|
||||||
|
|
||||||
|
def test_completed_slow_handle_gets_a_complete_framed_reply(self) -> None:
|
||||||
|
broker = SlowBroker(original_connection_budget() + 0.1)
|
||||||
|
broker_lock = threading.Lock()
|
||||||
|
server, client = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
client.settimeout(original_connection_budget() + 2.0)
|
||||||
|
worker = threading.Thread(
|
||||||
|
target=DAEMON.handle_connection,
|
||||||
|
args=(server, broker, broker_lock),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
worker.start()
|
||||||
|
client.sendall(b'{"action":"probe"}\n')
|
||||||
|
client.shutdown(socket.SHUT_WR)
|
||||||
|
|
||||||
|
reply = bytearray()
|
||||||
|
while True:
|
||||||
|
chunk = client.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
reply.extend(chunk)
|
||||||
|
worker.join(timeout=2.0)
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
self.assertFalse(worker.is_alive())
|
||||||
|
self.assertEqual(broker.calls, 1)
|
||||||
|
self.assertTrue(reply.endswith(b"\n"), f"unframed reply: {bytes(reply)!r}")
|
||||||
|
self.assertEqual(json.loads(reply), {"ok": True, "echo": "probe"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
629
packages/mosaic/src/lease-broker/lease-broker.acceptance.spec.ts
Normal file
629
packages/mosaic/src/lease-broker/lease-broker.acceptance.spec.ts
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { chmod, mkdtemp, readFile, stat, symlink, writeFile } from 'node:fs/promises';
|
||||||
|
import { createConnection, type Socket } from 'node:net';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { readBrokerReply, requestBrokerReply } from './broker-test-client.js';
|
||||||
|
|
||||||
|
interface BrokerReply {
|
||||||
|
ok: boolean;
|
||||||
|
code?: string;
|
||||||
|
session_id?: string;
|
||||||
|
peer?: { pid: number; uid: number; gid: number; starttime: string };
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const daemonPath = new URL('../../framework/tools/lease-broker/daemon.py', import.meta.url)
|
||||||
|
.pathname;
|
||||||
|
const children: ChildProcess[] = [];
|
||||||
|
|
||||||
|
async function withTimeout<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
label: string,
|
||||||
|
milliseconds = 3_000,
|
||||||
|
): Promise<T> {
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timer !== undefined) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rawRequest(
|
||||||
|
socketPath: string,
|
||||||
|
write: (socket: Socket) => void,
|
||||||
|
): Promise<BrokerReply> {
|
||||||
|
return await readBrokerReply<BrokerReply>(socketPath, write);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(socketPath: string, requestValue: object): Promise<BrokerReply> {
|
||||||
|
return await requestBrokerReply<BrokerReply>(socketPath, requestValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startBroker(
|
||||||
|
parentMode = 0o700,
|
||||||
|
): Promise<{ root: string; socket: string; state: string }> {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'mosaic-lease-broker-'));
|
||||||
|
await chmod(root, parentMode);
|
||||||
|
const socket = join(root, 'broker.sock');
|
||||||
|
const state = join(root, 'state.json');
|
||||||
|
const child = spawn('python3', [daemonPath, '--socket', socket, '--state', state], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
children.push(child);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr?.setEncoding('utf8');
|
||||||
|
child.stderr?.on('data', (chunk: string) => (stderr += chunk));
|
||||||
|
child.once('error', reject);
|
||||||
|
child.once('exit', (code: number | null) =>
|
||||||
|
reject(new Error(`broker exited ${code}: ${stderr}`)),
|
||||||
|
);
|
||||||
|
child.stdout?.once('data', () => resolve());
|
||||||
|
});
|
||||||
|
return { root, socket, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startBrokerWithState(stateValue: string): Promise<string> {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'mosaic-lease-broker-'));
|
||||||
|
await chmod(root, 0o700);
|
||||||
|
const state = join(root, 'state.json');
|
||||||
|
await writeFile(state, stateValue, { mode: 0o600 });
|
||||||
|
const child = spawn('python3', [
|
||||||
|
daemonPath,
|
||||||
|
'--socket',
|
||||||
|
join(root, 'broker.sock'),
|
||||||
|
'--state',
|
||||||
|
state,
|
||||||
|
]);
|
||||||
|
children.push(child);
|
||||||
|
return await new Promise<string>((resolve) => {
|
||||||
|
let raw = '';
|
||||||
|
child.stderr?.on('data', (chunk: Buffer) => (raw += chunk.toString()));
|
||||||
|
child.once('exit', () => resolve(raw));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const child of children.splice(0)) child.kill('SIGTERM');
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('authenticated external lease broker', () => {
|
||||||
|
test('peercred returns true kernel (pid,starttime)', async () => {
|
||||||
|
const getuid = process.getuid;
|
||||||
|
const getgid = process.getgid;
|
||||||
|
if (getuid === undefined || getgid === undefined) {
|
||||||
|
throw new Error('Linux peer credentials require process.getuid() and process.getgid()');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const reply = await request(socket, { action: 'register_anchor', runtime_generation: 1 });
|
||||||
|
const statText = await readFile(`/proc/${process.pid}/stat`, 'utf8');
|
||||||
|
const fields = statText.slice(statText.lastIndexOf(')') + 2).split(' ');
|
||||||
|
expect(reply).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
peer: {
|
||||||
|
pid: process.pid,
|
||||||
|
uid: getuid(),
|
||||||
|
gid: getgid(),
|
||||||
|
starttime: fields[19],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([null, '', 'chosen'])('caller-asserted session_id refused (%j)', async (session_id) => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const reply = await request(socket, {
|
||||||
|
action: 'register_anchor',
|
||||||
|
runtime_generation: 1,
|
||||||
|
session_id,
|
||||||
|
});
|
||||||
|
expect(reply).toMatchObject({ ok: false, code: 'CALLER_SESSION_ID_REFUSED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sibling-substitution rejected', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const launcher = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'-e',
|
||||||
|
`const n=require('net');const s=n.connect(${JSON.stringify(socket)},()=>s.end(JSON.stringify({action:'register_anchor',runtime_generation:1})+'\\n'));s.on('data',d=>{process.send(JSON.parse(d));setInterval(()=>{},1000)})`,
|
||||||
|
],
|
||||||
|
{ stdio: ['ignore', 'ignore', 'ignore', 'ipc'] },
|
||||||
|
);
|
||||||
|
children.push(launcher);
|
||||||
|
const registration = await new Promise<BrokerReply>((resolve) =>
|
||||||
|
launcher.once('message', (message) => resolve(message as BrokerReply)),
|
||||||
|
);
|
||||||
|
const attacker = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'-e',
|
||||||
|
`const n=require('net');const s=n.connect(${JSON.stringify(socket)},()=>s.end(JSON.stringify({action:'authenticate',session_id:${JSON.stringify(registration.session_id)},runtime_generation:1})+'\\n'));s.pipe(process.stdout)`,
|
||||||
|
],
|
||||||
|
{ stdio: ['ignore', 'pipe', 'ignore'] },
|
||||||
|
);
|
||||||
|
children.push(attacker);
|
||||||
|
let raw = '';
|
||||||
|
attacker.stdout?.setEncoding('utf8');
|
||||||
|
attacker.stdout?.on('data', (chunk: string) => (raw += chunk));
|
||||||
|
await new Promise<void>((resolve) => attacker.once('exit', () => resolve()));
|
||||||
|
expect(JSON.parse(raw)).toMatchObject({ ok: false, code: 'ANCESTRY_MISMATCH' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generation bump revokes prior incarnation', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const registered = await request(socket, { action: 'register_anchor', runtime_generation: 1 });
|
||||||
|
expect(
|
||||||
|
await request(socket, {
|
||||||
|
action: 'authenticate',
|
||||||
|
session_id: registered.session_id,
|
||||||
|
runtime_generation: 2,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ ok: true });
|
||||||
|
expect(
|
||||||
|
await request(socket, {
|
||||||
|
action: 'authenticate',
|
||||||
|
session_id: registered.session_id,
|
||||||
|
runtime_generation: 1,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ ok: false, code: 'STALE_GENERATION' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same anchor re-registration reuses its session and revokes the prior incarnation', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const first = await request(socket, { action: 'register_anchor', runtime_generation: 1 });
|
||||||
|
const binding = {
|
||||||
|
compaction_epoch: 2,
|
||||||
|
request_epoch: 3,
|
||||||
|
h_source: 'a'.repeat(64),
|
||||||
|
h_payload: 'b'.repeat(64),
|
||||||
|
schema_version: 1,
|
||||||
|
};
|
||||||
|
const minted = await request(socket, {
|
||||||
|
action: 'mint_token',
|
||||||
|
session_id: first.session_id,
|
||||||
|
runtime_generation: 1,
|
||||||
|
binding,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bumped = await request(socket, { action: 'register_anchor', runtime_generation: 2 });
|
||||||
|
const repeated = await request(socket, { action: 'register_anchor', runtime_generation: 2 });
|
||||||
|
const lower = await request(socket, { action: 'register_anchor', runtime_generation: 1 });
|
||||||
|
|
||||||
|
expect(bumped).toMatchObject({ ok: true, session_id: first.session_id });
|
||||||
|
expect(repeated).toMatchObject({ ok: true, session_id: first.session_id });
|
||||||
|
expect(lower).toMatchObject({ ok: false, code: 'STALE_GENERATION' });
|
||||||
|
expect(
|
||||||
|
await request(socket, {
|
||||||
|
action: 'authenticate',
|
||||||
|
session_id: first.session_id,
|
||||||
|
runtime_generation: 1,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ ok: false, code: 'STALE_GENERATION' });
|
||||||
|
expect(
|
||||||
|
await request(socket, {
|
||||||
|
action: 'consume_token',
|
||||||
|
session_id: first.session_id,
|
||||||
|
runtime_generation: 2,
|
||||||
|
token: minted.token,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ ok: false, code: 'TOKEN_REPLAY' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('crypto token path works when Math.random is poisoned', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||||
|
throw new Error('Math.random forbidden');
|
||||||
|
});
|
||||||
|
const registered = await request(socket, { action: 'register_anchor', runtime_generation: 1 });
|
||||||
|
const binding = {
|
||||||
|
compaction_epoch: 2,
|
||||||
|
request_epoch: 3,
|
||||||
|
h_source: 'a'.repeat(64),
|
||||||
|
h_payload: 'b'.repeat(64),
|
||||||
|
schema_version: 1,
|
||||||
|
};
|
||||||
|
const first = await request(socket, {
|
||||||
|
action: 'mint_token',
|
||||||
|
session_id: registered.session_id,
|
||||||
|
runtime_generation: 1,
|
||||||
|
binding,
|
||||||
|
});
|
||||||
|
const second = await request(socket, {
|
||||||
|
action: 'mint_token',
|
||||||
|
session_id: registered.session_id,
|
||||||
|
runtime_generation: 1,
|
||||||
|
binding,
|
||||||
|
});
|
||||||
|
expect(first.token).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(second.token).not.toBe(first.token);
|
||||||
|
expect(
|
||||||
|
await request(socket, {
|
||||||
|
action: 'consume_token',
|
||||||
|
session_id: registered.session_id,
|
||||||
|
runtime_generation: 1,
|
||||||
|
token: first.token,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ ok: true });
|
||||||
|
expect(
|
||||||
|
await request(socket, {
|
||||||
|
action: 'consume_token',
|
||||||
|
session_id: registered.session_id,
|
||||||
|
runtime_generation: 1,
|
||||||
|
token: first.token,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ ok: false, code: 'TOKEN_REPLAY' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('socket parent 0700 and socket 0600 enforced', async () => {
|
||||||
|
const { root, socket, state } = await startBroker();
|
||||||
|
await request(socket, { action: 'register_anchor', runtime_generation: 1 });
|
||||||
|
expect((await stat(root)).mode & 0o777).toBe(0o700);
|
||||||
|
expect((await stat(socket)).mode & 0o777).toBe(0o600);
|
||||||
|
expect((await stat(state)).mode & 0o777).toBe(0o600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('insecure existing posture refused', async () => {
|
||||||
|
await expect(startBroker(0o755)).rejects.toThrow(/INSECURE_PARENT_MODE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed and oversized frames fail closed without killing broker', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const malformed = await new Promise<string>((resolve, reject) => {
|
||||||
|
const connection = createConnection(socket, () => connection.end('{nope}\n'));
|
||||||
|
let raw = '';
|
||||||
|
connection.on('data', (chunk: Buffer) => (raw += chunk.toString()));
|
||||||
|
connection.once('end', () => resolve(raw));
|
||||||
|
connection.once('error', reject);
|
||||||
|
});
|
||||||
|
expect(JSON.parse(malformed)).toMatchObject({ ok: false, code: 'MALFORMED_REQUEST' });
|
||||||
|
const registered = await request(socket, {
|
||||||
|
action: 'register_anchor',
|
||||||
|
runtime_generation: 1,
|
||||||
|
nonce: randomUUID(),
|
||||||
|
});
|
||||||
|
expect(registered.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('silent connection deadline cannot prevent the next valid registration', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const silent = createConnection(socket);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
silent.once('connect', resolve);
|
||||||
|
silent.once('error', reject);
|
||||||
|
});
|
||||||
|
const registered = await withTimeout(
|
||||||
|
request(socket, { action: 'register_anchor', runtime_generation: 1 }),
|
||||||
|
'registration behind silent connection',
|
||||||
|
);
|
||||||
|
expect(registered.ok).toBe(true);
|
||||||
|
silent.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('queued silent peers cannot serialize the next valid registration', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const silentConnections = await Promise.all(
|
||||||
|
Array.from(
|
||||||
|
{ length: 4 },
|
||||||
|
() =>
|
||||||
|
new Promise<Socket>((resolve, reject) => {
|
||||||
|
const connection = createConnection(socket);
|
||||||
|
connection.once('connect', () => resolve(connection));
|
||||||
|
connection.once('error', reject);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
const started = performance.now();
|
||||||
|
const registered = await withTimeout(
|
||||||
|
request(socket, { action: 'register_anchor', runtime_generation: 1 }),
|
||||||
|
'registration behind queued silent connections',
|
||||||
|
6_000,
|
||||||
|
);
|
||||||
|
const elapsed = performance.now() - started;
|
||||||
|
|
||||||
|
expect(registered.ok).toBe(true);
|
||||||
|
expect(elapsed).toBeLessThan(1_500);
|
||||||
|
} finally {
|
||||||
|
for (const connection of silentConnections) connection.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('silent peers are reaped at the concurrency bound and their slots are reclaimed', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const concurrencyCap = 16;
|
||||||
|
const peers = Array.from({ length: concurrencyCap }, () => {
|
||||||
|
const connection = createConnection(socket);
|
||||||
|
return {
|
||||||
|
connection,
|
||||||
|
connected: new Promise<void>((resolve, reject) => {
|
||||||
|
connection.once('connect', resolve);
|
||||||
|
connection.once('error', reject);
|
||||||
|
}),
|
||||||
|
closed: new Promise<void>((resolve) => connection.once('close', () => resolve())),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(peers.map(({ connected }) => connected));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
const started = performance.now();
|
||||||
|
const registration = withTimeout(
|
||||||
|
request(socket, { action: 'register_anchor', runtime_generation: 1 }),
|
||||||
|
'registration while silent peers hold the concurrency bound',
|
||||||
|
2_500,
|
||||||
|
);
|
||||||
|
const reaping = withTimeout(
|
||||||
|
Promise.all(peers.map(({ closed }) => closed)),
|
||||||
|
'silent peer deadline reaping',
|
||||||
|
2_500,
|
||||||
|
);
|
||||||
|
const [registered] = await Promise.all([registration, reaping]);
|
||||||
|
const elapsed = performance.now() - started;
|
||||||
|
|
||||||
|
expect(registered.ok).toBe(true);
|
||||||
|
expect(elapsed).toBeGreaterThan(500);
|
||||||
|
expect(elapsed).toBeLessThan(2_500);
|
||||||
|
} finally {
|
||||||
|
for (const { connection } of peers) connection.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('newline-only client without half-close gets no success and cannot block next request', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const incomplete = createConnection(socket);
|
||||||
|
let raw = '';
|
||||||
|
incomplete.setEncoding('utf8');
|
||||||
|
incomplete.on('data', (chunk: string) => (raw += chunk));
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
incomplete.once('connect', () => {
|
||||||
|
incomplete.write(
|
||||||
|
`${JSON.stringify({ action: 'register_anchor', runtime_generation: 1 })}\n`,
|
||||||
|
);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
incomplete.once('error', reject);
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||||
|
expect(raw).not.toContain('"ok":true');
|
||||||
|
const registered = await withTimeout(
|
||||||
|
request(socket, { action: 'register_anchor', runtime_generation: 1 }),
|
||||||
|
'registration after non-half-closed client',
|
||||||
|
);
|
||||||
|
expect(registered.ok).toBe(true);
|
||||||
|
incomplete.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client disconnect cannot prevent the next valid authentication', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const registered = await request(socket, { action: 'register_anchor', runtime_generation: 1 });
|
||||||
|
const reset = createConnection(socket);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
reset.once('connect', () => {
|
||||||
|
reset.write(`${JSON.stringify({ action: 'register_anchor', runtime_generation: 1 })}\n`);
|
||||||
|
reset.destroy();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
reset.once('error', reject);
|
||||||
|
});
|
||||||
|
const authenticated = await withTimeout(
|
||||||
|
request(socket, {
|
||||||
|
action: 'authenticate',
|
||||||
|
session_id: registered.session_id,
|
||||||
|
runtime_generation: 1,
|
||||||
|
}),
|
||||||
|
'authentication after client disconnect',
|
||||||
|
);
|
||||||
|
expect(authenticated.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delayed second frame is rejected and the next request succeeds', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const reply = await rawRequest(socket, (connection) => {
|
||||||
|
connection.write(`${JSON.stringify({ action: 'register_anchor', runtime_generation: 1 })}\n`);
|
||||||
|
setTimeout(() => connection.end('{}\n'), 50);
|
||||||
|
});
|
||||||
|
expect(reply).toMatchObject({ ok: false, code: 'MALFORMED_REQUEST' });
|
||||||
|
expect(
|
||||||
|
await request(socket, { action: 'register_anchor', runtime_generation: 1 }),
|
||||||
|
).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unterminated frame is rejected and the next request succeeds', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const reply = await rawRequest(socket, (connection) => connection.end('{}'));
|
||||||
|
expect(reply).toMatchObject({ ok: false, code: 'MALFORMED_REQUEST' });
|
||||||
|
expect(
|
||||||
|
await request(socket, { action: 'register_anchor', runtime_generation: 1 }),
|
||||||
|
).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('genuinely oversized frame is rejected and the next request succeeds', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const reply = await rawRequest(socket, (connection) =>
|
||||||
|
connection.end(`${JSON.stringify({ padding: 'x'.repeat(64 * 1024) })}\n`),
|
||||||
|
);
|
||||||
|
expect(reply).toMatchObject({ ok: false, code: 'MALFORMED_REQUEST' });
|
||||||
|
expect(
|
||||||
|
await request(socket, { action: 'register_anchor', runtime_generation: 1 }),
|
||||||
|
).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('boolean runtime generations fail closed', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
expect(
|
||||||
|
await request(socket, { action: 'register_anchor', runtime_generation: true }),
|
||||||
|
).toMatchObject({ ok: false, code: 'INVALID_GENERATION' });
|
||||||
|
const registered = await request(socket, { action: 'register_anchor', runtime_generation: 1 });
|
||||||
|
expect(
|
||||||
|
await request(socket, {
|
||||||
|
action: 'authenticate',
|
||||||
|
session_id: registered.session_id,
|
||||||
|
runtime_generation: false,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ ok: false, code: 'INVALID_IDENTITY' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ compaction_epoch: true, request_epoch: 0, schema_version: 1 },
|
||||||
|
{ compaction_epoch: 0, request_epoch: -1, schema_version: 1 },
|
||||||
|
{ compaction_epoch: 0, request_epoch: 0, schema_version: false },
|
||||||
|
{ compaction_epoch: 0, request_epoch: 0, schema_version: -1 },
|
||||||
|
{ compaction_epoch: 0, request_epoch: 0, schema_version: 1, h_source: 'A'.repeat(64) },
|
||||||
|
{ compaction_epoch: 0, request_epoch: 0, schema_version: 1, h_payload: 'a'.repeat(63) },
|
||||||
|
])('invalid cycle binding fails closed without persisting a token (%j)', async (override) => {
|
||||||
|
const { socket, state } = await startBroker();
|
||||||
|
const registered = await request(socket, { action: 'register_anchor', runtime_generation: 1 });
|
||||||
|
const binding = Object.assign(
|
||||||
|
{
|
||||||
|
compaction_epoch: 0,
|
||||||
|
request_epoch: 0,
|
||||||
|
h_source: 'a'.repeat(64),
|
||||||
|
h_payload: 'b'.repeat(64),
|
||||||
|
schema_version: 1,
|
||||||
|
},
|
||||||
|
override,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await request(socket, {
|
||||||
|
action: 'mint_token',
|
||||||
|
session_id: registered.session_id,
|
||||||
|
runtime_generation: 1,
|
||||||
|
binding,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ ok: false, code: 'INVALID_BINDING' });
|
||||||
|
const persisted = JSON.parse(await readFile(state, 'utf8')) as { tokens: object };
|
||||||
|
expect(persisted.tokens).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('StateStore write-all unit path handles partial writes and cleans failed temp files', () => {
|
||||||
|
const result = spawnSync('python3', [join(import.meta.dirname, 'state_store_unittest.py')], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
expect(result.status, result.stderr).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persistence integrity failure refuses startup', async () => {
|
||||||
|
expect(await startBrokerWithState('{corrupt')).toContain('STATE_INTEGRITY');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ version: 1, sessions: {}, tokens: {}, unexpected: true },
|
||||||
|
{ version: 1, sessions: { bad: {} }, tokens: {} },
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
sessions: {
|
||||||
|
['a'.repeat(64)]: { anchor_pid: true, anchor_starttime: '1', runtime_generation: 0 },
|
||||||
|
},
|
||||||
|
tokens: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
sessions: {
|
||||||
|
['a'.repeat(64)]: { anchor_pid: 1, anchor_starttime: '01', runtime_generation: 0 },
|
||||||
|
},
|
||||||
|
tokens: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
sessions: {
|
||||||
|
['a'.repeat(64)]: { anchor_pid: 1, anchor_starttime: '1', runtime_generation: 0 },
|
||||||
|
['b'.repeat(64)]: { anchor_pid: 1, anchor_starttime: '1', runtime_generation: 1 },
|
||||||
|
},
|
||||||
|
tokens: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
sessions: {
|
||||||
|
['a'.repeat(64)]: { anchor_pid: 1, anchor_starttime: '1', runtime_generation: 0 },
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
['b'.repeat(64)]: {
|
||||||
|
session_id: 'c'.repeat(64),
|
||||||
|
runtime_generation: 0,
|
||||||
|
binding: {
|
||||||
|
compaction_epoch: 0,
|
||||||
|
request_epoch: 0,
|
||||||
|
h_source: 'd'.repeat(64),
|
||||||
|
h_payload: 'e'.repeat(64),
|
||||||
|
schema_version: 1,
|
||||||
|
},
|
||||||
|
consumed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
sessions: {
|
||||||
|
['a'.repeat(64)]: { anchor_pid: 1, anchor_starttime: '1', runtime_generation: 1 },
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
['b'.repeat(64)]: {
|
||||||
|
session_id: 'a'.repeat(64),
|
||||||
|
runtime_generation: 2,
|
||||||
|
binding: {
|
||||||
|
compaction_epoch: 0,
|
||||||
|
request_epoch: 0,
|
||||||
|
h_source: 'd'.repeat(64),
|
||||||
|
h_payload: 'e'.repeat(64),
|
||||||
|
schema_version: 1,
|
||||||
|
},
|
||||||
|
consumed: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])('nested corrupt state refuses startup (%#)', async (stateValue) => {
|
||||||
|
expect(await startBrokerWithState(JSON.stringify(stateValue))).toContain('STATE_INTEGRITY');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('symlink state refuses startup', async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'mosaic-lease-broker-'));
|
||||||
|
await chmod(root, 0o700);
|
||||||
|
const target = join(root, 'target.json');
|
||||||
|
const state = join(root, 'state.json');
|
||||||
|
await writeFile(target, JSON.stringify({ version: 1, sessions: {}, tokens: {} }), {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
await symlink(target, state);
|
||||||
|
const child = spawn('python3', [
|
||||||
|
daemonPath,
|
||||||
|
'--socket',
|
||||||
|
join(root, 'broker.sock'),
|
||||||
|
'--state',
|
||||||
|
state,
|
||||||
|
]);
|
||||||
|
children.push(child);
|
||||||
|
const stderr = await new Promise<string>((resolve) => {
|
||||||
|
let raw = '';
|
||||||
|
child.stderr?.on('data', (chunk: Buffer) => (raw += chunk.toString()));
|
||||||
|
child.once('exit', () => resolve(raw));
|
||||||
|
});
|
||||||
|
expect(stderr).toContain('STATE_INTEGRITY');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('oversized state refuses startup', async () => {
|
||||||
|
expect(await startBrokerWithState(' '.repeat(4 * 1024 * 1024 + 1))).toContain(
|
||||||
|
'STATE_INTEGRITY',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
333
packages/mosaic/src/lease-broker/state_store_unittest.py
Normal file
333
packages/mosaic/src/lease-broker/state_store_unittest.py
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Standard-library edge tests for lease-broker atomic state persistence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
DAEMON_PATH = Path(__file__).parents[2] / "framework/tools/lease-broker/daemon.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("lease_broker_daemon", DAEMON_PATH)
|
||||||
|
if SPEC is None or SPEC.loader is None:
|
||||||
|
raise RuntimeError("unable to load lease broker daemon")
|
||||||
|
DAEMON = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(DAEMON)
|
||||||
|
|
||||||
|
|
||||||
|
class StateStoreCommitTest(unittest.TestCase):
|
||||||
|
def make_store(self, root: Path):
|
||||||
|
os.chmod(root, 0o700)
|
||||||
|
store = DAEMON.StateStore(root / "state.json")
|
||||||
|
store.value["marker"] = "partial-write-proof"
|
||||||
|
return store
|
||||||
|
|
||||||
|
def test_partial_writes_persist_the_complete_payload(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
store = self.make_store(root)
|
||||||
|
real_write = os.write
|
||||||
|
|
||||||
|
def partial_write(descriptor: int, payload: bytes) -> int:
|
||||||
|
return real_write(descriptor, payload[: max(1, len(payload) // 3)])
|
||||||
|
|
||||||
|
with patch.object(DAEMON.os, "write", side_effect=partial_write):
|
||||||
|
store.commit()
|
||||||
|
|
||||||
|
self.assertEqual(json.loads(store.path.read_text()), store.value)
|
||||||
|
|
||||||
|
def test_zero_progress_removes_owned_temporary_file(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
store = self.make_store(root)
|
||||||
|
with patch.object(DAEMON.os, "write", return_value=0):
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
store.commit()
|
||||||
|
|
||||||
|
self.assertFalse(store.path.exists())
|
||||||
|
self.assertEqual(list(root.glob(".*.tmp")), [])
|
||||||
|
|
||||||
|
def test_oversized_payload_is_refused_before_replacing_state(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
store = self.make_store(root)
|
||||||
|
store.value.pop("marker")
|
||||||
|
store.commit()
|
||||||
|
durable = store.path.read_bytes()
|
||||||
|
store.value["oversized"] = "x" * DAEMON.MAX_STATE
|
||||||
|
|
||||||
|
with patch.object(DAEMON.os, "open", wraps=os.open) as mocked_open:
|
||||||
|
with self.assertRaisesRegex(DAEMON.BrokerFailure, "STATE_TOO_LARGE"):
|
||||||
|
store.commit()
|
||||||
|
|
||||||
|
self.assertEqual(mocked_open.call_count, 0)
|
||||||
|
self.assertEqual(store.path.read_bytes(), durable)
|
||||||
|
self.assertEqual(list(root.glob(".*.tmp")), [])
|
||||||
|
|
||||||
|
|
||||||
|
class StateStoreValidationTest(unittest.TestCase):
|
||||||
|
@staticmethod
|
||||||
|
def binding() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"compaction_epoch": 0,
|
||||||
|
"request_epoch": 0,
|
||||||
|
"h_source": "a" * 64,
|
||||||
|
"h_payload": "b" * 64,
|
||||||
|
"schema_version": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_impossible_or_over_capacity_token_state_is_rejected(self) -> None:
|
||||||
|
session_id = "1" * 64
|
||||||
|
session = {
|
||||||
|
"anchor_pid": 123,
|
||||||
|
"anchor_starttime": "456",
|
||||||
|
"runtime_generation": 2,
|
||||||
|
}
|
||||||
|
live_token = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"runtime_generation": 2,
|
||||||
|
"binding": self.binding(),
|
||||||
|
"consumed": False,
|
||||||
|
}
|
||||||
|
cases = {
|
||||||
|
"stale generation": {
|
||||||
|
"2" * 64: {**live_token, "runtime_generation": 1},
|
||||||
|
},
|
||||||
|
"consumed token": {
|
||||||
|
"2" * 64: {**live_token, "consumed": True},
|
||||||
|
},
|
||||||
|
"over capacity": {
|
||||||
|
f"{index:064x}": copy.deepcopy(live_token)
|
||||||
|
for index in range(DAEMON.MAX_PENDING_TOKENS + 1)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for label, tokens in cases.items():
|
||||||
|
with self.subTest(label=label), tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
os.chmod(root, 0o700)
|
||||||
|
state_path = root / "state.json"
|
||||||
|
state_path.write_text(json.dumps({
|
||||||
|
"version": 1,
|
||||||
|
"sessions": {session_id: session},
|
||||||
|
"tokens": tokens,
|
||||||
|
}))
|
||||||
|
os.chmod(state_path, 0o600)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(DAEMON.BrokerFailure, "STATE_INTEGRITY"):
|
||||||
|
DAEMON.StateStore(state_path)
|
||||||
|
|
||||||
|
|
||||||
|
class BrokerBehaviorTest(unittest.TestCase):
|
||||||
|
def make_broker(self, root: Path):
|
||||||
|
os.chmod(root, 0o700)
|
||||||
|
return DAEMON.Broker(DAEMON.StateStore(root / "state.json"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def binding() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"compaction_epoch": 0,
|
||||||
|
"request_epoch": 0,
|
||||||
|
"h_source": "a" * 64,
|
||||||
|
"h_payload": "b" * 64,
|
||||||
|
"schema_version": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
def register(self, broker, generation: int = 1) -> str:
|
||||||
|
response = broker.handle((123, 1000, 1000), {
|
||||||
|
"action": "register_anchor",
|
||||||
|
"runtime_generation": generation,
|
||||||
|
})
|
||||||
|
return response["session_id"]
|
||||||
|
|
||||||
|
def mint(self, broker, session_id: str, generation: int = 1) -> str:
|
||||||
|
response = broker.handle((123, 1000, 1000), {
|
||||||
|
"action": "mint_token",
|
||||||
|
"session_id": session_id,
|
||||||
|
"runtime_generation": generation,
|
||||||
|
"binding": self.binding(),
|
||||||
|
})
|
||||||
|
return response["token"]
|
||||||
|
|
||||||
|
def consume(self, broker, session_id: str, token: str, generation: int = 1):
|
||||||
|
return broker.handle((123, 1000, 1000), {
|
||||||
|
"action": "consume_token",
|
||||||
|
"session_id": session_id,
|
||||||
|
"runtime_generation": generation,
|
||||||
|
"token": token,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_anchor_generation_bump_reuses_session_and_revokes_token(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
broker = self.make_broker(root)
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
DAEMON,
|
||||||
|
"proc_node",
|
||||||
|
return_value={"pid": 123, "ppid": 1, "starttime": "456"},
|
||||||
|
),
|
||||||
|
patch.object(DAEMON, "verified_ancestry", return_value=True),
|
||||||
|
):
|
||||||
|
first = broker.handle((123, 1000, 1000), {
|
||||||
|
"action": "register_anchor",
|
||||||
|
"runtime_generation": 1,
|
||||||
|
})
|
||||||
|
minted = broker.handle((123, 1000, 1000), {
|
||||||
|
"action": "mint_token",
|
||||||
|
"session_id": first["session_id"],
|
||||||
|
"runtime_generation": 1,
|
||||||
|
"binding": self.binding(),
|
||||||
|
})
|
||||||
|
bumped = broker.handle((123, 1000, 1000), {
|
||||||
|
"action": "register_anchor",
|
||||||
|
"runtime_generation": 2,
|
||||||
|
})
|
||||||
|
repeated = broker.handle((123, 1000, 1000), {
|
||||||
|
"action": "register_anchor",
|
||||||
|
"runtime_generation": 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(bumped["session_id"], first["session_id"])
|
||||||
|
self.assertEqual(repeated["session_id"], first["session_id"])
|
||||||
|
self.assertNotIn(minted["token"], broker.store.tokens())
|
||||||
|
restarted = self.make_broker(root)
|
||||||
|
self.assertEqual(restarted.store.tokens(), {})
|
||||||
|
self.assertEqual(
|
||||||
|
restarted.store.sessions()[first["session_id"]]["runtime_generation"], 2
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(DAEMON.BrokerFailure, "STALE_GENERATION"):
|
||||||
|
with patch.object(DAEMON, "proc_node", return_value={"starttime": "456"}):
|
||||||
|
broker.handle((123, 1000, 1000), {
|
||||||
|
"action": "register_anchor",
|
||||||
|
"runtime_generation": 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_successful_consume_deletes_token_and_replay_is_refused(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory, (
|
||||||
|
patch.object(DAEMON, "proc_node", return_value={"pid": 123, "ppid": 1, "starttime": "456"})
|
||||||
|
), patch.object(DAEMON, "verified_ancestry", return_value=True):
|
||||||
|
broker = self.make_broker(Path(directory))
|
||||||
|
session_id = self.register(broker)
|
||||||
|
token = self.mint(broker, session_id)
|
||||||
|
|
||||||
|
self.assertEqual(self.consume(broker, session_id, token), {"ok": True})
|
||||||
|
self.assertNotIn(token, broker.store.tokens())
|
||||||
|
with self.assertRaisesRegex(DAEMON.BrokerFailure, "TOKEN_REPLAY"):
|
||||||
|
self.consume(broker, session_id, token)
|
||||||
|
|
||||||
|
def test_normal_cycles_remain_bounded_and_restartable(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory, (
|
||||||
|
patch.object(DAEMON, "proc_node", return_value={"pid": 123, "ppid": 1, "starttime": "456"})
|
||||||
|
), patch.object(DAEMON, "verified_ancestry", return_value=True):
|
||||||
|
root = Path(directory)
|
||||||
|
broker = self.make_broker(root)
|
||||||
|
session_id = self.register(broker)
|
||||||
|
|
||||||
|
for _ in range(DAEMON.MAX_PENDING_TOKENS * 3):
|
||||||
|
self.consume(broker, session_id, self.mint(broker, session_id))
|
||||||
|
|
||||||
|
self.assertEqual(broker.store.tokens(), {})
|
||||||
|
self.assertLess((root / "state.json").stat().st_size, DAEMON.MAX_STATE)
|
||||||
|
restarted = self.make_broker(root)
|
||||||
|
self.assertEqual(restarted.store.tokens(), {})
|
||||||
|
self.assertIn(session_id, restarted.store.sessions())
|
||||||
|
|
||||||
|
def test_pending_token_capacity_refusal_does_not_mutate_state(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory, (
|
||||||
|
patch.object(DAEMON, "proc_node", return_value={"pid": 123, "ppid": 1, "starttime": "456"})
|
||||||
|
), patch.object(DAEMON, "verified_ancestry", return_value=True):
|
||||||
|
root = Path(directory)
|
||||||
|
broker = self.make_broker(root)
|
||||||
|
session_id = self.register(broker)
|
||||||
|
for _ in range(DAEMON.MAX_PENDING_TOKENS):
|
||||||
|
self.mint(broker, session_id)
|
||||||
|
before = copy.deepcopy(broker.store.value)
|
||||||
|
durable = broker.store.path.read_bytes()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(DAEMON.BrokerFailure, "TOKEN_CAPACITY"):
|
||||||
|
self.mint(broker, session_id)
|
||||||
|
|
||||||
|
self.assertEqual(broker.store.value, before)
|
||||||
|
self.assertEqual(broker.store.path.read_bytes(), durable)
|
||||||
|
|
||||||
|
def test_directory_fsync_failure_poisoned_store_cannot_continue(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory, patch.object(
|
||||||
|
DAEMON,
|
||||||
|
"proc_node",
|
||||||
|
return_value={"pid": 123, "ppid": 1, "starttime": "456"},
|
||||||
|
):
|
||||||
|
root = Path(directory)
|
||||||
|
broker = self.make_broker(root)
|
||||||
|
real_fsync = os.fsync
|
||||||
|
|
||||||
|
def fail_directory_fsync(descriptor: int) -> None:
|
||||||
|
if os.path.isdir(f"/proc/self/fd/{descriptor}"):
|
||||||
|
raise OSError("directory fsync failed")
|
||||||
|
real_fsync(descriptor)
|
||||||
|
|
||||||
|
with patch.object(DAEMON.os, "fsync", side_effect=fail_directory_fsync):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
DAEMON.StateCommitUncertain, "STATE_COMMIT_UNCERTAIN"
|
||||||
|
):
|
||||||
|
self.register(broker)
|
||||||
|
|
||||||
|
durable = json.loads(broker.store.path.read_text())
|
||||||
|
self.assertEqual(broker.store.value, durable)
|
||||||
|
self.assertTrue(broker.store.poisoned)
|
||||||
|
before = copy.deepcopy(broker.store.value)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
DAEMON.StateCommitUncertain, "STATE_COMMIT_UNCERTAIN"
|
||||||
|
):
|
||||||
|
broker.handle((123, 1000, 1000), {
|
||||||
|
"action": "register_anchor",
|
||||||
|
"runtime_generation": 2,
|
||||||
|
})
|
||||||
|
self.assertEqual(broker.store.value, before)
|
||||||
|
|
||||||
|
def test_commit_failures_before_replace_roll_back_every_broker_mutation(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory, (
|
||||||
|
patch.object(DAEMON, "proc_node", return_value={"pid": 123, "ppid": 1, "starttime": "456"})
|
||||||
|
), patch.object(DAEMON, "verified_ancestry", return_value=True):
|
||||||
|
root = Path(directory)
|
||||||
|
broker = self.make_broker(root)
|
||||||
|
session_id = self.register(broker)
|
||||||
|
token = self.mint(broker, session_id)
|
||||||
|
|
||||||
|
def assert_rollback(request: dict[str, object]) -> None:
|
||||||
|
before = copy.deepcopy(broker.store.value)
|
||||||
|
durable = broker.store.path.read_bytes()
|
||||||
|
with patch.object(broker.store, "commit", side_effect=OSError("fsync failed")):
|
||||||
|
with self.assertRaisesRegex(OSError, "fsync failed"):
|
||||||
|
broker.handle((123, 1000, 1000), request)
|
||||||
|
self.assertEqual(broker.store.value, before)
|
||||||
|
self.assertEqual(broker.store.path.read_bytes(), durable)
|
||||||
|
|
||||||
|
assert_rollback({"action": "register_anchor", "runtime_generation": 2})
|
||||||
|
assert_rollback({
|
||||||
|
"action": "mint_token", "session_id": session_id,
|
||||||
|
"runtime_generation": 1, "binding": self.binding(),
|
||||||
|
})
|
||||||
|
assert_rollback({
|
||||||
|
"action": "consume_token", "session_id": session_id,
|
||||||
|
"runtime_generation": 1, "token": token,
|
||||||
|
})
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as second_directory:
|
||||||
|
second = self.make_broker(Path(second_directory))
|
||||||
|
with patch.object(second.store, "commit", side_effect=OSError("fsync failed")):
|
||||||
|
with self.assertRaisesRegex(OSError, "fsync failed"):
|
||||||
|
self.register(second)
|
||||||
|
self.assertEqual(
|
||||||
|
second.store.value, {"version": 1, "sessions": {}, "tokens": {}}
|
||||||
|
)
|
||||||
|
self.assertFalse(second.store.path.exists())
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
657
packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts
Normal file
657
packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts
Normal file
@@ -0,0 +1,657 @@
|
|||||||
|
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js';
|
||||||
|
import { requestBrokerReply } from '../lease-broker/broker-test-client.js';
|
||||||
|
|
||||||
|
interface BrokerReply {
|
||||||
|
ok: boolean;
|
||||||
|
code?: string;
|
||||||
|
decision?: 'allow' | 'deny';
|
||||||
|
state?: 'UNVERIFIED' | 'PENDING_VERIFICATION' | 'VERIFIED';
|
||||||
|
session_id?: string;
|
||||||
|
promotion_token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrokerPaths {
|
||||||
|
socket: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameworkRoot = new URL('../../framework/', import.meta.url).pathname;
|
||||||
|
const daemonPath = join(frameworkRoot, 'tools/lease-broker/daemon.py');
|
||||||
|
const gatePath = join(frameworkRoot, 'tools/lease-broker/mutator-gate.py');
|
||||||
|
const launchGuardPath = join(frameworkRoot, 'tools/lease-broker/check-runtime-launches.py');
|
||||||
|
const launcherPath = join(frameworkRoot, 'tools/lease-broker/launch-runtime.py');
|
||||||
|
const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json');
|
||||||
|
const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts');
|
||||||
|
const prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh');
|
||||||
|
const prdyUpdatePath = join(frameworkRoot, 'tools/prdy/prdy-update.sh');
|
||||||
|
const remediationHandlerPath = join(frameworkRoot, 'tools/qa/remediation-hook-handler.sh');
|
||||||
|
const children: ChildProcess[] = [];
|
||||||
|
const temporaryRoots: string[] = [];
|
||||||
|
|
||||||
|
const binding = (compaction_epoch = 1) => ({
|
||||||
|
compaction_epoch,
|
||||||
|
request_epoch: 0,
|
||||||
|
h_source: 'a'.repeat(64),
|
||||||
|
h_payload: 'b'.repeat(64),
|
||||||
|
schema_version: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function request(socketPath: string, requestValue: object): Promise<BrokerReply> {
|
||||||
|
return await requestBrokerReply<BrokerReply>(socketPath, requestValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startBroker(): Promise<BrokerPaths> {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'mosaic-mutator-gate-'));
|
||||||
|
await chmod(root, 0o700);
|
||||||
|
const socket = join(root, 'broker.sock');
|
||||||
|
const child = spawn(
|
||||||
|
'python3',
|
||||||
|
[daemonPath, '--socket', socket, '--state', join(root, 'state.json')],
|
||||||
|
{
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
children.push(child);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr?.setEncoding('utf8');
|
||||||
|
child.stderr?.on('data', (chunk: string) => (stderr += chunk));
|
||||||
|
child.once('error', reject);
|
||||||
|
child.once('exit', (code: number | null) =>
|
||||||
|
reject(new Error(`broker exited ${code}: ${stderr}`)),
|
||||||
|
);
|
||||||
|
child.stdout?.once('data', () => resolve());
|
||||||
|
});
|
||||||
|
return { socket };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeLaunchEntry {
|
||||||
|
name: string;
|
||||||
|
script: string;
|
||||||
|
prepare(root: string): Promise<string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeLaunchEntries: RuntimeLaunchEntry[] = [
|
||||||
|
{
|
||||||
|
name: 'prdy-init',
|
||||||
|
script: prdyInitPath,
|
||||||
|
prepare: async (root) => ['--project', root, '--name', 'Gate Test'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'prdy-update',
|
||||||
|
script: prdyUpdatePath,
|
||||||
|
prepare: async (root) => {
|
||||||
|
await mkdir(join(root, 'docs'), { recursive: true });
|
||||||
|
await writeFile(join(root, 'docs/PRD.md'), '# Existing PRD\n');
|
||||||
|
return ['--project', root];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qa-remediation',
|
||||||
|
script: remediationHandlerPath,
|
||||||
|
prepare: async (root) => {
|
||||||
|
const pending = join(root, 'reports/pending');
|
||||||
|
await mkdir(pending, { recursive: true });
|
||||||
|
const report = join(pending, 'gate_remediation_needed.md');
|
||||||
|
await writeFile(report, '# remediation\n');
|
||||||
|
return [report];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function runRuntimeLaunchEntry(entry: RuntimeLaunchEntry, socket: string) {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), `mosaic-${entry.name}-gate-`));
|
||||||
|
temporaryRoots.push(root);
|
||||||
|
const binDir = join(root, 'bin');
|
||||||
|
await mkdir(binDir, { recursive: true });
|
||||||
|
const fakeClaude = join(binDir, 'claude');
|
||||||
|
await writeFile(
|
||||||
|
fakeClaude,
|
||||||
|
`#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
session_id = os.environ.get("MOSAIC_LEASE_SESSION_ID", "")
|
||||||
|
denied = subprocess.run(
|
||||||
|
["python3", ${JSON.stringify(gatePath)}, "--runtime", "claude"],
|
||||||
|
input=json.dumps({"tool_name": "Bash"}) + "\\n",
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
env=os.environ,
|
||||||
|
).returncode == 2
|
||||||
|
print("RUNTIME_PROBE=" + json.dumps({"session_id": session_id, "denied": denied}))
|
||||||
|
raise SystemExit(0 if len(session_id) == 64 and denied else 1)
|
||||||
|
`,
|
||||||
|
{ mode: 0o700 },
|
||||||
|
);
|
||||||
|
await chmod(fakeClaude, 0o700);
|
||||||
|
const args = await entry.prepare(root);
|
||||||
|
return spawnSync('bash', [entry.script, ...args], {
|
||||||
|
cwd: root,
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||||
|
MOSAIC_HOME: frameworkRoot,
|
||||||
|
MOSAIC_PRDY_RUNTIME: 'claude',
|
||||||
|
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||||
|
MOSAIC_RUNTIME_GENERATION: '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(socket: string, runtime_generation = 1): Promise<string> {
|
||||||
|
const reply = await request(socket, { action: 'register_anchor', runtime_generation });
|
||||||
|
expect(reply.ok).toBe(true);
|
||||||
|
expect(reply.session_id).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
return reply.session_id!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function beginVerification(
|
||||||
|
socket: string,
|
||||||
|
session_id: string,
|
||||||
|
runtime: 'claude' | 'pi',
|
||||||
|
runtime_generation = 1,
|
||||||
|
ttl_seconds = 300,
|
||||||
|
compactionEpoch = 1,
|
||||||
|
): Promise<BrokerReply> {
|
||||||
|
return await request(socket, {
|
||||||
|
action: 'begin_verification',
|
||||||
|
session_id,
|
||||||
|
runtime_generation,
|
||||||
|
runtime,
|
||||||
|
ttl_seconds,
|
||||||
|
binding: binding(compactionEpoch),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promote(
|
||||||
|
socket: string,
|
||||||
|
session_id: string,
|
||||||
|
promotion_token: string,
|
||||||
|
runtime_generation = 1,
|
||||||
|
): Promise<BrokerReply> {
|
||||||
|
return await request(socket, {
|
||||||
|
action: 'promote_lease',
|
||||||
|
session_id,
|
||||||
|
runtime_generation,
|
||||||
|
promotion_token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authorize(
|
||||||
|
socket: string,
|
||||||
|
session_id: string,
|
||||||
|
runtime: 'claude' | 'pi',
|
||||||
|
tool_name: string,
|
||||||
|
runtime_generation = 1,
|
||||||
|
): Promise<BrokerReply> {
|
||||||
|
return await request(socket, {
|
||||||
|
action: 'authorize_tool',
|
||||||
|
session_id,
|
||||||
|
runtime_generation,
|
||||||
|
runtime,
|
||||||
|
tool_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runRuntimeGate(
|
||||||
|
socket: string,
|
||||||
|
sessionId: string,
|
||||||
|
runtime: 'claude' | 'pi',
|
||||||
|
toolName: string,
|
||||||
|
generation = 1,
|
||||||
|
) {
|
||||||
|
return spawnSync('python3', [gatePath, '--runtime', runtime], {
|
||||||
|
input: `${JSON.stringify({ tool_name: toolName })}\n`,
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||||
|
MOSAIC_LEASE_SESSION_ID: sessionId,
|
||||||
|
MOSAIC_RUNTIME_GENERATION: String(generation),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const child of children.splice(0)) child.kill('SIGTERM');
|
||||||
|
await Promise.all(
|
||||||
|
temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('whole mutator-class lease gate', () => {
|
||||||
|
test('revoke-first and promote-last structurally bracket mutator authority', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const sessionId = await register(socket);
|
||||||
|
|
||||||
|
expect(await promote(socket, sessionId, 'c'.repeat(64))).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'INVALID_LEASE_TRANSITION',
|
||||||
|
});
|
||||||
|
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'MUTATOR_UNVERIFIED',
|
||||||
|
decision: 'deny',
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = await beginVerification(socket, sessionId, 'claude');
|
||||||
|
expect(pending).toMatchObject({ ok: true, state: 'PENDING_VERIFICATION' });
|
||||||
|
expect(pending.promotion_token).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(await authorize(socket, sessionId, 'claude', 'Write')).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
decision: 'deny',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await promote(socket, sessionId, pending.promotion_token!)).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
state: 'VERIFIED',
|
||||||
|
});
|
||||||
|
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
decision: 'allow',
|
||||||
|
state: 'VERIFIED',
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextCycle = await beginVerification(socket, sessionId, 'claude', 1, 300, 2);
|
||||||
|
expect(nextCycle).toMatchObject({ ok: true, state: 'PENDING_VERIFICATION' });
|
||||||
|
expect(await authorize(socket, sessionId, 'claude', 'Edit')).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
decision: 'deny',
|
||||||
|
});
|
||||||
|
expect(await promote(socket, sessionId, pending.promotion_token!)).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'PROMOTION_TOKEN_MISMATCH',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-dangerous parser residual is denied by the global all-tools hook without a lease', async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'mosaic-parser-residual-'));
|
||||||
|
temporaryRoots.push(root);
|
||||||
|
const source = join(root, 'packages/probe/launch.sh');
|
||||||
|
await mkdir(join(root, 'packages/probe'), { recursive: true });
|
||||||
|
await writeFile(source, 'alias hidden_runtime=claude\nhidden_runtime -p x\n');
|
||||||
|
|
||||||
|
const parserResult = spawnSync('python3', [launchGuardPath, '--root', root, '--json'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
expect(parserResult.status).toBe(0);
|
||||||
|
expect(JSON.parse(parserResult.stdout)).toMatchObject({ gated: 0, total: 0 });
|
||||||
|
|
||||||
|
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
|
||||||
|
hooks: { PreToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> };
|
||||||
|
};
|
||||||
|
const allToolsHook = settings.hooks.PreToolUse.find((hook) => hook.matcher === '.*');
|
||||||
|
expect(allToolsHook?.hooks[0]?.command).toContain('mutator-gate.py --runtime claude');
|
||||||
|
|
||||||
|
const environment = { ...process.env };
|
||||||
|
delete environment['MOSAIC_LEASE_SESSION_ID'];
|
||||||
|
delete environment['MOSAIC_LEASE_BROKER_SOCKET'];
|
||||||
|
for (const toolName of ['Bash', 'Read', 'mcp__provider__custom']) {
|
||||||
|
const gateResult = spawnSync('python3', [gatePath, '--runtime', 'claude'], {
|
||||||
|
input: `${JSON.stringify({ tool_name: toolName })}\n`,
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: environment,
|
||||||
|
});
|
||||||
|
expect(gateResult.status, toolName).toBe(2);
|
||||||
|
expect(gateResult.stderr, toolName).toContain('GATE_UNAVAILABLE');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('T-B raw and custom mutator tools are default-denied without shell parsing', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const sessionId = await register(socket);
|
||||||
|
const mutators: Array<['claude' | 'pi', string]> = [
|
||||||
|
['claude', 'Bash'],
|
||||||
|
['claude', 'Edit'],
|
||||||
|
['claude', 'Write'],
|
||||||
|
['claude', 'NotebookEdit'],
|
||||||
|
['claude', 'mcp__provider__close_issue'],
|
||||||
|
['pi', 'bash'],
|
||||||
|
['pi', 'edit'],
|
||||||
|
['pi', 'write'],
|
||||||
|
['pi', 'deploy'],
|
||||||
|
['pi', 'unknown_custom_tool'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [runtime, toolName] of mutators) {
|
||||||
|
expect(
|
||||||
|
await authorize(socket, sessionId, runtime, toolName),
|
||||||
|
`${runtime}:${toolName}`,
|
||||||
|
).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'MUTATOR_UNVERIFIED',
|
||||||
|
decision: 'deny',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [runtime, toolName] of [
|
||||||
|
['claude', 'Read'],
|
||||||
|
['claude', 'Grep'],
|
||||||
|
['pi', 'read'],
|
||||||
|
['pi', 'grep'],
|
||||||
|
['pi', 'mosaic_context_recover'],
|
||||||
|
] as const) {
|
||||||
|
expect(await authorize(socket, sessionId, runtime, toolName)).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
decision: 'allow',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lease and tool validation failures remain fail-closed at the broker boundary', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const sessionId = await register(socket);
|
||||||
|
const baseRequest = {
|
||||||
|
action: 'begin_verification',
|
||||||
|
session_id: sessionId,
|
||||||
|
runtime_generation: 1,
|
||||||
|
runtime: 'claude',
|
||||||
|
ttl_seconds: 300,
|
||||||
|
binding: binding(),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(await request(socket, { ...baseRequest, runtime: 'codex' })).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'INVALID_RUNTIME',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await request(socket, { ...baseRequest, binding: { ...binding(), h_source: 'bad' } }),
|
||||||
|
).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'INVALID_BINDING',
|
||||||
|
});
|
||||||
|
expect(await request(socket, { ...baseRequest, ttl_seconds: 0 })).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'INVALID_LEASE_TTL',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await request(socket, {
|
||||||
|
action: 'authorize_tool',
|
||||||
|
session_id: sessionId,
|
||||||
|
runtime_generation: 1,
|
||||||
|
runtime: 'codex',
|
||||||
|
tool_name: 'Read',
|
||||||
|
}),
|
||||||
|
).toMatchObject({ ok: false, code: 'INVALID_RUNTIME' });
|
||||||
|
expect(
|
||||||
|
await request(socket, {
|
||||||
|
action: 'authorize_tool',
|
||||||
|
session_id: sessionId,
|
||||||
|
runtime_generation: 1,
|
||||||
|
runtime: 'claude',
|
||||||
|
tool_name: 'x'.repeat(257),
|
||||||
|
}),
|
||||||
|
).toMatchObject({ ok: false, code: 'INVALID_TOOL' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const sessionId = await register(socket);
|
||||||
|
const pending = await beginVerification(socket, sessionId, 'claude', 1, 1);
|
||||||
|
await promote(socket, sessionId, pending.promotion_token!);
|
||||||
|
|
||||||
|
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
decision: 'allow',
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||||
|
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'LEASE_EXPIRED',
|
||||||
|
decision: 'deny',
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 2);
|
||||||
|
await promote(socket, sessionId, refreshed.promotion_token!);
|
||||||
|
expect(
|
||||||
|
await request(socket, {
|
||||||
|
action: 'revoke_lease',
|
||||||
|
session_id: sessionId,
|
||||||
|
runtime_generation: 1,
|
||||||
|
reason: 'compaction_observer',
|
||||||
|
}),
|
||||||
|
).toMatchObject({ ok: true, state: 'UNVERIFIED' });
|
||||||
|
expect(await authorize(socket, sessionId, 'claude', 'Write')).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'MUTATOR_UNVERIFIED',
|
||||||
|
decision: 'deny',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime-generation replacement cannot inherit a verified lease', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const sessionId = await register(socket);
|
||||||
|
const pending = await beginVerification(socket, sessionId, 'pi');
|
||||||
|
await promote(socket, sessionId, pending.promotion_token!);
|
||||||
|
|
||||||
|
expect(await authorize(socket, sessionId, 'pi', 'bash', 2)).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'MUTATOR_UNVERIFIED',
|
||||||
|
decision: 'deny',
|
||||||
|
});
|
||||||
|
expect(await authorize(socket, sessionId, 'pi', 'bash', 1)).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
code: 'STALE_GENERATION',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime launcher anchors broker identity before exec and fails closed without broker', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const probe = [
|
||||||
|
'import json,os,socket',
|
||||||
|
's=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)',
|
||||||
|
"s.connect(os.environ['MOSAIC_LEASE_BROKER_SOCKET'])",
|
||||||
|
"request={'action':'authorize_tool','session_id':os.environ['MOSAIC_LEASE_SESSION_ID'],'runtime_generation':int(os.environ['MOSAIC_RUNTIME_GENERATION']),'runtime':'claude','tool_name':'Read'}",
|
||||||
|
"s.sendall((json.dumps(request)+'\\n').encode())",
|
||||||
|
's.shutdown(socket.SHUT_WR)',
|
||||||
|
"print(json.dumps({'session_id':os.environ['MOSAIC_LEASE_SESSION_ID'],'reply':json.loads(s.recv(65536))}))",
|
||||||
|
].join(';');
|
||||||
|
const launched = spawnSync(
|
||||||
|
'python3',
|
||||||
|
[launcherPath, '--runtime', 'claude', '--', 'python3', '-c', probe],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||||
|
MOSAIC_RUNTIME_GENERATION: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(launched.status, launched.stderr).toBe(0);
|
||||||
|
expect(JSON.parse(launched.stdout)).toMatchObject({
|
||||||
|
session_id: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||||
|
reply: { ok: true, decision: 'allow' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const unavailable = spawnSync(
|
||||||
|
'python3',
|
||||||
|
[launcherPath, '--runtime', 'claude', '--', 'python3', '-c', "print('EXECUTED')"],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
MOSAIC_LEASE_BROKER_SOCKET: join(tmpdir(), 'missing-mosaic-broker.sock'),
|
||||||
|
MOSAIC_RUNTIME_GENERATION: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(unavailable.status).not.toBe(0);
|
||||||
|
expect(unavailable.stdout).not.toContain('EXECUTED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(runtimeLaunchEntries)(
|
||||||
|
'$name registers before launch, denies an unverified mutator, and fails closed without broker',
|
||||||
|
async (entry) => {
|
||||||
|
const missingSocket = join(tmpdir(), `missing-${entry.name}-${process.pid}.sock`);
|
||||||
|
const unavailable = await runRuntimeLaunchEntry(entry, missingSocket);
|
||||||
|
expect(unavailable.status).not.toBe(0);
|
||||||
|
expect(`${unavailable.stdout}${unavailable.stderr}`).not.toContain('RUNTIME_PROBE=');
|
||||||
|
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const launched = await runRuntimeLaunchEntry(entry, socket);
|
||||||
|
expect(launched.status, launched.stderr).toBe(0);
|
||||||
|
const match = /RUNTIME_PROBE=(\{[^\n]+\})/.exec(`${launched.stdout}${launched.stderr}`);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
expect(JSON.parse(match![1]!)).toEqual({
|
||||||
|
session_id: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||||
|
denied: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ command: 'mosaic claudex', yolo: false },
|
||||||
|
{ command: 'mosaic yolo claudex', yolo: true },
|
||||||
|
])(
|
||||||
|
'$command registers a broker anchor, installs the all-tools hook, and denies an unverified mutator',
|
||||||
|
async ({ yolo }) => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'mosaic-claudex-gate-'));
|
||||||
|
temporaryRoots.push(root);
|
||||||
|
const configDir = join(root, 'isolated-claude');
|
||||||
|
const binDir = join(root, 'bin');
|
||||||
|
await mkdir(configDir, { recursive: true });
|
||||||
|
await mkdir(binDir, { recursive: true });
|
||||||
|
|
||||||
|
const fakeClaude = join(binDir, 'claude');
|
||||||
|
const probe = `#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
session_id = os.environ.get("MOSAIC_LEASE_SESSION_ID", "")
|
||||||
|
settings_path = Path(os.environ["CLAUDE_CONFIG_DIR"]) / "settings.json"
|
||||||
|
try:
|
||||||
|
settings = json.loads(settings_path.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
settings = {}
|
||||||
|
pre_tool = settings.get("hooks", {}).get("PreToolUse", [])
|
||||||
|
hook_present = any(
|
||||||
|
item.get("matcher") == ".*" and any("mutator-gate.py" in hook.get("command", "") for hook in item.get("hooks", []))
|
||||||
|
for item in pre_tool
|
||||||
|
)
|
||||||
|
denied = subprocess.run(
|
||||||
|
["python3", ${JSON.stringify(gatePath)}, "--runtime", "claude"],
|
||||||
|
input=json.dumps({"tool_name": "Bash"}) + "\\n",
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
env=os.environ,
|
||||||
|
).returncode == 2
|
||||||
|
is_yolo = "--dangerously-skip-permissions" in sys.argv[1:]
|
||||||
|
result = {
|
||||||
|
"session_id": session_id,
|
||||||
|
"hook_present": hook_present,
|
||||||
|
"denied": denied,
|
||||||
|
"is_yolo": is_yolo,
|
||||||
|
}
|
||||||
|
print(json.dumps(result))
|
||||||
|
raise SystemExit(0 if len(session_id) == 64 and hook_present and denied else 1)
|
||||||
|
`;
|
||||||
|
await writeFile(fakeClaude, probe, { mode: 0o700 });
|
||||||
|
await chmod(fakeClaude, 0o700);
|
||||||
|
|
||||||
|
let execution: ReturnType<typeof spawnSync> | undefined;
|
||||||
|
const run = (cmd: string, args: string[], env: NodeJS.ProcessEnv) => {
|
||||||
|
execution = spawnSync(cmd, args, { encoding: 'utf8', env });
|
||||||
|
};
|
||||||
|
const adapter = {
|
||||||
|
harnessPreflight: () => {},
|
||||||
|
composePrompt: () => '# composed Claude contract',
|
||||||
|
// Claudex exposes only the shared register-before-exec boundary.
|
||||||
|
execLeaseGated: (args: string[], env: NodeJS.ProcessEnv, dangerous: boolean) =>
|
||||||
|
run(
|
||||||
|
'python3',
|
||||||
|
[
|
||||||
|
launcherPath,
|
||||||
|
...(dangerous ? ['--dangerous'] : []),
|
||||||
|
'--runtime',
|
||||||
|
'claude',
|
||||||
|
'--',
|
||||||
|
'claude',
|
||||||
|
...args,
|
||||||
|
],
|
||||||
|
env,
|
||||||
|
),
|
||||||
|
} as unknown as ClaudexHarnessAdapter;
|
||||||
|
|
||||||
|
await launchClaudex([], yolo, adapter, {
|
||||||
|
baseEnv: {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||||
|
MOSAIC_LEASE_BROKER_SOCKET: socket,
|
||||||
|
MOSAIC_RUNTIME_GENERATION: '1',
|
||||||
|
},
|
||||||
|
proxyGate: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
report: {
|
||||||
|
binaryPresent: true,
|
||||||
|
binaryPath: '/test/claude-code-proxy',
|
||||||
|
auth: { state: 'valid' },
|
||||||
|
live: true,
|
||||||
|
listenerVerdict: 'ok',
|
||||||
|
needsReauth: false,
|
||||||
|
ok: true,
|
||||||
|
problems: [],
|
||||||
|
},
|
||||||
|
problems: [],
|
||||||
|
}),
|
||||||
|
resolveConfigDir: () => configDir,
|
||||||
|
log: () => {},
|
||||||
|
errorLog: () => {},
|
||||||
|
fail: ((code: number) => {
|
||||||
|
throw new Error(`exit ${code}`);
|
||||||
|
}) as (code: number) => never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(execution).toBeDefined();
|
||||||
|
expect(execution!.status, String(execution!.stderr)).toBe(0);
|
||||||
|
expect(JSON.parse(String(execution!.stdout))).toEqual({
|
||||||
|
session_id: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||||
|
hook_present: true,
|
||||||
|
denied: true,
|
||||||
|
is_yolo: yolo,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('Claude and Pi runtime adapters consult the broker for every tool class', async () => {
|
||||||
|
const { socket } = await startBroker();
|
||||||
|
const sessionId = await register(socket);
|
||||||
|
|
||||||
|
expect(runRuntimeGate(socket, sessionId, 'claude', 'Read').status).toBe(0);
|
||||||
|
expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(2);
|
||||||
|
expect(runRuntimeGate(socket, sessionId, 'pi', 'unknown_custom_tool').status).toBe(2);
|
||||||
|
|
||||||
|
const pending = await beginVerification(socket, sessionId, 'claude');
|
||||||
|
await promote(socket, sessionId, pending.promotion_token!);
|
||||||
|
expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(0);
|
||||||
|
|
||||||
|
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
|
||||||
|
hooks: { PreToolUse: Array<{ matcher?: string; hooks: Array<{ command: string }> }> };
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
settings.hooks.PreToolUse.some(
|
||||||
|
(entry) =>
|
||||||
|
entry.matcher === '.*' &&
|
||||||
|
entry.hooks.some((hook) => hook.command.includes('mutator-gate.py')),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const piExtension = await readFile(piExtensionPath, 'utf8');
|
||||||
|
expect(piExtension).toContain("pi.on('tool_call'");
|
||||||
|
expect(piExtension).toContain('mutator-gate.py');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Contract tests for the permanent consequential-runtime launch guard."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import runpy
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
MOSAIC_ROOT = Path(__file__).parents[2]
|
||||||
|
REPO_ROOT = Path(__file__).parents[4]
|
||||||
|
GUARD_PATH = MOSAIC_ROOT / "framework/tools/lease-broker/check-runtime-launches.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("runtime_launch_guard", GUARD_PATH)
|
||||||
|
if SPEC is None or SPEC.loader is None:
|
||||||
|
raise RuntimeError("unable to load runtime launch guard")
|
||||||
|
GUARD = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(GUARD)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeLaunchGuardTest(unittest.TestCase):
|
||||||
|
def test_detects_direct_shell_and_process_api_launches(self) -> None:
|
||||||
|
cases = {
|
||||||
|
"shell-exec.sh": 'exec claude --dangerously-skip-permissions "prompt"\n',
|
||||||
|
"shell-print.sh": 'claude -p "prompt" | tee report.log\n',
|
||||||
|
"typescript.ts": "spawn('pi', ['--print', prompt]);\n",
|
||||||
|
"python.py": "subprocess.run(['claude', '-p', prompt])\n",
|
||||||
|
"dynamic.ts": "return [runtime, '-p', prompt];\n",
|
||||||
|
"plain-shell.sh": 'claude "$prompt"\n',
|
||||||
|
"node-exec.ts": 'exec("claude --print hello");\n',
|
||||||
|
"command-array.ts": "const launchCommand = ['pi', '--print', prompt];\n",
|
||||||
|
"python-system.py": 'os.system("claude -p prompt")\n',
|
||||||
|
"dynamic-shell.sh": 'exec "$runtime" "$prompt"\n',
|
||||||
|
"dynamic-spawn.ts": 'spawn(runtime, args);\n',
|
||||||
|
"dynamic-command.sh": 'LAUNCH_COMMAND=("$MOSAIC_AGENT_RUNTIME" --print)\n',
|
||||||
|
"absolute-shell.sh": 'exec /usr/local/bin/claude -p prompt\n',
|
||||||
|
"absolute-spawn.ts": "spawn('/opt/bin/pi', args);\n",
|
||||||
|
"terra-comment.sh": 'exec claude --dangerously-skip-permissions "terra-r3" # launch-runtime.py\n',
|
||||||
|
"python-comment.py": "subprocess.run(['claude', '-p', prompt]) # launch-runtime.py\n",
|
||||||
|
"typescript-comment.ts": "spawn('pi', args); // launch-runtime.py\n",
|
||||||
|
"marker-argument.sh": 'exec claude --dangerously-skip-permissions "launch-runtime.py"\n',
|
||||||
|
"marker-echo.sh": 'exec claude --dangerously-skip-permissions "prompt"; echo launch-runtime.py\n',
|
||||||
|
"marker-variable.sh": 'marker=launch-runtime.py; exec claude --dangerously-skip-permissions "prompt"\n',
|
||||||
|
"heredoc.sh": "cat <<'EOF'\nexec claude --dangerously-skip-permissions prompt # launch-runtime.py\nEOF\n",
|
||||||
|
"continued.sh": "exec \\\n claude --dangerously-skip-permissions prompt # launch-runtime.py\n",
|
||||||
|
"chain-semicolon.sh": "true; claude -p prompt\n",
|
||||||
|
"chain-and.sh": "true && claude -p prompt\n",
|
||||||
|
"chain-pipe.sh": "printf input | claude -p prompt\n",
|
||||||
|
"command-substitution.sh": "output=$(claude -p prompt)\n",
|
||||||
|
"eval.sh": "launcher='claude -p prompt'\neval \"$launcher\"\n",
|
||||||
|
"variable-exec.sh": "launcher=claude\n\"$launcher\" -p prompt\n",
|
||||||
|
"env-prefix.sh": "env SAFE=1 claude --help\n",
|
||||||
|
"command-prefix.sh": "command pi --help\n",
|
||||||
|
"nohup-prefix.sh": "nohup claude --help &\n",
|
||||||
|
}
|
||||||
|
for filename, source in cases.items():
|
||||||
|
with self.subTest(filename=filename):
|
||||||
|
violations = GUARD.scan_text(Path(filename), source)
|
||||||
|
self.assertNotEqual(violations, [], source)
|
||||||
|
|
||||||
|
def test_allows_only_explicit_gated_boundaries(self) -> None:
|
||||||
|
cases = {
|
||||||
|
"shell-helper.sh": 'exec "$GATED_RUNTIME" claude -- claude -p "prompt"\n',
|
||||||
|
"mosaic.sh": 'exec mosaic yolo "$runtime" "prompt"\n',
|
||||||
|
"launch.ts": "execLeaseGatedRuntime('claude', args);\n",
|
||||||
|
"coord.ts": "return ['mosaic', runtime, '-p', prompt];\n",
|
||||||
|
}
|
||||||
|
for filename, source in cases.items():
|
||||||
|
with self.subTest(filename=filename):
|
||||||
|
self.assertEqual(GUARD.scan_text(Path(filename), source), [])
|
||||||
|
|
||||||
|
def test_detects_prefixed_tracked_runtime_variable_execution(self) -> None:
|
||||||
|
multiline = {
|
||||||
|
"exec-quoted": 'exec "$v" -p x',
|
||||||
|
"exec-unquoted": "exec $v -p x",
|
||||||
|
"command": 'command "$v" -p x',
|
||||||
|
"nohup": 'nohup "$v" -p x',
|
||||||
|
"env": 'env A=1 "$v" -p x',
|
||||||
|
}
|
||||||
|
cases = {
|
||||||
|
**{f"multiline-{name}.sh": f"v=claude\n{command}\n" for name, command in multiline.items()},
|
||||||
|
**{f"same-line-{name}.sh": f"v=claude; {command}\n" for name, command in multiline.items()},
|
||||||
|
}
|
||||||
|
for filename, source in cases.items():
|
||||||
|
with self.subTest(filename=filename):
|
||||||
|
self.assertNotEqual(GUARD.scan_text(Path(filename), source), [], source)
|
||||||
|
|
||||||
|
def test_accepts_only_validated_multiline_typescript_wrapper_invocation(self) -> None:
|
||||||
|
source = """execRuntime(
|
||||||
|
'python3',
|
||||||
|
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
||||||
|
environment,
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
sites = GUARD.classify_text(Path("launch.ts"), source)
|
||||||
|
self.assertEqual(len(sites), 1)
|
||||||
|
self.assertEqual(sites[0].classification, "gated")
|
||||||
|
|
||||||
|
def test_marker_comments_strings_and_assignments_are_not_gated_sites(self) -> None:
|
||||||
|
harmless_sources = {
|
||||||
|
"comment.sh": "# launch-runtime.py --runtime claude --\n",
|
||||||
|
"echo.sh": "echo 'launch-runtime.py --runtime claude --'\n",
|
||||||
|
"assignment.sh": "marker='launch-runtime.py --runtime claude --'\n",
|
||||||
|
"argument.sh": "printf '%s' 'launch-runtime.py --runtime claude --'\n",
|
||||||
|
}
|
||||||
|
for filename, source in harmless_sources.items():
|
||||||
|
with self.subTest(filename=filename):
|
||||||
|
self.assertEqual(GUARD.classify_text(Path(filename), source), [])
|
||||||
|
|
||||||
|
def test_dangerous_primitive_backstops_parser_exotic_alias_indirection(self) -> None:
|
||||||
|
source = (
|
||||||
|
"alias hidden_runtime=claude\n"
|
||||||
|
"hidden_runtime --dangerously-skip-permissions -p x\n"
|
||||||
|
)
|
||||||
|
sites = GUARD.scan_text(Path("alias-launch.sh"), source)
|
||||||
|
self.assertEqual(len(sites), 1)
|
||||||
|
self.assertEqual(sites[0].classification, "dangerous-primitive")
|
||||||
|
|
||||||
|
def test_dangerous_primitive_is_owned_only_by_the_choke_point(self) -> None:
|
||||||
|
primitive = "--dangerously-skip-permissions"
|
||||||
|
self.assertNotEqual(GUARD.scan_text(Path("caller.ts"), f"args = ['{primitive}'];\n"), [])
|
||||||
|
self.assertEqual(
|
||||||
|
GUARD.scan_text(Path("framework/tools/lease-broker/launch-runtime.py"), f'FLAG = "{primitive}"\n'),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_repository_has_no_ungated_consequential_runtime_launch(self) -> None:
|
||||||
|
violations = GUARD.scan_repository(REPO_ROOT)
|
||||||
|
self.assertEqual(
|
||||||
|
violations,
|
||||||
|
[],
|
||||||
|
"\n".join(GUARD.format_violation(violation) for violation in violations),
|
||||||
|
)
|
||||||
|
inventory = GUARD.inventory_repository(REPO_ROOT)
|
||||||
|
self.assertEqual(len(inventory), 14)
|
||||||
|
self.assertTrue(all(site.classification == "gated" for site in inventory))
|
||||||
|
|
||||||
|
def test_repository_walk_skips_tests_build_outputs_and_reports_unscannable_source(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
production = root / "packages/example/src/launch.sh"
|
||||||
|
production.parent.mkdir(parents=True)
|
||||||
|
production.write_text("exec claude -p prompt\n")
|
||||||
|
(production.parent / "launch.spec.ts").write_text("spawn('pi', [])\n")
|
||||||
|
dist = root / "packages/example/dist/launch.js"
|
||||||
|
dist.parent.mkdir(parents=True)
|
||||||
|
dist.write_text("exec('claude -p prompt')\n")
|
||||||
|
ignored_suffix = production.parent / "notes.txt"
|
||||||
|
ignored_suffix.write_text("claude -p prompt\n")
|
||||||
|
invalid = production.parent / "invalid.py"
|
||||||
|
invalid.write_bytes(b"\xff\xfe")
|
||||||
|
|
||||||
|
violations = GUARD.scan_repository(root)
|
||||||
|
formatted = [GUARD.format_violation(item) for item in violations]
|
||||||
|
self.assertEqual(len(violations), 2)
|
||||||
|
self.assertTrue(any("launch.sh:1: direct" in item for item in formatted))
|
||||||
|
self.assertTrue(any("invalid.py:0: unscannable" in item for item in formatted))
|
||||||
|
self.assertFalse(any("spec" in item or "dist" in item or "notes" in item for item in formatted))
|
||||||
|
|
||||||
|
inventory = GUARD.inventory_repository(root)
|
||||||
|
self.assertEqual({item.classification for item in inventory}, {"direct", "unscannable"})
|
||||||
|
|
||||||
|
def test_main_emits_machine_inventory_and_fails_on_a_direct_site(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
source = root / "packages/example/launch.sh"
|
||||||
|
source.parent.mkdir(parents=True)
|
||||||
|
source.write_text(
|
||||||
|
'exec claude --dangerously-skip-permissions "terra-r3" # launch-runtime.py\n'
|
||||||
|
)
|
||||||
|
stdout = io.StringIO()
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||||
|
result = GUARD.main(["--root", str(root), "--json"])
|
||||||
|
payload = json.loads(stdout.getvalue())
|
||||||
|
self.assertEqual(result, 1)
|
||||||
|
self.assertEqual(payload["gated"], 0)
|
||||||
|
self.assertEqual(payload["total"], 1)
|
||||||
|
self.assertIn("ungated consequential runtime", stderr.getvalue())
|
||||||
|
|
||||||
|
def test_main_text_mode_reports_a_green_gated_inventory(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
source = root / "packages/example/launch.sh"
|
||||||
|
source.parent.mkdir(parents=True)
|
||||||
|
source.write_text("exec mosaic yolo claude prompt\n")
|
||||||
|
stdout = io.StringIO()
|
||||||
|
with redirect_stdout(stdout):
|
||||||
|
result = GUARD.main(["--root", str(root)])
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertIn("1 gated/1 total", stdout.getvalue())
|
||||||
|
|
||||||
|
def test_script_entrypoint_uses_current_directory_default(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / "packages").mkdir()
|
||||||
|
with patch.object(sys, "argv", [str(GUARD_PATH)]), patch("pathlib.Path.cwd", return_value=root):
|
||||||
|
with redirect_stdout(io.StringIO()), self.assertRaises(SystemExit) as raised:
|
||||||
|
runpy.run_path(str(GUARD_PATH), run_name="__main__")
|
||||||
|
self.assertEqual(raised.exception.code, 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
436
packages/mosaic/src/mutator-gate/runtime_tools_unittest.py
Normal file
436
packages/mosaic/src/mutator-gate/runtime_tools_unittest.py
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Branch-focused tests for the lease-gated runtime executables."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import runpy
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stderr
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS_DIR = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
||||||
|
|
||||||
|
|
||||||
|
def load_tool(module_name: str, filename: str):
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, TOOLS_DIR / filename)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise RuntimeError(f"unable to load {filename}")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
LAUNCHER = load_tool("lease_runtime_launcher", "launch-runtime.py")
|
||||||
|
GATE = load_tool("lease_mutator_gate", "mutator-gate.py")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSocket:
|
||||||
|
def __init__(self, *chunks: bytes):
|
||||||
|
self.chunks = list(chunks)
|
||||||
|
self.timeout = None
|
||||||
|
self.connected = None
|
||||||
|
self.sent = b""
|
||||||
|
self.shutdown_how = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def settimeout(self, value: float) -> None:
|
||||||
|
self.timeout = value
|
||||||
|
|
||||||
|
def connect(self, value: str) -> None:
|
||||||
|
self.connected = value
|
||||||
|
|
||||||
|
def sendall(self, value: bytes) -> None:
|
||||||
|
self.sent += value
|
||||||
|
|
||||||
|
def shutdown(self, how: int) -> None:
|
||||||
|
self.shutdown_how = how
|
||||||
|
|
||||||
|
def recv(self, _size: int) -> bytes:
|
||||||
|
return self.chunks.pop(0) if self.chunks else b""
|
||||||
|
|
||||||
|
|
||||||
|
class LaunchRuntimeTest(unittest.TestCase):
|
||||||
|
def test_success_registers_then_injects_session_before_exec(self) -> None:
|
||||||
|
calls: dict[str, object] = {}
|
||||||
|
session_id = "a" * 64
|
||||||
|
|
||||||
|
def request(path: Path, payload: dict[str, object]) -> dict[str, object]:
|
||||||
|
calls["path"] = path
|
||||||
|
calls["request"] = payload
|
||||||
|
return {"ok": True, "session_id": session_id}
|
||||||
|
|
||||||
|
def execute(command: str, argv: list[str], environment: dict[str, str]) -> None:
|
||||||
|
calls["execute"] = (command, argv, environment)
|
||||||
|
|
||||||
|
result = LAUNCHER.main(
|
||||||
|
["--runtime", "claude", "--", "claude", "--print", "hello"],
|
||||||
|
environ={
|
||||||
|
"MOSAIC_LEASE_BROKER_SOCKET": "/run/test/broker.sock",
|
||||||
|
"MOSAIC_RUNTIME_GENERATION": "7",
|
||||||
|
"PRESERVED": "yes",
|
||||||
|
},
|
||||||
|
request=request,
|
||||||
|
execute=execute,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertEqual(calls["path"], Path("/run/test/broker.sock"))
|
||||||
|
self.assertEqual(
|
||||||
|
calls["request"],
|
||||||
|
{"action": "register_anchor", "runtime_generation": 7},
|
||||||
|
)
|
||||||
|
command, argv, environment = calls["execute"]
|
||||||
|
self.assertEqual(command, "claude")
|
||||||
|
self.assertEqual(argv, ["claude", "--print", "hello"])
|
||||||
|
self.assertEqual(environment["MOSAIC_LEASE_SESSION_ID"], session_id)
|
||||||
|
self.assertEqual(environment["MOSAIC_RUNTIME_GENERATION"], "7")
|
||||||
|
self.assertEqual(environment["MOSAIC_LEASE_RUNTIME"], "claude")
|
||||||
|
self.assertEqual(environment["PRESERVED"], "yes")
|
||||||
|
|
||||||
|
def test_dangerous_claude_mode_is_owned_and_injected_by_the_wrapper(self) -> None:
|
||||||
|
executed: list[tuple[str, list[str], dict[str, str]]] = []
|
||||||
|
result = LAUNCHER.main(
|
||||||
|
["--runtime", "claude", "--dangerous", "--", "claude", "-p", "hello"],
|
||||||
|
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/broker"},
|
||||||
|
request=lambda *_args: {"ok": True, "session_id": "e" * 64},
|
||||||
|
execute=lambda *args: executed.append(args),
|
||||||
|
)
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertEqual(
|
||||||
|
executed[0][1],
|
||||||
|
["claude", "--dangerously-skip-permissions", "-p", "hello"],
|
||||||
|
)
|
||||||
|
|
||||||
|
with redirect_stderr(io.StringIO()):
|
||||||
|
self.assertEqual(
|
||||||
|
LAUNCHER.main(
|
||||||
|
["--runtime", "pi", "--dangerous", "--", "pi"],
|
||||||
|
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/broker"},
|
||||||
|
request=lambda *_args: {"ok": True, "session_id": "e" * 64},
|
||||||
|
execute=lambda *_args: self.fail("invalid dangerous runtime executed"),
|
||||||
|
),
|
||||||
|
64,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_command_without_separator_is_forwarded_unchanged(self) -> None:
|
||||||
|
executed: list[tuple[str, list[str], dict[str, str]]] = []
|
||||||
|
result = LAUNCHER.main(
|
||||||
|
["--runtime", "pi", "pi", "--print", "hello"],
|
||||||
|
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/broker"},
|
||||||
|
request=lambda *_args: {"ok": True, "session_id": "f" * 64},
|
||||||
|
execute=lambda *args: executed.append(args),
|
||||||
|
)
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertEqual(executed[0][0:2], ("pi", ["pi", "--print", "hello"]))
|
||||||
|
|
||||||
|
def test_missing_command_is_usage_error(self) -> None:
|
||||||
|
with redirect_stderr(io.StringIO()):
|
||||||
|
self.assertEqual(
|
||||||
|
LAUNCHER.main(
|
||||||
|
["--runtime", "pi", "--"],
|
||||||
|
environ={},
|
||||||
|
request=lambda *_args: {},
|
||||||
|
execute=lambda *_args: None,
|
||||||
|
),
|
||||||
|
64,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_registration_validation_and_environment_fail_closed(self) -> None:
|
||||||
|
good_session = "b" * 64
|
||||||
|
cases = [
|
||||||
|
({}, {"ok": True, "session_id": good_session}),
|
||||||
|
({"MOSAIC_LEASE_BROKER_SOCKET": "/x", "MOSAIC_RUNTIME_GENERATION": "bad"}, {}),
|
||||||
|
({"MOSAIC_LEASE_BROKER_SOCKET": "/x", "MOSAIC_RUNTIME_GENERATION": "-1"}, {}),
|
||||||
|
({"MOSAIC_LEASE_BROKER_SOCKET": "/x"}, {"ok": False, "session_id": good_session}),
|
||||||
|
({"MOSAIC_LEASE_BROKER_SOCKET": "/x"}, {"ok": True, "session_id": 4}),
|
||||||
|
({"MOSAIC_LEASE_BROKER_SOCKET": "/x"}, {"ok": True, "session_id": "b" * 63}),
|
||||||
|
({"MOSAIC_LEASE_BROKER_SOCKET": "/x"}, {"ok": True, "session_id": "z" * 64}),
|
||||||
|
]
|
||||||
|
for environment, reply in cases:
|
||||||
|
with self.subTest(environment=environment, reply=reply), redirect_stderr(io.StringIO()):
|
||||||
|
executed: list[object] = []
|
||||||
|
result = LAUNCHER.main(
|
||||||
|
["--runtime", "pi", "--", "pi"],
|
||||||
|
environ=environment,
|
||||||
|
request=lambda *_args, value=reply: value,
|
||||||
|
execute=lambda *args: executed.append(args),
|
||||||
|
)
|
||||||
|
self.assertEqual(result, 1)
|
||||||
|
self.assertEqual(executed, [])
|
||||||
|
|
||||||
|
def test_registration_exceptions_fail_closed(self) -> None:
|
||||||
|
failures = [ValueError("bad"), OSError("down"), json.JSONDecodeError("bad", "x", 0)]
|
||||||
|
for failure in failures:
|
||||||
|
with self.subTest(failure=type(failure).__name__), redirect_stderr(io.StringIO()):
|
||||||
|
def request(*_args, error=failure):
|
||||||
|
raise error
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
LAUNCHER.main(
|
||||||
|
["--runtime", "claude", "--", "claude"],
|
||||||
|
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/x"},
|
||||||
|
request=request,
|
||||||
|
execute=lambda *_args: self.fail("must not execute"),
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_exec_failure_is_fail_closed(self) -> None:
|
||||||
|
with redirect_stderr(io.StringIO()):
|
||||||
|
self.assertEqual(
|
||||||
|
LAUNCHER.main(
|
||||||
|
["--runtime", "pi", "--", "pi"],
|
||||||
|
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/x"},
|
||||||
|
request=lambda *_args: {"ok": True, "session_id": "c" * 64},
|
||||||
|
execute=lambda *_args: (_ for _ in ()).throw(OSError("missing")),
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_broker_reply_framing_and_shape_validation(self) -> None:
|
||||||
|
replies = [
|
||||||
|
(b'{"ok":true}\n', {"ok": True}),
|
||||||
|
(b'{"ok":true}', ValueError),
|
||||||
|
(b'[]\n', ValueError),
|
||||||
|
(b"x" * (LAUNCHER.MAX_FRAME + 1), ValueError),
|
||||||
|
]
|
||||||
|
for wire_reply, expected in replies:
|
||||||
|
with self.subTest(size=len(wire_reply)):
|
||||||
|
fake = FakeSocket(wire_reply)
|
||||||
|
with patch.object(LAUNCHER.socket, "socket", return_value=fake):
|
||||||
|
if isinstance(expected, type) and issubclass(expected, Exception):
|
||||||
|
with self.assertRaises(expected):
|
||||||
|
LAUNCHER.broker_request(Path("/broker"), {"action": "register_anchor"})
|
||||||
|
else:
|
||||||
|
self.assertEqual(
|
||||||
|
LAUNCHER.broker_request(Path("/broker"), {"action": "register_anchor"}),
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
self.assertEqual(fake.timeout, LAUNCHER.BROKER_TIMEOUT_SECONDS)
|
||||||
|
self.assertEqual(fake.connected, "/broker")
|
||||||
|
self.assertEqual(fake.shutdown_how, socket.SHUT_WR)
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutableEntrypointTest(unittest.TestCase):
|
||||||
|
def test_real_claude_and_pi_gates_fail_closed_on_empty_or_truncated_reply(self) -> None:
|
||||||
|
for runtime in ("claude", "pi"):
|
||||||
|
for wire_reply in (b"", b'{"ok":true'):
|
||||||
|
with self.subTest(runtime=runtime, wire_reply=wire_reply), tempfile.TemporaryDirectory() as root:
|
||||||
|
socket_path = Path(root) / "broker.sock"
|
||||||
|
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
server.bind(str(socket_path))
|
||||||
|
server.listen(1)
|
||||||
|
|
||||||
|
def serve_reply() -> None:
|
||||||
|
with server:
|
||||||
|
connection, _ = server.accept()
|
||||||
|
with connection:
|
||||||
|
while connection.recv(4096):
|
||||||
|
pass
|
||||||
|
if wire_reply:
|
||||||
|
connection.sendall(wire_reply)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=serve_reply, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
environment = {
|
||||||
|
**os.environ,
|
||||||
|
"MOSAIC_LEASE_BROKER_SOCKET": str(socket_path),
|
||||||
|
"MOSAIC_LEASE_SESSION_ID": "d" * 64,
|
||||||
|
"MOSAIC_RUNTIME_GENERATION": "1",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(TOOLS_DIR / "mutator-gate.py"), "--runtime", runtime],
|
||||||
|
input=b'{"tool_name":"Read"}\n',
|
||||||
|
capture_output=True,
|
||||||
|
env=environment,
|
||||||
|
check=False,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
server.close()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
self.fail(
|
||||||
|
f"{runtime} gate hung on wire reply {wire_reply!r}: {exc}"
|
||||||
|
)
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
self.assertFalse(thread.is_alive())
|
||||||
|
self.assertEqual(result.returncode, 2)
|
||||||
|
self.assertIn(b"GATE_UNAVAILABLE", result.stderr)
|
||||||
|
|
||||||
|
def test_launcher_entrypoint_returns_usage_without_a_command(self) -> None:
|
||||||
|
with patch.object(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
[str(TOOLS_DIR / "launch-runtime.py"), "--runtime", "claude"],
|
||||||
|
), redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as raised:
|
||||||
|
runpy.run_path(str(TOOLS_DIR / "launch-runtime.py"), run_name="__main__")
|
||||||
|
self.assertEqual(raised.exception.code, 64)
|
||||||
|
|
||||||
|
def test_gate_entrypoint_denies_when_identity_environment_is_absent(self) -> None:
|
||||||
|
class Stdin:
|
||||||
|
buffer = io.BytesIO(b'{"tool_name":"Bash"}')
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
[str(TOOLS_DIR / "mutator-gate.py"), "--runtime", "claude"],
|
||||||
|
), patch.object(sys, "stdin", Stdin()), patch.dict(
|
||||||
|
os.environ, {}, clear=True
|
||||||
|
), redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as raised:
|
||||||
|
runpy.run_path(str(TOOLS_DIR / "mutator-gate.py"), run_name="__main__")
|
||||||
|
self.assertEqual(raised.exception.code, 2)
|
||||||
|
|
||||||
|
|
||||||
|
class MutatorGateTest(unittest.TestCase):
|
||||||
|
@staticmethod
|
||||||
|
def environment() -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"MOSAIC_LEASE_BROKER_SOCKET": "/run/test/broker.sock",
|
||||||
|
"MOSAIC_LEASE_SESSION_ID": "d" * 64,
|
||||||
|
"MOSAIC_RUNTIME_GENERATION": "2",
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_main(self, *, tool: object = "Bash", reply: dict[str, object] | None = None):
|
||||||
|
calls: list[tuple[Path, dict[str, object]]] = []
|
||||||
|
|
||||||
|
def request(path: Path, payload: dict[str, object]) -> dict[str, object]:
|
||||||
|
calls.append((path, payload))
|
||||||
|
return reply if reply is not None else {"ok": True, "decision": "allow"}
|
||||||
|
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with redirect_stderr(stderr):
|
||||||
|
result = GATE.main(
|
||||||
|
["--runtime", "claude"],
|
||||||
|
environ=self.environment(),
|
||||||
|
stream=io.BytesIO(json.dumps({"tool_name": tool}).encode()),
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return result, stderr.getvalue(), calls
|
||||||
|
|
||||||
|
def test_allow_and_denial_decisions(self) -> None:
|
||||||
|
allowed, allowed_stderr, calls = self.run_main()
|
||||||
|
self.assertEqual(allowed, 0)
|
||||||
|
self.assertEqual(allowed_stderr, "")
|
||||||
|
self.assertEqual(calls[0][0], Path("/run/test/broker.sock"))
|
||||||
|
self.assertEqual(
|
||||||
|
calls[0][1],
|
||||||
|
{
|
||||||
|
"action": "authorize_tool",
|
||||||
|
"session_id": "d" * 64,
|
||||||
|
"runtime_generation": 2,
|
||||||
|
"runtime": "claude",
|
||||||
|
"tool_name": "Bash",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
denied, denied_stderr, _ = self.run_main(reply={"ok": False, "code": "LEASE_EXPIRED"})
|
||||||
|
self.assertEqual(denied, 2)
|
||||||
|
self.assertIn("LEASE_EXPIRED", denied_stderr)
|
||||||
|
|
||||||
|
defaulted, defaulted_stderr, _ = self.run_main(reply={"ok": False, "code": 4})
|
||||||
|
self.assertEqual(defaulted, 2)
|
||||||
|
self.assertIn("MUTATOR_UNVERIFIED", defaulted_stderr)
|
||||||
|
|
||||||
|
def test_input_validation_fails_closed(self) -> None:
|
||||||
|
payloads = [
|
||||||
|
b"x" * (GATE.MAX_FRAME + 1),
|
||||||
|
b"[]",
|
||||||
|
b"{}",
|
||||||
|
json.dumps({"tool_name": ""}).encode(),
|
||||||
|
json.dumps({"tool_name": 4}).encode(),
|
||||||
|
json.dumps({"tool_name": "x" * 257}).encode(),
|
||||||
|
b"not-json",
|
||||||
|
]
|
||||||
|
for payload in payloads:
|
||||||
|
with self.subTest(size=len(payload)), redirect_stderr(io.StringIO()):
|
||||||
|
self.assertEqual(
|
||||||
|
GATE.main(
|
||||||
|
["--runtime", "pi"],
|
||||||
|
environ=self.environment(),
|
||||||
|
stream=io.BytesIO(payload),
|
||||||
|
request=lambda *_args: self.fail("invalid input reached broker"),
|
||||||
|
),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_environment_generation_and_request_failures_deny(self) -> None:
|
||||||
|
environments = [
|
||||||
|
{},
|
||||||
|
{**self.environment(), "MOSAIC_RUNTIME_GENERATION": "bad"},
|
||||||
|
{**self.environment(), "MOSAIC_RUNTIME_GENERATION": "-1"},
|
||||||
|
]
|
||||||
|
for environment in environments:
|
||||||
|
with self.subTest(environment=environment), redirect_stderr(io.StringIO()):
|
||||||
|
self.assertEqual(
|
||||||
|
GATE.main(
|
||||||
|
["--runtime", "claude"],
|
||||||
|
environ=environment,
|
||||||
|
stream=io.BytesIO(b'{"tool_name":"Read"}'),
|
||||||
|
request=lambda *_args: {},
|
||||||
|
),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
failures = [ValueError("bad"), OSError("down"), json.JSONDecodeError("bad", "x", 0)]
|
||||||
|
for failure in failures:
|
||||||
|
with self.subTest(failure=type(failure).__name__), redirect_stderr(io.StringIO()):
|
||||||
|
def request(*_args, error=failure):
|
||||||
|
raise error
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
GATE.main(
|
||||||
|
["--runtime", "claude"],
|
||||||
|
environ=self.environment(),
|
||||||
|
stream=io.BytesIO(b'{"tool_name":"Read"}'),
|
||||||
|
request=request,
|
||||||
|
),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_broker_request_framing_payload_and_shape_validation(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
GATE.broker_request(Path("/broker"), {"session_id": "x" * GATE.MAX_FRAME})
|
||||||
|
|
||||||
|
replies = [
|
||||||
|
(b'{"ok":true,"decision":"allow"}\n', {"ok": True, "decision": "allow"}),
|
||||||
|
(b'{"ok":true}', ValueError),
|
||||||
|
(b'[]\n', ValueError),
|
||||||
|
(b"x" * (GATE.MAX_FRAME + 1), ValueError),
|
||||||
|
]
|
||||||
|
for wire_reply, expected in replies:
|
||||||
|
with self.subTest(size=len(wire_reply)):
|
||||||
|
fake = FakeSocket(wire_reply)
|
||||||
|
with patch.object(GATE.socket, "socket", return_value=fake):
|
||||||
|
if isinstance(expected, type) and issubclass(expected, Exception):
|
||||||
|
with self.assertRaises(expected):
|
||||||
|
GATE.broker_request(Path("/broker"), {"action": "authorize_tool"})
|
||||||
|
else:
|
||||||
|
self.assertEqual(
|
||||||
|
GATE.broker_request(Path("/broker"), {"action": "authorize_tool"}),
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
self.assertEqual(fake.timeout, GATE.BROKER_TIMEOUT_SECONDS)
|
||||||
|
self.assertEqual(fake.connected, "/broker")
|
||||||
|
self.assertEqual(fake.shutdown_how, socket.SHUT_WR)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
mkdirSync,
|
mkdirSync,
|
||||||
readdirSync,
|
readdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
|
readlinkSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
statSync,
|
statSync,
|
||||||
symlinkSync,
|
symlinkSync,
|
||||||
@@ -300,6 +301,50 @@ describe('repairFleetCommsTools', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('runFrameworkReseed', () => {
|
describe('runFrameworkReseed', () => {
|
||||||
|
it('auto-registers every canonical skill after a successful upgrade re-seed', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'mosaic-reseed-skills-'));
|
||||||
|
const framework = join(root, 'framework');
|
||||||
|
const home = join(root, 'mosaic');
|
||||||
|
const claudeSkills = join(root, '.claude', 'skills');
|
||||||
|
mkdirSync(framework, { recursive: true });
|
||||||
|
mkdirSync(join(home, 'skills', 'added-after-setup'), { recursive: true });
|
||||||
|
mkdirSync(join(home, 'skills', 'another-new-skill'), { recursive: true });
|
||||||
|
writeFileSync(join(framework, 'install.sh'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
|
||||||
|
|
||||||
|
const res = runFrameworkReseed(framework, home, claudeSkills);
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.skillSync).toMatchObject({
|
||||||
|
registered: ['added-after-setup', 'another-new-skill'],
|
||||||
|
conflicts: [],
|
||||||
|
});
|
||||||
|
expect(readlinkSync(join(claudeSkills, 'added-after-setup'))).toBe(
|
||||||
|
join(home, 'skills', 'added-after-setup'),
|
||||||
|
);
|
||||||
|
expect(readlinkSync(join(claudeSkills, 'another-new-skill'))).toBe(
|
||||||
|
join(home, 'skills', 'another-new-skill'),
|
||||||
|
);
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a successful framework re-seed successful when bridge reconciliation fails', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'mosaic-reseed-bridge-failure-'));
|
||||||
|
const framework = join(root, 'framework');
|
||||||
|
const home = join(root, 'mosaic');
|
||||||
|
const claudeSkills = join(root, '.claude', 'skills');
|
||||||
|
mkdirSync(framework, { recursive: true });
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
writeFileSync(join(home, 'skills'), 'invalid canonical root\n');
|
||||||
|
writeFileSync(join(framework, 'install.sh'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 });
|
||||||
|
|
||||||
|
const res = runFrameworkReseed(framework, home, claudeSkills);
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.skillSync).toBeUndefined();
|
||||||
|
expect(res.skillSyncError).toMatch(/not a directory/i);
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
it('reports not-ok (not throw) when the installer is absent', () => {
|
it('reports not-ok (not throw) when the installer is absent', () => {
|
||||||
const missing = mkdtempSync(join(tmpdir(), 'mosaic-noinstaller-'));
|
const missing = mkdtempSync(join(tmpdir(), 'mosaic-noinstaller-'));
|
||||||
const res = runFrameworkReseed(missing, join(missing, 'home'));
|
const res = runFrameworkReseed(missing, join(missing, 'home'));
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
ensureManagedDirectory,
|
ensureManagedDirectory,
|
||||||
readRegularFileSecure,
|
readRegularFileSecure,
|
||||||
} from '../fleet/secure-file.js';
|
} from '../fleet/secure-file.js';
|
||||||
|
import { getDefaultSkillPaths, syncClaudeSkills, type SkillSyncResult } from '../commands/skill.js';
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -871,19 +872,39 @@ export function repairFleetCommsTools(
|
|||||||
* describing what happened (so callers can message + decide on relaunch).
|
* describing what happened (so callers can message + decide on relaunch).
|
||||||
* Best-effort: a missing installer or a non-zero exit is reported, not thrown.
|
* Best-effort: a missing installer or a non-zero exit is reported, not thrown.
|
||||||
*/
|
*/
|
||||||
|
export interface FrameworkReseedResult {
|
||||||
|
ok: boolean;
|
||||||
|
reason?: string;
|
||||||
|
skillSync?: SkillSyncResult;
|
||||||
|
skillSyncError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function runFrameworkReseed(
|
export function runFrameworkReseed(
|
||||||
frameworkRoot = resolveBundledFrameworkRoot(),
|
frameworkRoot = resolveBundledFrameworkRoot(),
|
||||||
mosaicHome = join(homedir(), '.config', 'mosaic'),
|
mosaicHome = join(homedir(), '.config', 'mosaic'),
|
||||||
): { ok: boolean; reason?: string } {
|
claudeSkillsDir = getDefaultSkillPaths().claudeSkillsDir,
|
||||||
|
): FrameworkReseedResult {
|
||||||
const { installer, command, env } = buildReseedCommand(frameworkRoot, mosaicHome);
|
const { installer, command, env } = buildReseedCommand(frameworkRoot, mosaicHome);
|
||||||
if (!existsSync(installer)) {
|
if (!existsSync(installer)) {
|
||||||
return { ok: false, reason: `installer not found: ${installer}` };
|
return { ok: false, reason: `installer not found: ${installer}` };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
execSync(command, { stdio: 'inherit', env: { ...process.env, ...env }, timeout: 120_000 });
|
execSync(command, { stdio: 'inherit', env: { ...process.env, ...env }, timeout: 120_000 });
|
||||||
return { ok: true };
|
} catch (error: unknown) {
|
||||||
} catch (err) {
|
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
||||||
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const skillSync = syncClaudeSkills({
|
||||||
|
mosaicSkillsDir: join(mosaicHome, 'skills'),
|
||||||
|
claudeSkillsDir,
|
||||||
|
});
|
||||||
|
return { ok: true, skillSync };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
skillSyncError: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
import { mkdtempSync, mkdirSync, readlinkSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import type { WizardState } from '../types.js';
|
import type { WizardState } from '../types.js';
|
||||||
@@ -113,6 +113,40 @@ describe('finalizeStage — skill installer', () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('auto-registers every canonical skill even when it was added after initial setup', async () => {
|
||||||
|
const claudeHome = join(tmp, '.claude');
|
||||||
|
const previousClaudeHome = process.env['CLAUDE_HOME'];
|
||||||
|
process.env['CLAUDE_HOME'] = claudeHome;
|
||||||
|
mkdirSync(join(tmp, 'skills', 'added-after-setup'), { recursive: true });
|
||||||
|
mkdirSync(join(tmp, 'skills', 'another-new-skill'), { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await finalizeStage(buildPrompter(), makeState(tmp, []), makeConfigService());
|
||||||
|
|
||||||
|
expect(readlinkSync(join(claudeHome, 'skills', 'added-after-setup'))).toBe(
|
||||||
|
join(tmp, 'skills', 'added-after-setup'),
|
||||||
|
);
|
||||||
|
expect(readlinkSync(join(claudeHome, 'skills', 'another-new-skill'))).toBe(
|
||||||
|
join(tmp, 'skills', 'another-new-skill'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (previousClaudeHome === undefined) delete process.env['CLAUDE_HOME'];
|
||||||
|
else process.env['CLAUDE_HOME'] = previousClaudeHome;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns and completes finalization when bridge-wide reconciliation fails', async () => {
|
||||||
|
writeFileSync(join(tmp, 'skills'), 'invalid canonical root\n');
|
||||||
|
const p = buildPrompter();
|
||||||
|
|
||||||
|
await finalizeStage(p, makeState(tmp, []), makeConfigService());
|
||||||
|
|
||||||
|
expect(p.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/Claude skill reconciliation skipped.*not a directory/i),
|
||||||
|
);
|
||||||
|
expect(p.outro).toHaveBeenCalledWith('Mosaic is ready.');
|
||||||
|
});
|
||||||
|
|
||||||
it('passes MOSAIC_INSTALL_SKILLS with the selected skill list', async () => {
|
it('passes MOSAIC_INSTALL_SKILLS with the selected skill list', async () => {
|
||||||
const state = makeState(tmp, ['brainstorming', 'lint', 'systematic-debugging']);
|
const state = makeState(tmp, ['brainstorming', 'lint', 'systematic-debugging']);
|
||||||
const p = buildPrompter();
|
const p = buildPrompter();
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import type { ConfigService } from '../config/config-service.js';
|
|||||||
import type { WizardState } from '../types.js';
|
import type { WizardState } from '../types.js';
|
||||||
import { getShellProfilePath } from '../platform/detect.js';
|
import { getShellProfilePath } from '../platform/detect.js';
|
||||||
import { ManifestError } from '../framework/manifest.js';
|
import { ManifestError } from '../framework/manifest.js';
|
||||||
|
import {
|
||||||
|
getDefaultSkillPaths,
|
||||||
|
syncClaudeSkills,
|
||||||
|
type SkillSyncResult as ClaudeSkillSyncResult,
|
||||||
|
} from '../commands/skill.js';
|
||||||
|
|
||||||
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
|
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
|
||||||
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
||||||
@@ -205,7 +210,27 @@ export async function finalizeStage(
|
|||||||
skillsResult = syncSkills(state.mosaicHome, state.selectedSkills);
|
skillsResult = syncSkills(state.mosaicHome, state.selectedSkills);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Run doctor
|
// 5. Reconcile every canonical Mosaic skill into Claude Code. This is
|
||||||
|
// intentionally independent of the first-run selected-skill fetch above:
|
||||||
|
// framework installs/upgrades must also register skills added after setup.
|
||||||
|
spin.update('Registering Mosaic skills with Claude Code...');
|
||||||
|
let bridgeResult: ClaudeSkillSyncResult = {
|
||||||
|
registered: [],
|
||||||
|
repaired: [],
|
||||||
|
unchanged: [],
|
||||||
|
conflicts: [],
|
||||||
|
};
|
||||||
|
let bridgeFailure: string | undefined;
|
||||||
|
try {
|
||||||
|
bridgeResult = syncClaudeSkills({
|
||||||
|
mosaicSkillsDir: join(state.mosaicHome, 'skills'),
|
||||||
|
claudeSkillsDir: getDefaultSkillPaths().claudeSkillsDir,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
bridgeFailure = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Run doctor
|
||||||
spin.update('Running health audit...');
|
spin.update('Running health audit...');
|
||||||
const doctorResult = runDoctor(state.mosaicHome);
|
const doctorResult = runDoctor(state.mosaicHome);
|
||||||
|
|
||||||
@@ -217,10 +242,15 @@ export async function finalizeStage(
|
|||||||
p.warn("Run 'mosaic sync' manually after installation to install skills.");
|
p.warn("Run 'mosaic sync' manually after installation to install skills.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. PATH setup
|
if (bridgeFailure) p.warn(`Claude skill reconciliation skipped: ${bridgeFailure}`);
|
||||||
|
for (const conflict of bridgeResult.conflicts) {
|
||||||
|
p.warn(`Skill registration skipped for ${conflict.name}: ${conflict.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. PATH setup
|
||||||
const pathAction = setupPath(state.mosaicHome, p);
|
const pathAction = setupPath(state.mosaicHome, p);
|
||||||
|
|
||||||
// 7. Summary
|
// 8. Summary
|
||||||
const skillsSummary = skillsResult.success
|
const skillsSummary = skillsResult.success
|
||||||
? skillsResult.installedCount > 0
|
? skillsResult.installedCount > 0
|
||||||
? `${skillsResult.installedCount.toString()} installed`
|
? `${skillsResult.installedCount.toString()} installed`
|
||||||
@@ -245,7 +275,7 @@ export async function finalizeStage(
|
|||||||
|
|
||||||
p.note(summary.join('\n'), 'Installation Summary');
|
p.note(summary.join('\n'), 'Installation Summary');
|
||||||
|
|
||||||
// 8. Next steps
|
// 9. Next steps
|
||||||
const nextSteps: string[] = [];
|
const nextSteps: string[] = [];
|
||||||
if (pathAction === 'added') {
|
if (pathAction === 'added') {
|
||||||
const profilePath = getShellProfilePath();
|
const profilePath = getShellProfilePath();
|
||||||
|
|||||||
@@ -5,5 +5,17 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
testTimeout: 30_000,
|
testTimeout: 30_000,
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
include: ['src/commands/skill.ts', 'src/lease-broker/broker-test-client.ts'],
|
||||||
|
reporter: ['text', 'json-summary'],
|
||||||
|
thresholds: {
|
||||||
|
perFile: true,
|
||||||
|
statements: 85,
|
||||||
|
branches: 85,
|
||||||
|
functions: 85,
|
||||||
|
lines: 85,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -607,6 +607,9 @@ importers:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.3.0
|
specifier: ^18.3.0
|
||||||
version: 18.3.28
|
version: 18.3.28
|
||||||
|
'@vitest/coverage-v8':
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.1.9(vitest@2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1))
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.21.0
|
version: 4.21.0
|
||||||
|
|||||||
@@ -61,17 +61,38 @@ if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
|
|||||||
FLAG_DEV=true
|
FLAG_DEV=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
installer_usage() {
|
||||||
|
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
|
||||||
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--check) FLAG_CHECK=true; shift ;;
|
--check) FLAG_CHECK=true; shift ;;
|
||||||
--framework) FLAG_CLI=false; shift ;;
|
--framework) FLAG_CLI=false; shift ;;
|
||||||
--cli) FLAG_FRAMEWORK=false; shift ;;
|
--cli) FLAG_FRAMEWORK=false; shift ;;
|
||||||
--ref) GIT_REF="${2:-main}"; shift 2 ;;
|
--ref)
|
||||||
|
if [[ $# -lt 2 ]] || [[ -z "$2" ]]; then
|
||||||
|
printf 'Error: Missing value for --ref\n' >&2
|
||||||
|
installer_usage
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "$2" == -* ]]; then
|
||||||
|
printf 'Error: Unknown argument: %s\n' "$2" >&2
|
||||||
|
installer_usage
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
GIT_REF="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--dev) FLAG_DEV=true; shift ;;
|
--dev) FLAG_DEV=true; shift ;;
|
||||||
--yes|-y) FLAG_YES=true; shift ;;
|
--yes|-y) FLAG_YES=true; shift ;;
|
||||||
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
||||||
--uninstall) FLAG_UNINSTALL=true; shift ;;
|
--uninstall) FLAG_UNINSTALL=true; shift ;;
|
||||||
*) shift ;;
|
*)
|
||||||
|
printf 'Error: Unknown argument: %s\n' "$1" >&2
|
||||||
|
installer_usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -212,7 +233,7 @@ ok() { echo "${G}✔${RESET} $*"; }
|
|||||||
warn() { echo "${Y}⚠${RESET} $*"; }
|
warn() { echo "${Y}⚠${RESET} $*"; }
|
||||||
fail() { echo "${R}✖${RESET} $*" >&2; }
|
fail() { echo "${R}✖${RESET} $*" >&2; }
|
||||||
dim() { echo "${DIM}$*${RESET}"; }
|
dim() { echo "${DIM}$*${RESET}"; }
|
||||||
step() { echo ""; echo "${BOLD}$*${RESET}"; }
|
step() { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; }
|
||||||
|
|
||||||
# ─── helpers ──────────────────────────────────────────────────────────────────
|
# ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user