Compare commits
4 Commits
main
...
fix/795-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
027a99b558 | ||
|
|
0e50c694cb | ||
|
|
080a6b4663 | ||
|
|
13c50c5fa9 |
@@ -97,10 +97,7 @@ mosaic config path # Print config file path
|
||||
```bash
|
||||
mosaic doctor # Health audit — detect drift and missing files
|
||||
mosaic sync # Sync skills from canonical source
|
||||
mosaic skill list # Audit Claude skill registrations and conflicts
|
||||
mosaic skill register <name> # Register one canonical skill with Claude Code
|
||||
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
||||
mosaic update # Update CLI/framework and auto-register canonical skills
|
||||
mosaic update # Check for and install CLI updates
|
||||
mosaic wizard # Full guided setup wizard
|
||||
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
|
||||
mosaic coord init # Initialize a new orchestration mission
|
||||
@@ -352,8 +349,6 @@ bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||
```
|
||||
|
||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
||||
|
||||
## Contributing
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
# 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.
|
||||
- [WI-1 security notes](architecture/lease-broker-security.md) — threat-boundary summary and coordinator review requirements.
|
||||
|
||||
## CLI and skill management
|
||||
|
||||
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
||||
- [Skill bridge developer guide](guides/dev-guide.md#claude-code-skill-bridge) — path-validation, ownership, clobber-protection, install/update wiring, tests, and Pi/Codex scope notes.
|
||||
|
||||
## Fleet configuration management
|
||||
|
||||
- [Generated environment boundary](fleet/reference/generated-env-boundary.md) — roster-derived launch projection, strict local data, legacy quarantine, and downstream interface evidence.
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Authenticated external lease broker protocol
|
||||
|
||||
The compaction-refresh lease broker is a Linux-only, newline-framed JSON protocol over a Unix stream socket. It is runtime-neutral; M1 consumers are limited to Claude and Pi. This is an internal process boundary, not an HTTP API, so it is intentionally absent from OpenAPI.
|
||||
|
||||
The broker, never the caller, obtains `(pid, uid, gid)` from kernel `SO_PEERCRED`. It correlates the PID with `/proc/<pid>/stat` field 22 (`starttime`) and mints `session_id` on `register_anchor`. Presence of `session_id` in that request is refused even when its value is `null` or empty. Later requests must originate from the anchor or a descendant. The broker walks parent PIDs to the `(pid,starttime)` anchor and then rereads every walked PID's starttime before accepting the chain.
|
||||
|
||||
## Request and response boundary
|
||||
|
||||
Each connection carries exactly one UTF-8 JSON object followed by one newline, capped at 64 KiB. The protocol deliberately uses EOF to prove that there is exactly one frame: immediately after writing the newline, the client **MUST half-close its write side** with `shutdown(SHUT_WR)` (or Node `socket.end()`) before awaiting the response. A client that writes a newline but leaves its write side open receives no successful response; the broker's one-second connection deadline fails closed. Malformed, unterminated, multiple (including a delayed second frame), or oversized frames fail closed. Responses are one JSON object and one newline. Success has `{"ok":true,...}`; refusal has `{"ok":false,"code":"TYPED_CODE"}`. Requests are:
|
||||
|
||||
- `register_anchor`: `action`, non-negative `runtime_generation`; no `session_id` field.
|
||||
- `authenticate`: `action`, broker-minted `session_id`, non-negative `runtime_generation`.
|
||||
- `mint_token`: authenticated identity plus `binding` containing exactly `compaction_epoch`, `request_epoch`, `h_source`, `h_payload`, and `schema_version`.
|
||||
- `consume_token`: authenticated identity plus `token`.
|
||||
|
||||
A higher generation for the same anchor atomically replaces the stored incarnation and deletes all prior tokens 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. Their persisted binding is the WI-1 substrate for later receipt work; WI-1 does not implement receipts, promotion, hooks, payload builders, mutator gates, or recovery.
|
||||
|
||||
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.
|
||||
@@ -1,11 +0,0 @@
|
||||
# WI-1 lease broker security notes
|
||||
|
||||
- Trusted identity comes only from Linux `SO_PEERCRED` plus `/proc` starttime, never request identity fields.
|
||||
- Descendant authorization is anchored to `(pid,starttime)` and uses a complete second starttime pass to fail closed on disappearance or PID-reuse races.
|
||||
- Runtime generations are monotonic per anchor; a bump revokes prior-incarnation tokens before persistence commits.
|
||||
- Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources.
|
||||
- Framing and persistence failures fail closed. Sensitive tokens are not logged.
|
||||
- Built-in `0700`/`0600` filesystem modes provide same-principal hardening only, not socket authenticity against the same UID. WI-1 provides no distinct-principal isolation. That stronger deployment requires an external protected proxy, ACL, or service boundary, and the boundary must preserve authenticated client identity for the broker's `SO_PEERCRED` and ancestry authorization rather than substituting a shared proxy identity.
|
||||
- WI-2+ security surfaces—receipts, promotion transactions, mutator gates, hooks, payload construction, and recovery—are explicitly out of scope.
|
||||
|
||||
Coordinator security review must rerun the real socket/peercred acceptance suite on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration.
|
||||
@@ -8,9 +8,8 @@
|
||||
4. [Adding New Agent Tools](#adding-new-agent-tools)
|
||||
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
||||
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
||||
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
|
||||
8. [API Endpoint Reference](#api-endpoint-reference)
|
||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
7. [API Endpoint Reference](#api-endpoint-reference)
|
||||
8. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -354,37 +353,6 @@ defined there.
|
||||
|
||||
---
|
||||
|
||||
## Claude Code Skill Bridge
|
||||
|
||||
The framework's canonical skill root is `~/.config/mosaic/skills/`; Claude Code
|
||||
requires registrations under `~/.claude/skills/`. The implementation in
|
||||
`packages/mosaic/src/commands/skill.ts` owns only direct-child symlinks whose
|
||||
resolved target remains inside the canonical root.
|
||||
|
||||
Security invariants:
|
||||
|
||||
1. Validate the user-supplied name before filesystem access against
|
||||
`[A-Za-z0-9][A-Za-z0-9._-]*`. Separators, control characters, whitespace,
|
||||
`..`, absolute paths, and leading `-` are invalid; filesystem-derived invalid
|
||||
names are escaped before terminal output.
|
||||
2. Never replace a real file, directory, foreign symlink, or live misdirected
|
||||
symlink in the Claude skill directory.
|
||||
3. Repair a dangling link only when its lexical target is inside the canonical
|
||||
Mosaic skills root.
|
||||
4. Unregister only a symlink pointing inside that root.
|
||||
5. Enumerate canonical directories at runtime; never hardcode framework skill
|
||||
names.
|
||||
|
||||
`finalizeStage` reconciles after wizard/framework synchronization, and
|
||||
`runFrameworkReseed` reconciles after the sync-only `mosaic update` path. A
|
||||
foreign conflict is reported but does not prevent unrelated canonical skills
|
||||
from registering. Filesystem tests use injected temporary roots in
|
||||
`skill.spec.ts`, `finalize-skills.spec.ts`, and `update-checker.reseed.spec.ts`.
|
||||
|
||||
M1 intentionally manages Claude Code only. Pi's Mosaic launcher can discover the
|
||||
canonical root directly. Codex still relies on the existing full skill-sync
|
||||
linker and needs separate parity analysis before this lifecycle API is extended.
|
||||
|
||||
## API Endpoint Reference
|
||||
|
||||
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Lease broker operations
|
||||
|
||||
Place the socket and state file in a dedicated directory with mode `0700`. Start the packaged daemon with:
|
||||
|
||||
```bash
|
||||
python3 "$MOSAIC_HOME/tools/lease-broker/daemon.py" \
|
||||
--socket /run/user/1000/mosaic-lease/broker.sock \
|
||||
--state /run/user/1000/mosaic-lease/state.json
|
||||
```
|
||||
|
||||
The broker refuses an existing parent directory whose mode is not exactly `0700`, an existing state file not at `0600`, corrupt/incompatible state, or an already-existing socket path. After bind it sets the socket to `0600`. It never silently unlinks a pre-existing socket. On normal termination it unlinks only the socket inode it created, so it does not remove a replacement path.
|
||||
|
||||
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 in WI-1. After a crash, preserve the protected state file and restart only after verifying that no broker owns the socket. 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.
|
||||
@@ -183,8 +183,6 @@ non-interactive use:
|
||||
--no-auto-launch # Skip auto-launch of wizard after install
|
||||
```
|
||||
|
||||
Unrecognized flags or positional arguments fail before installation starts and print the supported-option usage.
|
||||
|
||||
Or if installed globally:
|
||||
|
||||
```bash
|
||||
@@ -309,39 +307,6 @@ mosaic quality-rails
|
||||
|
||||
---
|
||||
|
||||
### Claude Code Skill Registration
|
||||
|
||||
Mosaic stores canonical skills under `~/.config/mosaic/skills/`. Claude Code scans
|
||||
`~/.claude/skills/`, so Mosaic maintains one symlink per skill between those
|
||||
directories.
|
||||
|
||||
```bash
|
||||
mosaic skill list
|
||||
mosaic skill register <name>
|
||||
mosaic skill unregister <name>
|
||||
```
|
||||
|
||||
- `register` is idempotent and repairs a dangling Mosaic-owned link. Names use
|
||||
the safe grammar `[A-Za-z0-9][A-Za-z0-9._-]*`; files, directories, foreign
|
||||
symlinks, path traversal, absolute paths, and names beginning with `-` are
|
||||
refused.
|
||||
- `unregister` is idempotent when no entry exists. It removes only symlinks that
|
||||
point inside `~/.config/mosaic/skills/`; foreign entries are never removed.
|
||||
- `list` reports `registered`, `unregistered`, `dangling`, `foreign`,
|
||||
`foreign-dangling`, or `misdirected` for each canonical or Claude entry.
|
||||
|
||||
Install, wizard finalization, and `mosaic update` framework re-seeding reconcile
|
||||
every canonical skill automatically. A skill directory added after initial
|
||||
setup therefore receives its Claude bridge without a per-skill code change or
|
||||
manual `ln -s`. If Claude Code is already running, use `/reload-skills` or start
|
||||
a new session after registration so its in-process skill registry rescans.
|
||||
|
||||
This command group is Claude-only in M1. Pi can consume Mosaic's canonical skill
|
||||
root through its Mosaic launcher configuration and does not need this Claude
|
||||
bridge. Codex has a separate link path managed by the legacy full skill-sync
|
||||
script; equivalent lifecycle management remains follow-up scope and is not
|
||||
changed here.
|
||||
|
||||
## Sub-package Commands
|
||||
|
||||
Each Mosaic sub-package exposes its full API surface through the `mosaic` CLI.
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
# Issue #804 — fail closed on unknown installer arguments
|
||||
|
||||
## Objective
|
||||
|
||||
Implement Part 1 of Gitea issue #804 only: `tools/install.sh` must reject every unrecognized flag or argument with an actionable STDERR error and nonzero exit before installation starts.
|
||||
|
||||
## Scope and constraints
|
||||
|
||||
- Preserve all currently recognized options and behavior, including `-y` and `--ref <branch>`.
|
||||
- No positional arguments are currently accepted by the parser.
|
||||
- Do not add `--next`, `MOSAIC_NEXT`, prerelease routing, or any Part 2 behavior.
|
||||
- TDD is mandatory: add and observe a failing process-level regression test before changing `tools/install.sh`.
|
||||
- Worker lifecycle ends after branch push, PR creation, and coordinator notification; do not merge or close #804.
|
||||
- Existing launcher-owned changes in `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` are out of scope and must not be committed.
|
||||
|
||||
## Requirements and acceptance criteria
|
||||
|
||||
- Unknown input names the offending argument on STDERR.
|
||||
- STDERR includes a short installer usage hint.
|
||||
- Exit status is nonzero.
|
||||
- The installer does not invoke npm or otherwise proceed into installation.
|
||||
- Existing recognized flags remain unchanged.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add a process-level Vitest regression using the installer test location under `packages/mosaic/src/commands/`.
|
||||
2. Run the focused test and record the expected RED failure.
|
||||
3. Commit the RED test as `test(#804): ...`.
|
||||
4. Replace the parser catch-all with a fail-closed STDERR error and usage hint.
|
||||
5. Update concise installer-facing documentation without introducing prerelease behavior.
|
||||
6. Run focused tests, shell syntax validation, package tests, lint, typecheck, and format checks.
|
||||
7. Run independent review tooling and remediate findings.
|
||||
8. Commit as `fix(#804): ...`, queue-guard, push, open a PR containing `Closes #804.`, notify the coordinator, and exit.
|
||||
|
||||
## Budget
|
||||
|
||||
- No explicit token cap supplied.
|
||||
- Working estimate: 8K tokens; narrow two-file behavior/test change plus concise docs and delivery gates.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-07-17: Loaded mission state, issue #804, delivery/QA/documentation rails, and relevant TDD/Vitest/pnpm/Gitea skills.
|
||||
- 2026-07-17: Confirmed the parser has no legitimate positional arguments and currently drops all unmatched input via `*) shift ;;`.
|
||||
- 2026-07-17: Installed locked workspace dependencies with a worktree-local pnpm store; no lockfile changes.
|
||||
- 2026-07-17: Added the process-level unknown-argument regression with an isolated `$HOME` and npm shim.
|
||||
- 2026-07-17: Replaced the silent catch-all with STDERR error + usage output and exit 2 before preflight or installation.
|
||||
- 2026-07-17: Initial Codex code review found an unknown option could still be consumed as the `--ref` value. Added a second RED reproducer, then rejected option-shaped/missing `--ref` values without changing valid `--ref <branch>` behavior. The review's launcher-state note is handled by excluding both `.mosaic/orchestrator/` files from commits.
|
||||
- 2026-07-17: Updated README, user guide, and packaged framework README with the fail-closed argument contract. No API, auth, admin, sitemap/navigation, or publishing surface changed.
|
||||
|
||||
## Verification
|
||||
|
||||
- RED: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/install-arguments.spec.ts` — expected failure: installer exited `0` instead of nonzero at the exit-status assertion; confirms the test reproduces the silent-drop defect before production changes.
|
||||
- Remediation RED: the added `--cli --ref --bogus` case exited `0`, proving `--ref` could swallow an unknown option before the guard was added.
|
||||
- GREEN: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/install-arguments.spec.ts src/commands/install-heading.spec.ts` — 2 files, 3 tests passed.
|
||||
- Situational process check: unknown positional input exited 2, named the input on STDERR, printed usage, and did not call the npm shim.
|
||||
- `bash -n tools/install.sh` — passed.
|
||||
- Bare `--ref` process check — exited 2 with `Missing value for --ref` and usage.
|
||||
- `pnpm --filter @mosaicstack/mosaic test` — 69 files, 1,287 tests passed; framework shell checks passed. The first attempt lacked generated `dist/cli.js`; `pnpm --filter @mosaicstack/mosaic build` restored the required test precondition and the full rerun passed.
|
||||
- `pnpm lint` — 23/23 tasks passed.
|
||||
- `pnpm typecheck` — 42/42 tasks passed.
|
||||
- `pnpm format:check` — passed.
|
||||
- Codex code re-review against `origin/main` — `approve`, 0 blockers/should-fix/suggestions.
|
||||
- Codex security re-review against `origin/main` — risk `none`, 0 findings.
|
||||
|
||||
## Acceptance evidence
|
||||
|
||||
| Criterion | Evidence |
|
||||
| --- | --- |
|
||||
| Unknown input is named on STDERR | Process-level Vitest assertions for `--bogus`, including after `--ref` |
|
||||
| Short usage hint is printed on STDERR | Vitest usage regex + manual process output |
|
||||
| Exit is nonzero | Vitest status assertions and manual exit 2 |
|
||||
| Installation does not proceed | Isolated npm shim marker remains absent |
|
||||
| Recognized behavior is preserved | Parser cases are unchanged except validation of malformed `--ref`; full Mosaic package suite passed |
|
||||
| Part 2 is excluded | No `--next`, `MOSAIC_NEXT`, dist-tag, or prerelease routing changes |
|
||||
|
||||
## Documentation checklist
|
||||
|
||||
- Current canonical `docs/PRD.md` remains unchanged; issue #804 and the coordinator brief supply this bounded defect's acceptance contract.
|
||||
- Updated installer behavior in root README, user guide, and packaged framework README in the same logical change set.
|
||||
- API/OpenAPI, auth/permissions, admin operations, developer architecture, sitemap/navigation, and external publishing are not affected.
|
||||
- Scratchpad remains under `docs/scratchpads/`; no root-hygiene changes.
|
||||
|
||||
## Risks and blockers
|
||||
|
||||
- Part 2 remains owner-gated under #805 and is intentionally excluded.
|
||||
- No implementation blocker remains. Independent coordinator RoR, CI, merge, and issue closure remain pending after worker handoff.
|
||||
@@ -1,112 +0,0 @@
|
||||
# Issue #824 — Mosaic skill CLI and Claude bridge auto-sync
|
||||
|
||||
## Objective
|
||||
|
||||
Deliver `mosaic skill register|unregister|list` plus install/upgrade reconciliation of every canonical `~/.config/mosaic/skills/*` entry into `~/.claude/skills/`, without clobbering runtime-owned files or directories.
|
||||
|
||||
## Scope and constraints
|
||||
|
||||
- Issue: mosaicstack/stack#824
|
||||
- Branch: `feat/824-mosaic-skill-cli`
|
||||
- M1 runtime: Claude Code only.
|
||||
- Pi/Codex parity is documentation-only; no non-Claude bridge implementation.
|
||||
- Do not author the downstream `mosaic-context-refresh` skill.
|
||||
- Workers do not modify `docs/TASKS.md`, merge, close #824, or touch `main`.
|
||||
- TDD is mandatory and red-first; filesystem tests use temporary directories only.
|
||||
- Budget: no explicit token cap supplied; use a focused single-worker implementation with no new dependencies.
|
||||
|
||||
## Requirements mapping
|
||||
|
||||
1. Register creates the canonical Claude symlink and is idempotent.
|
||||
2. Names are untrusted: reject empty/escaping/absolute/separator/`..`/leading-dash names before filesystem mutation, with clear CLI stderr and nonzero status.
|
||||
3. Register repairs only Mosaic-owned dangling symlinks and refuses foreign files, directories, and symlinks.
|
||||
4. Unregister removes only symlinks pointing inside the canonical Mosaic skills root and is idempotent when absent.
|
||||
5. List reports registered, dangling, foreign, and canonical-but-unregistered skills.
|
||||
6. Install and upgrade generically reconcile all canonical skills after framework sync/re-seed, continuing past foreign conflicts without clobbering them.
|
||||
7. User/developer documentation describes commands, status meanings, security boundaries, and Claude-only M1 scope.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add co-located failing Vitest coverage for all filesystem behaviors and auto-sync.
|
||||
2. Run the focused spec and record the expected RED failure.
|
||||
3. Commit the red contract as `test(#824): ...`.
|
||||
4. Implement the skill bridge and Commander command registration.
|
||||
5. Wire reconciliation into wizard finalize and `mosaic update` re-seed, preserving non-clobber behavior.
|
||||
6. Update canonical docs and sitemap if navigation changes.
|
||||
7. Run focused tests, package tests, typecheck, lint, and formatting.
|
||||
8. Commit implementation/docs as `feat(#824): ...`, queue-guard, push, open PR with `Closes #824.`, fire completion event, and notify the coordinator.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-07-17: Loaded mission/delivery/TDD/documentation rails, issue #824, active mission state, and relevant installer/update paths.
|
||||
- 2026-07-17: Confirmed `mosaic update` invokes `framework/install.sh` with `MOSAIC_SYNC_ONLY=1`; that path exits before existing post-install skill linking, leaving newly present canonical skills unregistered.
|
||||
- 2026-07-17: Coordinator addendum classified the user-supplied skill name and runtime symlink target as a path-traversal/symlink-injection surface. Expanded the initial red contract to reject traversal before mutation, preserve every foreign entry, and unregister Mosaic-owned links only.
|
||||
- 2026-07-17: Implemented the Commander command group and secure generic bridge; wired wizard finalize and successful framework re-seed reconciliation; updated user/developer/installed/root docs and sitemap.
|
||||
- 2026-07-17: Focused, package-wide, repository baseline, temp-home situational, and independent review gates completed. Ready for scoped feature commit, queue guard, push, and PR handoff.
|
||||
|
||||
## Tests and evidence
|
||||
|
||||
### TDD evidence
|
||||
|
||||
- RED environment attempt: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/skill.spec.ts` initially could not locate Vitest because this fresh worktree had no dependencies.
|
||||
- Dependency setup: `pnpm install --frozen-lockfile --store-dir /home/hermes/.local/share/pnpm/store` succeeded. The explicit store was required because machine pnpm config incorrectly resolves the default store under `/root`.
|
||||
- RED behavior: focused Vitest failed with `Failed to load url ./skill.js ... Does the file exist?`, proving the bridge API was absent.
|
||||
- RED integration: finalize/update specs failed because no Claude links or `skillSync` result existed.
|
||||
- RED symlink injection: symlinked Claude/canonical root tests failed because the initial implementation followed ancestor links.
|
||||
- GREEN after review remediation: `skill.spec.ts` 36/36, `finalize-skills.spec.ts` 6/6, and `update-checker.reseed.spec.ts` 30/30.
|
||||
|
||||
### Baseline gates
|
||||
|
||||
- `pnpm --filter '@mosaicstack/mosaic...' run build` — pass (fresh-worktree dependency outputs built).
|
||||
- `pnpm --filter @mosaicstack/mosaic run typecheck` — pass.
|
||||
- `pnpm --filter @mosaicstack/mosaic run lint` — pass.
|
||||
- `pnpm --filter @mosaicstack/mosaic test` — pass: 69 files, 1,325 Vitest tests plus framework shell suite.
|
||||
- `pnpm typecheck` — pass: 42/42 Turbo tasks.
|
||||
- `pnpm lint` — pass: 23/23 Turbo tasks.
|
||||
- `pnpm format:check` — pass.
|
||||
|
||||
### Situational evidence
|
||||
|
||||
A built-CLI temp-home smoke test (no real `~/.claude` or Mosaic config touched) proved:
|
||||
|
||||
- register creates the exact link and a second run reports `already registered`;
|
||||
- list reports registered and unregistered canonical skills;
|
||||
- `../../etc` exits 1 with `Invalid skill name` and creates no escaped path;
|
||||
- unregister removes the managed link and a second run reports `already unregistered`;
|
||||
- a fake successful framework re-seed generically registered both `added-after-setup` and `second-skill` from runtime directory enumeration.
|
||||
|
||||
### Review evidence
|
||||
|
||||
- Initial uncommitted Codex code/security review described name validation/clobber protection as strong; its only finding was the harness-owned, unrelated `.mosaic/orchestrator/session.lock`, which is excluded from all commits and the PR.
|
||||
- Exact branch review then identified two remediations: preserve successful framework re-seed status when bridge-wide reconciliation fails, and reject/escape control-character names to prevent terminal/log injection.
|
||||
- Both findings were reproduced red-first and remediated. A subsequent exact review identified one finalize failure-isolation blocker; a root-wide bridge error now warns and allows wizard doctor/summary/next-steps completion, with a red-first regression.
|
||||
- All remediations passed the full package and repository gates. Final exact-head review is rerun after amending the feature commit.
|
||||
|
||||
### Acceptance mapping
|
||||
|
||||
| Acceptance criterion | Evidence |
|
||||
| --- | --- |
|
||||
| register/unregister/list, idempotent | `skill.spec.ts` and built-CLI temp-home smoke |
|
||||
| traversal/symlink-injection protection | invalid-name matrix, foreign file/dir/link tests, symlinked-root tests |
|
||||
| list flags dangling and foreign entries | deterministic list status test |
|
||||
| install and upgrade auto-sync every canonical directory | finalize + framework re-seed integration specs; two-skill built-module smoke |
|
||||
| newly added skill becomes discoverable without manual link | `added-after-setup` auto-sync creates exact Claude link; Claude can rescan with `/reload-skills` or a new session |
|
||||
| Pi/Codex parity captured as scope note | user guide, developer guide, installed framework README |
|
||||
| documentation gate | root README, user guide, developer guide, framework README, sitemap |
|
||||
|
||||
## Risks
|
||||
|
||||
- Symlink replacement uses `lstat` semantics so dangling links are detectable without following them.
|
||||
- Link ownership is determined lexically against the canonical skills root, and existing symlink ancestors in either managed root are rejected before mutation.
|
||||
- Auto-sync continues across per-skill conflicts while never deleting real files/directories or foreign symlinks.
|
||||
- Claude Code discovers filesystem skills at session launch/reload boundaries; bridge creation makes a later `/reload-skills` or new session able to discover the skill, but cannot mutate an already-cached in-process registry by itself.
|
||||
- Pi does not need this Claude bridge because its Mosaic launcher can consume the canonical root. Codex lifecycle parity remains explicitly deferred.
|
||||
- No deployment surface is affected.
|
||||
|
||||
## PR #826 review remediation
|
||||
|
||||
- 2026-07-17: Exact-head RoR requested changes for two ownership bugs: installer pruning deleted foreign-name links under `MOSAIC_HOME` outside canonical skills, and unregister deleted a same-root link targeting a different skill. It also requested trailing-dot rejection and executable coverage support.
|
||||
- RED evidence: focused regression run failed 4 tests: register/unregister accepted `safe.`, misdirected unregister did not throw, and the install linker deleted the foreign-name link.
|
||||
- GREEN evidence: `skill.spec.ts` passes 43/43, including live and dangling foreign-name links in a temp HOME/MOSAIC_HOME and the misdirected unregister invariant.
|
||||
- Coverage: `vitest run src/commands/skill.spec.ts --coverage` passes configured 85% thresholds for `skill.ts`: 91.05% statements/lines, 86.27% branches, 95.23% functions.
|
||||
- Full gates: package build passed; package tests passed 69 files / 1,332 tests plus framework shell suite; repository typecheck 42/42, lint 23/23, and format check passed.
|
||||
@@ -1,86 +0,0 @@
|
||||
# WI-1 Scratchpad — Authenticated external lease broker
|
||||
|
||||
- **Issue:** Gitea #828
|
||||
- **Milestone:** 188 — Compaction-Refresh Mechanism (M1: Claude + Pi)
|
||||
- **Branch:** `feat/828-lease-broker`
|
||||
- **Starting HEAD:** `d801d6c4c8a984d6a95033c49714210018d3d9a8`
|
||||
- **Session role:** Orchestrator coordinating implementation; Mos retains merge authority.
|
||||
|
||||
## Objective
|
||||
|
||||
Implement the ratified WI-1 product lease broker under `packages/mosaic/`: Linux `SO_PEERCRED` identity, broker-minted logical session IDs, `(pid,starttime)` launcher anchors with per-hop `/proc` starttime revalidation, sibling-substitution rejection, same-PID runtime-generation revocation, crypto-RNG single-use token persistence, and protected Unix-socket posture.
|
||||
|
||||
## Authority verification
|
||||
|
||||
Verified before code on session start; all exact SHA-256 values matched:
|
||||
|
||||
- BUILD-BRIEF: `89fdbc27ed0e5050dc7b52f3ef2ddaea691edf17fd89d51b15e26fb5ed47171b`
|
||||
- SPEC-v5: `a6d07ade835758e8488ca10d3b0631caf0beb93ea3a6733631f151b0c2f01433`
|
||||
- Ratification: `bac58319c9c4028b5b40e1129e0033cdb5a6b7b02033c25f06f4cb77d7779c67`
|
||||
- P6 planner ruling: `b7bbb6ea6e8d9a5c3366993642ab4e4f65b961af04936dcac20bfbcdcbaf1a09`
|
||||
- WI-0 Gate0 evidence: `5d418306fcc597fd514e500bee40d1509f0bf467e46ee13fc5c280ed8274759d`
|
||||
|
||||
## Locked constraints
|
||||
|
||||
- Build against the ratified design; do not re-derive it.
|
||||
- Product code only in `packages/mosaic`; Gate0 Python probes are reference prototypes and are not shipped.
|
||||
- Caller-supplied/asserted `session_id` is refused.
|
||||
- Tokens use the operating-system CSPRNG via Python `secrets`; never `Math.random` or model output.
|
||||
- Socket parent directory mode `0700`, socket mode `0600` minimum; document distinct-principal deployment as the stronger T-C-closing posture.
|
||||
- Red-first TDD for six named cases; new-code coverage >=85%.
|
||||
- No merge. PR must say `closes #828`; exact 40-character head handed to Mos for Opus-SECREV and independent review.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Load security/testing/docs guidance and inspect existing `packages/mosaic` architecture.
|
||||
2. Write the six required tests first and capture RED evidence.
|
||||
3. Implement minimal broker modules and CLI/runtime integration necessary for product use.
|
||||
4. Run focused tests with coverage, package gates, then full repository gates/suite.
|
||||
5. Run author-side review/remediation, commit `closes #828`, queue guard, push, and open PR through Mosaic wrappers.
|
||||
6. Send PR number + exact head SHA to `web1:mosaic-100`; stop without merging.
|
||||
|
||||
## Risks / boundaries
|
||||
|
||||
- Same-UID counterfeit socket replacement remains the disclosed T-C residual unless broker runs under a distinct principal; filesystem modes alone are minimum hardening, not a complete authenticity proof.
|
||||
- `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` were already modified at session start and must not be included in this PR.
|
||||
- Repository Woodpecker pipelines exist; CI is the canonical build path. No manual image build/deploy is in scope.
|
||||
|
||||
## Progress / evidence
|
||||
|
||||
- 2026-07-18 session start: mandatory mission files and orchestration guides loaded.
|
||||
- STEP 0: all four authority hashes matched; artifacts read in full.
|
||||
- Branch/HEAD confirmed; issue #828 open; Gate0 evidence hash confirmed.
|
||||
- Initial RED: focused Vitest acceptance suite failed 11/11 because the product daemon did not exist; the expected missing-product failure was observed before implementation.
|
||||
- Review-remediation RED: partial/zero-progress state writes, nested corrupt state, symlink state, canonical starttime, and duplicate-anchor generation behavior failed before their fixes. Real socket RED/GREEN runs were executed by the unrestricted parent harness because the delegated worker sandbox denies `AF_UNIX.bind()`.
|
||||
- Product implementation added at `packages/mosaic/framework/tools/lease-broker/daemon.py`; Gate0 probe scripts were read as references but not copied or shipped.
|
||||
- Independent Codex code review round 1 found 2 blockers + 1 should-fix (connection stall/crash, partial writes, packet-dependent framing); all were remediated with tests.
|
||||
- Independent Codex code review round 2 found 2 blockers + 1 relevant should-fix (half-close contract ambiguity, incomplete persisted-state validation, symlink/non-regular state); all were remediated with tests and documentation. Pre-existing `.mosaic/*` session dirt remains excluded from the PR.
|
||||
- Unrestricted focused situational suite: `35/35` GREEN.
|
||||
- New Python product module coverage: `90%` (`356` statements, `36` missed), above the user-required 85%.
|
||||
- Root typecheck: `42/42` Turbo tasks GREEN.
|
||||
- Root lint: `23/23` Turbo tasks GREEN.
|
||||
- Root format check: GREEN.
|
||||
- Package build + suite: `71/71` files and `1,369/1,369` tests GREEN, including framework shell tests.
|
||||
- Full root suite: `43/43` Turbo tasks GREEN after the oversized-frame production race fix.
|
||||
- Focused acceptance suite: `35/35` GREEN in three consecutive unrestricted runs; exact-head instrumented run also `35/35` GREEN.
|
||||
- Exact-head Python product coverage: `90%` (`365` statements, `37` missed), above the required 85%.
|
||||
- Review-triggered oversized-frame race was fixed in production by bounded drain-to-EOF; tests were not changed.
|
||||
- Commits banked in red/green cadence: `d61c5441` (RED contract), `deb11df7` (GREEN implementation/docs), `57770e34` (oversized-frame production fix).
|
||||
- Final-review blocker remediated: added a 256-token pending-state cap, deletion on consume/generation revocation, pre-open serialized-size enforcement, and request-wide in-memory rollback for every broker mutation/commit failure while retaining the v1 live-token schema.
|
||||
- Distinct-principal docs now state built-in `0700`/`0600` is same-principal only; WI-1 does not provide the external identity-preserving proxy/ACL/service boundary needed for the stronger deployment.
|
||||
- Exact Python unit suite: `8/8` GREEN. Unrestricted focused acceptance: `35/35` GREEN.
|
||||
- Exact-head package build/suite: `71/71` files and `1,369/1,369` tests GREEN.
|
||||
- Exact-head Python product coverage: `90%` (`376` statements, `36` missed), above required 85%.
|
||||
- Root typecheck: `42/42` GREEN. Root lint: `23/23` GREEN. Root format check and `git diff --check`: GREEN.
|
||||
- Final exact-head rereview found two persistence blockers: post-rename directory-fsync uncertainty and acceptance of impossible persisted token records. RED was captured as three invariant failures plus one missing fail-stop error; commits `a94b1220` (RED) and `d05465e5` (GREEN) remediate both without weakening tests.
|
||||
- Post-remediation evidence: Python unit suite `10/10`, focused real-socket acceptance `35/35`, full root suite `43/43` Turbo tasks, broker coverage `90%` (`395` statements, `38` missed), lint `23/23`, typecheck `42/42`, format check and `git diff --check` GREEN.
|
||||
- Independent Codex review of remediation commit `d05465e54736c4966294c4af8fbd6a4ad8fe81aa`: APPROVE, confidence `0.94`, zero findings. Reviewer sandbox could not allocate temp directories; unrestricted parent test evidence above is canonical.
|
||||
|
||||
- Remediation session: terra review comment `18072` reproduced a SERIAL-ACCEPT DoS; scope is RED regressions plus bounded concurrent connection handling on PR #836, preserving all existing broker security properties.
|
||||
- RED evidence against reviewed daemon: four silent peers delayed registration `3920 ms` beyond the `1500 ms` bound; 16 silent peers were not reaped within `2500 ms`. The first bounded implementation then exposed slot exhaustion by rejecting the valid queued caller with `EPIPE`; admission was corrected to wait for a reclaimed bounded slot. GREEN evidence: queued-peer test `211 ms`; strengthened cap/reap/reclaim test `1118 ms`; complete real-socket acceptance `37/37` and Python persistence suite `10/10`.
|
||||
|
||||
## Coordinator handoff requirements
|
||||
|
||||
1. Mandatory Opus-SECREV on the exact PR head; no GPT/terra substitute.
|
||||
2. Independent exact-head code review and exact-head RoR before Mos-authorized merge.
|
||||
3. Mos retains merge authority; this WI author stops after PR + full 40-character head handoff.
|
||||
@@ -1,38 +0,0 @@
|
||||
# ms-792 — Fleet roster error handling and installer heading
|
||||
|
||||
## Objective
|
||||
|
||||
Make expected missing or malformed fleet roster configuration fail with an actionable message and nonzero exit instead of a raw Node stack trace. Ensure the installer preserves the `@mosaicstack/mosaic` heading.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add failing coverage for missing and malformed roster input.
|
||||
2. Centralize roster-file read and parse error translation; add the CLI async error boundary.
|
||||
3. Sweep fleet command read paths that bypass the roster loader.
|
||||
4. Replace the installer heading output with format-safe rendering and test it.
|
||||
5. Run focused and repository quality checks; request independent review.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-07-16: Confirmed issue #792 and branch base `9745bc3f`.
|
||||
- 2026-07-16: Installed locked workspace dependencies using a worktree-local pnpm store; no `.mosaic/` files were changed intentionally.
|
||||
- 2026-07-16: Added a shared roster read/parse guard and routed v1 fleet commands plus v1/v2 selection through 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.
|
||||
@@ -177,23 +177,15 @@ bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||
```
|
||||
|
||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
||||
|
||||
## Universal Skills
|
||||
|
||||
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`. Install, wizard finalization, and `mosaic update` automatically reconcile every canonical skill into Claude Code's `~/.claude/skills/` directory.
|
||||
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`, then links each skill into runtime directories.
|
||||
|
||||
```bash
|
||||
mosaic sync # Full canonical catalog sync
|
||||
mosaic skill list # Show registered, missing, dangling, and foreign entries
|
||||
mosaic skill register <name> # Register or repair one canonical Claude link
|
||||
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
||||
mosaic sync # Full sync (clone + link)
|
||||
~/.config/mosaic/bin/mosaic-sync-skills --link-only # Re-link only
|
||||
```
|
||||
|
||||
Skill names are direct children using `[A-Za-z0-9][A-Za-z0-9._-]*`, not paths. Registration rejects traversal/control characters and never replaces foreign files, directories, or symlinks; unregister removes only links that point inside the canonical Mosaic skill root. After registering during a running Claude Code session, use `/reload-skills` or start a new session.
|
||||
|
||||
M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosaic root through its launcher configuration. Codex parity remains follow-up scope and continues to use the existing full skill-sync linker.
|
||||
|
||||
## Health Audit
|
||||
|
||||
```bash
|
||||
|
||||
@@ -161,7 +161,6 @@ link_targets=(
|
||||
)
|
||||
|
||||
canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")"
|
||||
local_real="$(readlink -f "$MOSAIC_LOCAL_SKILLS_DIR")"
|
||||
|
||||
# Build an associative array from the colon-separated whitelist for O(1) lookup.
|
||||
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
|
||||
@@ -204,14 +203,7 @@ link_skill_into_target() {
|
||||
link_path="$target_dir/$name"
|
||||
|
||||
if [[ -L "$link_path" ]]; then
|
||||
local raw_target resolved_target
|
||||
raw_target="$(readlink "$link_path")"
|
||||
resolved_target="$(node -e 'const p=require("node:path"); process.stdout.write(p.resolve(p.dirname(process.argv[1]), process.argv[2]));' "$link_path" "$raw_target")"
|
||||
if [[ "$resolved_target" == "$canonical_real/"* || "$resolved_target" == "$local_real/"* ]]; then
|
||||
ln -sfn "$skill_path" "$link_path"
|
||||
else
|
||||
echo "[mosaic-skills] Preserve foreign runtime symlink: $link_path"
|
||||
fi
|
||||
ln -sfn "$skill_path" "$link_path"
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -242,10 +234,14 @@ prune_stale_links_in_target() {
|
||||
continue
|
||||
fi
|
||||
|
||||
# -m resolves lexical dangling targets too. If resolution fails, ownership
|
||||
# is unproven and the link must be preserved.
|
||||
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
||||
if [[ -n "$resolved" && "$resolved" == "$canonical_real/"* ]]; then
|
||||
resolved="$(readlink -f "$link_path" 2>/dev/null || true)"
|
||||
if [[ -z "$resolved" ]]; then
|
||||
rm -f "$link_path"
|
||||
echo "[mosaic-skills] Removed stale broken skill link: $link_path"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$resolved" == "$MOSAIC_HOME/"* ]]; then
|
||||
rm -f "$link_path"
|
||||
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
|
||||
fi
|
||||
|
||||
@@ -79,26 +79,9 @@ function Link-SkillIntoTarget {
|
||||
|
||||
$linkPath = Join-Path $TargetDir $name
|
||||
|
||||
# Recreate only Mosaic-owned junctions/symlinks. Foreign reparse points are
|
||||
# runtime-owned and must never be clobbered by install/upgrade auto-sync.
|
||||
# Already a junction/symlink — recreate
|
||||
$existing = Get-Item $linkPath -Force -ErrorAction SilentlyContinue
|
||||
if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
||||
$rawTarget = @($existing.Target)[0]
|
||||
$candidate = if ([System.IO.Path]::IsPathRooted($rawTarget)) {
|
||||
$rawTarget
|
||||
}
|
||||
else {
|
||||
Join-Path (Split-Path $linkPath -Parent) $rawTarget
|
||||
}
|
||||
$resolvedTarget = [System.IO.Path]::GetFullPath($candidate)
|
||||
$canonicalRoot = [System.IO.Path]::GetFullPath($MosaicSkillsDir).TrimEnd('\') + '\'
|
||||
$localRoot = [System.IO.Path]::GetFullPath($MosaicLocalSkillsDir).TrimEnd('\') + '\'
|
||||
$owned = $resolvedTarget.StartsWith($canonicalRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
$resolvedTarget.StartsWith($localRoot, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
if (-not $owned) {
|
||||
Write-Host "[mosaic-skills] Preserve foreign runtime symlink: $linkPath"
|
||||
return
|
||||
}
|
||||
Remove-Item $linkPath -Force
|
||||
}
|
||||
elseif ($existing) {
|
||||
|
||||
@@ -1,544 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mosaic external lease broker for Linux SO_PEERCRED authenticated clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import errno
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
MAX_FRAME: Final = 64 * 1024
|
||||
MAX_STATE: Final = 4 * 1024 * 1024
|
||||
MAX_PENDING_TOKENS: Final = 256
|
||||
MAX_IN_FLIGHT_CONNECTIONS: Final = 16
|
||||
STATE_VERSION: Final = 1
|
||||
CONNECTION_DEADLINE_SECONDS: Final = 1.0
|
||||
HEX_256_LENGTH: Final = 64
|
||||
|
||||
|
||||
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
|
||||
|
||||
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_tokens(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 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)
|
||||
try:
|
||||
response = self._handle(peer, request)
|
||||
if self.store.value != previous:
|
||||
self.store.commit()
|
||||
return response
|
||||
except StateCommitUncertain:
|
||||
raise
|
||||
except Exception:
|
||||
self.store.value = previous
|
||||
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_tokens(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")
|
||||
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": request["runtime_generation"],
|
||||
"binding": binding,
|
||||
"consumed": False,
|
||||
}
|
||||
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}
|
||||
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:
|
||||
deadline = time.monotonic() + CONNECTION_DEADLINE_SECONDS
|
||||
try:
|
||||
raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
|
||||
peer = struct.unpack("3i", raw)
|
||||
request = read_frame(connection, deadline)
|
||||
except BrokerFailure as exc:
|
||||
reply = {"ok": False, "code": exc.code}
|
||||
except OSError:
|
||||
return
|
||||
else:
|
||||
try:
|
||||
with broker_lock:
|
||||
reply = broker.handle(peer, request)
|
||||
except BrokerFailure as exc:
|
||||
reply = {"ok": False, "code": exc.code}
|
||||
try:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return
|
||||
connection.settimeout(remaining)
|
||||
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)
|
||||
@@ -53,7 +53,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitest/coverage-v8": "^2.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
|
||||
@@ -18,7 +18,6 @@ import { registerFleetCommand } from './commands/fleet.js';
|
||||
import { registerMissionCommand } from './commands/mission.js';
|
||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||
import { registerRestoreCommand } from './commands/restore.js';
|
||||
import { registerSkillCommand } from './commands/skill.js';
|
||||
// prdy is registered via launch.ts
|
||||
import { registerLaunchCommands } from './commands/launch.js';
|
||||
import { registerAuthCommand } from './commands/auth.js';
|
||||
@@ -68,7 +67,7 @@ Command Groups:
|
||||
|
||||
Runtime: tui, login, sessions
|
||||
Gateway: gateway
|
||||
Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, skill, sync, upgrade, wizard, yolo
|
||||
Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, sync, upgrade, wizard, yolo
|
||||
Platform: update
|
||||
Runtimes: claude, codex, opencode, pi
|
||||
`,
|
||||
@@ -412,10 +411,6 @@ registerUninstallCommand(program);
|
||||
|
||||
registerRestoreCommand(program);
|
||||
|
||||
// ─── skill ───────────────────────────────────────────────────────────────────
|
||||
|
||||
registerSkillCommand(program);
|
||||
|
||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||
|
||||
registerTelemetryCommand(program);
|
||||
@@ -476,18 +471,6 @@ program
|
||||
return;
|
||||
}
|
||||
console.log('✔ Framework re-seeded.');
|
||||
if (reseed.skillSyncError) {
|
||||
console.error(` ⚠ Claude skill reconciliation skipped: ${reseed.skillSyncError}`);
|
||||
}
|
||||
const skillConflicts = reseed.skillSync?.conflicts ?? [];
|
||||
const skillChanges =
|
||||
(reseed.skillSync?.registered.length ?? 0) + (reseed.skillSync?.repaired.length ?? 0);
|
||||
if (skillChanges > 0) {
|
||||
console.log(`✔ Registered ${skillChanges.toString()} Mosaic skill(s) with Claude Code.`);
|
||||
}
|
||||
for (const conflict of skillConflicts) {
|
||||
console.error(` ⚠ Skill registration skipped for ${conflict.name}: ${conflict.reason}`);
|
||||
}
|
||||
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
|
||||
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
|
||||
const units = refreshActiveFleetUnits();
|
||||
|
||||
@@ -150,17 +150,6 @@ describe('projectRosterV2AgentGeneratedEnv', (): void => {
|
||||
});
|
||||
|
||||
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[][] = [];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
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
|
||||
@@ -304,7 +303,7 @@ function defaultReadRoster(
|
||||
mosaicHome: string,
|
||||
): (rosterPath: string) => Promise<FleetRosterV2> {
|
||||
return async (rosterPath: string): Promise<FleetRosterV2> => {
|
||||
const roster = parseRosterV2(await readFleetRosterText(rosterPath), 'yaml');
|
||||
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), '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.
|
||||
@@ -434,10 +433,6 @@ export function registerFleetRegenCommand(fleetCommand: Command, deps: FleetRege
|
||||
// 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,7 +60,6 @@ import {
|
||||
type SleepFn,
|
||||
} from './fleet.js';
|
||||
import { registerAgentCommand } from './agent.js';
|
||||
import { parseFleetRosterDocument, readFleetRosterText } from '../fleet/fleet-roster-v1.js';
|
||||
|
||||
function buildProgram(): Command {
|
||||
const program = new Command();
|
||||
@@ -167,91 +166,6 @@ 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', () => {
|
||||
let cleanup: string | undefined;
|
||||
|
||||
|
||||
@@ -19,11 +19,8 @@ import * as readline from 'node:readline';
|
||||
import type { Command } from 'commander';
|
||||
import YAML from 'yaml';
|
||||
import {
|
||||
FleetRosterConfigurationError,
|
||||
getRosterAgent,
|
||||
loadFleetRoster,
|
||||
parseFleetRosterDocument,
|
||||
readFleetRosterText,
|
||||
resolveInstalledFleetRosterPath,
|
||||
type FleetAgent,
|
||||
type FleetRoster,
|
||||
@@ -1916,7 +1913,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
||||
const roster = await loadRosterAtPath(cmd, rosterPath);
|
||||
const roster = await loadFleetRoster(rosterPath);
|
||||
|
||||
const newAgent: FleetAgent = {
|
||||
name,
|
||||
@@ -1976,7 +1973,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
||||
const roster = await loadRosterAtPath(cmd, rosterPath);
|
||||
const roster = await loadFleetRoster(rosterPath);
|
||||
|
||||
// Guard: throws if removing leaves 0 orchestrators or agent not in roster
|
||||
const updatedRoster = removeAgentFromRoster(roster, name);
|
||||
@@ -2405,19 +2402,14 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
||||
|
||||
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
return loadRosterAtPath(cmd, await resolveRosterPath(opts.mosaicHome, opts.roster));
|
||||
return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
|
||||
}
|
||||
|
||||
/** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */
|
||||
async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
|
||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseFleetRosterDocument(await readFleetRosterText(path), path);
|
||||
} catch (error) {
|
||||
reportFleetRosterConfigurationError(cmd, error);
|
||||
}
|
||||
const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
|
||||
return (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
@@ -2433,22 +2425,7 @@ async function loadRosterFromAgentCommand(
|
||||
): Promise<FleetRoster> {
|
||||
const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>();
|
||||
const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome();
|
||||
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;
|
||||
return loadFleetRoster(await resolveRosterPath(mosaicHome, opts.roster));
|
||||
}
|
||||
|
||||
function resolveMosaicHomeFromCommand(command: Command, override?: string): string {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
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']));
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
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)"');
|
||||
});
|
||||
});
|
||||
@@ -1,421 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,419 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -104,10 +104,6 @@ export interface FleetRoster {
|
||||
|
||||
export type FleetRosterInputFormat = 'yaml' | 'json';
|
||||
|
||||
export class FleetRosterConfigurationError extends Error {
|
||||
override name = 'FleetRosterConfigurationError';
|
||||
}
|
||||
|
||||
export function resolveInstalledFleetRosterPath(mosaicHome: string): string {
|
||||
const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
try {
|
||||
@@ -142,52 +138,8 @@ export function parseFleetRosterV1(
|
||||
}
|
||||
|
||||
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
||||
const source = await readFleetRosterText(path);
|
||||
try {
|
||||
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'))
|
||||
);
|
||||
const source = await readFile(path, 'utf8');
|
||||
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
||||
}
|
||||
|
||||
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
||||
@@ -197,16 +149,6 @@ export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
||||
}
|
||||
|
||||
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');
|
||||
assertKnownKeys(raw, 'Fleet roster', [
|
||||
'version',
|
||||
|
||||
@@ -1,650 +0,0 @@
|
||||
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';
|
||||
|
||||
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 withTimeout(
|
||||
new Promise<BrokerReply>((resolve, reject) => {
|
||||
const socket = createConnection(socketPath);
|
||||
let response = '';
|
||||
socket.setEncoding('utf8');
|
||||
socket.once('error', reject);
|
||||
socket.on('data', (chunk: string) => {
|
||||
response += chunk;
|
||||
});
|
||||
socket.once('end', () => resolve(JSON.parse(response) as BrokerReply));
|
||||
socket.once('connect', () => write(socket));
|
||||
}),
|
||||
'raw broker request',
|
||||
);
|
||||
}
|
||||
|
||||
async function request(socketPath: string, requestValue: object): Promise<BrokerReply> {
|
||||
return await new Promise<BrokerReply>((resolve, reject) => {
|
||||
const socket = createConnection(socketPath);
|
||||
let response = '';
|
||||
socket.setEncoding('utf8');
|
||||
socket.once('error', reject);
|
||||
socket.on('data', (chunk: string) => {
|
||||
response += chunk;
|
||||
});
|
||||
socket.once('end', () => resolve(JSON.parse(response) as BrokerReply));
|
||||
socket.once('connect', () => socket.end(`${JSON.stringify(requestValue)}\n`));
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,333 +0,0 @@
|
||||
#!/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()
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
@@ -301,50 +300,6 @@ describe('repairFleetCommsTools', () => {
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const missing = mkdtempSync(join(tmpdir(), 'mosaic-noinstaller-'));
|
||||
const res = runFrameworkReseed(missing, join(missing, 'home'));
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
ensureManagedDirectory,
|
||||
readRegularFileSecure,
|
||||
} from '../fleet/secure-file.js';
|
||||
import { getDefaultSkillPaths, syncClaudeSkills, type SkillSyncResult } from '../commands/skill.js';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -872,39 +871,19 @@ export function repairFleetCommsTools(
|
||||
* describing what happened (so callers can message + decide on relaunch).
|
||||
* 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(
|
||||
frameworkRoot = resolveBundledFrameworkRoot(),
|
||||
mosaicHome = join(homedir(), '.config', 'mosaic'),
|
||||
claudeSkillsDir = getDefaultSkillPaths().claudeSkillsDir,
|
||||
): FrameworkReseedResult {
|
||||
): { ok: boolean; reason?: string } {
|
||||
const { installer, command, env } = buildReseedCommand(frameworkRoot, mosaicHome);
|
||||
if (!existsSync(installer)) {
|
||||
return { ok: false, reason: `installer not found: ${installer}` };
|
||||
}
|
||||
try {
|
||||
execSync(command, { stdio: 'inherit', env: { ...process.env, ...env }, timeout: 120_000 });
|
||||
} catch (error: unknown) {
|
||||
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, readlinkSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import type { WizardState } from '../types.js';
|
||||
@@ -113,40 +113,6 @@ 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 () => {
|
||||
const state = makeState(tmp, ['brainstorming', 'lint', 'systematic-debugging']);
|
||||
const p = buildPrompter();
|
||||
|
||||
@@ -7,11 +7,6 @@ import type { ConfigService } from '../config/config-service.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { getShellProfilePath } from '../platform/detect.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 {
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
||||
@@ -210,27 +205,7 @@ export async function finalizeStage(
|
||||
skillsResult = syncSkills(state.mosaicHome, state.selectedSkills);
|
||||
}
|
||||
|
||||
// 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
|
||||
// 5. Run doctor
|
||||
spin.update('Running health audit...');
|
||||
const doctorResult = runDoctor(state.mosaicHome);
|
||||
|
||||
@@ -242,15 +217,10 @@ export async function finalizeStage(
|
||||
p.warn("Run 'mosaic sync' manually after installation to install skills.");
|
||||
}
|
||||
|
||||
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
|
||||
// 6. PATH setup
|
||||
const pathAction = setupPath(state.mosaicHome, p);
|
||||
|
||||
// 8. Summary
|
||||
// 7. Summary
|
||||
const skillsSummary = skillsResult.success
|
||||
? skillsResult.installedCount > 0
|
||||
? `${skillsResult.installedCount.toString()} installed`
|
||||
@@ -275,7 +245,7 @@ export async function finalizeStage(
|
||||
|
||||
p.note(summary.join('\n'), 'Installation Summary');
|
||||
|
||||
// 9. Next steps
|
||||
// 8. Next steps
|
||||
const nextSteps: string[] = [];
|
||||
if (pathAction === 'added') {
|
||||
const profilePath = getShellProfilePath();
|
||||
|
||||
@@ -5,16 +5,5 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
testTimeout: 30_000,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/commands/skill.ts'],
|
||||
reporter: ['text', 'json-summary'],
|
||||
thresholds: {
|
||||
statements: 85,
|
||||
branches: 85,
|
||||
functions: 85,
|
||||
lines: 85,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -607,9 +607,6 @@ importers:
|
||||
'@types/react':
|
||||
specifier: ^18.3.0
|
||||
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:
|
||||
specifier: ^4.0.0
|
||||
version: 4.21.0
|
||||
|
||||
@@ -61,38 +61,17 @@ if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
|
||||
FLAG_DEV=true
|
||||
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
|
||||
case "$1" in
|
||||
--check) FLAG_CHECK=true; shift ;;
|
||||
--framework) FLAG_CLI=false; shift ;;
|
||||
--cli) FLAG_FRAMEWORK=false; shift ;;
|
||||
--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
|
||||
;;
|
||||
--ref) GIT_REF="${2:-main}"; shift 2 ;;
|
||||
--dev) FLAG_DEV=true; shift ;;
|
||||
--yes|-y) FLAG_YES=true; shift ;;
|
||||
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
||||
--uninstall) FLAG_UNINSTALL=true; shift ;;
|
||||
*)
|
||||
printf 'Error: Unknown argument: %s\n' "$1" >&2
|
||||
installer_usage
|
||||
exit 2
|
||||
;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -233,7 +212,7 @@ ok() { echo "${G}✔${RESET} $*"; }
|
||||
warn() { echo "${Y}⚠${RESET} $*"; }
|
||||
fail() { echo "${R}✖${RESET} $*" >&2; }
|
||||
dim() { echo "${DIM}$*${RESET}"; }
|
||||
step() { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; }
|
||||
step() { echo ""; echo "${BOLD}$*${RESET}"; }
|
||||
|
||||
# ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user