Compare commits
20 Commits
fix/766-ex
...
docs/771-k
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a13286ea6 | ||
|
|
18e253c879 | ||
|
|
d857463a8a | ||
|
|
be0ebfdc6a | ||
|
|
3de18fc834 | ||
|
|
981183eb7e | ||
|
|
b0bc036dfb | ||
|
|
1beff936f7 | ||
|
|
fecb1b9d08 | ||
|
|
87bbb222f6 | ||
|
|
b1a4ef5ce7 | ||
|
|
fcb73352ed | ||
|
|
1f66793d63 | ||
|
|
cb50965ce1 | ||
|
|
7efe2e29bd | ||
|
|
dad1179fc7 | ||
|
|
256f9f6222 | ||
|
|
315f388f4f | ||
|
|
4d9f99f76e | ||
|
|
d1b657d2b6 |
70
docs/PRD.md
70
docs/PRD.md
@@ -125,76 +125,6 @@ are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
||||
|
||||
---
|
||||
|
||||
## Exact Cross-Harness Fleet Communications Contract (#766)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
Fleet runtime contracts currently combine exact peer rows with generic operational metavariables and
|
||||
independently parsed roster data. Non-Claude harnesses can mistake those metavariables for values to
|
||||
infer, producing incorrect host, session, socket, or helper targets. The objective is one
|
||||
roster-resolved communications contract that every supported harness receives unchanged.
|
||||
|
||||
### Normative requirements
|
||||
|
||||
1. `FCOM-REQ-01`: Fleet commands and runtime composition SHALL use one shared v1 roster structural
|
||||
resolver. A second lenient communications parser is forbidden.
|
||||
2. `FCOM-REQ-02`: The composed contract SHALL render the local roster member's authoritative host,
|
||||
exact agent/session name, resolved tmux socket, exact helper path, and deterministic communications
|
||||
generation.
|
||||
3. `FCOM-REQ-03`: Every known peer SHALL have one exact executable command. Same-host commands SHALL
|
||||
omit `-H`; cross-host commands SHALL use only that peer's explicit roster `ssh` target; the one
|
||||
supported fleet-wide named socket SHALL use `-L` with its exact value. A per-agent socket declaration
|
||||
must equal that fleet-wide value; unsupported independent sockets and missing cross-host SSH data SHALL
|
||||
fail closed.
|
||||
4. `FCOM-REQ-04`: Operational fleet examples SHALL not contain unresolved host, session, socket, or
|
||||
helper-path metavariables. Agents SHALL select an exact rendered peer row and SHALL NOT infer,
|
||||
substitute, or fuzzy-match targeting values.
|
||||
5. `FCOM-REQ-05`: An unknown local member or requested peer SHALL fail closed with exact-name discovery
|
||||
guidance. Runtime composition SHALL not silently omit a requested fleet member's communications
|
||||
contract.
|
||||
6. `FCOM-REQ-06`: Claude Code, Codex, OpenCode, and Pi SHALL receive equivalent authoritative
|
||||
communications data through the common runtime composer.
|
||||
7. `FCOM-REQ-07`: Tests SHALL prove the contract from framework-source `TOOLS.md`, through a fresh
|
||||
installed `TOOLS.md`, to final runtime composition and helper executability. User-owned installed
|
||||
`TOOLS.md` content SHALL remain preserved.
|
||||
8. `FCOM-REQ-08`: Stale installed or active composed context SHALL be reported with deterministic
|
||||
generation/repair/relaunch guidance. Currency requires the expected source and installed contract
|
||||
marker/version plus bounded byte equality. The supported current-version repair SHALL run independently
|
||||
of package updates, preserve divergent `TOOLS.md` bytes in a digest-qualified no-clobber backup, restore
|
||||
a regular executable helper without following symlinks, and be idempotent. Detection and reporting SHALL
|
||||
NOT rewrite active context, restart a session, or mutate a live fleet.
|
||||
9. `FCOM-REQ-09`: The shared resolver SHALL preserve and strictly validate every schema-supported v1
|
||||
connector kind (`tmux`, `discord`, and `matrix`) from YAML and JSON. Every accepted snake/camel alias
|
||||
pair SHALL reject differing dual declarations and accept identical declarations. JSON roster fallback
|
||||
SHALL occur only when `roster.yaml` is absent; all other YAML access failures SHALL fail closed.
|
||||
10. `FCOM-REQ-10`: The communications generation SHALL cover the complete canonical rendered semantic
|
||||
contract, including identity, role/class, resolved host/socket/helper, peer metadata, and exact commands.
|
||||
Installed helpers SHALL be validated with no-follow filesystem inspection as regular executable files.
|
||||
Keep-mode reseed and relaunch discovery SHALL preserve and support both YAML and JSON rosters.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `AC-FCOM-01`: Contract fixtures contain no unresolved operational targeting metavariables; local
|
||||
identity contains exact host/session/socket/helper values.
|
||||
2. `AC-FCOM-02`: Same-host, cross-host, named-socket, literal-default-socket, and missing-SSH tests prove
|
||||
exact targeting and fail-closed behavior.
|
||||
3. `AC-FCOM-03`: Unknown identities and peers report known exact names plus an exact self-scoped
|
||||
discovery command; no fuzzy session selection is emitted.
|
||||
4. `AC-FCOM-04`: Four-harness tests prove byte-equal authoritative communications sections.
|
||||
5. `AC-FCOM-05`: Source, fresh-install, preserved-custom-install, stale-installed, composed-generation,
|
||||
helper executable, agent-send socket isolation, and exact-target tests pass.
|
||||
6. `AC-FCOM-06`: Documentation defines non-mutating stale-context detection and operator-authorized,
|
||||
exact-agent relaunch; no implementation path performs automatic session mutation.
|
||||
7. `AC-FCOM-07`: YAML and JSON fixtures cover every connector kind; all snake/camel aliases cover
|
||||
identical acceptance and conflicting rejection; non-`ENOENT` YAML failures do not fall back.
|
||||
8. `AC-FCOM-08`: Missing, directory, symlink, and non-executable installed helpers fail closed. Explicit
|
||||
current-version repair proves partial-deletion recovery, digest-qualified backup collision safety,
|
||||
symlink-target safety, and repeated-run idempotence.
|
||||
9. `AC-FCOM-09`: Markerless-equal and wrong-version source/installed contracts are stale, and a rendered
|
||||
role/class change produces a different communications generation.
|
||||
|
||||
---
|
||||
|
||||
## KBN-101 Database Runtime/Migration Role Split (#771)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
@@ -66,12 +66,8 @@ checks the exact `=<agent-name>` tmux target; it never uses an ambient socket or
|
||||
The same strict parser runs before exact-stop behavior. A fresh native Pi heartbeat remains authoritative;
|
||||
the shell sidecar only provides fallback state when the native marker is stale or absent.
|
||||
|
||||
`mosaic agent comms-block <exact-member>` can inspect that exact roster member's resolved Fleet-Comms
|
||||
block. It is a read-only inspection tool and fails loudly for an unknown exact member or missing roster.
|
||||
On Linux, the installed roster, TOOLS contract, and executable helper are opened through a held
|
||||
descriptor chain rooted at `/`; every managed path component uses no-follow traversal, and content plus
|
||||
execute validation stay bound to the same opened file. Systems without Linux `/proc/self/fd` support
|
||||
fail closed rather than falling back to pathname revalidation.
|
||||
`mosaic fleet comms-block <role>` can inspect the role's resolved Fleet-Comms block. It is a read-only
|
||||
inspection tool and fails loudly for an unknown role or missing roster.
|
||||
|
||||
## Current M2 boundary
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Safely Reconcile and Control a Local Fleet Agent
|
||||
|
||||
Use the canonical local roster-v2 command surface:
|
||||
|
||||
```sh
|
||||
mosaic fleet apply --expected-generation <n> --dry-run
|
||||
mosaic fleet apply --expected-generation <n>
|
||||
mosaic fleet reconcile --expected-generation <n>
|
||||
mosaic fleet start <name> --expected-generation <n>
|
||||
mosaic fleet stop <name> --expected-generation <n>
|
||||
mosaic fleet restart <name> --expected-generation <n>
|
||||
mosaic fleet status [name]
|
||||
mosaic fleet verify
|
||||
mosaic fleet doctor
|
||||
```
|
||||
|
||||
Start with `--dry-run`. It validates roster semantics, deterministic projections, private managed paths, exact holder ownership, and named-socket state without changing files or lifecycle state. `apply` and `reconcile` rebuild derived projections and enforce only persisted roster state: enabled `running` agents may start, while stopped or disabled agents are not started.
|
||||
|
||||
`start`, `stop`, and `restart` are explicit one-shot exact-service actions. They do not persist a lifecycle change. Roster CRUD is the only way to change persisted desired state.
|
||||
|
||||
Every command prints JSON. Observation commands report drift without mutation; `verify` exits non-zero on ownership mismatch, unmanaged sessions, or drift. A failed apply that wrote some derived projections reports `projections: "incomplete"` with bounded recovery to regenerate from the roster. A lifecycle failure after projections reports incomplete lifecycle work; it is never represented as a rollback or no-op.
|
||||
|
||||
These commands are local only. Remote/SSH/connector entries are inventory/validation-only. Commands do not accept arbitrary runtime commands, channels, secrets, generated-file desired state, or arbitrary tmux sockets.
|
||||
@@ -1,26 +0,0 @@
|
||||
# Reconcile and Recover a Local Fleet
|
||||
|
||||
## Safe sequence
|
||||
|
||||
1. Read `mosaic fleet doctor` and `mosaic fleet status`.
|
||||
2. Run `mosaic fleet apply --expected-generation <n> --dry-run`.
|
||||
3. Resolve stale generation, ownership mismatch, unsafe path, projection validation, or unmanaged-session findings before applying.
|
||||
4. Run `mosaic fleet apply --expected-generation <n>` only after the plan is understood.
|
||||
|
||||
The reconciler uses the exact roster tmux socket, exact holder session, private installation holder identity, and the complete expected global environment. For mutations it acquires its exclusive lock before rereading the canonical roster and fencing its generation; only that under-lock roster drives validation, planning, projections, and lifecycle effects. Before effects, its exclusive lock proves real private `MOSAIC_HOME` and `fleet` ancestors, uses a private `0600` lock leaf, and binds cleanup to the created file identity and ownership token. A fake holder, contaminated global environment, missing identity, unsafe lock path, or unmanaged session fails closed. It does not adopt, kill, or rename any unproven session. A crash can leave a stale lock for explicit operator inspection; reconciliation deliberately does not guess ownership or remove it.
|
||||
|
||||
## Partial results
|
||||
|
||||
The roster is never changed by reconciliation. If derived projection application partially fails, JSON reports:
|
||||
|
||||
```json
|
||||
{
|
||||
"applied": false,
|
||||
"authoritativeRoster": "unchanged",
|
||||
"projections": "incomplete",
|
||||
"lifecycle": "not-applied",
|
||||
"recovery": { "code": "projection-apply-failed", "action": "regenerate-projections-from-roster" }
|
||||
}
|
||||
```
|
||||
|
||||
If projections completed but lifecycle work failed, JSON reports `projections: "complete"`, `lifecycle: "incomplete"`, and the bounded action `rerun-after-inspecting-owned-resources`. If lock cleanup cannot be proven after an effect result, it adds `cleanup: { "code": "lock-cleanup-failed", "action": "inspect-lock-before-retry" }` without changing the known projection, lifecycle, or primary recovery truth. Inspect the retained lock before retrying; no rollback, release, or stale-lock removal is implied. Results do not include environment values, secrets, or privileged command content.
|
||||
@@ -1,20 +0,0 @@
|
||||
# Fleet Control-Plane CLI
|
||||
|
||||
The local roster-v2 control plane is `mosaic fleet`.
|
||||
|
||||
```text
|
||||
mosaic fleet apply --expected-generation <n> [--dry-run]
|
||||
mosaic fleet reconcile --expected-generation <n> [--dry-run]
|
||||
mosaic fleet start [name] --expected-generation <n> [--dry-run]
|
||||
mosaic fleet stop [name] --expected-generation <n> [--dry-run]
|
||||
mosaic fleet restart [name] --expected-generation <n> [--dry-run]
|
||||
mosaic fleet status [name]
|
||||
mosaic fleet verify
|
||||
mosaic fleet doctor
|
||||
```
|
||||
|
||||
`apply` and `reconcile` use roster desired state. `start`, `stop`, and `restart` are exact local one-shot lifecycle effects and never persist a desired-state edit. `status`, `verify`, and `doctor` are observational.
|
||||
|
||||
Commands emit one JSON object. Handled precondition errors emit `{ "error": { "code": "..." } }` and exit non-zero. Partial derived/lifecycle effects use explicit `authoritativeRoster`, `projections`, `lifecycle`, and bounded `recovery` fields; they never claim rollback. Any additive `cleanup` diagnostic also exits non-zero, even where known effects are complete: it is not a clean completion and the lock requires inspection before retry.
|
||||
|
||||
This control plane is separate from the gateway-backed `mosaic agent` catalog. It is local-only and rejects remote/connector lifecycle mutation, arbitrary command/channel/secret input, and unproven tmux ownership.
|
||||
@@ -1,14 +0,0 @@
|
||||
# Local Fleet Lifecycle Transitions
|
||||
|
||||
FCM-M3-001 uses the roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` fields as the only desired-state authority. Systemd, tmux, generated environment files, and heartbeats are derived or observed state.
|
||||
|
||||
| Command | Desired-state write | Runtime effect | Preconditions |
|
||||
| --------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `fleet apply` / `fleet reconcile` | Never | Rebuilds projections, then starts only enabled agents desired `running`; stops disabled or desired-`stopped` roster agents | Current generation; private managed paths; valid projections; proven holder ownership; no unmanaged named-socket sessions |
|
||||
| `fleet start <name>` | Never | One-shot exact `mosaic-agent@<name>.service` start | Current generation; exact enabled roster name; proven ownership |
|
||||
| `fleet stop <name>` | Never | One-shot exact service stop | Current generation; exact roster name; proven ownership |
|
||||
| `fleet restart <name>` | Never | One-shot exact service restart | Current generation; exact roster name; proven ownership |
|
||||
|
||||
A stopped roster agent is never started by `apply` or `reconcile`. Direct lifecycle commands are explicit one-shot actions and do not change persisted desired state. Use roster CRUD with the explicit persisted-start option to change that desired state.
|
||||
|
||||
All mutations require `--expected-generation <n>` and acquire one private roster-adjacent reconciliation lock before projection or lifecycle effects. Missing or stale generations and concurrent writers fail before effects; the lock is released after success, partial failure, or thrown lifecycle failure. Stale, ownership, unmanaged-session, unsupported-runtime, path, projection, and lifecycle-precondition failures return stable redacted JSON errors and a non-zero exit. No command targets a fuzzy tmux name, arbitrary socket, arbitrary command, channel, secret, or generated file as authority.
|
||||
@@ -1,13 +0,0 @@
|
||||
# Local Fleet Status and Drift
|
||||
|
||||
`mosaic fleet status [name]`, `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, change desired state, start services, stop services, restart services, or mutate tmux.
|
||||
|
||||
The report distinguishes:
|
||||
|
||||
- `missing-session`: an enabled agent desired `running` has no exact roster-named tmux session.
|
||||
- `unexpected-session`: a desired-`stopped` agent still has its exact session.
|
||||
- `disabled-running`: a disabled roster agent has its exact session.
|
||||
- `unmanagedSessions`: sessions on the configured named socket that are neither the exact holder nor an exact roster agent.
|
||||
- `holder`: `owned`, `missing`, or `ownership-mismatch` after exact holder, private install identity, and complete global tmux environment validation.
|
||||
|
||||
`doctor` and `status` classify rather than adopt, destroy, or repair unmanaged state. `verify` is observational too, but exits non-zero if ownership cannot be proven, unmanaged sessions exist, or drift is present. Reconciliation fails closed under those conditions and never kills or adopts an unmanaged session.
|
||||
@@ -1,39 +0,0 @@
|
||||
# FCM-M3-001 — Local roster-owned reconciliation and lifecycle
|
||||
|
||||
- **Task / issue:** FCM-M3-001 / #758
|
||||
- **Branch / base:** `feat/758-local-reconciler` from `origin/main` `bc5e73629e92c56a80fa6a769ebad17c0177f504`
|
||||
- **Base tree:** `1b9ebe4fa1a90734b6f81118e120bae5290cd350`
|
||||
- **Scope:** source, isolated fake-adapter tests, and card documentation only. No live fleet/systemd/tmux action.
|
||||
|
||||
## Objective
|
||||
|
||||
Provide local roster-v2 `apply`/`reconcile` and lifecycle/status contracts. The roster remains desired-state authority; projections and runtime observations are derived state.
|
||||
|
||||
## Red-first evidence
|
||||
|
||||
The initial focused reconciler test failed because `fleet-reconciler.ts` did not exist. The initial new-worktree test invocation also exposed absent dependencies; `pnpm install --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store` restored local workspace dependencies without changing source.
|
||||
|
||||
## Design
|
||||
|
||||
- A new `fleet-reconciler.ts` accepts only typed roster-v2 input plus injected command and projection adapters.
|
||||
- It targets only exact `mosaic-agent@<roster-name>.service` units and the exact configured tmux socket/session.
|
||||
- It classifies unowned/unmanaged state and fails mutation closed rather than adopting or killing it.
|
||||
- It validates the private install-derived holder identity and complete expected tmux global environment before mutating lifecycle state.
|
||||
- REVIEW-1 remediation: RED review evidence found service-level apply could omit generation and had no mutation lock. Every non-observational command now requires an expected generation; a private exclusive roster-adjacent lock is acquired before effects and released on success or partial failure. Tests cover missing/stale values, concurrent denial, no effects, release, and lock-free observation.
|
||||
- REVIEW-2 remediation: lock acquisition now validates private real `MOSAIC_HOME`/`fleet` ancestors, rejects symlink or unsafe leaves, distinguishes `EEXIST` concurrency from other I/O, and binds release to the created inode plus random ownership token. A replacement lock is retained and reported, not unlinked. A crash may leave a stale lock for inspection; no stale-lock break is claimed.
|
||||
- REVIEW-3 remediation: a lock cleanup failure now adds bounded `cleanup` diagnostics to a known successful or partial effect result without replacing its projection/lifecycle/recovery truth. Cleanup is not claimed as complete, and the retained lock requires inspection before retry.
|
||||
- REVIEW-4 remediation: command JSON with an additive cleanup diagnostic now exits non-zero even where known effects completed; clean effect and observational JSON remain zero-exit.
|
||||
- REVIEW-5 remediation: mutating operations acquire the private lock before rereading canonical `roster.yaml`; the fenced reread, not a caller snapshot, supplies generation validation, plan, projection, and lifecycle authority.
|
||||
- `apply` starts only enabled agents whose persisted desired state is `running`; stopped/default agents are never started by reconciliation.
|
||||
- Observational commands produce JSON classification only. Partial projection or lifecycle effects report explicit recovery without values.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Excluded: live host actions, remote/SSH/connector lifecycle mutation, migrations, canaries, deployment, gateway changes, arbitrary command/channel/secret inputs, `docs/TASKS.md`, and orchestration ledgers.
|
||||
|
||||
## Verification
|
||||
|
||||
- Focused reconciler/Commander/CRUD/fleet tests: 4 files / 229 tests passed.
|
||||
- Full `@mosaicstack/mosaic` suite: 56 files / 820 tests passed after REVIEW-5 canonical roster fencing remediation.
|
||||
- Package and root typecheck/lint, root format check, and `git diff --check`: passed.
|
||||
- Isolated launcher and systemd template harnesses passed; they use fixtures only. No live fleet, systemd, tmux, session, remote, connector, or runtime action occurred.
|
||||
@@ -1,80 +0,0 @@
|
||||
# Issue 766 — exact cross-harness fleet comms targeting
|
||||
|
||||
- **Issue:** #766
|
||||
- **Branch:** `fix/766-exact-fleet-comms`
|
||||
- **Worktree:** `/home/jarvis/src/stack-issue-766`
|
||||
- **Delivery boundary:** source/tests/docs only; no live tmux, session, or fleet actions; leave uncommitted for independent review.
|
||||
|
||||
## Objective
|
||||
|
||||
Replace inference-prone fleet onboarding guidance with one roster-resolved contract that gives Claude Code, Codex, OpenCode, and Pi the same authoritative local identity and exact executable command for every known peer.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add issue-specific normative requirements to `docs/PRD.md` before source changes; do not modify orchestrator-owned `docs/TASKS.md`.
|
||||
2. Extract the existing v1 roster parsing/normalization into one lightweight shared resolver used by both fleet commands and runtime comms composition.
|
||||
3. Write failing contract tests for explicit SSH-only cross-host targeting, global/default socket authority, authoritative identity, unknown-peer failure, no operational metavariables, and four-harness parity.
|
||||
4. Make source `TOOLS.md` non-operational and marker-versioned; prove fresh installation preserves that exact contract and composition detects a stale installed copy without rewriting it.
|
||||
5. Render a deterministic comms generation and document comparison/relaunch handling; never rewrite an active session.
|
||||
6. Run focused Vitest and shell exact-target tests, then package/repository typecheck, lint, format, test, and build gates as relevant.
|
||||
7. Reconstruct the exact uncommitted tree, including untracked files, for independent review and remediate findings without committing.
|
||||
|
||||
## Contract decisions
|
||||
|
||||
- `tmux.socket_name` is the one supported socket authority for every local fleet session. A per-agent `socket`, when present for compatibility, must equal that global value; independent per-agent sockets fail closed because the runtime does not provision them. A named socket renders `-L`, while the empty literal default renders no `-L`.
|
||||
- A peer is same-host only when its resolved host equals the current roster member's resolved host. Every host-omitted member resolves against the stable local fleet-host baseline, never against the viewer's explicit host. Same-host rows never render `-H`.
|
||||
- A cross-host row requires that peer's explicit roster `ssh`; absence is a contract error. Never substitute `host` as an SSH target.
|
||||
- The current member's explicit roster `host` wins; otherwise the local machine's short hostname is the baseline for host-omitted local members.
|
||||
- Unknown members/peers return a deterministic error listing exact known names and an exact self-scoped discovery command. No fuzzy session lookup.
|
||||
- Exact command fields are structurally constrained to safe targeting grammars and shell-rendered as individual arguments. Unsafe host/SSH/socket values fail roster normalization rather than entering executable guidance.
|
||||
- Existing installed `TOOLS.md` remains user-owned during ordinary keep-mode updates. Currency requires the expected source and installed marker/version plus bounded SHA-256 byte identity. Explicit `mosaic update --repair-tools` is the supported current-version recovery path: it makes a digest-qualified no-clobber backup, restores the contract and regular executable helper, and does not rewrite active context.
|
||||
- The v1 resolver preserves and validates `tmux`, `discord`, and `matrix` connector blocks in YAML and JSON; conflicting snake/camel aliases fail closed unless their values are identical.
|
||||
- JSON roster fallback occurs only when `roster.yaml` is absent. Keep-mode reseed preserves both formats, and relaunch discovery uses the same canonical resolver.
|
||||
- The helper is inspected without following symlinks and must be a regular executable file. Missing, directory, symlink, and non-executable installations fail closed with deterministic repair guidance.
|
||||
- Active contexts carry a deterministic comms generation. Operators compare it to `mosaic agent comms-block <exact-agent>` output; mismatch means stale and requires an explicit exact-agent relaunch.
|
||||
|
||||
## Risks
|
||||
|
||||
- Import cycles if runtime composition imports the command-heavy `fleet.ts`; mitigate with a lightweight shared roster module and re-export compatibility.
|
||||
- Existing schema prose allowed independent per-agent sockets even though runtime provisioning used one global socket; constrain compatibility declarations to the global value and preserve empty-global default behavior.
|
||||
- Remote inventory may be incomplete. Fail composition closed for an unreachable cross-host row rather than generating a guessed command.
|
||||
- `TOOLS.md` is user-seeded and intentionally preserved. Detect/report drift instead of overwriting custom content.
|
||||
|
||||
## Planned evidence
|
||||
|
||||
- `comms-onboarding.spec.ts`: resolver/renderer/failure/generation contracts.
|
||||
- `compose-contract.spec.ts`: identical authoritative comms section for all four harnesses and stale installed-contract reporting without mutation.
|
||||
- `file-adapter.test.ts`: source-to-fresh-install byte equality and preservation of customized installed `TOOLS.md`.
|
||||
- Existing `agent-send.test.sh`, socket isolation, and tmux runtime transport tests.
|
||||
- Repository quality gates and independent uncommitted-tree review.
|
||||
|
||||
## Evidence log
|
||||
|
||||
- Preflight collision scan: no issue-766 local/remote branch, worktree, or open PR collision before branch creation.
|
||||
- Isolated branch created from fetched `origin/main` at `4990905`; original checkout not edited.
|
||||
- One strict v1 resolver now serves fleet commands and communications composition; roster writes preserve `host`, `ssh`, and `socket`.
|
||||
- Exact renderer covers authoritative self identity, global/default socket authority, rejected independent sockets, stable hostless-peer resolution, same-host omission of `-H`, explicit-SSH-only cross-host rows, shell-safe argv rendering, deterministic generations, and fail-closed unknown/missing targets.
|
||||
- Real framework `defaults/TOOLS.md` is tested byte-equal through a fresh `FileConfigAdapter` install, the installed helper is executable, and the final Pi contract contains the same source contract plus exact generated command; separate parity coverage proves byte-equivalent comms sections for Claude Code, Codex, OpenCode, and Pi.
|
||||
- Second review remediation adds strict connector/alias coverage, full-semantic generation coverage, ENOENT-only fallback, no-follow helper validation, unconditional current-version repair, digest-qualified no-clobber backups, and marker/version-gated currency.
|
||||
- The helper, roster, installed TOOLS, and framework source files are read with canonical containment, every existing ancestor and target rejected if symlinked, `O_NOFOLLOW` descriptor reads, inode stability checks, and effective-identity execute access. Read-only TOOLS status treats source/installed symlinks as unavailable without following or rewriting them.
|
||||
- Explicit repair validates both bundled inputs before destination creation, stages backup/TOOLS/helper plus exact-mode rollback files before any persistent file commit, revalidates destination identity at each commit boundary, installs the digest backup without clobber, and removes or exactly rolls back every committed output on injected failure. `changed: false` is returned only after full cleanup; cleanup/rollback failure is reported as `changed: true`.
|
||||
- Connector schema and runtime normalization require kind-matching settings and reject inactive connector blocks. Keep-mode installers preserve only exact `roster.yaml`, `roster.json`, `agents/`, and `run/` paths while refreshing framework `roster.schema.json`; shell evidence covers byte preservation and schema refresh.
|
||||
- Solo contracts render normalized role/class plus explicit no-peer/no-remote authority boundaries; composed-contract evidence keeps role Mandate/Boundaries before Fleet Comms.
|
||||
- Operational documentation and CLI metavariable now use `mosaic agent comms-block <exact-member>`; historical issue-633 scratchpad text remains historical.
|
||||
- The latest independent review rejected synthetic tree `556ae4ea04f2715a4e9d381f3cafaf4c8b991b2e` on three mandatory findings: installed `TOOLS.md` could be read through target/ancestor symlinks before unsafe status was reported; ambient class/tool-policy state could split identity authority from the canonical roster member; and the connector schema admitted empty or whitespace-only Discord/Matrix strings rejected by runtime parsing.
|
||||
- Red-first reproduction proved all three findings with 20 failures and 79 passes. Remediation routes installed `TOOLS.md` through the bounded secure regular-file reader before composition, resolves one exact canonical fleet identity for persona/tool policy/normalized class/Fleet Comms, rejects canonicalized ambient class mismatches, canonicalizes compatibility classes during roster parsing, and aligns parser/schema non-whitespace requirements.
|
||||
- Four-runtime coverage proves unsafe target and ancestor symlink content is omitted without mutation, while Claude Code, Codex, OpenCode, and Pi all project the same canonical member authority. Connector parser/schema coverage includes empty and whitespace-only Discord `channel_id` and Matrix `homeserver_url`, `user_id`, and `room_id` values.
|
||||
- Remediated focused gates passed: 99/99 across the two finding-focused suites plus connector schema regression PASS; the six changed-suite matrix passed 341/341; secure-file/transaction coverage remains green, including 28/28 transactional repair tests; installer migration passed 21/21.
|
||||
- Mosaic package suite passed 906/906. Shell/runtime regressions passed: `agent-send.test.sh` `PASS=11 FAIL=0`; named-socket isolation; matrix/tmux transport 12/12 (Matrix 5/5, tmux 7/7).
|
||||
- Final repository gates passed: format check; typecheck 42/42 tasks; lint 23/23; tests 42/42 tasks (Mosaic 906/906, gateway 628 passed/12 skipped); build 23/23.
|
||||
- A subsequent immutable review of tree `aa6414123643a504145fce6ac1d66f0b535feb5e` found one roster-authority blocker: a canonical member with omitted `tool_policy` inherited ambient `MOSAIC_AGENT_TOOL_POLICY`. Red-first four-runtime coverage failed 4/39 specifically on the leaked operator-interaction policy. Composition now branches on canonical membership: fleet launches use only `canonicalMember.toolPolicy` (including canonical absence), while genuinely non-fleet launches retain ambient fallback.
|
||||
- Final remediated gates passed: four-runtime regression 39/39; six changed-suite matrix 345/345; connector schema regression PASS; Mosaic package 910/910; installer migration 21/21; `agent-send.test.sh` 11/11; named-socket isolation PASS; Matrix/tmux transport 12/12; repository format PASS; typecheck 42/42 tasks; lint 23/23 tasks; tests 42/42 tasks; build 23/23 tasks.
|
||||
- No live tmux/session/fleet mutation, commit, push, PR mutation, issue mutation, context mutation, or reviewer launch performed.
|
||||
- Exact synthetic-tree reconstruction and frozen evidence are included in the coordinator handoff.
|
||||
- Sole-remediation preflight reverified the clean committed checkout at head `0dc47cac92c93a3ffd39ba9dd6685ac4165f6361`, tree `538de6ccce1f8c44ba288a7493286e63a3413e75`, branch `fix/766-exact-fleet-comms`; issue and PR state were read only through Mosaic wrappers.
|
||||
- Deterministic red-first ancestor substitution swapped validated `root/tools` for an external symlink immediately after `lstat`; current head returned `external marker` (`1 failed, 4 passed`) before implementation.
|
||||
- Secure reads now hold `/` and every root/descendant directory descriptor, traverse appended components through Linux `/proc/self/fd` with `O_DIRECTORY|O_NOFOLLOW`, and read plus effective-identity execute-check the same final descriptor. Non-Linux or unavailable proc-fd capability fails closed; stable errors redact managed paths while retaining Node `code` compatibility for missing/non-executable repair behavior.
|
||||
- Added deterministic root-selection, descendant-ancestor, and final-target substitution coverage. All return trusted descriptor-bound bytes after rename/symlink replacement; the race suite passed 50/50 repeated runs.
|
||||
- Isolated CLI verification drove `mosaic agent --mosaic-home <fixture> comms-block self` while repeatedly swapping `fleet/` with an external symlink: `trusted=2 fail_closed=10 external_marker=0`; a persistent symlink ancestor exited 1 with a redacted unsafe-ancestor error. No live fleet state was used or mutated.
|
||||
- Remediation gates: focused secure-file/comms/launch/tmux/Matrix `110/110`; full `@mosaicstack/mosaic` `914/914`; package and repository typecheck pass (`42/42` repository tasks); package and repository lint pass (`23/23` repository tasks); repository format check and `git diff --check` pass.
|
||||
- Independent review found one production hardening blocker (nonblocking final open), one redacted-error blocker, and a deterministic ancestor-test gap. Remediation added `O_NONBLOCK`, normalized execute errors while preserving errno, proved the ancestor hook fires, and added final-target substitution coverage; post-remediation review evidence is clean on the production invariant.
|
||||
@@ -5,20 +5,20 @@ Tool suites live at `~/.config/mosaic/tools/<suite>/`. This is the index only.
|
||||
read it (or the relevant service guide) when your task actually touches that service.
|
||||
Project-specific tooling belongs in the project's `AGENTS.md`, not here.
|
||||
|
||||
## Most-used fleet tools (reach for these first)
|
||||
## ⚡ Most-used fleet tools (reach for these FIRST — don't hand-roll)
|
||||
|
||||
<!-- fleet-comms-contract: 1 -->
|
||||
You are a Mosaic fleet agent. These cover the highest-frequency cross-agent and git-provider
|
||||
tasks — use them before improvising with raw `tmux send-keys`, raw `tea`/`gh`/`glab`, or `curl`.
|
||||
|
||||
You are a Mosaic fleet agent. Use the runtime-composed **Fleet Comms — authoritative exact targets**
|
||||
section for inter-agent messaging. It renders your authoritative local host, exact agent/session, resolved
|
||||
tmux socket, installed helper path, generation, and one executable command per known peer.
|
||||
**1. Message another agent** → `tools/tmux/agent-send.sh` (NOT raw `tmux send-keys`):
|
||||
|
||||
Select only a peer row rendered for your exact roster identity. Never invent, substitute, or fuzzy-match
|
||||
a host, session, socket, SSH destination, or helper path. If a peer is absent, stop and run the exact
|
||||
self-scoped discovery command shown in that composed section; report the peer as unknown if it remains
|
||||
absent. Do not use raw `tmux send-keys` for fleet messaging.
|
||||
```bash
|
||||
tools/tmux/agent-send.sh -s <target-session> -m "message" # or -f <file> to send a file's contents
|
||||
```
|
||||
|
||||
**Issues / PRs / milestones** → `tools/git/*.sh` wrappers (before raw `tea`/`gh`/`glab`):
|
||||
The coordinator session is `mos-claude` — send status, findings, and questions there.
|
||||
|
||||
**2. Issues / PRs / milestones** → `tools/git/*.sh` wrappers (before raw `tea`/`gh`/`glab`):
|
||||
|
||||
```bash
|
||||
tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ...
|
||||
|
||||
@@ -94,11 +94,11 @@
|
||||
"type": "string"
|
||||
},
|
||||
"ssh": {
|
||||
"description": "Explicit SSH target (normally user@host) for a cross-host inventory peer. Exact comms rendering requires this whenever the peer's resolved host differs from the current agent's host; the host value is never substituted as an SSH destination.",
|
||||
"description": "SSH target (user@host) for a cross-host peer, so onboarding renders the `agent-send.sh -H <user@host>` form. Optional; only needed for agents on a different host than the fleet.",
|
||||
"type": "string"
|
||||
},
|
||||
"socket": {
|
||||
"description": "Optional compatibility declaration of the fleet-wide tmux socket. When present it must exactly equal tmux.socket_name; independent per-agent sockets are rejected because the local fleet runtime provisions every session on the fleet-wide socket.",
|
||||
"description": "tmux socket the agent's session runs on. Onboarding renders `-L <socket>` when set; absent = the default socket (no `-L`). Must match the LIVE socket, not blindly inherit the roster's tmux.socket_name.",
|
||||
"type": "string"
|
||||
},
|
||||
"working_directory": {
|
||||
@@ -150,67 +150,29 @@
|
||||
"description": "Orchestrator chat connector (F4). Optional — absent means tmux (back-compat). Secrets (access/bot tokens) come from the environment, never this file.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": { "kind": { "const": "tmux" } },
|
||||
"required": ["kind"],
|
||||
"not": {
|
||||
"anyOf": [{ "required": ["discord"] }, { "required": ["matrix"] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"kind": { "const": "discord" },
|
||||
"discord": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["channel_id"],
|
||||
"properties": {
|
||||
"channel_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
}
|
||||
}
|
||||
"kind": {
|
||||
"enum": ["tmux", "discord", "matrix"]
|
||||
},
|
||||
"required": ["kind", "discord"],
|
||||
"not": { "required": ["matrix"] }
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"kind": { "const": "matrix" },
|
||||
"matrix": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["homeserver_url", "user_id", "room_id"],
|
||||
"properties": {
|
||||
"homeserver_url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"room_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
}
|
||||
"homeserver_url": { "type": "string" },
|
||||
"user_id": { "type": "string" },
|
||||
"room_id": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"required": ["kind", "matrix"],
|
||||
"not": { "required": ["discord"] }
|
||||
}
|
||||
],
|
||||
"discord": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["channel_id"],
|
||||
"properties": {
|
||||
"kind": { "enum": ["tmux", "discord", "matrix"] },
|
||||
"matrix": { "type": "object" },
|
||||
"discord": { "type": "object" }
|
||||
"channel_id": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,11 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||
# fleet/* — the framework SEEDS fleet/examples, fleet/roles, fleet/profiles, and
|
||||
# fleet/roster.schema.json (synced normally — every fleet/roles/*.md role contract
|
||||
# and fleet/profiles/*.yaml system-type profile lands automatically via this sync,
|
||||
# so no per-file entry is needed; exact preserved roster paths are anchored to
|
||||
# the top level only and do NOT shadow fleet/profiles/*.yaml). The user's
|
||||
# so no per-file entry is needed; the preserved "fleet/*.yaml" glob is anchored to
|
||||
# the top level only and does NOT shadow fleet/profiles/*.yaml). The user's
|
||||
# own fleet files MUST
|
||||
# survive `mosaic update` (which runs this sync automatically): the active
|
||||
# rosters (`fleet/roster.yaml` and `fleet/roster.json`), per-agent env
|
||||
# roster (`fleet/roster.yaml` + any other `fleet/*.yaml`), per-agent env
|
||||
# (`fleet/agents/`), heartbeat run dir (`fleet/run/`), and the Mosaic-native
|
||||
# backlog-of-record store (`fleet/backlog/` — embedded PGlite data dir; see
|
||||
# packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update
|
||||
@@ -44,7 +44,7 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||
# and user-ADDED personas instead live in fleet/roles.local/ and MUST survive
|
||||
# `mosaic update` — they win over the baseline on merge (AC-NS-7; see
|
||||
# packages/mosaic/src/commands/fleet-personas.ts).
|
||||
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/roster.yaml" "fleet/roster.json" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")
|
||||
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/*.yaml" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")
|
||||
|
||||
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
|
||||
# user must not edit them; a divergent copy is backed up once before overwrite).
|
||||
@@ -200,8 +200,8 @@ sync_framework() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback: cp-based sync. Exact top-level preserved paths mirror the
|
||||
# root-anchored rsync excludes above.
|
||||
# Fallback: cp-based sync. Glob-aware so entries like "fleet/*.yaml" preserve
|
||||
# every matching user file (parity with the rsync --exclude path above).
|
||||
local preserve_tmp=""
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"
|
||||
|
||||
@@ -61,33 +61,25 @@ MOSAIC_HOME="$T5" MOSAIC_INSTALL_MODE=bogus MOSAIC_SYNC_ONLY=1 bash "$INSTALL" >
|
||||
chk "F5 failure: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]"
|
||||
chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
|
||||
|
||||
# F6 — keep-mode re-seed (the `mosaic update` path) MUST preserve only the
|
||||
# exact user-owned roster paths while refreshing framework-owned schema/examples.
|
||||
# F6 — keep-mode re-seed (the `mosaic update` path) MUST NOT wipe user fleet data.
|
||||
# Regression for the roster-loss bug: fleet/ was not in PRESERVE_PATHS.
|
||||
T6=$(mktemp -d); mkdir -p "$T6/fleet/examples" "$T6/fleet/run" "$T6/fleet/agents"
|
||||
printf '# persona\n' > "$T6/SOUL.md" # makes it a recognized existing install (→ keep mode)
|
||||
printf 'version: 1\nagents:\n - name: coder0\n' > "$T6/fleet/roster.yaml"
|
||||
printf '{"version":1,"agents":[{"name":"json-user"}]}\n' > "$T6/fleet/roster.json"
|
||||
printf 'version: 1\nagents:\n - name: not-active-roster\n' > "$T6/fleet/my-fleet.yaml"
|
||||
printf 'version: 1\nagents:\n - name: custom\n' > "$T6/fleet/my-fleet.yaml"
|
||||
printf 'ts=x\n' > "$T6/fleet/run/coder0.hb"
|
||||
printf 'MOSAIC_AGENT_NAME=coder0\n' > "$T6/fleet/agents/coder0.env"
|
||||
printf '# stale preset\n' > "$T6/fleet/examples/general.yaml"
|
||||
printf '{"stale":true}\n' > "$T6/fleet/roster.schema.json"
|
||||
E6=$(mktemp -d)
|
||||
cp "$T6/fleet/roster.yaml" "$E6/roster-yaml.expected"
|
||||
cp "$T6/fleet/roster.json" "$E6/roster-json.expected"
|
||||
cp "$T6/fleet/run/coder0.hb" "$E6/run.expected"
|
||||
cp "$T6/fleet/agents/coder0.env" "$E6/agent.expected"
|
||||
echo 3 > "$T6/.framework-version"
|
||||
run "$T6" keep
|
||||
chk "F6 reseed: exact roster.yaml bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.yaml' '$E6/roster-yaml.expected'"
|
||||
chk "F6 reseed: exact roster.json bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.json' '$E6/roster-json.expected'"
|
||||
chk "F6 reseed: unrelated fleet YAML is not preserved" "[ ! -f '$T6/fleet/my-fleet.yaml' ]"
|
||||
chk "F6 reseed: per-agent env bytes survive" "cmp -s '$T6/fleet/agents/coder0.env' '$E6/agent.expected'"
|
||||
chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'"
|
||||
chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
|
||||
chk "F6 reseed: framework roster schema is refreshed" "cmp -s '$T6/fleet/roster.schema.json' '$FW/fleet/roster.schema.json'"
|
||||
chk "F6 reseed: user roster.yaml SURVIVES keep-mode sync" "grep -q coder0 '$T6/fleet/roster.yaml'"
|
||||
chk "F6 reseed: other user fleet/*.yaml survives (glob)" "[ -f '$T6/fleet/my-fleet.yaml' ]"
|
||||
chk "F6 reseed: per-agent env (fleet/agents) survives" "[ -f '$T6/fleet/agents/coder0.env' ]"
|
||||
chk "F6 reseed: heartbeat run dir (fleet/run) survives" "[ -f '$T6/fleet/run/coder0.hb' ]"
|
||||
chk "F6 reseed: framework examples ARE refreshed (not preserved stale)" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
|
||||
chk "F6 reseed: framework roster.schema.json seeded" "[ -f '$T6/fleet/roster.schema.json' ]"
|
||||
|
||||
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6" "$E6"
|
||||
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6"
|
||||
echo
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression checks for connector-kind-conditional fleet roster schema."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
schema_path = Path(__file__).resolve().parents[3] / "fleet" / "roster.schema.json"
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
validator = Draft202012Validator(schema)
|
||||
base = {
|
||||
"version": 1,
|
||||
"transport": "tmux",
|
||||
"agents": [{"name": "orchestrator", "runtime": "pi"}],
|
||||
}
|
||||
|
||||
valid = [
|
||||
{"kind": "tmux"},
|
||||
{"kind": "discord", "discord": {"channel_id": "123"}},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
]
|
||||
invalid = [
|
||||
{"kind": "tmux", "discord": {"channel_id": "123"}},
|
||||
{"kind": "tmux", "matrix": {}},
|
||||
{"kind": "discord"},
|
||||
{"kind": "discord", "matrix": {}},
|
||||
{
|
||||
"kind": "discord",
|
||||
"discord": {"channel_id": "123"},
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
{"kind": "matrix"},
|
||||
{"kind": "matrix", "discord": {"channel_id": "123"}},
|
||||
{"kind": "discord", "discord": {"channel_id": ""}},
|
||||
{"kind": "discord", "discord": {"channel_id": " "}},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "\t",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": "",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": " ",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "\n",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
for connector in valid:
|
||||
errors = list(validator.iter_errors({**base, "connector": connector}))
|
||||
if errors:
|
||||
print(f"expected valid connector {connector}: {errors}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
for connector in invalid:
|
||||
if not list(validator.iter_errors({**base, "connector": connector})):
|
||||
print(f"expected invalid connector: {connector}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
print("connector schema regression: PASS")
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
checkForAllUpdates,
|
||||
formatAllPackagesTable,
|
||||
getInstallAllCommand,
|
||||
repairFleetCommsTools,
|
||||
runFrameworkReseed,
|
||||
refreshActiveFleetUnits,
|
||||
readRosterAgentNames,
|
||||
@@ -421,33 +420,7 @@ program
|
||||
'Skip re-seeding framework files into ~/.config/mosaic after the CLI update',
|
||||
)
|
||||
.option('--relaunch', 'Restart durable fleet agents so the new launcher/runtime takes effect')
|
||||
.option(
|
||||
'--repair-tools',
|
||||
'Restore the supported current-version TOOLS contract and executable fleet helper',
|
||||
)
|
||||
.action(
|
||||
async (opts: {
|
||||
check?: boolean;
|
||||
reseed?: boolean;
|
||||
relaunch?: boolean;
|
||||
repairTools?: boolean;
|
||||
}) => {
|
||||
if (opts.repairTools) {
|
||||
const repair = repairFleetCommsTools();
|
||||
if (!repair.ok) {
|
||||
console.error(`Fleet communications tools repair failed: ${repair.reason ?? 'unknown'}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
repair.changed
|
||||
? 'Fleet communications tools repaired from the supported current framework.'
|
||||
: 'Fleet communications tools already match the supported current framework.',
|
||||
);
|
||||
if (repair.backupPath) console.log(`Preserved previous TOOLS.md at ${repair.backupPath}.`);
|
||||
console.log('No active context or session was rewritten.');
|
||||
return;
|
||||
}
|
||||
.action(async (opts: { check?: boolean; reseed?: boolean; relaunch?: boolean }) => {
|
||||
// checkForAllUpdates imported statically above
|
||||
const { execSync } = await import('node:child_process');
|
||||
|
||||
@@ -555,8 +528,7 @@ program
|
||||
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ─── wizard ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
accessSync,
|
||||
chmodSync,
|
||||
constants,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
symlinkSync,
|
||||
} from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { FileConfigAdapter } from '../config/file-adapter.js';
|
||||
import { composeContract } from './launch.js';
|
||||
|
||||
/**
|
||||
@@ -35,9 +22,7 @@ import { composeContract } from './launch.js';
|
||||
const CONSTITUTION = '# CONSTITUTION\n\nGATE-1: the non-negotiable law.\n';
|
||||
const AGENTS = '# Mosaic Agent Dispatcher\n\nLoad order + guide router.\n';
|
||||
const USER = '# operator\n\nName: Test Operator\n';
|
||||
const TOOLS = '# tools index\n\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
const FRAMEWORK_SOURCE = fileURLToPath(new URL('../../framework', import.meta.url));
|
||||
const SOURCE_TOOLS_PATH = join(FRAMEWORK_SOURCE, 'defaults', 'TOOLS.md');
|
||||
const TOOLS = '# tools index\n';
|
||||
|
||||
function makeHome(): { home: string; root: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-compose-'));
|
||||
@@ -46,12 +31,6 @@ function makeHome(): { home: string; root: string } {
|
||||
mkdirSync(join(home, 'runtime', h), { recursive: true });
|
||||
writeFileSync(join(home, 'runtime', h, 'RUNTIME.md'), `# ${h} runtime contract\n`);
|
||||
}
|
||||
mkdirSync(join(home, 'defaults'), { recursive: true });
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'defaults', 'TOOLS.md'), TOOLS);
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
writeFileSync(helper, '#!/bin/sh\n');
|
||||
chmodSync(helper, 0o755);
|
||||
writeFileSync(join(home, 'CONSTITUTION.md'), CONSTITUTION);
|
||||
writeFileSync(join(home, 'AGENTS.md'), AGENTS);
|
||||
writeFileSync(join(home, 'USER.md'), USER);
|
||||
@@ -63,31 +42,16 @@ describe('composeContract — overlay composer', () => {
|
||||
let fixture: ReturnType<typeof makeHome>;
|
||||
let prevCwd: string;
|
||||
let cwdDir: string;
|
||||
let prevAgentName: string | undefined;
|
||||
let prevAgentClass: string | undefined;
|
||||
let prevAgentToolPolicy: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = makeHome();
|
||||
prevCwd = process.cwd();
|
||||
prevAgentName = process.env['MOSAIC_AGENT_NAME'];
|
||||
prevAgentClass = process.env['MOSAIC_AGENT_CLASS'];
|
||||
prevAgentToolPolicy = process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||
delete process.env['MOSAIC_AGENT_NAME'];
|
||||
delete process.env['MOSAIC_AGENT_CLASS'];
|
||||
delete process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||
cwdDir = mkdtempSync(join(tmpdir(), 'mosaic-cwd-'));
|
||||
process.chdir(cwdDir); // neutralize cwd-relative mission/PRD blocks
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(prevCwd);
|
||||
if (prevAgentName === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = prevAgentName;
|
||||
if (prevAgentClass === undefined) delete process.env['MOSAIC_AGENT_CLASS'];
|
||||
else process.env['MOSAIC_AGENT_CLASS'] = prevAgentClass;
|
||||
if (prevAgentToolPolicy === undefined) delete process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||
else process.env['MOSAIC_AGENT_TOOL_POLICY'] = prevAgentToolPolicy;
|
||||
rmSync(fixture.root, { recursive: true, force: true });
|
||||
rmSync(cwdDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -100,17 +64,13 @@ describe('composeContract — overlay composer', () => {
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'tmux:',
|
||||
' socket_name: mosaic-fleet',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
' host: w-jarvis',
|
||||
' - name: enhancer',
|
||||
' runtime: claude',
|
||||
' class: enhancer',
|
||||
' host: w-jarvis',
|
||||
' - name: coder0-0',
|
||||
' runtime: claude',
|
||||
' class: implementer',
|
||||
@@ -122,266 +82,19 @@ describe('composeContract — overlay composer', () => {
|
||||
const prev = process.env['MOSAIC_AGENT_NAME'];
|
||||
try {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'enhancer';
|
||||
const outputs = (['claude', 'codex', 'opencode', 'pi'] as const).map((runtime) =>
|
||||
composeContract(runtime, fixture.home),
|
||||
);
|
||||
for (const out of outputs) {
|
||||
const out = composeContract('claude', fixture.home);
|
||||
expect(out).toContain('# Fleet Comms');
|
||||
expect(out).toContain('Host: `w-jarvis`');
|
||||
expect(out).toContain('Agent/session: `enhancer`');
|
||||
expect(out).toContain('tmux socket: `mosaic-fleet`');
|
||||
expect(out).toContain(
|
||||
`Helper: \`${join(fixture.home, 'tools', 'tmux', 'agent-send.sh')}\``,
|
||||
);
|
||||
expect(out).toContain('-L mosaic-fleet -s orchestrator -m "…"');
|
||||
expect(out).toContain('-L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"');
|
||||
expect(out).not.toContain('-H jwoltje@10.1.10.37 -s orchestrator');
|
||||
}
|
||||
const commsSection = (out: string): string => out.slice(out.indexOf('# Fleet Comms'));
|
||||
const authoritative = commsSection(outputs[0]!);
|
||||
expect(outputs.map(commsSection)).toEqual([
|
||||
authoritative,
|
||||
authoritative,
|
||||
authoritative,
|
||||
authoritative,
|
||||
]);
|
||||
expect(out).toMatch(/`\[[^\]]+:enhancer\]`/); // own [host:session] identity (host machine-dependent)
|
||||
// local peer → no -H; cross-host peer → -H ssh
|
||||
expect(out).toContain('-s orchestrator -m "…"');
|
||||
expect(out).toContain('-H jwoltje@10.1.10.37 -s coder0-0 -m "…"');
|
||||
expect(out).not.toContain('-H jwoltje@10.1.10.37 -s orchestrator'); // local stays local
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'derives canonical class, persona, tool policy, and comms from one roster member for %s',
|
||||
(runtime) => {
|
||||
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roles', 'code.md'),
|
||||
'# Code\n\n(`class: code`)\n\nCANONICAL-CODE-MANDATE.\n',
|
||||
);
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: exact-coder',
|
||||
` runtime: ${runtime}`,
|
||||
' class: implementer',
|
||||
' tool_policy: operator-interaction',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
|
||||
process.env['MOSAIC_AGENT_CLASS'] = 'implementer';
|
||||
process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'ambient-policy-must-not-win';
|
||||
|
||||
const out = composeContract(runtime, fixture.home);
|
||||
|
||||
expect(out).toContain('# Persona Contract (code)');
|
||||
expect(out).toContain('CANONICAL-CODE-MANDATE');
|
||||
expect(out).toContain('Role/class: `code`');
|
||||
expect(out).toContain('# Fleet Tool Policy (operator-interaction)');
|
||||
expect(out).not.toContain('ambient-policy-must-not-win');
|
||||
expect(out.indexOf('# Persona Contract')).toBeLessThan(out.indexOf('# Fleet Comms'));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'does not inherit ambient tool policy when canonical member omits tool_policy for %s',
|
||||
(runtime) => {
|
||||
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roles', 'code.md'),
|
||||
'# Code\n\n(`class: code`)\n\nCANONICAL-CODE-MANDATE.\n',
|
||||
);
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: exact-coder',
|
||||
` runtime: ${runtime}`,
|
||||
' class: implementer',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
|
||||
process.env['MOSAIC_AGENT_CLASS'] = 'implementer';
|
||||
process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'operator-interaction';
|
||||
|
||||
const out = composeContract(runtime, fixture.home);
|
||||
|
||||
expect(out).toContain('# Persona Contract (code)');
|
||||
expect(out).toContain('Role/class: `code`');
|
||||
expect(out).toContain('# Fleet Comms');
|
||||
expect(out).not.toContain('# Fleet Tool Policy (operator-interaction)');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'rejects an ambient class that mismatches the canonical roster member for %s',
|
||||
(runtime) => {
|
||||
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: exact-coder',
|
||||
` runtime: ${runtime}`,
|
||||
' class: implementer',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
|
||||
process.env['MOSAIC_AGENT_CLASS'] = 'reviewer';
|
||||
|
||||
expect(() => composeContract(runtime, fixture.home)).toThrow(
|
||||
/ambient MOSAIC_AGENT_CLASS.*review.*canonical roster.*code/i,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('composes solo role mandate and boundaries before explicit no-peer authority', () => {
|
||||
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roles', 'orchestrator.md'),
|
||||
'# Orchestrator\n\n## Mandate\n\nCoordinate exact work.\n\n## Boundaries\n\nDo not infer authority.\n',
|
||||
);
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: solo',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'solo';
|
||||
process.env['MOSAIC_AGENT_CLASS'] = 'orchestrator';
|
||||
|
||||
const out = composeContract('claude', fixture.home);
|
||||
|
||||
expect(out).toContain('## Mandate');
|
||||
expect(out).toContain('## Boundaries');
|
||||
expect(out).toContain('Role/class: `orchestrator`');
|
||||
expect(out).toContain('## Solo authority boundaries');
|
||||
expect(out.indexOf('## Mandate')).toBeLessThan(out.indexOf('# Fleet Comms'));
|
||||
expect(out.indexOf('## Boundaries')).toBeLessThan(out.indexOf('# Fleet Comms'));
|
||||
expect(out).toContain('no peer, orchestrator, or remote communication authority');
|
||||
});
|
||||
|
||||
it('proves real source TOOLS.md through fresh install, executable helper, and final composition', async () => {
|
||||
const installRoot = mkdtempSync(join(tmpdir(), 'mosaic-real-contract-'));
|
||||
const installedHome = join(installRoot, 'mosaic-home');
|
||||
mkdirSync(installedHome, { recursive: true });
|
||||
const previous = process.env['MOSAIC_AGENT_NAME'];
|
||||
|
||||
try {
|
||||
const adapter = new FileConfigAdapter(installedHome, FRAMEWORK_SOURCE);
|
||||
await adapter.syncFramework('fresh');
|
||||
|
||||
const sourceTools = readFileSync(SOURCE_TOOLS_PATH, 'utf8');
|
||||
const installedToolsPath = join(installedHome, 'TOOLS.md');
|
||||
expect(readFileSync(installedToolsPath, 'utf8')).toBe(sourceTools);
|
||||
expect(sourceTools).toContain('fleet-comms-contract: 1');
|
||||
expect(sourceTools).not.toMatch(
|
||||
/<(?:user@host|src_host|src_session|dst_host|dst_session|target-session)>/,
|
||||
);
|
||||
|
||||
const helper = join(installedHome, 'tools', 'tmux', 'agent-send.sh');
|
||||
expect(() => accessSync(helper, constants.X_OK)).not.toThrow();
|
||||
|
||||
mkdirSync(join(installedHome, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(installedHome, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'tmux:',
|
||||
' socket_name: exact-socket',
|
||||
'agents:',
|
||||
' - name: exact-self',
|
||||
' runtime: pi',
|
||||
' class: orchestrator',
|
||||
' host: local-host',
|
||||
' - name: exact-peer',
|
||||
' runtime: claude',
|
||||
' class: implementer',
|
||||
' host: local-host',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'exact-self';
|
||||
|
||||
const composed = composeContract('pi', installedHome);
|
||||
expect(composed).toContain(sourceTools);
|
||||
expect(composed).toContain(`Helper: \`${helper}\``);
|
||||
expect(composed).toContain(`${helper} -L exact-socket -s exact-peer -m "…"`);
|
||||
expect(composed).not.toContain('# Fleet Comms Installation Status');
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = previous;
|
||||
rmSync(installRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'never injects installed TOOLS.md through a target symlink for %s',
|
||||
(runtime) => {
|
||||
const toolsPath = join(fixture.home, 'TOOLS.md');
|
||||
const external = join(fixture.root, 'attacker-tools.md');
|
||||
writeFileSync(external, 'UNSAFE-TARGET-SYMLINK-CONTENT\n');
|
||||
rmSync(toolsPath);
|
||||
symlinkSync(external, toolsPath);
|
||||
|
||||
const out = composeContract(runtime, fixture.home);
|
||||
|
||||
expect(out).not.toContain('UNSAFE-TARGET-SYMLINK-CONTENT');
|
||||
expect(out).toContain('# Fleet Comms Installation Status');
|
||||
expect(out).toContain('unavailable');
|
||||
expect(readFileSync(external, 'utf8')).toBe('UNSAFE-TARGET-SYMLINK-CONTENT\n');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'never injects installed TOOLS.md through an ancestor symlink for %s',
|
||||
(runtime) => {
|
||||
const realHome = join(fixture.root, 'real-mosaic-home');
|
||||
renameSync(fixture.home, realHome);
|
||||
symlinkSync(realHome, fixture.home, 'dir');
|
||||
writeFileSync(join(realHome, 'TOOLS.md'), 'UNSAFE-ANCESTOR-SYMLINK-CONTENT\n');
|
||||
|
||||
const out = composeContract(runtime, fixture.home);
|
||||
|
||||
expect(out).not.toContain('UNSAFE-ANCESTOR-SYMLINK-CONTENT');
|
||||
expect(out).toContain('# Fleet Comms Installation Status');
|
||||
expect(out).toContain('unavailable');
|
||||
expect(readFileSync(join(realHome, 'TOOLS.md'), 'utf8')).toBe(
|
||||
'UNSAFE-ANCESTOR-SYMLINK-CONTENT\n',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('reports stale preserved TOOLS.md without rewriting it', () => {
|
||||
const toolsPath = join(fixture.home, 'TOOLS.md');
|
||||
const stale = '# user-customized tools without fleet contract marker\n';
|
||||
writeFileSync(toolsPath, stale);
|
||||
|
||||
const out = composeContract('pi', fixture.home);
|
||||
|
||||
expect(out).toContain('# Fleet Comms Installation Status');
|
||||
expect(out).toContain('does not byte-match');
|
||||
expect(out).toContain('active context was not rewritten');
|
||||
expect(readFileSync(toolsPath, 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('does NOT inject fleet comms when MOSAIC_AGENT_NAME is unset (non-fleet launch)', () => {
|
||||
const prev = process.env['MOSAIC_AGENT_NAME'];
|
||||
try {
|
||||
@@ -392,24 +105,6 @@ describe('composeContract — overlay composer', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when an explicitly requested fleet identity is unknown', () => {
|
||||
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: exact-agent', ' runtime: pi'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
const previous = process.env['MOSAIC_AGENT_NAME'];
|
||||
try {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'invented-agent';
|
||||
expect(() => composeContract('pi', fixture.home)).toThrow(/known exact names: exact-agent/i);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it('includes the per-tier anchors and the selected harness runtime', () => {
|
||||
const out = composeContract('claude', fixture.home);
|
||||
expect(out).toContain('GATE-1: the non-negotiable law.'); // L0
|
||||
@@ -545,14 +240,10 @@ describe('composeContract — overlay composer', () => {
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
' - name: enhancer',
|
||||
' runtime: claude',
|
||||
' class: enhancer',
|
||||
'',
|
||||
].join('\n'),
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { type FleetReconcileDeps } from '../fleet/fleet-reconciler.js';
|
||||
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
|
||||
|
||||
const roster = `
|
||||
version: 2
|
||||
generation: 7
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-fleet
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: /srv/mosaic
|
||||
runtime: pi
|
||||
runtimes:
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: coder0
|
||||
alias: Coder 0
|
||||
class: code
|
||||
runtime: pi
|
||||
provider: openai
|
||||
model: gpt-5.6-sol
|
||||
reasoning: high
|
||||
tool_policy: code
|
||||
working_directory: /srv/mosaic
|
||||
persistent_persona: false
|
||||
reset_between_tasks: true
|
||||
lifecycle:
|
||||
enabled: true
|
||||
desired_state: stopped
|
||||
launch:
|
||||
yolo: true
|
||||
`;
|
||||
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
async function fleetHome(): Promise<string> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-reconciler-command-'));
|
||||
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
||||
await mkdir(join(cleanup, directory), { recursive: true, mode: 0o700 });
|
||||
await chmod(join(cleanup, directory), 0o700);
|
||||
}
|
||||
await chmod(cleanup, 0o700);
|
||||
await writeFile(join(cleanup, 'fleet', 'roster.yaml'), roster, { mode: 0o600 });
|
||||
await writeFile(join(cleanup, 'fleet', 'roles', 'code.md'), '# code\n\n(`class: code`)\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
function program(
|
||||
mosaicHome: string,
|
||||
runner: FleetCommandDeps['runner'],
|
||||
reconcileOverrides: Partial<FleetReconcileDeps> = {},
|
||||
): Command {
|
||||
const result = new Command();
|
||||
result.exitOverride();
|
||||
registerFleetCommand(result, {
|
||||
mosaicHome,
|
||||
runner,
|
||||
reconcileDeps: {
|
||||
homeDirectory: '/home/mosaic',
|
||||
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
||||
validateRoster: async () => undefined,
|
||||
prepareProjections: async () => [{ agentName: 'coder0' }],
|
||||
applyProjection: async () => undefined,
|
||||
...reconcileOverrides,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function capture(): string[] {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||
lines.push(value);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
function ownedRunner(
|
||||
calls: string[][],
|
||||
): (command: string, args: string[]) => Promise<CommandResult> {
|
||||
return async (command: string, args: string[]): Promise<CommandResult> => {
|
||||
calls.push([command, ...args]);
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return {
|
||||
stdout:
|
||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
}
|
||||
|
||||
describe('mosaic fleet reconciler commands', (): void => {
|
||||
it('plans apply as stable JSON without applying projections or lifecycle effects', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const lines = capture();
|
||||
|
||||
await program(home, ownedRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'apply',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'not-applied',
|
||||
});
|
||||
expect(calls).not.toContainEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'start',
|
||||
'mosaic-agent@coder0.service',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses direct fleet status and doctor as observational JSON commands', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
const calls: string[][] = [];
|
||||
const cli = program(home, ownedRunner(calls));
|
||||
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'status', 'coder0']);
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'doctor']);
|
||||
|
||||
expect(lines.map((line: string): unknown => JSON.parse(line))).toMatchObject([
|
||||
{ applied: false, lifecycle: 'not-applied' },
|
||||
{ applied: false, lifecycle: 'not-applied' },
|
||||
]);
|
||||
expect(
|
||||
calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['start', 'stop', 'restart'] as const)(
|
||||
'uses exact roster-owned systemd targeting for %s',
|
||||
async (operation: 'start' | 'stop' | 'restart'): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const lines = capture();
|
||||
|
||||
await program(home, ownedRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
operation,
|
||||
'coder0',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({ applied: true, lifecycle: 'complete' });
|
||||
expect(calls).toContainEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
operation,
|
||||
'mosaic-agent@coder0.service',
|
||||
]);
|
||||
expect(calls.some((call: string[]): boolean => call.join(' ').includes('coder0-extra'))).toBe(
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('makes cleanup-incomplete effect JSON non-zero without losing effect truth', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
await program(home, ownedRunner([]), {
|
||||
acquireMutationLock: async () => async () => {
|
||||
throw new Error('cleanup failure');
|
||||
},
|
||||
}).parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
|
||||
applied: true,
|
||||
projections: 'complete',
|
||||
lifecycle: 'complete',
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps primary partial recovery JSON and exits non-zero when cleanup is also incomplete', async (): Promise<void> => {
|
||||
const cleanupFailure = async () => async () => {
|
||||
throw new Error('cleanup failure');
|
||||
};
|
||||
const projectionHome = await fleetHome();
|
||||
const projectionLines = capture();
|
||||
await program(projectionHome, ownedRunner([]), {
|
||||
applyProjection: async () => {
|
||||
throw new Error('projection failure');
|
||||
},
|
||||
acquireMutationLock: cleanupFailure,
|
||||
}).parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
|
||||
expect(JSON.parse(projectionLines.pop() ?? '')).toMatchObject({
|
||||
projections: 'incomplete',
|
||||
lifecycle: 'not-applied',
|
||||
recovery: { code: 'projection-apply-failed' },
|
||||
cleanup: { code: 'lock-cleanup-failed' },
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps clean effect and observational commands at zero exit', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const cli = program(home, ownedRunner([]));
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
|
||||
expect(process.exitCode).toBe(0);
|
||||
process.exitCode = undefined;
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects stale apply generations as non-zero redacted JSON', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
|
||||
await program(home, ownedRunner([])).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'apply',
|
||||
'--expected-generation',
|
||||
'6',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toEqual({ error: { code: 'stale-generation' } });
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import type { CommandRunner } from './fleet.js';
|
||||
import {
|
||||
executeFleetReconcile,
|
||||
FleetReconcileError,
|
||||
type FleetReconcileCommand,
|
||||
type FleetReconcileDeps,
|
||||
} from '../fleet/fleet-reconciler.js';
|
||||
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
||||
|
||||
export interface FleetReconcilerCommandDeps {
|
||||
readonly runner: CommandRunner;
|
||||
readonly mosaicHome?: string;
|
||||
readonly reconcileDeps?: Omit<FleetReconcileDeps, 'runner' | 'mosaicHome'>;
|
||||
}
|
||||
|
||||
interface ReconcileOptions {
|
||||
readonly expectedGeneration?: string;
|
||||
readonly dryRun?: boolean;
|
||||
}
|
||||
|
||||
/** Registers roster-v2 reconciliation commands on the canonical fleet control plane. */
|
||||
export function registerFleetReconcilerCommands(
|
||||
fleetCommand: Command,
|
||||
deps: FleetReconcilerCommandDeps,
|
||||
): void {
|
||||
for (const operation of ['apply', 'reconcile'] as const) {
|
||||
fleetCommand
|
||||
.command(operation)
|
||||
.description(`${operation} local roster-owned projections and desired lifecycle`)
|
||||
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
|
||||
.option('--dry-run', 'Plan and preflight without writing projections or lifecycle state')
|
||||
.action(async (opts: ReconcileOptions): Promise<void> => {
|
||||
await writeOutcome(async (): Promise<void> => {
|
||||
await executeReconcilerCommand(fleetCommand, deps, operation, opts);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fleetCommand
|
||||
.command('doctor')
|
||||
.description('Classify local roster-owned drift without mutation')
|
||||
.action(async (): Promise<void> => {
|
||||
await writeOutcome(async (): Promise<void> => {
|
||||
await executeReconcilerCommand(fleetCommand, deps, 'doctor', {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeReconcilerCommandJson(
|
||||
fleetCommand: Command,
|
||||
deps: FleetReconcilerCommandDeps,
|
||||
operation: FleetReconcileCommand,
|
||||
opts: ReconcileOptions,
|
||||
agentName?: string,
|
||||
): Promise<void> {
|
||||
await writeOutcome(async (): Promise<void> => {
|
||||
await executeReconcilerCommand(fleetCommand, deps, operation, opts, agentName);
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeReconcilerCommand(
|
||||
fleetCommand: Command,
|
||||
deps: FleetReconcilerCommandDeps,
|
||||
operation: FleetReconcileCommand,
|
||||
opts: ReconcileOptions,
|
||||
agentName?: string,
|
||||
): Promise<void> {
|
||||
const mosaicHome = resolveMosaicHome(fleetCommand, deps);
|
||||
const rosterPath = resolveRosterPath(fleetCommand, mosaicHome);
|
||||
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
||||
const mutating = operation === 'apply' || operation === 'reconcile' || isLifecycle(operation);
|
||||
const expectedGeneration = mutating
|
||||
? parseExpectedGeneration(opts.expectedGeneration)
|
||||
: undefined;
|
||||
const result = await executeFleetReconcile({
|
||||
roster,
|
||||
command: opts.dryRun === true ? 'plan' : operation,
|
||||
...(agentName === undefined ? {} : { agentName }),
|
||||
...(expectedGeneration === undefined ? {} : { expectedGeneration }),
|
||||
deps: {
|
||||
runner: async (command: string, args: readonly string[]) => deps.runner(command, [...args]),
|
||||
mosaicHome,
|
||||
rolesDir: join(mosaicHome, 'fleet', 'roles'),
|
||||
overrideDir: join(mosaicHome, 'fleet', 'roles.local'),
|
||||
readRoster: async (): Promise<typeof roster> =>
|
||||
parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml'),
|
||||
...(deps.reconcileDeps ?? {}),
|
||||
},
|
||||
});
|
||||
printJson(result);
|
||||
process.exitCode = result.recovery === undefined && result.cleanup === undefined ? 0 : 1;
|
||||
}
|
||||
|
||||
function isLifecycle(operation: FleetReconcileCommand): boolean {
|
||||
return operation === 'start' || operation === 'stop' || operation === 'restart';
|
||||
}
|
||||
|
||||
function parseExpectedGeneration(value: string | undefined): number {
|
||||
const generation = Number(value);
|
||||
if (!Number.isSafeInteger(generation) || generation < 1) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'--expected-generation must be a positive safe integer.',
|
||||
);
|
||||
}
|
||||
return generation;
|
||||
}
|
||||
|
||||
function resolveMosaicHome(fleetCommand: Command, deps: FleetReconcilerCommandDeps): string {
|
||||
const options = fleetCommand.optsWithGlobals<{ mosaicHome?: string }>();
|
||||
return (
|
||||
options.mosaicHome ?? deps.mosaicHome ?? join(process.env['HOME'] ?? '', '.config', 'mosaic')
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRosterPath(fleetCommand: Command, mosaicHome: string): string {
|
||||
const options = fleetCommand.optsWithGlobals<{ roster?: string }>();
|
||||
const canonical = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
if (options.roster !== undefined && resolve(options.roster) !== resolve(canonical)) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'Roster-v2 reconciliation requires the canonical roster path.',
|
||||
);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
async function writeOutcome(action: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await action();
|
||||
} catch (error: unknown) {
|
||||
process.exitCode = 1;
|
||||
printJson({
|
||||
error: {
|
||||
code: error instanceof FleetReconcileError ? error.code : 'reconcile-failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function printJson(value: object): void {
|
||||
console.log(JSON.stringify(value));
|
||||
}
|
||||
@@ -81,11 +81,9 @@ describe('registerFleetCommand', () => {
|
||||
expect(fleet).toBeDefined();
|
||||
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
||||
'add',
|
||||
'apply',
|
||||
'backlog',
|
||||
'create',
|
||||
'delete',
|
||||
'doctor',
|
||||
'get',
|
||||
'init',
|
||||
'install',
|
||||
@@ -95,7 +93,6 @@ describe('registerFleetCommand', () => {
|
||||
'profile',
|
||||
'provision',
|
||||
'ps',
|
||||
'reconcile',
|
||||
'remove',
|
||||
'restart',
|
||||
'start',
|
||||
@@ -288,8 +285,8 @@ describe('fleet roster parsing', () => {
|
||||
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
// Reflects the roster's canonicalized compatibility class (A3a).
|
||||
'MOSAIC_AGENT_CLASS=code',
|
||||
// Reflects the roster's non-default `class: implementer` (A3a).
|
||||
'MOSAIC_AGENT_CLASS=implementer',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'MOSAIC_AGENT_MODEL=',
|
||||
'MOSAIC_AGENT_REASONING=',
|
||||
@@ -493,13 +490,7 @@ describe('fleet command construction', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs legacy fleet status through injected runner without touching tmux in tests', async () => {
|
||||
const home = await tempDir();
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
'version: 1\ntransport: tmux\nagents: []\n',
|
||||
);
|
||||
it('runs fleet status through injected runner without touching tmux in tests', async () => {
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
@@ -507,14 +498,11 @@ describe('fleet command construction', () => {
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
registerFleetCommand(program, { runner });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
|
||||
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('verifies liveness with tmux has-session and does not trust systemd active exited', async () => {
|
||||
@@ -630,19 +618,13 @@ describe('fleet command construction', () => {
|
||||
).rejects.toThrow('Unsupported fleet profile');
|
||||
});
|
||||
|
||||
it('sets process exitCode when legacy status runner fails', async () => {
|
||||
const home = await tempDir();
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
'version: 1\ntransport: tmux\nagents: []\n',
|
||||
);
|
||||
it('sets process exitCode when status runner fails', async () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const runner: CommandRunner = async () => ({ stdout: '', stderr: 'missing\n', exitCode: 3 });
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
registerFleetCommand(program, { runner });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
@@ -650,7 +632,6 @@ describe('fleet command construction', () => {
|
||||
} finally {
|
||||
process.exitCode = originalExitCode;
|
||||
stderrSpy.mockRestore();
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3505,66 +3486,7 @@ describe('fleet add/remove — pure helpers', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['tmux', { kind: 'tmux' }],
|
||||
['discord', { kind: 'discord', discord: { channelId: '1234567890' } }],
|
||||
[
|
||||
'matrix',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserverUrl: 'https://matrix.example.test',
|
||||
userId: '@mosaic:example.test',
|
||||
roomId: '!fleet:example.test',
|
||||
},
|
||||
},
|
||||
],
|
||||
] as const)('round-trips the supported %s connector through YAML', async (_kind, connector) => {
|
||||
const yaml = serializeRosterToYaml({ ...baseRoster, connector });
|
||||
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-connector-'));
|
||||
const rosterPath = join(dir, 'roster.yaml');
|
||||
try {
|
||||
await writeFile(rosterPath, yaml);
|
||||
expect((await loadFleetRoster(rosterPath)).connector).toEqual(connector);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['tmux', { kind: 'tmux' }],
|
||||
['discord', { kind: 'discord', discord: { channel_id: '1234567890' } }],
|
||||
[
|
||||
'matrix',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example.test',
|
||||
user_id: '@mosaic:example.test',
|
||||
room_id: '!fleet:example.test',
|
||||
},
|
||||
},
|
||||
],
|
||||
] as const)('parses the supported %s connector from JSON', async (_kind, connector) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-connector-'));
|
||||
const rosterPath = join(dir, 'roster.json');
|
||||
try {
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'orchestrator', runtime: 'claude', class: 'orchestrator' }],
|
||||
connector,
|
||||
}),
|
||||
);
|
||||
expect((await loadFleetRoster(rosterPath)).connector?.kind).toBe(_kind);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('serializeRosterToYaml round-trips optional fields and exact comms targets', async () => {
|
||||
it('serializeRosterToYaml round-trips optional fields (modelHint, workingDirectory)', async () => {
|
||||
const rosterWithOptionals: FleetRoster = {
|
||||
...baseRoster,
|
||||
agents: [
|
||||
@@ -3576,9 +3498,6 @@ describe('fleet add/remove — pure helpers', () => {
|
||||
workingDirectory: '/tmp/work',
|
||||
persistentPersona: true,
|
||||
resetBetweenTasks: false,
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
socket: 'mosaic-fleet',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -3586,9 +3505,6 @@ describe('fleet add/remove — pure helpers', () => {
|
||||
expect(yaml).toContain('model_hint: claude-3-5-sonnet');
|
||||
expect(yaml).toContain('working_directory: /tmp/work');
|
||||
expect(yaml).toContain('persistent_persona: true');
|
||||
expect(yaml).toContain('host: 10.1.10.37');
|
||||
expect(yaml).toContain('ssh: jwoltje@10.1.10.37');
|
||||
expect(yaml).toContain('socket: mosaic-fleet');
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||
const rosterPath = join(dir, 'roster.yaml');
|
||||
@@ -3598,9 +3514,6 @@ describe('fleet add/remove — pure helpers', () => {
|
||||
expect(loaded.agents[0]!.modelHint).toBe('claude-3-5-sonnet');
|
||||
expect(loaded.agents[0]!.workingDirectory).toBe('/tmp/work');
|
||||
expect(loaded.agents[0]!.persistentPersona).toBe(true);
|
||||
expect(loaded.agents[0]!.host).toBe('10.1.10.37');
|
||||
expect(loaded.agents[0]!.ssh).toBe('jwoltje@10.1.10.37');
|
||||
expect(loaded.agents[0]!.socket).toBe('mosaic-fleet');
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -18,28 +18,10 @@ import { spawn } from 'node:child_process';
|
||||
import * as readline from 'node:readline';
|
||||
import type { Command } from 'commander';
|
||||
import YAML from 'yaml';
|
||||
import {
|
||||
getRosterAgent,
|
||||
loadFleetRoster,
|
||||
resolveInstalledFleetRosterPath,
|
||||
type FleetAgent,
|
||||
type FleetRoster,
|
||||
} from '../fleet/fleet-roster-v1.js';
|
||||
export {
|
||||
getRosterAgent,
|
||||
loadFleetRoster,
|
||||
resolveInstalledFleetRosterPath,
|
||||
} from '../fleet/fleet-roster-v1.js';
|
||||
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
|
||||
import {
|
||||
registerFleetAgentCrudCommands,
|
||||
type FleetAgentCrudCommandDeps,
|
||||
} from './fleet-agent-crud-command.js';
|
||||
import {
|
||||
executeReconcilerCommandJson,
|
||||
registerFleetReconcilerCommands,
|
||||
type FleetReconcilerCommandDeps,
|
||||
} from './fleet-reconciler-command.js';
|
||||
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
|
||||
import {
|
||||
applyPreparedAgentEnvironmentProjection,
|
||||
@@ -96,7 +78,72 @@ export interface FleetCommandDeps {
|
||||
*/
|
||||
isStdinTTY?: boolean;
|
||||
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
|
||||
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
|
||||
}
|
||||
|
||||
interface RawFleetRoster {
|
||||
version?: unknown;
|
||||
transport?: unknown;
|
||||
tmux?: {
|
||||
socket_name?: unknown;
|
||||
socketName?: unknown;
|
||||
holder_session?: unknown;
|
||||
holderSession?: unknown;
|
||||
};
|
||||
defaults?: {
|
||||
working_directory?: unknown;
|
||||
workingDirectory?: unknown;
|
||||
};
|
||||
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
||||
agents?: Array<{
|
||||
name?: unknown;
|
||||
alias?: unknown;
|
||||
provider?: unknown;
|
||||
runtime?: unknown;
|
||||
class?: unknown;
|
||||
working_directory?: unknown;
|
||||
workingDirectory?: unknown;
|
||||
model_hint?: unknown;
|
||||
modelHint?: unknown;
|
||||
reasoning_level?: unknown;
|
||||
reasoningLevel?: unknown;
|
||||
tool_policy?: unknown;
|
||||
toolPolicy?: unknown;
|
||||
persistent_persona?: unknown;
|
||||
persistentPersona?: unknown;
|
||||
reset_between_tasks?: unknown;
|
||||
resetBetweenTasks?: unknown;
|
||||
kickstart_template?: unknown;
|
||||
kickstartTemplate?: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FleetAgent {
|
||||
name: string;
|
||||
alias?: string;
|
||||
provider?: string;
|
||||
runtime: string;
|
||||
className: string;
|
||||
workingDirectory?: string;
|
||||
modelHint?: string;
|
||||
reasoningLevel?: string;
|
||||
toolPolicy?: string;
|
||||
persistentPersona?: boolean | string;
|
||||
resetBetweenTasks?: boolean;
|
||||
kickstartTemplate?: string;
|
||||
}
|
||||
|
||||
export interface FleetRoster {
|
||||
version: 1;
|
||||
transport: 'tmux';
|
||||
tmux: {
|
||||
socketName: string;
|
||||
holderSession: string;
|
||||
};
|
||||
defaults: {
|
||||
workingDirectory: string;
|
||||
};
|
||||
runtimes: Record<string, { resetCommand: string }>;
|
||||
agents: FleetAgent[];
|
||||
}
|
||||
|
||||
export interface FleetPaths {
|
||||
@@ -117,6 +164,8 @@ type FleetServiceAction = 'start' | 'stop' | 'restart' | 'status';
|
||||
* fallback for a socket-less roster (that now resolves to the default socket).
|
||||
*/
|
||||
export const DEFAULT_SOCKET_NAME = 'mosaic-fleet';
|
||||
const DEFAULT_HOLDER_SESSION = '_holder';
|
||||
const DEFAULT_WORKING_DIRECTORY = '~/src';
|
||||
|
||||
/**
|
||||
* tmux `-L` args for a socket name. An empty/absent socket ⇒ the LITERAL default
|
||||
@@ -140,6 +189,13 @@ export const VERIFY_POLL_INTERVAL_MS = 400;
|
||||
* Configurable via `--verify-timeout <ms>` on `agent send`.
|
||||
*/
|
||||
export const VERIFY_DEFAULT_TIMEOUT_MS = 6_000;
|
||||
const DEFAULT_RUNTIME_RESETS: Record<string, { resetCommand: string }> = {
|
||||
claude: { resetCommand: '/clear' },
|
||||
codex: { resetCommand: '/clear' },
|
||||
opencode: { resetCommand: '/clear' },
|
||||
pi: { resetCommand: '/new' },
|
||||
};
|
||||
|
||||
export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths {
|
||||
return {
|
||||
mosaicHome,
|
||||
@@ -167,6 +223,20 @@ function assertDefaultMosaicHomeForSystemd(mosaicHome: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
||||
const rawText = await readFile(path, 'utf8');
|
||||
const parsed = parseRosterText(rawText, path);
|
||||
return normalizeRoster(parsed);
|
||||
}
|
||||
|
||||
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
||||
const agent = roster.agents.find((candidate) => candidate.name === name);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent "${name}" is not in the fleet roster.`);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NORTH_STAR — machine-readable fleet planning source + Markdown projection
|
||||
//
|
||||
@@ -481,7 +551,7 @@ function generateAgentEnvValues(
|
||||
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
|
||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
|
||||
MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory),
|
||||
MOSAIC_TMUX_SOCKET: agent.socket ?? roster.tmux.socketName,
|
||||
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1536,23 +1606,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
cmd
|
||||
.command(`${action} [agent]`)
|
||||
.description(`${action} the fleet holder or one agent`)
|
||||
.option('--expected-generation <number>', 'Authoritative roster generation for roster-v2')
|
||||
.option('--dry-run', 'Plan roster-v2 lifecycle effects without mutation')
|
||||
.action(
|
||||
async (
|
||||
agent: string | undefined,
|
||||
opts: { expectedGeneration?: string; dryRun?: boolean },
|
||||
) => {
|
||||
if (await usesRosterV2ControlPlane(cmd)) {
|
||||
await executeReconcilerCommandJson(
|
||||
cmd,
|
||||
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
|
||||
action,
|
||||
opts,
|
||||
agent,
|
||||
);
|
||||
return;
|
||||
}
|
||||
.action(async (agent?: string) => {
|
||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
@@ -1598,8 +1652,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
cmd
|
||||
@@ -1607,16 +1660,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
.description('Show fleet holder or agent systemd status')
|
||||
.option('--json', 'Print JSON status')
|
||||
.action(async (agent: string | undefined, opts: { json?: boolean }) => {
|
||||
if (await usesRosterV2ControlPlane(cmd)) {
|
||||
await executeReconcilerCommandJson(
|
||||
cmd,
|
||||
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
|
||||
'status',
|
||||
{},
|
||||
agent,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (agent) {
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
getRosterAgent(roster, agent);
|
||||
@@ -1640,15 +1683,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
.command('verify')
|
||||
.description('Verify the local canary holder and roster sessions on the isolated socket')
|
||||
.action(async () => {
|
||||
if (await usesRosterV2ControlPlane(cmd)) {
|
||||
await executeReconcilerCommandJson(
|
||||
cmd,
|
||||
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
|
||||
'verify',
|
||||
{},
|
||||
);
|
||||
return;
|
||||
}
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
const socketName = roster.tmux.socketName;
|
||||
await runChecked(runner, [
|
||||
@@ -2048,11 +2082,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
// Roster-v2 desired-state mutations belong directly to the fleet control
|
||||
// plane; they do not share the root `mosaic agent` gateway-backed surface.
|
||||
registerFleetAgentCrudCommands(cmd, deps);
|
||||
registerFleetReconcilerCommands(cmd, {
|
||||
runner,
|
||||
mosaicHome: deps.mosaicHome,
|
||||
reconcileDeps: deps.reconcileDeps,
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -2081,11 +2110,14 @@ export function registerFleetAgentCommands(
|
||||
});
|
||||
|
||||
agentCommand
|
||||
.command('comms-block <exact-member>')
|
||||
.description('Print the Fleet Comms contract for one exact roster member')
|
||||
.action((exactMember: string) => {
|
||||
.command('comms-block <role>')
|
||||
.description(
|
||||
"Print the Fleet Comms cheat-sheet for a roster role (preview a peer's peer-reach view)",
|
||||
)
|
||||
.option('--host <host>', 'Override the fleet host (preview a cross-host peer view)')
|
||||
.action((role: string, opts: { host?: string }) => {
|
||||
const mosaicHome = resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome);
|
||||
const res = resolveCommsBlock(mosaicHome, exactMember);
|
||||
const res = resolveCommsBlock(mosaicHome, role, opts.host);
|
||||
if (!res.ok) {
|
||||
console.error(`[mosaic] comms-block: ${res.error}`);
|
||||
process.exitCode = 1;
|
||||
@@ -2383,20 +2415,6 @@ async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
||||
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);
|
||||
const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
|
||||
return (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed) &&
|
||||
'version' in parsed &&
|
||||
parsed.version === 2
|
||||
);
|
||||
}
|
||||
|
||||
async function loadRosterFromAgentCommand(
|
||||
command: Command,
|
||||
mosaicHomeOverride?: string,
|
||||
@@ -2411,6 +2429,249 @@ function resolveMosaicHomeFromCommand(command: Command, override?: string): stri
|
||||
return opts.mosaicHome ?? override ?? defaultMosaicHome();
|
||||
}
|
||||
|
||||
function parseRosterText(text: string, path: string): RawFleetRoster {
|
||||
const trimmed = text.trim();
|
||||
if (path.endsWith('.json')) {
|
||||
return JSON.parse(trimmed) as RawFleetRoster;
|
||||
}
|
||||
return YAML.parse(trimmed) as RawFleetRoster;
|
||||
}
|
||||
|
||||
function normalizeRoster(raw: RawFleetRoster): FleetRoster {
|
||||
assertObject(raw, 'Fleet roster');
|
||||
assertKnownKeys(raw, 'Fleet roster', [
|
||||
'version',
|
||||
'transport',
|
||||
'tmux',
|
||||
'defaults',
|
||||
'runtimes',
|
||||
'agents',
|
||||
]);
|
||||
if (raw.tmux !== undefined) {
|
||||
assertObject(raw.tmux, 'Fleet roster tmux');
|
||||
assertKnownKeys(raw.tmux, 'Fleet roster tmux', [
|
||||
'socket_name',
|
||||
'socketName',
|
||||
'holder_session',
|
||||
'holderSession',
|
||||
]);
|
||||
}
|
||||
if (raw.defaults !== undefined) {
|
||||
assertObject(raw.defaults, 'Fleet roster defaults');
|
||||
assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
]);
|
||||
}
|
||||
if (raw.runtimes !== undefined) {
|
||||
assertObject(raw.runtimes, 'Fleet roster runtimes');
|
||||
for (const [runtime, config] of Object.entries(raw.runtimes)) {
|
||||
assertObject(config, `Fleet roster runtime "${runtime}"`);
|
||||
assertKnownKeys(config, `Fleet roster runtime "${runtime}"`, [
|
||||
'reset_command',
|
||||
'resetCommand',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (raw.version !== 1) {
|
||||
throw new Error('Fleet roster version must be 1.');
|
||||
}
|
||||
if (raw.transport !== 'tmux') {
|
||||
throw new Error('Fleet roster transport must be "tmux".');
|
||||
}
|
||||
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
|
||||
throw new Error('Fleet roster must define at least one agent.');
|
||||
}
|
||||
|
||||
const agents = raw.agents.map(normalizeAgent);
|
||||
assertUniqueAgentNames(agents);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: {
|
||||
// Absent socket_name ⇒ '' (the literal default tmux socket, no -L) — NOT
|
||||
// mosaic-fleet. Shipped presets set socket_name explicitly, so they are
|
||||
// unaffected; only socket-less rosters get default-socket behavior.
|
||||
socketName: stringValue(
|
||||
raw.tmux?.socket_name ?? raw.tmux?.socketName,
|
||||
'',
|
||||
'Fleet roster tmux socket_name',
|
||||
),
|
||||
holderSession: stringValue(
|
||||
raw.tmux?.holder_session ?? raw.tmux?.holderSession,
|
||||
DEFAULT_HOLDER_SESSION,
|
||||
'Fleet roster tmux holder_session',
|
||||
),
|
||||
},
|
||||
defaults: {
|
||||
workingDirectory: stringValue(
|
||||
raw.defaults?.working_directory ?? raw.defaults?.workingDirectory,
|
||||
DEFAULT_WORKING_DIRECTORY,
|
||||
'Fleet roster defaults working_directory',
|
||||
),
|
||||
},
|
||||
runtimes: normalizeRuntimes(raw.runtimes as RawFleetRoster['runtimes']),
|
||||
agents,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): FleetAgent {
|
||||
assertObject(raw, 'Fleet roster agent');
|
||||
assertKnownKeys(raw, 'Fleet roster agent', [
|
||||
'name',
|
||||
'alias',
|
||||
'provider',
|
||||
'runtime',
|
||||
'class',
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
'model_hint',
|
||||
'modelHint',
|
||||
'reasoning_level',
|
||||
'reasoningLevel',
|
||||
'tool_policy',
|
||||
'toolPolicy',
|
||||
'persistent_persona',
|
||||
'persistentPersona',
|
||||
'reset_between_tasks',
|
||||
'resetBetweenTasks',
|
||||
'kickstart_template',
|
||||
'kickstartTemplate',
|
||||
]);
|
||||
const name = stringValue(raw.name, '', 'Fleet roster agent name');
|
||||
const runtime = stringValue(
|
||||
raw.runtime,
|
||||
'',
|
||||
`Fleet roster agent "${name || '<unknown>'}" runtime`,
|
||||
);
|
||||
if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
|
||||
throw new Error(`Invalid fleet agent name: ${name || '<empty>'}`);
|
||||
}
|
||||
if (!runtime) {
|
||||
throw new Error(`Fleet agent "${name}" must define a runtime.`);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`),
|
||||
provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`),
|
||||
runtime,
|
||||
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
||||
workingDirectory: optionalString(
|
||||
raw.working_directory ?? raw.workingDirectory,
|
||||
`Fleet roster agent "${name}" working_directory`,
|
||||
),
|
||||
modelHint: optionalString(
|
||||
raw.model_hint ?? raw.modelHint,
|
||||
`Fleet roster agent "${name}" model_hint`,
|
||||
),
|
||||
reasoningLevel: optionalString(
|
||||
raw.reasoning_level ?? raw.reasoningLevel,
|
||||
`Fleet roster agent "${name}" reasoning_level`,
|
||||
),
|
||||
toolPolicy: optionalString(
|
||||
raw.tool_policy ?? raw.toolPolicy,
|
||||
`Fleet roster agent "${name}" tool_policy`,
|
||||
),
|
||||
persistentPersona: optionalBooleanOrString(
|
||||
raw.persistent_persona ?? raw.persistentPersona,
|
||||
`Fleet roster agent "${name}" persistent_persona`,
|
||||
),
|
||||
resetBetweenTasks: optionalBoolean(
|
||||
raw.reset_between_tasks ?? raw.resetBetweenTasks,
|
||||
`Fleet roster agent "${name}" reset_between_tasks`,
|
||||
),
|
||||
kickstartTemplate: optionalString(
|
||||
raw.kickstart_template ?? raw.kickstartTemplate,
|
||||
`Fleet roster agent "${name}" kickstart_template`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRuntimes(
|
||||
raw: RawFleetRoster['runtimes'] | undefined,
|
||||
): Record<string, { resetCommand: string }> {
|
||||
const result: Record<string, { resetCommand: string }> = { ...DEFAULT_RUNTIME_RESETS };
|
||||
for (const [runtime, config] of Object.entries(raw ?? {})) {
|
||||
result[runtime] = {
|
||||
resetCommand: stringValue(
|
||||
config.reset_command ?? config.resetCommand,
|
||||
'/clear',
|
||||
`Fleet roster runtime "${runtime}" reset_command`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertObject(value: unknown, label: string): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertKnownKeys(
|
||||
value: Record<string, unknown>,
|
||||
label: string,
|
||||
allowedKeys: readonly string[],
|
||||
): void {
|
||||
const allowed = new Set(allowedKeys);
|
||||
const unknownKeys = Object.keys(value).filter((key) => !allowed.has(key));
|
||||
if (unknownKeys.length > 0) {
|
||||
throw new Error(`${label} has unknown field(s): ${unknownKeys.join(', ')}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertUniqueAgentNames(agents: FleetAgent[]): void {
|
||||
const seen = new Set<string>();
|
||||
for (const agent of agents) {
|
||||
if (seen.has(agent.name)) {
|
||||
throw new Error(`Fleet roster has duplicate agent name: ${agent.name}.`);
|
||||
}
|
||||
seen.add(agent.name);
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = '', label = 'Value'): string {
|
||||
if (value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label = 'Value'): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBoolean(value: unknown, label = 'Value'): boolean | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`${label} must be a boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBooleanOrString(value: unknown, label = 'Value'): boolean | string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'boolean' && typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a boolean or string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function expandHome(path: string): string {
|
||||
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
|
||||
}
|
||||
@@ -2567,23 +2828,6 @@ export function removeAgentFromRoster(roster: FleetRoster, name: string): FleetR
|
||||
};
|
||||
}
|
||||
|
||||
function serializeConnector(
|
||||
connector: NonNullable<FleetRoster['connector']>,
|
||||
): Record<string, unknown> {
|
||||
if (connector.kind === 'tmux') return { kind: 'tmux' };
|
||||
if (connector.kind === 'discord') {
|
||||
return { kind: 'discord', discord: { channel_id: connector.discord.channelId } };
|
||||
}
|
||||
return {
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: connector.matrix.homeserverUrl,
|
||||
user_id: connector.matrix.userId,
|
||||
room_id: connector.matrix.roomId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a FleetRoster to YAML text (snake_case keys).
|
||||
* The output is parseable by loadFleetRoster.
|
||||
@@ -2601,15 +2845,6 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
|
||||
if (agent.provider !== undefined) {
|
||||
raw['provider'] = agent.provider;
|
||||
}
|
||||
if (agent.host !== undefined) {
|
||||
raw['host'] = agent.host;
|
||||
}
|
||||
if (agent.ssh !== undefined) {
|
||||
raw['ssh'] = agent.ssh;
|
||||
}
|
||||
if (agent.socket !== undefined) {
|
||||
raw['socket'] = agent.socket;
|
||||
}
|
||||
if (agent.workingDirectory !== undefined) {
|
||||
raw['working_directory'] = agent.workingDirectory;
|
||||
}
|
||||
@@ -2651,7 +2886,6 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
|
||||
},
|
||||
runtimes,
|
||||
agents,
|
||||
...(roster.connector ? { connector: serializeConnector(roster.connector) } : {}),
|
||||
};
|
||||
|
||||
return YAML.stringify(raw);
|
||||
@@ -2798,5 +3032,6 @@ export async function resolveRosterPath(
|
||||
if (await canRead(yamlPath)) {
|
||||
return yamlPath;
|
||||
}
|
||||
return resolveInstalledFleetRosterPath(mosaicHome);
|
||||
const jsonPath = join(mosaicHome, 'fleet', 'roster.json');
|
||||
return jsonPath;
|
||||
}
|
||||
|
||||
@@ -19,17 +19,10 @@ import { createRequire } from 'node:module';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import {
|
||||
buildResolvedFleetCommsBlock,
|
||||
renderToolsContractStatus,
|
||||
resolveFleetIdentity,
|
||||
} from '../fleet/comms-onboarding.js';
|
||||
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
||||
import { readFleetCommsBlock } from '../fleet/comms-onboarding.js';
|
||||
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
||||
import { canonicalizeRoleClass } from './fleet-personas.js';
|
||||
|
||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
||||
|
||||
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
||||
|
||||
@@ -192,17 +185,6 @@ function readOptional(path: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function readInstalledToolsSecure(mosaicHome: string): string {
|
||||
try {
|
||||
return readRegularFileSecure(join(mosaicHome, 'TOOLS.md'), {
|
||||
root: mosaicHome,
|
||||
maxBytes: MAX_INSTALLED_TOOLS_BYTES,
|
||||
}).content.toString('utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(path: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
|
||||
@@ -379,25 +361,9 @@ For required push/merge/issue-close/release actions, execute without routine con
|
||||
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
|
||||
}
|
||||
|
||||
const fleetIdentity = resolveFleetIdentity(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
|
||||
if (!fleetIdentity.ok) {
|
||||
throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`);
|
||||
}
|
||||
const canonicalMember = fleetIdentity.identity?.member;
|
||||
if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) {
|
||||
const ambientClass = canonicalizeRoleClass(process.env['MOSAIC_AGENT_CLASS']).canonicalClass;
|
||||
if (ambientClass !== canonicalMember.className) {
|
||||
throw new Error(
|
||||
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TOOLS.md
|
||||
const tools = readInstalledToolsSecure(mosaicHome);
|
||||
const tools = readOptional(join(mosaicHome, 'TOOLS.md'));
|
||||
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
|
||||
const toolsContractStatus = renderToolsContractStatus(mosaicHome);
|
||||
if (toolsContractStatus) parts.push('\n\n' + toolsContractStatus);
|
||||
|
||||
// Operator overlays whose base layers are load-on-demand (SOUL, STANDARDS):
|
||||
// inject only the small `.local` delta by value so the customization reaches
|
||||
@@ -419,23 +385,24 @@ For required push/merge/issue-close/release actions, execute without routine con
|
||||
// Runtime-specific contract
|
||||
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
|
||||
|
||||
// Fleet launches derive every identity projection from the one canonical roster
|
||||
// member resolved above. Non-fleet launches retain the legacy ambient persona
|
||||
// and tool-policy behavior.
|
||||
const personaClass = canonicalMember?.className ?? process.env['MOSAIC_AGENT_CLASS'];
|
||||
const persona = readPersonaContractBlock(mosaicHome, personaClass);
|
||||
// Persona contract (A3b): when this agent was spawned with a class
|
||||
// (MOSAIC_AGENT_CLASS, exported into the pane env by A3a), inject its resolved
|
||||
// role contract so its identity (mandate + boundaries) is resident from the
|
||||
// first turn. Override-aware via the persona resolver: a user-customized
|
||||
// persona in fleet/roles.local/ wins over the baseline (AC-NS-7 launch proof).
|
||||
// Placed BEFORE fleet comms: identity first, then how-to-reach-peers. No-ops
|
||||
// silently when the class is unset/unknown (mirrors the comms block).
|
||||
const persona = readPersonaContractBlock(mosaicHome, process.env['MOSAIC_AGENT_CLASS']);
|
||||
if (persona) parts.push('\n\n' + persona);
|
||||
|
||||
const toolPolicyName = canonicalMember
|
||||
? canonicalMember.toolPolicy
|
||||
: process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||
const toolPolicy = readFleetToolPolicyBlock(toolPolicyName);
|
||||
const toolPolicy = readFleetToolPolicyBlock(process.env['MOSAIC_AGENT_TOOL_POLICY']);
|
||||
if (toolPolicy) parts.push('\n\n' + toolPolicy);
|
||||
|
||||
if (fleetIdentity.identity) {
|
||||
const fleetComms = buildResolvedFleetCommsBlock(fleetIdentity.identity);
|
||||
// Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set
|
||||
// and present in the roster), inject a comms cheat-sheet + peer roster so it
|
||||
// knows how to reach the orchestrator and its peers from its first turn.
|
||||
const fleetComms = readFleetCommsBlock(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
|
||||
if (fleetComms) parts.push('\n\n' + fleetComms);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
@@ -38,10 +38,7 @@ function makeFixture(): { sourceDir: string; mosaicHome: string; defaultsDir: st
|
||||
writeFileSync(join(defaultsDir, 'CONSTITUTION.md'), '# CONSTITUTION default\n');
|
||||
writeFileSync(join(defaultsDir, 'AGENTS.md'), '# AGENTS default\n');
|
||||
writeFileSync(join(defaultsDir, 'STANDARDS.md'), '# STANDARDS default\n');
|
||||
writeFileSync(
|
||||
join(defaultsDir, 'TOOLS.md'),
|
||||
'# TOOLS default\n\n<!-- fleet-comms-contract: 1 -->\n',
|
||||
);
|
||||
writeFileSync(join(defaultsDir, 'TOOLS.md'), '# TOOLS default\n');
|
||||
|
||||
// Non-contract files we must NOT seed on first install.
|
||||
writeFileSync(join(defaultsDir, 'SOUL.md'), '# SOUL default (should not be seeded)\n');
|
||||
@@ -74,8 +71,9 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
for (const name of DEFAULT_SEED_FILES) {
|
||||
expect(existsSync(join(fixture.mosaicHome, name))).toBe(true);
|
||||
}
|
||||
const sourceTools = readFileSync(join(fixture.defaultsDir, 'TOOLS.md'), 'utf-8');
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toBe(sourceTools);
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toContain(
|
||||
'# TOOLS default',
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT seed SOUL.md or USER.md from defaults/ (wizard stages own those)', async () => {
|
||||
@@ -155,20 +153,17 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n');
|
||||
});
|
||||
|
||||
it('preserves user fleet data (YAML/JSON rosters, agents/, run/) through a keep-mode sync', async () => {
|
||||
// Regression for roster loss (#631/#766): user-authored fleet files must
|
||||
it('preserves user fleet data (roster.yaml, agents/, run/) through a keep-mode sync', async () => {
|
||||
// Regression for the roster-loss bug (#631): user-authored fleet files must
|
||||
// survive the framework re-seed that `mosaic update` runs.
|
||||
mkdirSync(join(fixture.mosaicHome, 'fleet', 'run'), { recursive: true });
|
||||
mkdirSync(join(fixture.mosaicHome, 'fleet', 'agents'), { recursive: true });
|
||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'roster.yaml'), 'version: 1\nMINE\n');
|
||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'roster.json'), '{"mine":true}\n');
|
||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'run', 'a.hb'), 'ts=x\n');
|
||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'agents', 'a.env'), 'X=1\n');
|
||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'roster.schema.json'), '{"stale":true}\n');
|
||||
// The framework ships fleet/examples and roster.schema.json — both refresh.
|
||||
// The framework ships fleet/examples — it should still seed/refresh.
|
||||
mkdirSync(join(fixture.sourceDir, 'fleet', 'examples'), { recursive: true });
|
||||
writeFileSync(join(fixture.sourceDir, 'fleet', 'examples', 'general.yaml'), '# preset\n');
|
||||
writeFileSync(join(fixture.sourceDir, 'fleet', 'roster.schema.json'), '{"fresh":true}\n');
|
||||
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
await adapter.syncFramework('keep');
|
||||
@@ -176,16 +171,10 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.yaml'), 'utf-8')).toBe(
|
||||
'version: 1\nMINE\n',
|
||||
);
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.json'), 'utf-8')).toBe(
|
||||
'{"mine":true}\n',
|
||||
);
|
||||
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'run', 'a.hb'))).toBe(true);
|
||||
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'agents', 'a.env'))).toBe(true);
|
||||
// Framework-owned fleet assets are refreshed; unrelated user YAML is not preserved.
|
||||
// framework-owned fleet/examples is seeded
|
||||
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'examples', 'general.yaml'))).toBe(true);
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.schema.json'), 'utf-8')).toBe(
|
||||
'{"fresh":true}\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
|
||||
|
||||
@@ -177,8 +177,7 @@ export class FileConfigAdapter implements ConfigService {
|
||||
// The framework seeds only fleet/examples + fleet/roles +
|
||||
// fleet/roster.schema.json; the operator's roster, per-agent env, and
|
||||
// heartbeat run dir stay user-owned. (Mirror of install.sh PRESERVE_PATHS.)
|
||||
'fleet/roster.yaml',
|
||||
'fleet/roster.json',
|
||||
'fleet/*.yaml',
|
||||
'fleet/agents',
|
||||
'fleet/run',
|
||||
]
|
||||
|
||||
@@ -1,43 +1,30 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
symlinkSync,
|
||||
} from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { parseFleetRosterV1, type FleetRoster, type FleetAgent } from './fleet-roster-v1.js';
|
||||
import {
|
||||
parseRosterAgents,
|
||||
buildFleetCommsBlock,
|
||||
renderPeerReach,
|
||||
readFleetCommsBlock,
|
||||
resolveCommsBlock,
|
||||
resolvePeerCommand,
|
||||
renderToolsContractStatus,
|
||||
type CommsPeer,
|
||||
} from './comms-onboarding.js';
|
||||
|
||||
const ROSTER = [
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'tmux:',
|
||||
' socket_name: mosaic-fleet',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
' host: w-jarvis',
|
||||
' - name: enhancer',
|
||||
' runtime: claude',
|
||||
' class: enhancer',
|
||||
' host: w-jarvis',
|
||||
' - name: coder0',
|
||||
' runtime: pi',
|
||||
' class: implementer',
|
||||
' host: w-jarvis',
|
||||
' # a manually-listed cross-host peer (pre-federation stopgap)',
|
||||
' - name: coder0-0',
|
||||
' runtime: claude',
|
||||
' class: implementer',
|
||||
@@ -46,687 +33,206 @@ const ROSTER = [
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
function roster(source = ROSTER): FleetRoster {
|
||||
return parseFleetRosterV1(source, 'yaml');
|
||||
}
|
||||
|
||||
describe('shared fleet roster v1 resolver', () => {
|
||||
it('resolves comms fields and the global socket through the canonical roster contract', () => {
|
||||
const resolved = roster();
|
||||
expect(resolved.tmux.socketName).toBe('mosaic-fleet');
|
||||
expect(resolved.agents.find((agent) => agent.name === 'coder0-0')).toMatchObject({
|
||||
className: 'code',
|
||||
describe('parseRosterAgents', () => {
|
||||
it('parses name + class + optional host/ssh', () => {
|
||||
const peers = parseRosterAgents(ROSTER);
|
||||
expect(peers.map((p) => p.name)).toEqual(['orchestrator', 'enhancer', 'coder0', 'coder0-0']);
|
||||
expect(peers.find((p) => p.name === 'coder0')).toMatchObject({ className: 'implementer' });
|
||||
expect(peers.find((p) => p.name === 'coder0-0')).toMatchObject({
|
||||
className: 'implementer',
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
});
|
||||
// local agents have no host/ssh
|
||||
expect(peers.find((p) => p.name === 'orchestrator')!.host).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects unknown fields instead of leniently constructing a second roster view', () => {
|
||||
expect(() => parseFleetRosterV1(`${ROSTER}\nunknown: value\n`, 'yaml')).toThrow(
|
||||
/unknown field/i,
|
||||
it('parses an optional per-agent socket', () => {
|
||||
const peers = parseRosterAgents(
|
||||
['agents:', ' - name: a', ' class: worker', ' socket: mosaic-fleet'].join('\n'),
|
||||
);
|
||||
expect(peers[0]).toMatchObject({ name: 'a', socket: 'mosaic-fleet' });
|
||||
});
|
||||
|
||||
it('rejects an unsupported independent per-agent socket instead of targeting a nonexistent session', () => {
|
||||
expect(() =>
|
||||
roster(
|
||||
ROSTER.replace(
|
||||
' host: w-jarvis\n - name: coder0',
|
||||
' host: w-jarvis\n socket: other-socket\n - name: coder0',
|
||||
it('stops at the next top-level key', () => {
|
||||
const peers = parseRosterAgents(
|
||||
['agents:', ' - name: a', ' class: worker', 'defaults:', ' working_directory: ~'].join(
|
||||
'\n',
|
||||
),
|
||||
),
|
||||
).toThrow(/independent per-agent sockets are not supported/i);
|
||||
});
|
||||
|
||||
it('rejects unsafe operational targeting values', () => {
|
||||
expect(() =>
|
||||
roster(ROSTER.replace(' ssh: jwoltje@10.1.10.37', ' ssh: host;touch-owned')),
|
||||
).toThrow(/unsupported targeting characters/i);
|
||||
});
|
||||
|
||||
it('normalizes matching connector settings for YAML and JSON rosters', () => {
|
||||
const yamlSource = `${ROSTER}connector:\n kind: discord\n discord:\n channel_id: "123"\n`;
|
||||
const jsonSource = JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'a', runtime: 'claude', class: 'worker' }],
|
||||
connector: {
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: '@a:example',
|
||||
room_id: '!room:example',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parseFleetRosterV1(yamlSource, 'yaml').connector).toEqual({
|
||||
kind: 'discord',
|
||||
discord: { channelId: '123' },
|
||||
});
|
||||
expect(parseFleetRosterV1(jsonSource, 'json').connector).toEqual({
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserverUrl: 'https://matrix.example',
|
||||
userId: '@a:example',
|
||||
roomId: '!room:example',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['discord channel_id', { kind: 'discord', discord: { channel_id: '' } }],
|
||||
['discord channel_id whitespace', { kind: 'discord', discord: { channel_id: ' ' } }],
|
||||
[
|
||||
'matrix homeserver_url',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: { homeserver_url: '', user_id: '@a:example', room_id: '!room:example' },
|
||||
},
|
||||
],
|
||||
[
|
||||
'matrix homeserver_url whitespace',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: { homeserver_url: '\t', user_id: '@a:example', room_id: '!room:example' },
|
||||
},
|
||||
],
|
||||
[
|
||||
'matrix user_id',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: '',
|
||||
room_id: '!room:example',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'matrix user_id whitespace',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: ' ',
|
||||
room_id: '!room:example',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'matrix room_id',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: '@a:example',
|
||||
room_id: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'matrix room_id whitespace',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: '@a:example',
|
||||
room_id: '\n',
|
||||
},
|
||||
},
|
||||
],
|
||||
])(
|
||||
'rejects empty or whitespace-only parser-required connector string: %s',
|
||||
(_label, connector) => {
|
||||
expect(() =>
|
||||
parseFleetRosterV1(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'a', runtime: 'claude', class: 'worker' }],
|
||||
connector,
|
||||
}),
|
||||
'json',
|
||||
),
|
||||
).toThrow(/required/i);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['tmux with discord settings', { kind: 'tmux', discord: { channel_id: '123' } }],
|
||||
['discord without discord settings', { kind: 'discord' }],
|
||||
[
|
||||
'discord with matrix settings',
|
||||
{ kind: 'discord', discord: { channel_id: '123' }, matrix: {} },
|
||||
],
|
||||
['matrix without matrix settings', { kind: 'matrix' }],
|
||||
[
|
||||
'matrix with discord settings',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: '@a:example',
|
||||
room_id: '!room:example',
|
||||
},
|
||||
discord: { channel_id: '123' },
|
||||
},
|
||||
],
|
||||
])('rejects connector kind/settings mismatch: %s', (_label, connector) => {
|
||||
expect(() =>
|
||||
parseFleetRosterV1(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'a', runtime: 'claude', class: 'worker' }],
|
||||
connector,
|
||||
}),
|
||||
'json',
|
||||
),
|
||||
).toThrow();
|
||||
expect(peers.map((p) => p.name)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['tmux socket', ['tmux', 'socket_name'], ['tmux', 'socketName'], 'same', 'different'],
|
||||
['tmux holder', ['tmux', 'holder_session'], ['tmux', 'holderSession'], 'same', 'different'],
|
||||
[
|
||||
'defaults working directory',
|
||||
['defaults', 'working_directory'],
|
||||
['defaults', 'workingDirectory'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'runtime reset command',
|
||||
['runtimes', 'claude', 'reset_command'],
|
||||
['runtimes', 'claude', 'resetCommand'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'agent working directory',
|
||||
['agents', 0, 'working_directory'],
|
||||
['agents', 0, 'workingDirectory'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'agent model hint',
|
||||
['agents', 0, 'model_hint'],
|
||||
['agents', 0, 'modelHint'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'agent reasoning level',
|
||||
['agents', 0, 'reasoning_level'],
|
||||
['agents', 0, 'reasoningLevel'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'agent tool policy',
|
||||
['agents', 0, 'tool_policy'],
|
||||
['agents', 0, 'toolPolicy'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'agent persistent persona',
|
||||
['agents', 0, 'persistent_persona'],
|
||||
['agents', 0, 'persistentPersona'],
|
||||
true,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'agent reset between tasks',
|
||||
['agents', 0, 'reset_between_tasks'],
|
||||
['agents', 0, 'resetBetweenTasks'],
|
||||
true,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'agent kickstart template',
|
||||
['agents', 0, 'kickstart_template'],
|
||||
['agents', 0, 'kickstartTemplate'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
] as const)(
|
||||
'rejects conflicting %s aliases and accepts identical aliases',
|
||||
(_label, snake, camel, same, different) => {
|
||||
const base: Record<string, unknown> = {
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: {},
|
||||
defaults: {},
|
||||
runtimes: { claude: {} },
|
||||
agents: [{ name: 'a', runtime: 'claude', class: 'worker' }],
|
||||
};
|
||||
const assign = (
|
||||
root: Record<string, unknown>,
|
||||
path: readonly (string | number)[],
|
||||
value: unknown,
|
||||
) => {
|
||||
let cursor: unknown = root;
|
||||
for (const segment of path.slice(0, -1)) {
|
||||
cursor = (cursor as Record<string | number, unknown>)[segment];
|
||||
}
|
||||
(cursor as Record<string | number, unknown>)[path.at(-1)!] = value;
|
||||
};
|
||||
assign(base, snake, same);
|
||||
assign(base, camel, different);
|
||||
expect(() => parseFleetRosterV1(JSON.stringify(base), 'json')).toThrow(
|
||||
/aliases .* conflict/i,
|
||||
);
|
||||
assign(base, camel, same);
|
||||
expect(() => parseFleetRosterV1(JSON.stringify(base), 'json')).not.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('renderPeerReach — exact same-host/cross-host/socket targeting', () => {
|
||||
describe('renderPeerReach — same-host vs cross-host', () => {
|
||||
const send = '/home/u/.config/mosaic/tools/tmux/agent-send.sh';
|
||||
const base: FleetAgent = {
|
||||
name: 'peer',
|
||||
runtime: 'claude',
|
||||
className: 'worker',
|
||||
};
|
||||
|
||||
it('renders the global named socket and omits -H for a same-host peer', () => {
|
||||
expect(renderPeerReach(base, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||
`${send} -L mosaic-fleet -s peer -m "…"`,
|
||||
);
|
||||
it('renders the short form for a same-host peer', () => {
|
||||
const peer: CommsPeer = { name: 'enhancer', className: 'enhancer' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s enhancer -m "…"`);
|
||||
});
|
||||
|
||||
it('uses only the explicit roster ssh target for a cross-host peer', () => {
|
||||
const peer: FleetAgent = {
|
||||
...base,
|
||||
it('renders the -H form for a cross-host peer using ssh', () => {
|
||||
const peer: CommsPeer = {
|
||||
name: 'coder0-0',
|
||||
className: 'implementer',
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
};
|
||||
expect(renderPeerReach(peer, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
||||
`${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to host when a cross-host peer has no ssh', () => {
|
||||
const peer: CommsPeer = { name: 'x', className: 'worker', host: '10.0.0.9' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -H 10.0.0.9 -s x -m "…"`);
|
||||
});
|
||||
|
||||
it('treats a peer whose host equals the fleet host as same-host', () => {
|
||||
const peer: CommsPeer = { name: 'y', className: 'worker', host: 'w-jarvis' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s y -m "…"`);
|
||||
});
|
||||
|
||||
it('emits NO -L for an unset/default socket', () => {
|
||||
const peer: CommsPeer = { name: 'lead', className: 'orchestrator' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s lead -m "…"`);
|
||||
});
|
||||
|
||||
it('emits -L <socket> for a named socket', () => {
|
||||
const peer: CommsPeer = { name: 'coder0', className: 'implementer', socket: 'mosaic-fleet' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
||||
`${send} -L mosaic-fleet -s coder0 -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('combines -L (named socket) and -H (cross-host) in order', () => {
|
||||
const peer: CommsPeer = {
|
||||
name: 'coder0-0',
|
||||
className: 'implementer',
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
socket: 'mosaic-fleet',
|
||||
};
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
||||
`${send} -L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed when a cross-host peer has no explicit roster ssh target', () => {
|
||||
const peer: FleetAgent = { ...base, name: 'x', host: '10.0.0.9' };
|
||||
expect(() => renderPeerReach(peer, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toThrow(
|
||||
/explicit roster ssh target/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders only the fleet-wide supported socket', () => {
|
||||
const peer: FleetAgent = { ...base, socket: 'mosaic-fleet' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||
`${send} -L mosaic-fleet -s peer -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves hostless peers against the stable fleet-host baseline, not the viewer host', () => {
|
||||
const peer: FleetAgent = { ...base, ssh: 'fleet-user@w-jarvis' };
|
||||
expect(renderPeerReach(peer, 'remote-host', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||
`${send} -L mosaic-fleet -H fleet-user@w-jarvis -s peer -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('shell-quotes an exact helper path that contains spaces', () => {
|
||||
expect(
|
||||
renderPeerReach(base, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', '/home/test user/send.sh'),
|
||||
).toBe(`'/home/test user/send.sh' -L mosaic-fleet -s peer -m "…"`);
|
||||
});
|
||||
|
||||
it('omits -L only for the literal default socket', () => {
|
||||
expect(renderPeerReach(base, 'w-jarvis', 'w-jarvis', '', send)).toBe(`${send} -s peer -m "…"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFleetCommsBlock', () => {
|
||||
const send = '/h/.config/mosaic/tools/tmux/agent-send.sh';
|
||||
const agents = parseRosterAgents(ROSTER);
|
||||
|
||||
it('renders authoritative identity, exact rows, generation, and no operational metavariables', () => {
|
||||
it('excludes self, lists peers, flags the orchestrator, and emits both address forms', () => {
|
||||
const block = buildFleetCommsBlock({
|
||||
selfName: 'enhancer',
|
||||
roster: roster(),
|
||||
localHost: 'ignored-process-host',
|
||||
agents,
|
||||
fleetHost: 'w-jarvis',
|
||||
agentSendPath: send,
|
||||
});
|
||||
|
||||
expect(block).toContain('# Fleet Comms');
|
||||
expect(block).toContain('Host: `w-jarvis`');
|
||||
expect(block).toContain('Agent/session: `enhancer`');
|
||||
expect(block).toContain('tmux socket: `mosaic-fleet`');
|
||||
expect(block).toContain(`Helper: \`${send}\``);
|
||||
expect(block).toMatch(/Comms generation: `[a-f0-9]{64}`/);
|
||||
expect(block).toContain('You are **enhancer**');
|
||||
// criterion 1: agent's own [host:session] identity
|
||||
expect(block).toContain('`[w-jarvis:enhancer]`');
|
||||
// self excluded
|
||||
expect(block).not.toMatch(/\|\s*enhancer\s*\|/);
|
||||
expect(block).toContain(`${send} -L mosaic-fleet -s orchestrator -m "…"`);
|
||||
expect(block).toContain(`${send} -L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`);
|
||||
expect(block).toContain(`mosaic agent comms-block enhancer`);
|
||||
expect(block).toMatch(/Never invent, substitute, or fuzzy-match/i);
|
||||
expect(block).not.toMatch(
|
||||
/<(?:user@host|src_host|src_session|dst_host|dst_session|target-session)>/,
|
||||
);
|
||||
expect(block).not.toContain('FLIP the preamble');
|
||||
// peers present
|
||||
expect(block).toContain('| orchestrator |');
|
||||
expect(block).toContain('point of contact');
|
||||
// same-host peer short form
|
||||
expect(block).toContain(`${send} -s coder0 -m "…"`);
|
||||
// cross-host peer -H form + host annotation
|
||||
expect(block).toContain(`${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`);
|
||||
expect(block).toContain('host `10.1.10.37`');
|
||||
// conventions
|
||||
expect(block).toContain('FLIP the preamble');
|
||||
expect(block).toContain('ACCEPTED');
|
||||
});
|
||||
|
||||
it('changes the generation when a rendered peer role changes', () => {
|
||||
const generation = (block: string) => block.match(/Comms generation: `([a-f0-9]{64})`/)?.[1];
|
||||
const before = buildFleetCommsBlock({
|
||||
selfName: 'enhancer',
|
||||
roster: roster(),
|
||||
localHost: 'w-jarvis',
|
||||
agentSendPath: send,
|
||||
});
|
||||
const changedRoster = roster(ROSTER.replace('class: implementer', 'class: reviewer'));
|
||||
const after = buildFleetCommsBlock({
|
||||
selfName: 'enhancer',
|
||||
roster: changedRoster,
|
||||
localHost: 'w-jarvis',
|
||||
agentSendPath: send,
|
||||
});
|
||||
expect(generation(before)).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(generation(after)).not.toBe(generation(before));
|
||||
});
|
||||
|
||||
it('fails closed when any rendered cross-host row lacks ssh', () => {
|
||||
const bad = roster(ROSTER.replace(' ssh: jwoltje@10.1.10.37\n', ''));
|
||||
expect(() =>
|
||||
it('returns empty when the agent has no peers', () => {
|
||||
expect(
|
||||
buildFleetCommsBlock({
|
||||
selfName: 'enhancer',
|
||||
roster: bad,
|
||||
localHost: 'w-jarvis',
|
||||
selfName: 'solo',
|
||||
agents: [{ name: 'solo', className: 'orchestrator' }],
|
||||
fleetHost: 'h',
|
||||
agentSendPath: send,
|
||||
}),
|
||||
).toThrow(/explicit roster ssh target/i);
|
||||
});
|
||||
|
||||
it('still renders authoritative local identity when the agent has no peers', () => {
|
||||
const solo = roster(
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: solo',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
].join('\n'),
|
||||
);
|
||||
const block = buildFleetCommsBlock({
|
||||
selfName: 'solo',
|
||||
roster: solo,
|
||||
localHost: 'h',
|
||||
agentSendPath: send,
|
||||
});
|
||||
expect(block).toContain('Host: `h`');
|
||||
expect(block).toContain('Agent/session: `solo`');
|
||||
expect(block).toContain('Role/class: `orchestrator`');
|
||||
expect(block).toMatch(/Comms generation: `[a-f0-9]{64}`/);
|
||||
expect(block).toContain('This roster has no peers');
|
||||
expect(block).toContain('## Solo authority boundaries');
|
||||
expect(block).toContain('no peer, orchestrator, or remote communication authority');
|
||||
expect(block).toContain('Do not send, infer a target, or claim fleet coordination');
|
||||
).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePeerCommand', () => {
|
||||
const send = '/h/.config/mosaic/tools/tmux/agent-send.sh';
|
||||
|
||||
it('returns one exact known-peer row', () => {
|
||||
const result = resolvePeerCommand(roster(), 'enhancer', 'coder0-0', 'w-jarvis', send);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.command).toContain('-H jwoltje@10.1.10.37 -s coder0-0');
|
||||
});
|
||||
|
||||
it('fails closed for an unknown peer with exact-name discovery guidance', () => {
|
||||
const result = resolvePeerCommand(roster(), 'enhancer', 'invented-host', 'w-jarvis', send);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.command).toBe('');
|
||||
expect(result.error).toContain('invented-host');
|
||||
expect(result.error).toContain('orchestrator, coder0, coder0-0');
|
||||
expect(result.error).toContain('mosaic agent comms-block enhancer');
|
||||
expect(result.error).not.toContain('tmux ls');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFleetCommsBlock — spawned-agent context', () => {
|
||||
describe('readFleetCommsBlock — situational (the context a spawned agent gets)', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mosaic-comms-'));
|
||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER);
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
writeFileSync(helper, '#!/bin/sh\n');
|
||||
chmodSync(helper, 0o755);
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
it('uses the authoritative self host and global socket from the shared roster resolver', () => {
|
||||
const result = readFleetCommsBlock(home, 'enhancer', 'process-host-must-not-win');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.output).toContain('Host: `w-jarvis`');
|
||||
expect(result.output).toContain('tmux socket: `mosaic-fleet`');
|
||||
expect(result.output).toContain('-L mosaic-fleet -s orchestrator');
|
||||
it('builds the cheat-sheet with correct peer addresses for a fleet member', () => {
|
||||
const block = readFleetCommsBlock(home, 'orchestrator', 'w-jarvis');
|
||||
expect(block).toContain('# Fleet Comms');
|
||||
expect(block).toContain('| enhancer |');
|
||||
expect(block).toContain(`${join(home, 'tools', 'tmux', 'agent-send.sh')} -s coder0 -m "…"`);
|
||||
expect(block).toContain('-H jwoltje@10.1.10.37 -s coder0-0');
|
||||
expect(block).not.toMatch(/\|\s*orchestrator\s*\|/); // self excluded
|
||||
});
|
||||
|
||||
it('fails closed for a requested fleet identity that is absent', () => {
|
||||
const result = readFleetCommsBlock(home, 'stranger', 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.output).toBe('');
|
||||
expect(result.error).toContain('Known exact names');
|
||||
});
|
||||
|
||||
it('resolves a supported JSON-only installed roster', () => {
|
||||
rmSync(join(home, 'fleet', 'roster.yaml'));
|
||||
writeFileSync(
|
||||
join(home, 'fleet', 'roster.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: { socket_name: 'mosaic-fleet' },
|
||||
agents: [
|
||||
{ name: 'enhancer', runtime: 'claude', class: 'enhancer', host: 'w-jarvis' },
|
||||
{ name: 'orchestrator', runtime: 'claude', class: 'orchestrator', host: 'w-jarvis' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const result = readFleetCommsBlock(home, 'enhancer', 'process-host-must-not-win');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.output).toContain('-L mosaic-fleet -s orchestrator');
|
||||
});
|
||||
|
||||
it('fails closed on a YAML I/O error instead of falling back to JSON', () => {
|
||||
rmSync(join(home, 'fleet', 'roster.yaml'));
|
||||
mkdirSync(join(home, 'fleet', 'roster.yaml'));
|
||||
writeFileSync(
|
||||
join(home, 'fleet', 'roster.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'enhancer', runtime: 'claude', class: 'enhancer' }],
|
||||
}),
|
||||
);
|
||||
const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('invalid fleet roster at');
|
||||
expect(result.error).toContain('roster.yaml');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', () => rmSync(join(home, 'tools', 'tmux', 'agent-send.sh'))],
|
||||
[
|
||||
'directory',
|
||||
() => {
|
||||
rmSync(join(home, 'tools', 'tmux', 'agent-send.sh'));
|
||||
mkdirSync(join(home, 'tools', 'tmux', 'agent-send.sh'));
|
||||
},
|
||||
],
|
||||
[
|
||||
'symlink',
|
||||
() => {
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
rmSync(helper);
|
||||
writeFileSync(join(home, 'real-send.sh'), '#!/bin/sh\n');
|
||||
symlinkSync(join(home, 'real-send.sh'), helper);
|
||||
},
|
||||
],
|
||||
['non-executable', () => chmodSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 0o644)],
|
||||
])('fails closed for a %s helper with deterministic repair guidance', (_case, mutate) => {
|
||||
mutate();
|
||||
const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.output).toBe('');
|
||||
expect(result.error).toContain('mosaic update --repair-tools');
|
||||
expect(result.error).toContain('no active context or session was rewritten');
|
||||
});
|
||||
|
||||
it('does not rewrite the roster while resolving context', () => {
|
||||
const path = join(home, 'fleet', 'roster.yaml');
|
||||
const before = readFileSync(path, 'utf8');
|
||||
readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
|
||||
expect(readFileSync(path, 'utf8')).toBe(before);
|
||||
it('returns empty when MOSAIC_AGENT_NAME is unset, no roster, or agent not a member', () => {
|
||||
expect(readFleetCommsBlock(home, undefined, 'w-jarvis')).toBe('');
|
||||
expect(readFleetCommsBlock(home, 'stranger', 'w-jarvis')).toBe('');
|
||||
expect(readFleetCommsBlock(mkdtempSync(join(tmpdir(), 'noroster-')), 'orchestrator')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderToolsContractStatus — non-mutating install drift', () => {
|
||||
let home: string;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mosaic-tools-status-'));
|
||||
mkdirSync(join(home, 'defaults'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, 'defaults', 'TOOLS.md'),
|
||||
'# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n',
|
||||
);
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
it('uses the supported repair command when installed TOOLS.md is missing', () => {
|
||||
const status = renderToolsContractStatus(home);
|
||||
expect(status).toContain('mosaic update --repair-tools');
|
||||
expect(status).not.toContain('--reseed');
|
||||
expect(status).toContain('authorized operator');
|
||||
});
|
||||
|
||||
it('reports stale preserved content without rewriting it', () => {
|
||||
const path = join(home, 'TOOLS.md');
|
||||
const stale = '# customized tools\n';
|
||||
writeFileSync(path, stale);
|
||||
const status = renderToolsContractStatus(home);
|
||||
expect(status).toContain('fleet-comms-contract: 1');
|
||||
expect(status).toContain('digest-qualified backup');
|
||||
expect(status).toContain('mosaic update --repair-tools');
|
||||
expect(status).toContain('active context was not rewritten');
|
||||
expect(readFileSync(path, 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('does not accept marker-only customized content as current', () => {
|
||||
const path = join(home, 'TOOLS.md');
|
||||
writeFileSync(path, '<!-- fleet-comms-contract: 1 -->\ncorrupt\n');
|
||||
expect(renderToolsContractStatus(home)).toContain('does not byte-match');
|
||||
});
|
||||
|
||||
it('rejects markerless byte-equal source and installed content', () => {
|
||||
const content = '# markerless but equal\n';
|
||||
writeFileSync(join(home, 'defaults', 'TOOLS.md'), content);
|
||||
writeFileSync(join(home, 'TOOLS.md'), content);
|
||||
const status = renderToolsContractStatus(home);
|
||||
expect(status).toContain('source contract');
|
||||
expect(status).toContain('does not declare the expected');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['source', join('defaults', 'TOOLS.md')],
|
||||
['installed', 'TOOLS.md'],
|
||||
])('rejects a wrong contract version in %s content', (_case, relativePath) => {
|
||||
const current = '# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), current);
|
||||
writeFileSync(join(home, 'defaults', 'TOOLS.md'), current);
|
||||
writeFileSync(join(home, relativePath), current.replace('contract: 1', 'contract: 2'));
|
||||
expect(renderToolsContractStatus(home)).not.toBe('');
|
||||
});
|
||||
|
||||
it('treats installed TOOLS.md symlinks as stale without following or rewriting them', () => {
|
||||
const external = join(home, 'external-tools.md');
|
||||
const externalContent = '# external\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
writeFileSync(external, externalContent);
|
||||
symlinkSync(external, join(home, 'TOOLS.md'));
|
||||
|
||||
const status = renderToolsContractStatus(home);
|
||||
|
||||
expect(status).toContain('unavailable');
|
||||
expect(status).toContain('mosaic update --repair-tools');
|
||||
expect(readFileSync(external, 'utf8')).toBe(externalContent);
|
||||
});
|
||||
|
||||
it('treats source TOOLS.md symlinks as unavailable without following them', () => {
|
||||
const external = join(home, 'external-source.md');
|
||||
const content = '# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
writeFileSync(external, content);
|
||||
rmSync(join(home, 'defaults', 'TOOLS.md'));
|
||||
symlinkSync(external, join(home, 'defaults', 'TOOLS.md'));
|
||||
writeFileSync(join(home, 'TOOLS.md'), content);
|
||||
|
||||
const status = renderToolsContractStatus(home);
|
||||
|
||||
expect(status).toContain('source contract');
|
||||
expect(status).toContain('unavailable');
|
||||
expect(readFileSync(external, 'utf8')).toBe(content);
|
||||
});
|
||||
|
||||
it('accepts byte-equal bounded source and installed contracts', () => {
|
||||
const source = readFileSync(join(home, 'defaults', 'TOOLS.md'), 'utf8');
|
||||
writeFileSync(join(home, 'TOOLS.md'), source);
|
||||
expect(renderToolsContractStatus(home)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCommsBlock — mosaic agent comms-block', () => {
|
||||
describe('resolveCommsBlock — `mosaic fleet comms-block <role>` emitter semantics', () => {
|
||||
// The emitter wraps readFleetCommsBlock but must NEVER print an empty string silently:
|
||||
// an unknown role / missing roster has to fail loud (caller maps !ok → stderr + exit 1)
|
||||
// so `mosaic fleet comms-block bogus` is a visible error, not a confusing no-op. The
|
||||
// success path returns the block verbatim for `mosaic fleet comms-block <peer>` previews.
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mosaic-commsblk-'));
|
||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER);
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
writeFileSync(helper, '#!/bin/sh\n');
|
||||
chmodSync(helper, 0o755);
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
it('returns the exact contract for a roster member', () => {
|
||||
const result = resolveCommsBlock(home, 'enhancer');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.output).toContain('Host: `w-jarvis`');
|
||||
expect(result.error).toBeUndefined();
|
||||
it('returns ok + the cheat-sheet for a roster member', () => {
|
||||
const res = resolveCommsBlock(home, 'orchestrator', 'w-jarvis');
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.output).toContain('# Fleet Comms');
|
||||
expect(res.output).toContain('| enhancer |');
|
||||
expect(res.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fails loud and lists known exact names for a non-member', () => {
|
||||
const result = resolveCommsBlock(home, 'stranger');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.output).toBe('');
|
||||
expect(result.error).toContain('stranger');
|
||||
expect(result.error).toContain('orchestrator');
|
||||
expect(result.error).toContain('enhancer');
|
||||
it('fails loud (not ok + error naming the role) for a non-member — never silently empty', () => {
|
||||
const res = resolveCommsBlock(home, 'stranger', 'w-jarvis');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.output).toBe('');
|
||||
expect(res.error).toContain('stranger');
|
||||
});
|
||||
|
||||
it('fails loud when no roster exists', () => {
|
||||
it('fails loud when no roster exists at the mosaic home', () => {
|
||||
const noRoster = mkdtempSync(join(tmpdir(), 'mosaic-noroster-'));
|
||||
mkdirSync(join(noRoster, 'tools', 'tmux'), { recursive: true });
|
||||
const helper = join(noRoster, 'tools', 'tmux', 'agent-send.sh');
|
||||
writeFileSync(helper, '#!/bin/sh\n');
|
||||
chmodSync(helper, 0o755);
|
||||
const result = resolveCommsBlock(noRoster, 'orchestrator');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('no fleet roster');
|
||||
const res = resolveCommsBlock(noRoster, 'orchestrator', 'w-jarvis');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toBeTruthy();
|
||||
rmSync(noRoster, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fails loud for a missing role argument', () => {
|
||||
const result = resolveCommsBlock(home, undefined);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('requires');
|
||||
const res = resolveCommsBlock(home, undefined, 'w-jarvis');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('honors a host override so a peer can preview its own cross-host view', () => {
|
||||
// coder0-0 viewing with its own host → its self-identity line uses that host.
|
||||
const res = resolveCommsBlock(home, 'coder0-0', '10.1.10.37');
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.output).toContain('`[10.1.10.37:coder0-0]`');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,423 +1,226 @@
|
||||
/**
|
||||
* Exact roster-resolved fleet communications contract (#766).
|
||||
* Fleet onboarding-injection (#620).
|
||||
*
|
||||
* The runtime composer and `mosaic fleet` command surface share the canonical
|
||||
* v1 roster resolver. This module never probes tmux, guesses an SSH target, or
|
||||
* mutates an active session.
|
||||
* Fleet agents are born not knowing how to reach their peers — the root cause of
|
||||
* a spawned agent's failed first send. When an agent boots via `mosaic yolo
|
||||
* <runtime>` (→ composeContract → system prompt), we append a comms cheat-sheet
|
||||
* + peer roster so it can talk to the orchestrator and other agents immediately.
|
||||
*
|
||||
* Cross-host aware: a peer may carry `host`/`ssh` (a deliberate pre-federation
|
||||
* stopgap — manual cross-host listing; federation/W1 auto-discovers later), so a
|
||||
* w-jarvis agent is born knowing the exact `-H` command to reach a dragon-lin
|
||||
* peer. Same-host peers render the short form.
|
||||
*
|
||||
* Standalone (no fleet.ts import) to keep launch.ts's prompt path free of the
|
||||
* heavy fleet command module. The roster is parsed leniently — the cheat-sheet
|
||||
* is best-effort onboarding, never a hard dependency.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { homedir, hostname } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { readRegularFileSecure } from './secure-file.js';
|
||||
import {
|
||||
parseFleetRosterV1,
|
||||
resolveInstalledFleetRosterPath,
|
||||
getRosterAgent,
|
||||
type FleetAgent,
|
||||
type FleetRoster,
|
||||
} from './fleet-roster-v1.js';
|
||||
|
||||
export interface CommsPeer {
|
||||
name: string;
|
||||
/** Roster `class` (orchestrator | enhancer | implementer | worker | …). */
|
||||
className: string;
|
||||
/** Host the peer runs on; absent ⇒ the fleet host (same host). */
|
||||
host?: string;
|
||||
/** SSH target (user@host) for a cross-host peer; renders the `-H` form. */
|
||||
ssh?: string;
|
||||
/** tmux socket the peer's session lives on; absent ⇒ default socket (no `-L`). */
|
||||
socket?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient parse of a fleet `roster.yaml` for agent name/class/host/ssh. Avoids a
|
||||
* dependency on the full fleet roster parser; the format is `- name:` list items
|
||||
* with `class:`/`host:`/`ssh:` siblings under `agents:`.
|
||||
*/
|
||||
export function parseRosterAgents(yamlText: string): CommsPeer[] {
|
||||
const peers: CommsPeer[] = [];
|
||||
let current: CommsPeer | null = null;
|
||||
let inAgents = false;
|
||||
const scalar = (line: string, key: string): string | null => {
|
||||
const m = line.match(new RegExp(`^\\s*${key}:\\s*["']?([^"'#]+?)["']?\\s*$`));
|
||||
return m ? (m[1] as string).trim() : null;
|
||||
};
|
||||
for (const rawLine of yamlText.split('\n')) {
|
||||
const line = rawLine.replace(/\s+$/, '');
|
||||
if (/^agents:\s*$/.test(line)) {
|
||||
inAgents = true;
|
||||
continue;
|
||||
}
|
||||
if (!inAgents) continue;
|
||||
// A new top-level key (no leading space) ends the agents block.
|
||||
if (/^\S/.test(line)) break;
|
||||
|
||||
const nameMatch = line.match(/^\s*-\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
|
||||
if (nameMatch) {
|
||||
if (current) peers.push(current);
|
||||
current = { name: nameMatch[1] as string, className: 'worker' };
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const cls = scalar(line, 'class');
|
||||
if (cls) current.className = cls;
|
||||
const host = scalar(line, 'host');
|
||||
if (host) current.host = host;
|
||||
const ssh = scalar(line, 'ssh');
|
||||
if (ssh) current.ssh = ssh;
|
||||
const socket = scalar(line, 'socket');
|
||||
if (socket) current.socket = socket;
|
||||
}
|
||||
if (current) peers.push(current);
|
||||
return peers;
|
||||
}
|
||||
|
||||
export interface FleetCommsOptions {
|
||||
/** Exact current roster member. */
|
||||
/** This agent's name (it is excluded from its own peer list). */
|
||||
selfName: string;
|
||||
/** Canonically resolved roster. */
|
||||
roster: FleetRoster;
|
||||
/** Stable fleet-host baseline for members whose roster host is absent. */
|
||||
localHost: string;
|
||||
/** Absolute helper path in this installation. */
|
||||
/** All roster agents (including self; filtered out internally). */
|
||||
agents: CommsPeer[];
|
||||
/** Host the fleet runs on (short hostname) — the same-host baseline. */
|
||||
fleetHost: string;
|
||||
/** Absolute path to agent-send.sh in this install. */
|
||||
agentSendPath: string;
|
||||
}
|
||||
|
||||
export interface CommsBlockResult {
|
||||
ok: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedFleetIdentity {
|
||||
readonly roster: FleetRoster;
|
||||
readonly member: FleetAgent;
|
||||
readonly requestedName: string;
|
||||
readonly agentSendPath: string;
|
||||
readonly localHost: string;
|
||||
}
|
||||
|
||||
export interface FleetIdentityResult {
|
||||
ok: boolean;
|
||||
identity?: ResolvedFleetIdentity;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PeerCommandResult {
|
||||
ok: boolean;
|
||||
command: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const FLEET_COMMS_TOOLS_CONTRACT = 'fleet-comms-contract: 1';
|
||||
const MAX_TOOLS_CONTRACT_BYTES = 256 * 1024;
|
||||
|
||||
function shortHostname(): string {
|
||||
return hostname().split('.')[0] || 'localhost';
|
||||
}
|
||||
|
||||
function resolvedHost(agent: FleetAgent, fleetHost: string): string {
|
||||
return agent.host ?? fleetHost;
|
||||
}
|
||||
|
||||
function displaySocket(socket: string): string {
|
||||
return socket || '(default)';
|
||||
}
|
||||
|
||||
function knownNames(roster: FleetRoster, except?: string): string {
|
||||
return roster.agents
|
||||
.filter((agent) => agent.name !== except)
|
||||
.map((agent) => agent.name)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function missingMemberError(roster: FleetRoster, selfName: string): string {
|
||||
return `Agent "${selfName}" is not in the fleet roster. Known exact names: ${knownNames(roster)}. Select an exact roster name; do not infer or fuzzy-match a tmux session.`;
|
||||
}
|
||||
|
||||
/** Render one shell argument without changing already-safe exact values. */
|
||||
function shellArg(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:@=+-]+$/.test(value)) return value;
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
/** Render the exact command for one peer. Throws rather than guessing. */
|
||||
export function renderPeerReach(
|
||||
peer: FleetAgent,
|
||||
selfHost: string,
|
||||
fleetHost: string,
|
||||
rosterSocket: string,
|
||||
agentSendPath: string,
|
||||
): string {
|
||||
const parts = [shellArg(agentSendPath)];
|
||||
if (rosterSocket) parts.push('-L', shellArg(rosterSocket));
|
||||
|
||||
const peerHost = resolvedHost(peer, fleetHost);
|
||||
if (peerHost !== selfHost) {
|
||||
if (!peer.ssh) {
|
||||
throw new Error(
|
||||
`Cross-host peer "${peer.name}" (${peerHost}) requires an explicit roster ssh target; refusing to substitute its host value.`,
|
||||
);
|
||||
}
|
||||
parts.push('-H', shellArg(peer.ssh));
|
||||
}
|
||||
parts.push('-s', shellArg(peer.name), '-m', '"…"');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/** Resolve one requested peer without fuzzy lookup. */
|
||||
export function resolvePeerCommand(
|
||||
roster: FleetRoster,
|
||||
selfName: string,
|
||||
peerName: string,
|
||||
localHost: string,
|
||||
agentSendPath: string,
|
||||
): PeerCommandResult {
|
||||
const self = roster.agents.find((agent) => agent.name === selfName);
|
||||
if (!self) return { ok: false, command: '', error: missingMemberError(roster, selfName) };
|
||||
const peer = roster.agents.find((agent) => agent.name === peerName && agent.name !== selfName);
|
||||
if (!peer) {
|
||||
return {
|
||||
ok: false,
|
||||
command: '',
|
||||
error:
|
||||
`Peer "${peerName}" is absent from the fleet roster. Known exact peer names: ${knownNames(roster, selfName)}. ` +
|
||||
`Run \`mosaic agent comms-block ${selfName}\` to rediscover exact rendered rows; do not infer or fuzzy-match a tmux session.`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
command: renderPeerReach(
|
||||
peer,
|
||||
resolvedHost(self, localHost),
|
||||
localHost,
|
||||
roster.tmux.socketName,
|
||||
agentSendPath,
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
command: '',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface ResolvedRow {
|
||||
readonly peer: FleetAgent;
|
||||
readonly host: string;
|
||||
readonly socket: string;
|
||||
readonly command: string;
|
||||
}
|
||||
|
||||
function resolveRows(opts: FleetCommsOptions, self: FleetAgent): readonly ResolvedRow[] {
|
||||
const selfHost = resolvedHost(self, opts.localHost);
|
||||
return opts.roster.agents
|
||||
.filter((agent) => agent.name !== opts.selfName)
|
||||
.map(
|
||||
(peer): ResolvedRow => ({
|
||||
peer,
|
||||
host: resolvedHost(peer, opts.localHost),
|
||||
socket: opts.roster.tmux.socketName,
|
||||
command: renderPeerReach(
|
||||
peer,
|
||||
selfHost,
|
||||
opts.localHost,
|
||||
opts.roster.tmux.socketName,
|
||||
opts.agentSendPath,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function commsGeneration(
|
||||
self: FleetAgent,
|
||||
selfHost: string,
|
||||
selfSocket: string,
|
||||
helper: string,
|
||||
rows: readonly ResolvedRow[],
|
||||
): string {
|
||||
const canonical = JSON.stringify({
|
||||
self: { ...self, resolvedHost: selfHost, resolvedSocket: selfSocket, helper },
|
||||
peers: rows.map((row) => ({
|
||||
...row.peer,
|
||||
resolvedHost: row.host,
|
||||
resolvedSocket: row.socket,
|
||||
exactCommand: row.command,
|
||||
})),
|
||||
});
|
||||
return createHash('sha256').update(canonical).digest('hex');
|
||||
}
|
||||
|
||||
/** Build the authoritative Markdown contract for one exact roster member. */
|
||||
export function buildFleetCommsBlock(opts: FleetCommsOptions): string {
|
||||
const self = opts.roster.agents.find((agent) => agent.name === opts.selfName);
|
||||
if (!self) throw new Error(missingMemberError(opts.roster, opts.selfName));
|
||||
const rows = resolveRows(opts, self);
|
||||
const selfHost = resolvedHost(self, opts.localHost);
|
||||
const selfSocket = opts.roster.tmux.socketName;
|
||||
const generation = commsGeneration(self, selfHost, selfSocket, opts.agentSendPath, rows);
|
||||
const orchestrator = rows.find((row) => row.peer.className === 'orchestrator');
|
||||
const peerSection =
|
||||
rows.length === 0
|
||||
? 'This roster has no peers. Do not invent a target.'
|
||||
: `| Agent | Role | Host | Socket | Exact command |
|
||||
| ----- | ---- | ---- | ------ | ------------- |
|
||||
${rows
|
||||
.map((row) => {
|
||||
const pointOfContact = row.peer.className === 'orchestrator' ? ' ← point of contact' : '';
|
||||
return `| ${row.peer.name} | ${row.peer.className}${pointOfContact} | ${row.host} | ${displaySocket(row.socket)} | \`${row.command}\` |`;
|
||||
})
|
||||
.join('\n')}`;
|
||||
const contact = orchestrator
|
||||
? `Your point of contact is **${orchestrator.peer.name}**. Select that exact peer row for status, questions, and decisions.`
|
||||
: rows.length === 0
|
||||
? 'No peer coordination target exists in this roster.'
|
||||
: 'This roster has no orchestrator. Select an exact peer row for coordination.';
|
||||
const soloAuthority =
|
||||
rows.length === 0
|
||||
? `\n## Solo authority boundaries\n\nThis member is normalized as role/class **${self.className}**. The roster grants no peer, orchestrator, or remote communication authority. Do not send, infer a target, or claim fleet coordination until an exact peer is added to the canonical roster and this block is recomposed.\n`
|
||||
: '';
|
||||
|
||||
return `# Fleet Comms — authoritative exact targets
|
||||
|
||||
## Local identity
|
||||
|
||||
- Host: \`${selfHost}\`
|
||||
- Agent/session: \`${self.name}\`
|
||||
- Role/class: \`${self.className}\`
|
||||
- tmux socket: \`${displaySocket(selfSocket)}\`
|
||||
- Helper: \`${opts.agentSendPath}\`
|
||||
- Comms generation: \`${generation}\`
|
||||
|
||||
The roster-resolved rows below are the only valid operational targets. Select the row whose Agent value
|
||||
exactly matches the requested peer. Never invent, substitute, or fuzzy-match host, session, socket, SSH,
|
||||
or helper-path values. If the peer is absent, stop and run \`mosaic agent comms-block ${self.name}\` to
|
||||
rediscover this exact member's rows; if it is still absent, report the unknown peer.
|
||||
|
||||
## Peers
|
||||
|
||||
${peerSection}
|
||||
|
||||
${contact}
|
||||
${soloAuthority}
|
||||
## Context freshness
|
||||
|
||||
This block is a snapshot; Mosaic does not rewrite an active agent's context. Compare its Comms generation
|
||||
with fresh output from \`mosaic agent comms-block ${self.name}\`. If they differ, report stale composed
|
||||
context and have an authorized operator relaunch only this exact roster member with
|
||||
\`mosaic fleet restart ${self.name}\`. Do not restart or mutate a session automatically.`;
|
||||
}
|
||||
|
||||
function validateAgentSendHelper(path: string, mosaicHome: string): string | undefined {
|
||||
try {
|
||||
readRegularFileSecure(path, { root: mosaicHome, executable: true });
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return `helper is unavailable or unsafe: ${path} (${reason})`;
|
||||
}
|
||||
}
|
||||
|
||||
function helperFailureGuidance(reason: string): string {
|
||||
return `${reason}. Run \`mosaic update --repair-tools\` to restore the supported current-version helper and TOOLS contract, then retry exact-member composition; no active context or session was rewritten.`;
|
||||
}
|
||||
|
||||
export function resolveFleetIdentity(
|
||||
mosaicHome: string,
|
||||
requestedName: string | undefined,
|
||||
localHost: string = shortHostname(),
|
||||
): FleetIdentityResult {
|
||||
if (!requestedName) return { ok: true };
|
||||
const agentSendPath = join(mosaicHome, 'tools', 'tmux', 'agent-send.sh');
|
||||
const helperError = validateAgentSendHelper(agentSendPath, mosaicHome);
|
||||
if (helperError) return { ok: false, error: helperFailureGuidance(helperError) };
|
||||
|
||||
let rosterPath: string;
|
||||
try {
|
||||
rosterPath = resolveInstalledFleetRosterPath(mosaicHome);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `cannot inspect fleet roster.yaml: ${error instanceof Error ? error.message : String(error)}; refusing JSON fallback because fallback is allowed only when YAML is absent`,
|
||||
};
|
||||
}
|
||||
if (!existsSync(rosterPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `no fleet roster at ${join(mosaicHome, 'fleet', 'roster.yaml')} or ${join(mosaicHome, 'fleet', 'roster.json')}`,
|
||||
};
|
||||
}
|
||||
|
||||
let roster: FleetRoster;
|
||||
try {
|
||||
roster = parseFleetRosterV1(
|
||||
readRegularFileSecure(rosterPath, { root: mosaicHome }).content.toString('utf8'),
|
||||
rosterPath.endsWith('.json') ? 'json' : 'yaml',
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `invalid fleet roster at ${rosterPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
identity: {
|
||||
roster,
|
||||
member: getRosterAgent(roster, requestedName),
|
||||
requestedName,
|
||||
agentSendPath,
|
||||
localHost,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, error: missingMemberError(roster, requestedName) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Render Fleet Comms from one already-resolved canonical member identity. */
|
||||
export function buildResolvedFleetCommsBlock(identity: ResolvedFleetIdentity): string {
|
||||
return buildFleetCommsBlock({
|
||||
selfName: identity.member.name,
|
||||
roster: identity.roster,
|
||||
localHost: identity.localHost,
|
||||
agentSendPath: identity.agentSendPath,
|
||||
});
|
||||
/** Is this peer on a different host than the fleet baseline? */
|
||||
function isRemote(peer: CommsPeer, fleetHost: string): boolean {
|
||||
return peer.host !== undefined && peer.host !== fleetHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and resolve the installed roster for runtime composition. A requested
|
||||
* fleet identity fails closed; only a genuinely non-fleet launch (no selfName)
|
||||
* is a quiet no-op.
|
||||
* Render the exact agent-send command to reach a peer (session = agent name).
|
||||
* Data-driven per peer: a named `socket` → `-L <socket>`; an unset socket → the
|
||||
* default tmux socket (no `-L`). A cross-host peer adds `-H <ssh|host>`.
|
||||
*/
|
||||
export function renderPeerReach(peer: CommsPeer, fleetHost: string, agentSendPath: string): string {
|
||||
const parts = [agentSendPath];
|
||||
if (peer.socket) parts.push('-L', peer.socket); // unset ⇒ default socket, no -L
|
||||
if (isRemote(peer, fleetHost)) parts.push('-H', peer.ssh ?? (peer.host as string));
|
||||
parts.push('-s', peer.name, '-m', '"…"');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `# Fleet Comms` onboarding block (pure markdown). Returns '' when
|
||||
* the agent has no peers (a single-agent roster has no one to talk to).
|
||||
*/
|
||||
export function buildFleetCommsBlock(opts: FleetCommsOptions): string {
|
||||
const peers = opts.agents.filter((a) => a.name !== opts.selfName);
|
||||
if (peers.length === 0) return '';
|
||||
|
||||
const orchestrator = peers.find((p) => p.className === 'orchestrator');
|
||||
const rows = peers
|
||||
.map((p) => {
|
||||
const where = isRemote(p, opts.fleetHost)
|
||||
? `${p.className} · host \`${p.host}\``
|
||||
: p.className;
|
||||
const role = p.className === 'orchestrator' ? `${where} ← point of contact` : where;
|
||||
return `| ${p.name} | ${role} | \`${renderPeerReach(p, opts.fleetHost, opts.agentSendPath)}\` |`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const orchLine = orchestrator
|
||||
? `Your point of contact is **${orchestrator.name}** (the orchestrator) — route questions, ` +
|
||||
`status, and decisions there.`
|
||||
: `This fleet has no orchestrator in its roster; coordinate with your peers directly.`;
|
||||
|
||||
return `# Fleet Comms — reach your peers
|
||||
|
||||
You are **${opts.selfName}** in this fleet. Your comms identity is \`[${opts.fleetHost}:${opts.selfName}]\` —
|
||||
that is the \`<src>\` other agents see and reply to. Reach other agents (durable tmux sessions) with the
|
||||
Mosaic comms tool at \`${opts.agentSendPath}\`. The **Reach** column below is the exact command per peer:
|
||||
same-host peers use the short form (no \`-H\`); cross-host peers include \`-H <user@host>\`.
|
||||
|
||||
## Peers
|
||||
|
||||
| Agent | Role | Reach (session = agent name) |
|
||||
| ----- | ---- | ---------------------------- |
|
||||
${rows}
|
||||
|
||||
${orchLine}
|
||||
|
||||
## Conventions
|
||||
|
||||
- Every message carries a self-identifying preamble \`[<src_host>:<src_session> -> <dst_host>:<dst_session>]\` — \`agent-send.sh\` adds it automatically.
|
||||
- **To reply, FLIP the preamble:** address your reply to the sender's \`src\` (their host:session becomes your \`-s\`/\`-H\`).
|
||||
- \`agent-send.sh\` (a.k.a. \`agent send --verify\`) confirms the message was **ACCEPTED** at the destination prompt — not merely injected. Prefer it for anything that matters.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the fleet roster from `mosaicHome` and build the comms block for
|
||||
* `selfName`. Returns '' when there is no roster, the agent is not in it, or
|
||||
* there are no peers — onboarding is best-effort and never throws.
|
||||
*/
|
||||
export function readFleetCommsBlock(
|
||||
mosaicHome: string,
|
||||
selfName: string | undefined,
|
||||
localHost: string = shortHostname(),
|
||||
): CommsBlockResult {
|
||||
const resolved = resolveFleetIdentity(mosaicHome, selfName, localHost);
|
||||
if (!resolved.ok) return { ok: false, output: '', error: resolved.error };
|
||||
if (!resolved.identity) return { ok: true, output: '' };
|
||||
fleetHost: string = hostname().split('.')[0] || 'localhost',
|
||||
): string {
|
||||
if (!selfName) return '';
|
||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
if (!existsSync(rosterPath)) return '';
|
||||
let text: string;
|
||||
try {
|
||||
return { ok: true, output: buildResolvedFleetCommsBlock(resolved.identity) };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
output: '',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
text = readFileSync(rosterPath, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
const agents = parseRosterAgents(text);
|
||||
if (!agents.some((a) => a.name === selfName)) return ''; // not a member of this fleet
|
||||
return buildFleetCommsBlock({
|
||||
selfName,
|
||||
agents,
|
||||
fleetHost,
|
||||
agentSendPath: join(mosaicHome, 'tools', 'tmux', 'agent-send.sh'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Backing resolver for `mosaic agent comms-block <exact-member>`. */
|
||||
/** Result of resolving a comms-block emit request — see `mosaic fleet comms-block`. */
|
||||
export interface CommsBlockResult {
|
||||
/** True when a cheat-sheet was produced; false maps to stderr + non-zero exit. */
|
||||
ok: boolean;
|
||||
/** The Fleet-Comms cheat-sheet (empty unless ok). */
|
||||
output: string;
|
||||
/** Operator-facing reason when !ok. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Fleet-Comms cheat-sheet for an explicit <role>, backing the
|
||||
* `mosaic fleet comms-block <role>` command. Unlike readFleetCommsBlock — which
|
||||
* returns '' on any miss so composeContract can no-op silently during a launch —
|
||||
* this NEVER silently emits empty: an unknown role or missing roster yields
|
||||
* ok:false + an operator-facing reason, so the CLI surfaces it (stderr + exit 1)
|
||||
* rather than printing nothing. That makes it safe to preview any peer's view,
|
||||
* e.g. `mosaic fleet comms-block coder0-0`.
|
||||
*/
|
||||
export function resolveCommsBlock(
|
||||
mosaicHome: string,
|
||||
exactMember: string | undefined,
|
||||
role: string | undefined,
|
||||
fleetHost?: string,
|
||||
): CommsBlockResult {
|
||||
if (!exactMember) {
|
||||
if (!role) {
|
||||
return { ok: false, output: '', error: 'comms-block requires a <role> argument' };
|
||||
}
|
||||
const block = fleetHost
|
||||
? readFleetCommsBlock(mosaicHome, role, fleetHost)
|
||||
: readFleetCommsBlock(mosaicHome, role);
|
||||
if (!block) {
|
||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
return {
|
||||
ok: false,
|
||||
output: '',
|
||||
error: 'comms-block requires an exact <exact-member> argument',
|
||||
error: existsSync(rosterPath)
|
||||
? `role "${role}" is not a member of the fleet roster at ${rosterPath}`
|
||||
: `no fleet roster at ${rosterPath}`,
|
||||
};
|
||||
}
|
||||
return readFleetCommsBlock(mosaicHome, exactMember);
|
||||
}
|
||||
|
||||
function expectedContractVersion(content: Buffer | string): boolean {
|
||||
return content.toString().includes(`<!-- ${FLEET_COMMS_TOOLS_CONTRACT} -->`);
|
||||
}
|
||||
|
||||
function boundedContractDigest(
|
||||
path: string,
|
||||
mosaicHome: string,
|
||||
): { digest?: string; versionOk: boolean } {
|
||||
try {
|
||||
const content = readRegularFileSecure(path, {
|
||||
root: mosaicHome,
|
||||
maxBytes: MAX_TOOLS_CONTRACT_BYTES,
|
||||
}).content;
|
||||
return {
|
||||
digest: createHash('sha256').update(content).digest('hex'),
|
||||
versionOk: expectedContractVersion(content),
|
||||
};
|
||||
} catch {
|
||||
return { versionOk: false };
|
||||
}
|
||||
}
|
||||
|
||||
function replacementGuidance(): string {
|
||||
return `Run \`mosaic update --repair-tools\` to make a digest-qualified backup and restore the supported current-version TOOLS contract, then have an authorized operator explicitly relaunch the exact roster member. The active context was not rewritten.`;
|
||||
}
|
||||
|
||||
/** Detect preserved installed TOOLS.md drift without changing it. */
|
||||
export function renderToolsContractStatus(mosaicHome: string): string {
|
||||
const installedPath = join(mosaicHome, 'TOOLS.md');
|
||||
const sourcePath = join(mosaicHome, 'defaults', 'TOOLS.md');
|
||||
if (!existsSync(installedPath)) {
|
||||
return `# Fleet Comms Installation Status\n\nInstalled TOOLS.md is missing at \`${installedPath}\`. ${replacementGuidance()}`;
|
||||
}
|
||||
|
||||
const installed = boundedContractDigest(installedPath, mosaicHome);
|
||||
const source = boundedContractDigest(sourcePath, mosaicHome);
|
||||
if (!source.digest || !source.versionOk) {
|
||||
return `# Fleet Comms Installation Status\n\nThe bounded framework source contract at \`${sourcePath}\` is unavailable or does not declare the expected \`${FLEET_COMMS_TOOLS_CONTRACT}\` version. Run \`mosaic update\` to restore framework source data, verify again, then have an authorized operator explicitly relaunch the exact roster member. The installed file and active context were not rewritten.`;
|
||||
}
|
||||
if (installed.versionOk && installed.digest === source.digest) return '';
|
||||
|
||||
return `# Fleet Comms Installation Status\n\nInstalled TOOLS.md is unavailable, has the wrong contract version, or does not byte-match the bounded framework source contract \`${FLEET_COMMS_TOOLS_CONTRACT}\`. ${replacementGuidance()}`;
|
||||
return { ok: true, output: block };
|
||||
}
|
||||
|
||||
/** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */
|
||||
export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');
|
||||
|
||||
@@ -1,440 +0,0 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
acquirePrivateReconcileLock,
|
||||
FleetReconcileError,
|
||||
executeFleetReconcile,
|
||||
type FleetReconcileCommand,
|
||||
type FleetReconcileDeps,
|
||||
} from './fleet-reconciler.js';
|
||||
import type { FleetRosterV2 } from './roster-v2.js';
|
||||
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
async function lockHome(): Promise<string> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-reconcile-lock-'));
|
||||
const fleet = join(cleanup, 'fleet');
|
||||
await mkdir(fleet, { mode: 0o700 });
|
||||
await chmod(cleanup, 0o700);
|
||||
await chmod(fleet, 0o700);
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
const roster: FleetRosterV2 = {
|
||||
version: 2,
|
||||
generation: 7,
|
||||
transport: 'tmux',
|
||||
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
|
||||
defaults: { workingDirectory: '/srv/mosaic', runtime: 'pi' },
|
||||
runtimes: { pi: { resetCommand: '/new' } },
|
||||
agents: [
|
||||
{
|
||||
name: 'coder0',
|
||||
alias: 'Coder 0',
|
||||
className: 'code',
|
||||
runtime: 'pi',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.6-sol',
|
||||
reasoning: 'high',
|
||||
toolPolicy: 'code',
|
||||
workingDirectory: '/srv/mosaic',
|
||||
persistentPersona: false,
|
||||
resetBetweenTasks: true,
|
||||
lifecycle: { enabled: true, desiredState: 'stopped' },
|
||||
launch: { yolo: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function deps(overrides: Partial<FleetReconcileDeps> = {}): FleetReconcileDeps {
|
||||
return {
|
||||
runner: async (command, args) => {
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return {
|
||||
stdout:
|
||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
homeDirectory: '/home/mosaic',
|
||||
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
||||
validateRoster: async () => undefined,
|
||||
prepareProjections: async () => [{ agentName: 'coder0' }],
|
||||
applyProjection: async () => undefined,
|
||||
readRoster: async () => roster,
|
||||
acquireMutationLock: async () => async () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function run(command: FleetReconcileCommand, overrides: Partial<FleetReconcileDeps> = {}) {
|
||||
return executeFleetReconcile({
|
||||
roster,
|
||||
command,
|
||||
...(command === 'status' || command === 'verify' || command === 'doctor'
|
||||
? {}
|
||||
: { expectedGeneration: 7 }),
|
||||
deps: deps({ readRoster: async () => roster, ...overrides }),
|
||||
});
|
||||
}
|
||||
|
||||
describe('fleet roster-owned reconciler', (): void => {
|
||||
it('fails closed on a symlinked fleet ancestor without touching its target', async (): Promise<void> => {
|
||||
const home = await lockHome();
|
||||
const fleet = join(home, 'fleet');
|
||||
const attacker = await mkdtemp(join(tmpdir(), 'mosaic-reconcile-attacker-'));
|
||||
await writeFile(join(attacker, 'sentinel'), 'unchanged\n', { mode: 0o600 });
|
||||
try {
|
||||
await rm(fleet, { recursive: true });
|
||||
await symlink(attacker, fleet, 'dir');
|
||||
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||
code: 'unsafe-managed-path',
|
||||
});
|
||||
expect(await readFile(join(attacker, 'sentinel'), 'utf8')).toBe('unchanged\n');
|
||||
} finally {
|
||||
await rm(attacker, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unsafe ancestors, symlink leaves, EEXIST, and non-EEXIST lock creation failures', async (): Promise<void> => {
|
||||
const home = await lockHome();
|
||||
const fleet = join(home, 'fleet');
|
||||
const lockPath = join(fleet, 'roster.yaml.reconcile.lock');
|
||||
|
||||
await chmod(fleet, 0o770);
|
||||
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||
code: 'unsafe-managed-path',
|
||||
});
|
||||
await chmod(fleet, 0o700);
|
||||
|
||||
const target = join(home, 'target');
|
||||
await writeFile(target, 'target\n', { mode: 0o600 });
|
||||
await symlink(target, lockPath);
|
||||
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||
code: 'unsafe-lock',
|
||||
});
|
||||
await rm(lockPath);
|
||||
|
||||
await writeFile(lockPath, 'other\n', { mode: 0o600 });
|
||||
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||
code: 'concurrent-mutation',
|
||||
});
|
||||
await rm(lockPath);
|
||||
|
||||
const ioFailure = Object.assign(new Error('injected I/O failure'), { code: 'EIO' });
|
||||
await expect(
|
||||
acquirePrivateReconcileLock(home, async () => Promise.reject(ioFailure))(),
|
||||
).rejects.toMatchObject({ code: 'lock-io-failed' });
|
||||
});
|
||||
|
||||
it('does not unlink a replacement lock and normally releases its own lock', async (): Promise<void> => {
|
||||
const home = await lockHome();
|
||||
const lockPath = join(home, 'fleet', 'roster.yaml.reconcile.lock');
|
||||
const release = await acquirePrivateReconcileLock(home)();
|
||||
await rm(lockPath);
|
||||
await writeFile(lockPath, 'replacement\n', { mode: 0o600 });
|
||||
await expect(release()).rejects.toMatchObject({ code: 'lock-cleanup-failed' });
|
||||
expect(await readFile(lockPath, 'utf8')).toBe('replacement\n');
|
||||
|
||||
await rm(lockPath);
|
||||
const normalRelease = await acquirePrivateReconcileLock(home)();
|
||||
await normalRelease();
|
||||
await expect(readFile(lockPath, 'utf8')).rejects.toThrow();
|
||||
});
|
||||
it('requires a generation before any mutating preflight or effect', async (): Promise<void> => {
|
||||
let effects = 0;
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster,
|
||||
command: 'apply',
|
||||
deps: deps({
|
||||
validateRoster: async () => {
|
||||
effects += 1;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'missing-generation' });
|
||||
expect(effects).toBe(0);
|
||||
});
|
||||
|
||||
it('fences mutation against the canonical roster reread under lock', async (): Promise<void> => {
|
||||
let effects = 0;
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 7,
|
||||
deps: deps({
|
||||
readRoster: async () => ({ ...roster, generation: 8 }),
|
||||
runner: async () => {
|
||||
effects += 1;
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
applyProjection: async () => {
|
||||
effects += 1;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'stale-generation' });
|
||||
expect(effects).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects stale generation before projection or lifecycle effects', async (): Promise<void> => {
|
||||
let effects = 0;
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 6,
|
||||
deps: deps({
|
||||
validateRoster: async () => {
|
||||
effects += 1;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'stale-generation' });
|
||||
expect(effects).toBe(0);
|
||||
});
|
||||
|
||||
it('denies a concurrent mutation lock without effects and leaves observations lock-free', async (): Promise<void> => {
|
||||
let effects = 0;
|
||||
const busy = async (): Promise<() => Promise<void>> => {
|
||||
throw new FleetReconcileError('concurrent-mutation', 'busy');
|
||||
};
|
||||
await expect(
|
||||
run('apply', {
|
||||
acquireMutationLock: busy,
|
||||
applyProjection: async () => {
|
||||
effects += 1;
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'concurrent-mutation' });
|
||||
expect(effects).toBe(0);
|
||||
|
||||
await expect(run('doctor', { acquireMutationLock: busy })).resolves.toMatchObject({
|
||||
applied: false,
|
||||
lifecycle: 'not-applied',
|
||||
});
|
||||
});
|
||||
|
||||
it('always releases the mutation lock after success and partial failure', async (): Promise<void> => {
|
||||
let releases = 0;
|
||||
const lock = async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
releases += 1;
|
||||
};
|
||||
await run('apply', { acquireMutationLock: lock });
|
||||
await run('apply', {
|
||||
acquireMutationLock: lock,
|
||||
applyProjection: async () => {
|
||||
throw new Error('injected failure');
|
||||
},
|
||||
});
|
||||
expect(releases).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves stopped desired state during apply', async (): Promise<void> => {
|
||||
const calls: string[][] = [];
|
||||
const result = await run('apply', {
|
||||
runner: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return {
|
||||
stdout:
|
||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.lifecycle).toBe('complete');
|
||||
expect(calls).not.toContainEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'start',
|
||||
'mosaic-agent@coder0.service',
|
||||
]);
|
||||
});
|
||||
|
||||
it('starts only an explicitly running roster agent with exact systemd targets', async (): Promise<void> => {
|
||||
const calls: string[][] = [];
|
||||
const runningRoster: FleetRosterV2 = {
|
||||
...roster,
|
||||
agents: [{ ...roster.agents[0]!, lifecycle: { enabled: true, desiredState: 'running' } }],
|
||||
};
|
||||
const result = await executeFleetReconcile({
|
||||
roster: runningRoster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 7,
|
||||
deps: deps({
|
||||
readRoster: async () => runningRoster,
|
||||
runner: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '', stderr: '', exitCode: 1 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.lifecycle).toBe('complete');
|
||||
expect(calls).toContainEqual(['systemctl', '--user', 'start', 'mosaic-tmux-holder.service']);
|
||||
expect(calls).toContainEqual(['systemctl', '--user', 'start', 'mosaic-agent@coder0.service']);
|
||||
});
|
||||
|
||||
it('rejects a fake holder with contaminated global state before projection application', async (): Promise<void> => {
|
||||
let projectionApplied = false;
|
||||
await expect(
|
||||
run('apply', {
|
||||
runner: async (command, args) => {
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return { stdout: 'MOSAIC_FLEET_OWNER=forged\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
applyProjection: async () => {
|
||||
projectionApplied = true;
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'ownership-mismatch' });
|
||||
expect(projectionApplied).toBe(false);
|
||||
});
|
||||
|
||||
it('reports but never adopts unmanaged sessions', async (): Promise<void> => {
|
||||
await expect(
|
||||
run('apply', {
|
||||
runner: async (command, args) => {
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\nother\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return {
|
||||
stdout:
|
||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'unmanaged-session' } satisfies Partial<FleetReconcileError>);
|
||||
});
|
||||
|
||||
it('rejects remote inventory before a local lifecycle command is constructed', async (): Promise<void> => {
|
||||
const calls: string[][] = [];
|
||||
const remoteRoster = {
|
||||
...roster,
|
||||
agents: [{ ...roster.agents[0]!, remote: { host: 'inventory-only' } }],
|
||||
} as unknown as FleetRosterV2;
|
||||
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster: remoteRoster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 7,
|
||||
deps: deps({
|
||||
readRoster: async () => remoteRoster,
|
||||
runner: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'lifecycle-precondition-failed' });
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('plans without applying projections or lifecycle effects', async (): Promise<void> => {
|
||||
let applied = false;
|
||||
const result = await run('plan', {
|
||||
applyProjection: async () => {
|
||||
applied = true;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.lifecycle).toBe('not-applied');
|
||||
expect(applied).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a truthful partial result if projection application fails', async (): Promise<void> => {
|
||||
const result = await run('apply', {
|
||||
applyProjection: async () => {
|
||||
throw new Error('injected failure');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
applied: false,
|
||||
projections: 'incomplete',
|
||||
lifecycle: 'not-applied',
|
||||
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
|
||||
});
|
||||
});
|
||||
|
||||
it('adds cleanup diagnostics without masking projection or lifecycle partial truth', async (): Promise<void> => {
|
||||
const failingRelease = async (): Promise<never> => {
|
||||
throw new FleetReconcileError('lock-cleanup-failed', 'injected');
|
||||
};
|
||||
const projectionPartial = await run('apply', {
|
||||
applyProjection: async () => {
|
||||
throw new Error('injected projection failure');
|
||||
},
|
||||
acquireMutationLock: async () => failingRelease,
|
||||
});
|
||||
expect(projectionPartial).toMatchObject({
|
||||
projections: 'incomplete',
|
||||
lifecycle: 'not-applied',
|
||||
recovery: { code: 'projection-apply-failed' },
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
});
|
||||
|
||||
const lifecyclePartial = await run('apply', {
|
||||
runner: async () => ({ stdout: '', stderr: '', exitCode: 1 }),
|
||||
acquireMutationLock: async () => failingRelease,
|
||||
});
|
||||
expect(lifecyclePartial).toMatchObject({
|
||||
projections: 'complete',
|
||||
lifecycle: 'incomplete',
|
||||
recovery: { code: 'lifecycle-apply-failed' },
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
});
|
||||
});
|
||||
|
||||
it('adds cleanup diagnostics after successful effects without changing effect completion', async (): Promise<void> => {
|
||||
const result = await run('apply', {
|
||||
acquireMutationLock: async () => async () => {
|
||||
throw new FleetReconcileError('lock-cleanup-failed', 'injected');
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
applied: true,
|
||||
projections: 'complete',
|
||||
lifecycle: 'complete',
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,785 +0,0 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { lstat, open, readFile, unlink, type FileHandle } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
applyPreparedAgentEnvironmentProjection,
|
||||
prepareAgentEnvironmentProjection,
|
||||
type PreparedAgentEnvironmentProjection,
|
||||
} from './generated-env-boundary.js';
|
||||
import {
|
||||
validateRosterV2Semantics,
|
||||
type FleetRosterV2,
|
||||
type FleetRosterV2Agent,
|
||||
} from './roster-v2.js';
|
||||
|
||||
export type FleetReconcileCommand =
|
||||
| 'plan'
|
||||
| 'apply'
|
||||
| 'reconcile'
|
||||
| 'start'
|
||||
| 'stop'
|
||||
| 'restart'
|
||||
| 'status'
|
||||
| 'verify'
|
||||
| 'doctor';
|
||||
|
||||
export interface FleetReconcileCommandResult {
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly exitCode: number;
|
||||
}
|
||||
|
||||
export type FleetReconcileRunner = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
) => Promise<FleetReconcileCommandResult>;
|
||||
|
||||
export interface FleetReconcileDeps {
|
||||
readonly runner: FleetReconcileRunner;
|
||||
readonly mosaicHome?: string;
|
||||
readonly rolesDir?: string;
|
||||
readonly overrideDir?: string;
|
||||
readonly homeDirectory?: string;
|
||||
readonly readHolderIdentity?: () => Promise<string>;
|
||||
readonly validateRoster?: (roster: FleetRosterV2) => Promise<void>;
|
||||
readonly prepareProjections?: (roster: FleetRosterV2) => Promise<readonly unknown[]>;
|
||||
readonly applyProjection?: (prepared: unknown) => Promise<unknown>;
|
||||
/** Canonical roster reader; mutation authority is reread under the private lock. */
|
||||
readonly readRoster?: () => Promise<FleetRosterV2>;
|
||||
/** Test seam; production uses a private exclusive roster-adjacent lock. */
|
||||
readonly acquireMutationLock?: () => Promise<() => Promise<void>>;
|
||||
/** Internal recursion guard for the under-lock canonical roster read. */
|
||||
readonly lockAlreadyHeld?: boolean;
|
||||
}
|
||||
|
||||
export interface FleetReconcileRequest {
|
||||
readonly roster: FleetRosterV2;
|
||||
readonly command: FleetReconcileCommand;
|
||||
readonly agentName?: string;
|
||||
readonly expectedGeneration?: number;
|
||||
readonly deps: FleetReconcileDeps;
|
||||
}
|
||||
|
||||
export interface FleetReconcileObservedAgent {
|
||||
readonly name: string;
|
||||
readonly desiredState: 'running' | 'stopped';
|
||||
readonly enabled: boolean;
|
||||
readonly systemd: 'active' | 'inactive' | 'unknown';
|
||||
readonly tmux: 'present' | 'missing';
|
||||
readonly drift: readonly ('missing-session' | 'unexpected-session' | 'disabled-running')[];
|
||||
}
|
||||
|
||||
export interface FleetReconcilePlan {
|
||||
readonly generation: number;
|
||||
readonly holder: 'owned' | 'missing' | 'ownership-mismatch';
|
||||
readonly agents: readonly FleetReconcileObservedAgent[];
|
||||
readonly unmanagedSessions: readonly string[];
|
||||
}
|
||||
|
||||
export type FleetReconcileProjectionState = 'not-applied' | 'complete' | 'incomplete';
|
||||
export type FleetReconcileLifecycleState = 'not-applied' | 'complete' | 'incomplete';
|
||||
|
||||
export interface FleetReconcileResult {
|
||||
readonly applied: boolean;
|
||||
readonly authoritativeRoster: 'unchanged';
|
||||
readonly projections: FleetReconcileProjectionState;
|
||||
readonly lifecycle: FleetReconcileLifecycleState;
|
||||
readonly plan: FleetReconcilePlan;
|
||||
readonly recovery?: {
|
||||
readonly code: 'projection-apply-failed' | 'lifecycle-apply-failed';
|
||||
readonly action:
|
||||
| 'regenerate-projections-from-roster'
|
||||
| 'rerun-after-inspecting-owned-resources';
|
||||
};
|
||||
/** Additive: effects remain truthful when private lock cleanup cannot be proven. */
|
||||
readonly cleanup?: {
|
||||
readonly code: 'lock-cleanup-failed';
|
||||
readonly action: 'inspect-lock-before-retry';
|
||||
};
|
||||
}
|
||||
|
||||
export class FleetReconcileError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
| 'missing-generation'
|
||||
| 'stale-generation'
|
||||
| 'concurrent-mutation'
|
||||
| 'unsafe-managed-path'
|
||||
| 'unsafe-lock'
|
||||
| 'lock-io-failed'
|
||||
| 'lock-cleanup-failed'
|
||||
| 'agent-not-found'
|
||||
| 'disabled-agent'
|
||||
| 'ownership-mismatch'
|
||||
| 'unmanaged-session'
|
||||
| 'lifecycle-precondition-failed',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = FleetReconcileError.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles only local roster-owned projections and exact service targets.
|
||||
* The roster is read-only desired state: no observed runtime result ever writes it.
|
||||
*/
|
||||
export async function executeFleetReconcile(
|
||||
request: FleetReconcileRequest,
|
||||
): Promise<FleetReconcileResult> {
|
||||
const mutating =
|
||||
request.command === 'apply' ||
|
||||
request.command === 'reconcile' ||
|
||||
isLifecycleCommand(request.command);
|
||||
if (mutating && !request.deps.lockAlreadyHeld) {
|
||||
if (request.expectedGeneration === undefined) assertExpectedGeneration(request);
|
||||
const acquire =
|
||||
request.deps.acquireMutationLock ?? acquirePrivateReconcileLock(mosaicHomeFor(request.deps));
|
||||
const release = await acquire();
|
||||
let result: FleetReconcileResult | undefined;
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
if (!request.deps.readRoster) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'Canonical roster state cannot be read for mutation.',
|
||||
);
|
||||
}
|
||||
const canonicalRoster = await request.deps.readRoster();
|
||||
result = await executeFleetReconcile({
|
||||
...request,
|
||||
roster: canonicalRoster,
|
||||
deps: { ...request.deps, lockAlreadyHeld: true },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
primaryError = error;
|
||||
}
|
||||
try {
|
||||
await release();
|
||||
} catch (cleanupError: unknown) {
|
||||
if (result) {
|
||||
return {
|
||||
...result,
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
};
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
throw cleanupError;
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
return result as FleetReconcileResult;
|
||||
}
|
||||
|
||||
assertExpectedGeneration(request);
|
||||
assertLocalRosterOnly(request.roster);
|
||||
const validateRoster = request.deps.validateRoster ?? defaultValidateRoster(request);
|
||||
await validateRoster(request.roster);
|
||||
const plan = scopePlan(await observeFleet(request.roster, request.deps), request.agentName);
|
||||
|
||||
if (request.command === 'status' || request.command === 'doctor') {
|
||||
return {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'not-applied',
|
||||
plan,
|
||||
};
|
||||
}
|
||||
if (request.command === 'verify') {
|
||||
assertVerificationSafe(plan);
|
||||
return {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'not-applied',
|
||||
plan,
|
||||
};
|
||||
}
|
||||
|
||||
assertMutationSafe(plan, request.command);
|
||||
const prepareProjections = request.deps.prepareProjections ?? defaultPrepareProjections(request);
|
||||
const prepared = await prepareProjections(request.roster);
|
||||
if (request.command === 'plan') {
|
||||
return {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'not-applied',
|
||||
plan,
|
||||
};
|
||||
}
|
||||
const targetAgents = targetAgentsFor(request.roster, request.agentName);
|
||||
const release = async (): Promise<void> => undefined;
|
||||
let result: FleetReconcileResult | undefined;
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
if (request.command !== 'apply' && request.command !== 'reconcile') {
|
||||
result = await executeExplicitLifecycle(request, plan, targetAgents);
|
||||
} else {
|
||||
const applyProjection = request.deps.applyProjection ?? defaultApplyProjection;
|
||||
try {
|
||||
for (const projection of prepared) await applyProjection(projection);
|
||||
} catch {
|
||||
result = {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'incomplete',
|
||||
lifecycle: 'not-applied',
|
||||
plan,
|
||||
recovery: {
|
||||
code: 'projection-apply-failed',
|
||||
action: 'regenerate-projections-from-roster',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!result) {
|
||||
try {
|
||||
await applyDesiredLifecycle(request.roster, plan, request.deps);
|
||||
result = {
|
||||
applied: true,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'complete',
|
||||
lifecycle: 'complete',
|
||||
plan,
|
||||
};
|
||||
} catch {
|
||||
result = {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'complete',
|
||||
lifecycle: 'incomplete',
|
||||
plan,
|
||||
recovery: {
|
||||
code: 'lifecycle-apply-failed',
|
||||
action: 'rerun-after-inspecting-owned-resources',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
primaryError = error;
|
||||
}
|
||||
|
||||
try {
|
||||
await release();
|
||||
} catch (cleanupError: unknown) {
|
||||
if (result) {
|
||||
return {
|
||||
...result,
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
};
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
throw cleanupError;
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
return result as FleetReconcileResult;
|
||||
}
|
||||
|
||||
function assertLocalRosterOnly(roster: FleetRosterV2): void {
|
||||
for (const agent of roster.agents) {
|
||||
const untypedAgent = agent as unknown as Record<string, unknown>;
|
||||
if (
|
||||
Object.hasOwn(untypedAgent, 'remote') ||
|
||||
Object.hasOwn(untypedAgent, 'ssh') ||
|
||||
Object.hasOwn(untypedAgent, 'connector') ||
|
||||
Object.hasOwn(untypedAgent, 'host')
|
||||
) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'Remote or connector inventory cannot receive local lifecycle actions.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertExpectedGeneration(request: FleetReconcileRequest): void {
|
||||
if (isObservational(request.command)) return;
|
||||
if (request.expectedGeneration === undefined) {
|
||||
throw new FleetReconcileError(
|
||||
'missing-generation',
|
||||
'A roster generation is required for mutation.',
|
||||
);
|
||||
}
|
||||
if (request.expectedGeneration !== request.roster.generation) {
|
||||
throw new FleetReconcileError('stale-generation', 'The roster generation is stale.');
|
||||
}
|
||||
}
|
||||
|
||||
function isObservational(command: FleetReconcileCommand): boolean {
|
||||
return command === 'plan' || command === 'status' || command === 'verify' || command === 'doctor';
|
||||
}
|
||||
|
||||
async function observeFleet(
|
||||
roster: FleetRosterV2,
|
||||
deps: FleetReconcileDeps,
|
||||
): Promise<FleetReconcilePlan> {
|
||||
const sessionsResult = await run(deps, 'tmux', [
|
||||
...tmuxSocketArgs(roster.tmux.socketName),
|
||||
'list-sessions',
|
||||
'-F',
|
||||
'#{session_name}',
|
||||
]);
|
||||
if (sessionsResult.exitCode !== 0) {
|
||||
return {
|
||||
generation: roster.generation,
|
||||
holder: 'missing',
|
||||
agents: await observeAgents(roster, deps, new Set<string>()),
|
||||
unmanagedSessions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const sessions = new Set(
|
||||
sessionsResult.stdout
|
||||
.split('\n')
|
||||
.map((value: string): string => value.trim())
|
||||
.filter((value: string): boolean => value.length > 0),
|
||||
);
|
||||
const knownSessions = new Set([
|
||||
roster.tmux.holderSession,
|
||||
...roster.agents.map((agent) => agent.name),
|
||||
]);
|
||||
const unmanagedSessions = [...sessions].filter(
|
||||
(session: string): boolean => !knownSessions.has(session),
|
||||
);
|
||||
const holder = await observeHolder(roster, deps, sessions);
|
||||
return {
|
||||
generation: roster.generation,
|
||||
holder,
|
||||
agents: await observeAgents(roster, deps, sessions),
|
||||
unmanagedSessions: Object.freeze(unmanagedSessions.sort()),
|
||||
};
|
||||
}
|
||||
|
||||
async function observeHolder(
|
||||
roster: FleetRosterV2,
|
||||
deps: FleetReconcileDeps,
|
||||
sessions: ReadonlySet<string>,
|
||||
): Promise<FleetReconcilePlan['holder']> {
|
||||
if (!sessions.has(roster.tmux.holderSession)) return 'ownership-mismatch';
|
||||
let owner: string;
|
||||
try {
|
||||
owner = await (deps.readHolderIdentity ?? defaultReadHolderIdentity(mosaicHomeFor(deps)))();
|
||||
} catch {
|
||||
return 'ownership-mismatch';
|
||||
}
|
||||
const environment = await run(deps, 'tmux', [
|
||||
...tmuxSocketArgs(roster.tmux.socketName),
|
||||
'show-environment',
|
||||
'-g',
|
||||
]);
|
||||
if (environment.exitCode !== 0) return 'ownership-mismatch';
|
||||
const homeDirectory = deps.homeDirectory ?? homedir();
|
||||
const expected = [
|
||||
`HOME=${homeDirectory}`,
|
||||
`MOSAIC_FLEET_OWNER=${owner}`,
|
||||
`MOSAIC_TMUX_HOLDER=${roster.tmux.holderSession}`,
|
||||
`MOSAIC_TMUX_SOCKET=${roster.tmux.socketName}`,
|
||||
'PATH=/usr/bin:/bin',
|
||||
`PWD=${homeDirectory}`,
|
||||
].sort();
|
||||
const actual = environment.stdout
|
||||
.split('\n')
|
||||
.filter((line: string): boolean => line.length > 0)
|
||||
.sort();
|
||||
return sameStringArray(actual, expected) ? 'owned' : 'ownership-mismatch';
|
||||
}
|
||||
|
||||
async function observeAgents(
|
||||
roster: FleetRosterV2,
|
||||
deps: FleetReconcileDeps,
|
||||
sessions: ReadonlySet<string>,
|
||||
): Promise<readonly FleetReconcileObservedAgent[]> {
|
||||
return Promise.all(
|
||||
roster.agents.map(async (agent: FleetRosterV2Agent): Promise<FleetReconcileObservedAgent> => {
|
||||
const service = await run(deps, 'systemctl', [
|
||||
'--user',
|
||||
'show',
|
||||
`mosaic-agent@${agent.name}.service`,
|
||||
'-p',
|
||||
'ActiveState',
|
||||
]);
|
||||
const active = /^ActiveState=active$/m.test(service.stdout)
|
||||
? 'active'
|
||||
: service.exitCode === 0
|
||||
? 'inactive'
|
||||
: 'unknown';
|
||||
const tmux = sessions.has(agent.name) ? 'present' : 'missing';
|
||||
const drift: Array<'missing-session' | 'unexpected-session' | 'disabled-running'> = [];
|
||||
if (
|
||||
agent.lifecycle.enabled &&
|
||||
agent.lifecycle.desiredState === 'running' &&
|
||||
tmux === 'missing'
|
||||
) {
|
||||
drift.push('missing-session');
|
||||
}
|
||||
if (agent.lifecycle.desiredState === 'stopped' && tmux === 'present')
|
||||
drift.push('unexpected-session');
|
||||
if (!agent.lifecycle.enabled && tmux === 'present') drift.push('disabled-running');
|
||||
return {
|
||||
name: agent.name,
|
||||
desiredState: agent.lifecycle.desiredState,
|
||||
enabled: agent.lifecycle.enabled,
|
||||
systemd: active,
|
||||
tmux,
|
||||
drift: Object.freeze(drift),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function assertMutationSafe(plan: FleetReconcilePlan, command: FleetReconcileCommand): void {
|
||||
if (plan.holder === 'ownership-mismatch') {
|
||||
throw new FleetReconcileError(
|
||||
'ownership-mismatch',
|
||||
'The named tmux server ownership cannot be proven.',
|
||||
);
|
||||
}
|
||||
if (plan.unmanagedSessions.length > 0 && affectsHolder(command)) {
|
||||
throw new FleetReconcileError(
|
||||
'unmanaged-session',
|
||||
'Unmanaged sessions are present on the named socket.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function affectsHolder(command: FleetReconcileCommand): boolean {
|
||||
return (
|
||||
command === 'apply' || command === 'reconcile' || command === 'start' || command === 'restart'
|
||||
);
|
||||
}
|
||||
|
||||
function scopePlan(plan: FleetReconcilePlan, agentName?: string): FleetReconcilePlan {
|
||||
if (agentName === undefined) return plan;
|
||||
const agent = plan.agents.find(
|
||||
(candidate: FleetReconcileObservedAgent): boolean => candidate.name === agentName,
|
||||
);
|
||||
if (!agent)
|
||||
throw new FleetReconcileError('agent-not-found', 'The lifecycle target is not roster-owned.');
|
||||
return { ...plan, agents: [agent] };
|
||||
}
|
||||
|
||||
function assertVerificationSafe(plan: FleetReconcilePlan): void {
|
||||
if (plan.holder !== 'owned' || plan.unmanagedSessions.length > 0) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'Fleet ownership cannot be verified.',
|
||||
);
|
||||
}
|
||||
if (plan.agents.some((agent: FleetReconcileObservedAgent): boolean => agent.drift.length > 0)) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'Fleet drift prevents verification.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function targetAgentsFor(roster: FleetRosterV2, agentName?: string): readonly FleetRosterV2Agent[] {
|
||||
if (agentName === undefined) return roster.agents;
|
||||
const agent = roster.agents.find(
|
||||
(candidate: FleetRosterV2Agent): boolean => candidate.name === agentName,
|
||||
);
|
||||
if (!agent)
|
||||
throw new FleetReconcileError('agent-not-found', 'The lifecycle target is not roster-owned.');
|
||||
return [agent];
|
||||
}
|
||||
|
||||
async function executeExplicitLifecycle(
|
||||
request: FleetReconcileRequest,
|
||||
plan: FleetReconcilePlan,
|
||||
agents: readonly FleetRosterV2Agent[],
|
||||
): Promise<FleetReconcileResult> {
|
||||
if (request.command === 'start') {
|
||||
for (const agent of agents) {
|
||||
if (!agent.lifecycle.enabled) {
|
||||
throw new FleetReconcileError(
|
||||
'disabled-agent',
|
||||
'A disabled roster agent cannot be started.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (request.command === 'start' && plan.holder === 'missing') {
|
||||
await runChecked(request.deps, 'systemctl', [
|
||||
'--user',
|
||||
'start',
|
||||
'mosaic-tmux-holder.service',
|
||||
]);
|
||||
}
|
||||
for (const agent of agents) {
|
||||
await runChecked(request.deps, 'systemctl', [
|
||||
'--user',
|
||||
request.command,
|
||||
`mosaic-agent@${agent.name}.service`,
|
||||
]);
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'incomplete',
|
||||
plan,
|
||||
recovery: {
|
||||
code: 'lifecycle-apply-failed',
|
||||
action: 'rerun-after-inspecting-owned-resources',
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
applied: true,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'complete',
|
||||
plan,
|
||||
};
|
||||
}
|
||||
|
||||
async function applyDesiredLifecycle(
|
||||
roster: FleetRosterV2,
|
||||
plan: FleetReconcilePlan,
|
||||
deps: FleetReconcileDeps,
|
||||
): Promise<void> {
|
||||
const needsRunningAgent = roster.agents.some(
|
||||
(agent: FleetRosterV2Agent): boolean =>
|
||||
agent.lifecycle.enabled && agent.lifecycle.desiredState === 'running',
|
||||
);
|
||||
if (needsRunningAgent && plan.holder === 'missing') {
|
||||
await runChecked(deps, 'systemctl', ['--user', 'start', 'mosaic-tmux-holder.service']);
|
||||
}
|
||||
for (const agent of roster.agents) {
|
||||
const action =
|
||||
agent.lifecycle.enabled && agent.lifecycle.desiredState === 'running' ? 'start' : 'stop';
|
||||
await runChecked(deps, 'systemctl', ['--user', action, `mosaic-agent@${agent.name}.service`]);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultValidateRoster(
|
||||
request: FleetReconcileRequest,
|
||||
): (roster: FleetRosterV2) => Promise<void> {
|
||||
return async (roster: FleetRosterV2): Promise<void> => {
|
||||
const mosaicHome = mosaicHomeFor(request.deps);
|
||||
await validateRosterV2Semantics(roster, {
|
||||
rolesDir: request.deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
|
||||
overrideDir: request.deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function defaultPrepareProjections(
|
||||
request: FleetReconcileRequest,
|
||||
): (roster: FleetRosterV2) => Promise<readonly PreparedAgentEnvironmentProjection[]> {
|
||||
return async (roster: FleetRosterV2): Promise<readonly PreparedAgentEnvironmentProjection[]> => {
|
||||
const mosaicHome = mosaicHomeFor(request.deps);
|
||||
return Promise.all(
|
||||
roster.agents.map(
|
||||
(agent: FleetRosterV2Agent): Promise<PreparedAgentEnvironmentProjection> =>
|
||||
prepareAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||
agentName: agent.name,
|
||||
generated: {
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_AGENT_CLASS: agent.className,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.model,
|
||||
MOSAIC_AGENT_REASONING: agent.reasoning,
|
||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
|
||||
MOSAIC_AGENT_WORKDIR: agent.workingDirectory,
|
||||
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultApplyProjection(prepared: unknown): Promise<unknown> {
|
||||
return applyPreparedAgentEnvironmentProjection(prepared as PreparedAgentEnvironmentProjection);
|
||||
}
|
||||
|
||||
function mosaicHomeFor(deps: FleetReconcileDeps): string {
|
||||
return deps.mosaicHome ?? join(homedir(), '.config', 'mosaic');
|
||||
}
|
||||
|
||||
/** Acquires a private lock only after proving the canonical managed path. */
|
||||
export function acquirePrivateReconcileLock(
|
||||
mosaicHome: string,
|
||||
openLock: typeof open = open,
|
||||
): () => Promise<() => Promise<void>> {
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const lockPath = join(fleetDir, 'roster.yaml.reconcile.lock');
|
||||
return async (): Promise<() => Promise<void>> => {
|
||||
await assertPrivateManagedDirectory(mosaicHome);
|
||||
await assertPrivateManagedDirectory(fleetDir);
|
||||
await assertSafeLockLeafIfPresent(lockPath);
|
||||
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await openLock(lockPath, 'wx', 0o600);
|
||||
} catch (error: unknown) {
|
||||
if (isCode(error, 'EEXIST')) {
|
||||
await assertSafeLockLeafIfPresent(lockPath);
|
||||
throw new FleetReconcileError(
|
||||
'concurrent-mutation',
|
||||
'Another roster reconciliation is in progress.',
|
||||
);
|
||||
}
|
||||
throw new FleetReconcileError('lock-io-failed', 'The reconciliation lock cannot be created.');
|
||||
}
|
||||
|
||||
const token = randomUUID();
|
||||
try {
|
||||
await handle.writeFile(`${token}\n`, 'utf8');
|
||||
const opened = await handle.stat();
|
||||
await handle.close();
|
||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'unsafe-lock');
|
||||
return async (): Promise<void> => {
|
||||
try {
|
||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
|
||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
|
||||
await unlink(lockPath);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError(
|
||||
'lock-cleanup-failed',
|
||||
'The reconciliation lock cleanup failed.',
|
||||
);
|
||||
}
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
await handle.close().catch((): void => {});
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError(
|
||||
'lock-io-failed',
|
||||
'The reconciliation lock cannot be initialized.',
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function assertPrivateManagedDirectory(path: string): Promise<void> {
|
||||
try {
|
||||
const metadata = await lstat(path);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||
throw new FleetReconcileError('unsafe-managed-path', 'The managed lock ancestor is unsafe.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError(
|
||||
'unsafe-managed-path',
|
||||
'The managed lock ancestor is unavailable.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertSafeLockLeafIfPresent(lockPath: string): Promise<void> {
|
||||
try {
|
||||
const metadata = await lstat(lockPath);
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||
throw new FleetReconcileError('unsafe-lock', 'The reconciliation lock path is unsafe.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isCode(error, 'ENOENT')) return;
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError('unsafe-lock', 'The reconciliation lock path is unavailable.');
|
||||
}
|
||||
}
|
||||
|
||||
async function assertLockOwnership(
|
||||
lockPath: string,
|
||||
device: number,
|
||||
inode: number,
|
||||
token: string,
|
||||
failureCode: 'unsafe-lock' | 'lock-cleanup-failed',
|
||||
): Promise<void> {
|
||||
try {
|
||||
const metadata = await lstat(lockPath);
|
||||
if (
|
||||
!metadata.isFile() ||
|
||||
metadata.isSymbolicLink() ||
|
||||
(metadata.mode & 0o077) !== 0 ||
|
||||
metadata.dev !== device ||
|
||||
metadata.ino !== inode
|
||||
) {
|
||||
throw new FleetReconcileError(failureCode, 'The reconciliation lock ownership changed.');
|
||||
}
|
||||
const handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const opened = await handle.stat();
|
||||
const contents = await handle.readFile({ encoding: 'utf8' });
|
||||
if (opened.dev !== device || opened.ino !== inode || contents !== `${token}\n`) {
|
||||
throw new FleetReconcileError(failureCode, 'The reconciliation lock ownership changed.');
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError(
|
||||
failureCode,
|
||||
'The reconciliation lock ownership cannot be proven.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isLifecycleCommand(command: FleetReconcileCommand): boolean {
|
||||
return command === 'start' || command === 'stop' || command === 'restart';
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
function defaultReadHolderIdentity(mosaicHome: string): () => Promise<string> {
|
||||
return async (): Promise<string> => {
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const runDir = join(fleetDir, 'run');
|
||||
for (const directory of [mosaicHome, fleetDir, runDir]) {
|
||||
const metadata = await lstat(directory);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||
throw new Error('Unsafe holder identity ancestor.');
|
||||
}
|
||||
}
|
||||
const identityPath = join(runDir, 'holder-owner');
|
||||
const metadata = await lstat(identityPath);
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||
throw new Error('Unsafe holder identity.');
|
||||
}
|
||||
const value = (await readFile(identityPath, 'utf8')).trim();
|
||||
if (!/^[a-f0-9-]{36}$/.test(value)) throw new Error('Malformed holder identity.');
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
async function run(
|
||||
deps: FleetReconcileDeps,
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
): Promise<FleetReconcileCommandResult> {
|
||||
return deps.runner(command, args);
|
||||
}
|
||||
|
||||
async function runChecked(
|
||||
deps: FleetReconcileDeps,
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
): Promise<void> {
|
||||
const result = await run(deps, command, args);
|
||||
if (result.exitCode !== 0) throw new Error('Lifecycle action failed.');
|
||||
}
|
||||
|
||||
function tmuxSocketArgs(socketName: string): readonly string[] {
|
||||
return socketName === '' ? [] : ['-L', socketName];
|
||||
}
|
||||
|
||||
function sameStringArray(left: readonly string[], right: readonly string[]): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((value: string, index: number): boolean => value === right[index])
|
||||
);
|
||||
}
|
||||
@@ -1,522 +0,0 @@
|
||||
import { lstatSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import YAML from 'yaml';
|
||||
import { canonicalizeRoleClass } from '../commands/fleet-personas.js';
|
||||
|
||||
interface RawFleetRoster {
|
||||
version?: unknown;
|
||||
transport?: unknown;
|
||||
tmux?: {
|
||||
socket_name?: unknown;
|
||||
socketName?: unknown;
|
||||
holder_session?: unknown;
|
||||
holderSession?: unknown;
|
||||
};
|
||||
defaults?: {
|
||||
working_directory?: unknown;
|
||||
workingDirectory?: unknown;
|
||||
};
|
||||
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
||||
agents?: Array<{
|
||||
name?: unknown;
|
||||
alias?: unknown;
|
||||
provider?: unknown;
|
||||
runtime?: unknown;
|
||||
class?: unknown;
|
||||
host?: unknown;
|
||||
ssh?: unknown;
|
||||
socket?: unknown;
|
||||
working_directory?: unknown;
|
||||
workingDirectory?: unknown;
|
||||
model_hint?: unknown;
|
||||
modelHint?: unknown;
|
||||
reasoning_level?: unknown;
|
||||
reasoningLevel?: unknown;
|
||||
tool_policy?: unknown;
|
||||
toolPolicy?: unknown;
|
||||
persistent_persona?: unknown;
|
||||
persistentPersona?: unknown;
|
||||
reset_between_tasks?: unknown;
|
||||
resetBetweenTasks?: unknown;
|
||||
kickstart_template?: unknown;
|
||||
kickstartTemplate?: unknown;
|
||||
}>;
|
||||
connector?: {
|
||||
kind?: unknown;
|
||||
matrix?: {
|
||||
homeserver_url?: unknown;
|
||||
user_id?: unknown;
|
||||
room_id?: unknown;
|
||||
};
|
||||
discord?: {
|
||||
channel_id?: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface FleetAgent {
|
||||
name: string;
|
||||
alias?: string;
|
||||
provider?: string;
|
||||
runtime: string;
|
||||
className: string;
|
||||
/** Resolved host identity. Absent means the caller's authoritative local host. */
|
||||
host?: string;
|
||||
/** Explicit SSH destination for a cross-host inventory peer. */
|
||||
ssh?: string;
|
||||
/** Compatibility declaration; when set it must equal fleet-wide tmux.socketName. */
|
||||
socket?: string;
|
||||
workingDirectory?: string;
|
||||
modelHint?: string;
|
||||
reasoningLevel?: string;
|
||||
toolPolicy?: string;
|
||||
persistentPersona?: boolean | string;
|
||||
resetBetweenTasks?: boolean;
|
||||
kickstartTemplate?: string;
|
||||
}
|
||||
|
||||
export type FleetConnector =
|
||||
| { kind: 'tmux' }
|
||||
| {
|
||||
kind: 'discord';
|
||||
discord: { channelId: string };
|
||||
}
|
||||
| {
|
||||
kind: 'matrix';
|
||||
matrix: { homeserverUrl: string; userId: string; roomId: string };
|
||||
};
|
||||
|
||||
export interface FleetRoster {
|
||||
version: 1;
|
||||
transport: 'tmux';
|
||||
tmux: {
|
||||
socketName: string;
|
||||
holderSession: string;
|
||||
};
|
||||
defaults: {
|
||||
workingDirectory: string;
|
||||
};
|
||||
runtimes: Record<string, { resetCommand: string }>;
|
||||
agents: FleetAgent[];
|
||||
connector?: FleetConnector;
|
||||
}
|
||||
|
||||
export type FleetRosterInputFormat = 'yaml' | 'json';
|
||||
|
||||
export function resolveInstalledFleetRosterPath(mosaicHome: string): string {
|
||||
const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
try {
|
||||
lstatSync(yamlPath);
|
||||
return yamlPath;
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
||||
return join(mosaicHome, 'fleet', 'roster.json');
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_HOLDER_SESSION = '_holder';
|
||||
const DEFAULT_WORKING_DIRECTORY = '~/src';
|
||||
const DEFAULT_RUNTIME_RESETS: Record<string, { resetCommand: string }> = {
|
||||
claude: { resetCommand: '/clear' },
|
||||
codex: { resetCommand: '/clear' },
|
||||
opencode: { resetCommand: '/clear' },
|
||||
pi: { resetCommand: '/new' },
|
||||
};
|
||||
|
||||
/** One structural v1 resolver used by fleet commands and runtime comms composition. */
|
||||
export function parseFleetRosterV1(
|
||||
source: string,
|
||||
format: FleetRosterInputFormat = 'yaml',
|
||||
): FleetRoster {
|
||||
const trimmed = source.trim();
|
||||
const parsed =
|
||||
format === 'json'
|
||||
? (JSON.parse(trimmed) as RawFleetRoster)
|
||||
: (YAML.parse(trimmed) as RawFleetRoster);
|
||||
return normalizeFleetRosterV1(parsed);
|
||||
}
|
||||
|
||||
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
||||
const source = await readFile(path, 'utf8');
|
||||
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
||||
}
|
||||
|
||||
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
||||
const agent = roster.agents.find((candidate) => candidate.name === name);
|
||||
if (!agent) throw new Error(`Agent "${name}" is not in the fleet roster.`);
|
||||
return agent;
|
||||
}
|
||||
|
||||
export function normalizeFleetRosterV1(raw: RawFleetRoster): FleetRoster {
|
||||
assertObject(raw, 'Fleet roster');
|
||||
assertKnownKeys(raw, 'Fleet roster', [
|
||||
'version',
|
||||
'transport',
|
||||
'tmux',
|
||||
'defaults',
|
||||
'runtimes',
|
||||
'agents',
|
||||
'connector',
|
||||
]);
|
||||
if (raw.tmux !== undefined) {
|
||||
assertObject(raw.tmux, 'Fleet roster tmux');
|
||||
assertKnownKeys(raw.tmux, 'Fleet roster tmux', [
|
||||
'socket_name',
|
||||
'socketName',
|
||||
'holder_session',
|
||||
'holderSession',
|
||||
]);
|
||||
}
|
||||
if (raw.defaults !== undefined) {
|
||||
assertObject(raw.defaults, 'Fleet roster defaults');
|
||||
assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
]);
|
||||
}
|
||||
if (raw.runtimes !== undefined) {
|
||||
assertObject(raw.runtimes, 'Fleet roster runtimes');
|
||||
for (const [runtime, config] of Object.entries(raw.runtimes)) {
|
||||
assertObject(config, `Fleet roster runtime "${runtime}"`);
|
||||
assertKnownKeys(config, `Fleet roster runtime "${runtime}"`, [
|
||||
'reset_command',
|
||||
'resetCommand',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (raw.version !== 1) throw new Error('Fleet roster version must be 1.');
|
||||
if (raw.transport !== 'tmux') throw new Error('Fleet roster transport must be "tmux".');
|
||||
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
|
||||
throw new Error('Fleet roster must define at least one agent.');
|
||||
}
|
||||
|
||||
const socketName = targetingString(
|
||||
aliasValue(raw.tmux, 'socket_name', 'socketName', 'Fleet roster tmux socket'),
|
||||
'',
|
||||
'Fleet roster tmux socket_name',
|
||||
/^[A-Za-z0-9_.-]+$/,
|
||||
);
|
||||
const agents = raw.agents.map(normalizeAgent);
|
||||
assertUniqueAgentNames(agents);
|
||||
for (const agent of agents) {
|
||||
if (agent.socket !== undefined && agent.socket !== socketName) {
|
||||
throw new Error(
|
||||
`Fleet agent "${agent.name}" socket must equal the fleet-wide tmux socket_name; independent per-agent sockets are not supported.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: {
|
||||
socketName,
|
||||
holderSession: stringValue(
|
||||
aliasValue(raw.tmux, 'holder_session', 'holderSession', 'Fleet roster tmux holder'),
|
||||
DEFAULT_HOLDER_SESSION,
|
||||
'Fleet roster tmux holder_session',
|
||||
),
|
||||
},
|
||||
defaults: {
|
||||
workingDirectory: stringValue(
|
||||
aliasValue(
|
||||
raw.defaults,
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
'Fleet roster defaults working directory',
|
||||
),
|
||||
DEFAULT_WORKING_DIRECTORY,
|
||||
'Fleet roster defaults working_directory',
|
||||
),
|
||||
},
|
||||
runtimes: normalizeRuntimes(raw.runtimes as RawFleetRoster['runtimes']),
|
||||
agents,
|
||||
connector: normalizeConnector(raw.connector as RawFleetRoster['connector']),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): FleetAgent {
|
||||
assertObject(raw, 'Fleet roster agent');
|
||||
assertKnownKeys(raw, 'Fleet roster agent', [
|
||||
'name',
|
||||
'alias',
|
||||
'provider',
|
||||
'runtime',
|
||||
'class',
|
||||
'host',
|
||||
'ssh',
|
||||
'socket',
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
'model_hint',
|
||||
'modelHint',
|
||||
'reasoning_level',
|
||||
'reasoningLevel',
|
||||
'tool_policy',
|
||||
'toolPolicy',
|
||||
'persistent_persona',
|
||||
'persistentPersona',
|
||||
'reset_between_tasks',
|
||||
'resetBetweenTasks',
|
||||
'kickstart_template',
|
||||
'kickstartTemplate',
|
||||
]);
|
||||
const name = stringValue(raw.name, '', 'Fleet roster agent name');
|
||||
const runtime = stringValue(
|
||||
raw.runtime,
|
||||
'',
|
||||
`Fleet roster agent "${name || '<unknown>'}" runtime`,
|
||||
);
|
||||
if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
|
||||
throw new Error(`Invalid fleet agent name: ${name || '<empty>'}`);
|
||||
}
|
||||
if (!runtime) throw new Error(`Fleet agent "${name}" must define a runtime.`);
|
||||
return {
|
||||
name,
|
||||
alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`),
|
||||
provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`),
|
||||
runtime,
|
||||
className: canonicalizeRoleClass(
|
||||
stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
||||
).canonicalClass,
|
||||
host: optionalTargetingString(
|
||||
raw.host,
|
||||
`Fleet roster agent "${name}" host`,
|
||||
/^[A-Za-z0-9_.:[\]-]+$/,
|
||||
),
|
||||
ssh: optionalTargetingString(
|
||||
raw.ssh,
|
||||
`Fleet roster agent "${name}" ssh`,
|
||||
/^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9_.:[\]-]+$/,
|
||||
),
|
||||
socket: optionalTargetingString(
|
||||
raw.socket,
|
||||
`Fleet roster agent "${name}" socket`,
|
||||
/^[A-Za-z0-9_.-]+$/,
|
||||
),
|
||||
workingDirectory: optionalString(
|
||||
aliasValue(
|
||||
raw,
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
`Fleet roster agent "${name}" working directory`,
|
||||
),
|
||||
`Fleet roster agent "${name}" working_directory`,
|
||||
),
|
||||
modelHint: optionalString(
|
||||
aliasValue(raw, 'model_hint', 'modelHint', `Fleet roster agent "${name}" model hint`),
|
||||
`Fleet roster agent "${name}" model_hint`,
|
||||
),
|
||||
reasoningLevel: optionalString(
|
||||
aliasValue(
|
||||
raw,
|
||||
'reasoning_level',
|
||||
'reasoningLevel',
|
||||
`Fleet roster agent "${name}" reasoning level`,
|
||||
),
|
||||
`Fleet roster agent "${name}" reasoning_level`,
|
||||
),
|
||||
toolPolicy: optionalString(
|
||||
aliasValue(raw, 'tool_policy', 'toolPolicy', `Fleet roster agent "${name}" tool policy`),
|
||||
`Fleet roster agent "${name}" tool_policy`,
|
||||
),
|
||||
persistentPersona: optionalBooleanOrString(
|
||||
aliasValue(
|
||||
raw,
|
||||
'persistent_persona',
|
||||
'persistentPersona',
|
||||
`Fleet roster agent "${name}" persistent persona`,
|
||||
),
|
||||
`Fleet roster agent "${name}" persistent_persona`,
|
||||
),
|
||||
resetBetweenTasks: optionalBoolean(
|
||||
aliasValue(
|
||||
raw,
|
||||
'reset_between_tasks',
|
||||
'resetBetweenTasks',
|
||||
`Fleet roster agent "${name}" reset between tasks`,
|
||||
),
|
||||
`Fleet roster agent "${name}" reset_between_tasks`,
|
||||
),
|
||||
kickstartTemplate: optionalString(
|
||||
aliasValue(
|
||||
raw,
|
||||
'kickstart_template',
|
||||
'kickstartTemplate',
|
||||
`Fleet roster agent "${name}" kickstart template`,
|
||||
),
|
||||
`Fleet roster agent "${name}" kickstart_template`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRuntimes(
|
||||
raw: RawFleetRoster['runtimes'] | undefined,
|
||||
): Record<string, { resetCommand: string }> {
|
||||
const result: Record<string, { resetCommand: string }> = { ...DEFAULT_RUNTIME_RESETS };
|
||||
for (const [runtime, config] of Object.entries(raw ?? {})) {
|
||||
result[runtime] = {
|
||||
resetCommand: stringValue(
|
||||
aliasValue(
|
||||
config,
|
||||
'reset_command',
|
||||
'resetCommand',
|
||||
`Fleet roster runtime "${runtime}" reset command`,
|
||||
),
|
||||
'/clear',
|
||||
`Fleet roster runtime "${runtime}" reset_command`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeConnector(raw: RawFleetRoster['connector']): FleetConnector | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
assertObject(raw, 'Fleet roster connector');
|
||||
assertKnownKeys(raw, 'Fleet roster connector', ['kind', 'matrix', 'discord']);
|
||||
const kind = stringValue(raw.kind, '', 'Fleet roster connector kind');
|
||||
if (kind === 'tmux') {
|
||||
if (raw.matrix !== undefined || raw.discord !== undefined) {
|
||||
throw new Error('Fleet roster tmux connector must not define matrix or discord settings.');
|
||||
}
|
||||
return { kind };
|
||||
}
|
||||
if (kind === 'discord') {
|
||||
if (raw.matrix !== undefined) {
|
||||
throw new Error('Fleet roster discord connector must not define matrix settings.');
|
||||
}
|
||||
assertObject(raw.discord, 'Fleet roster connector discord');
|
||||
assertKnownKeys(raw.discord, 'Fleet roster connector discord', ['channel_id']);
|
||||
return {
|
||||
kind,
|
||||
discord: {
|
||||
channelId: requiredString(
|
||||
raw.discord.channel_id,
|
||||
'Fleet roster connector discord channel_id',
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (kind === 'matrix') {
|
||||
if (raw.discord !== undefined) {
|
||||
throw new Error('Fleet roster matrix connector must not define discord settings.');
|
||||
}
|
||||
assertObject(raw.matrix, 'Fleet roster connector matrix');
|
||||
assertKnownKeys(raw.matrix, 'Fleet roster connector matrix', [
|
||||
'homeserver_url',
|
||||
'user_id',
|
||||
'room_id',
|
||||
]);
|
||||
return {
|
||||
kind,
|
||||
matrix: {
|
||||
homeserverUrl: requiredString(
|
||||
raw.matrix.homeserver_url,
|
||||
'Fleet roster connector matrix homeserver_url',
|
||||
),
|
||||
userId: requiredString(raw.matrix.user_id, 'Fleet roster connector matrix user_id'),
|
||||
roomId: requiredString(raw.matrix.room_id, 'Fleet roster connector matrix room_id'),
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('Fleet roster connector kind must be one of: tmux, discord, matrix.');
|
||||
}
|
||||
|
||||
function aliasValue<T extends Record<string, unknown>>(
|
||||
source: T | undefined,
|
||||
snake: keyof T,
|
||||
camel: keyof T,
|
||||
label: string,
|
||||
): unknown {
|
||||
const snakeValue = source?.[snake];
|
||||
const camelValue = source?.[camel];
|
||||
if (snakeValue !== undefined && camelValue !== undefined && snakeValue !== camelValue) {
|
||||
throw new Error(`${label} aliases ${String(snake)} and ${String(camel)} conflict.`);
|
||||
}
|
||||
return snakeValue ?? camelValue;
|
||||
}
|
||||
|
||||
function isNodeErrorCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, label: string): string {
|
||||
const resolved = stringValue(value, '', label).trim();
|
||||
if (!resolved) throw new Error(`${label} is required.`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function assertObject(value: unknown, label: string): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertKnownKeys(
|
||||
value: Record<string, unknown>,
|
||||
label: string,
|
||||
allowedKeys: readonly string[],
|
||||
): void {
|
||||
const allowed = new Set(allowedKeys);
|
||||
const unknownKeys = Object.keys(value).filter((key) => !allowed.has(key));
|
||||
if (unknownKeys.length > 0) {
|
||||
throw new Error(`${label} has unknown field(s): ${unknownKeys.join(', ')}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertUniqueAgentNames(agents: FleetAgent[]): void {
|
||||
const seen = new Set<string>();
|
||||
for (const agent of agents) {
|
||||
if (seen.has(agent.name)) {
|
||||
throw new Error(`Fleet roster has duplicate agent name: ${agent.name}.`);
|
||||
}
|
||||
seen.add(agent.name);
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = '', label = 'Value'): string {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value !== 'string') throw new Error(`${label} must be a string.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function targetingString(value: unknown, fallback: string, label: string, pattern: RegExp): string {
|
||||
const resolved = stringValue(value, fallback, label);
|
||||
if (resolved && !pattern.test(resolved)) {
|
||||
throw new Error(`${label} contains unsupported targeting characters.`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function optionalTargetingString(
|
||||
value: unknown,
|
||||
label: string,
|
||||
pattern: RegExp,
|
||||
): string | undefined {
|
||||
const resolved = optionalString(value, label);
|
||||
if (resolved !== undefined && (!resolved || !pattern.test(resolved))) {
|
||||
throw new Error(`${label} contains unsupported targeting characters.`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label = 'Value'): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'string') throw new Error(`${label} must be a string.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBoolean(value: unknown, label = 'Value'): boolean | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'boolean') throw new Error(`${label} must be a boolean.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBooleanOrString(value: unknown, label = 'Value'): boolean | string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'boolean' && typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a boolean or string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
type PathLike,
|
||||
} from 'node:fs';
|
||||
import type * as NodeFs from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
interface FilesystemRaceState {
|
||||
afterLstat?: (path: string) => void;
|
||||
afterOpen?: (path: string) => void;
|
||||
}
|
||||
|
||||
const filesystemRaceState = vi.hoisted<FilesystemRaceState>(() => ({}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFs>();
|
||||
return {
|
||||
...actual,
|
||||
lstatSync: (path: PathLike) => {
|
||||
const result = actual.lstatSync(path);
|
||||
filesystemRaceState.afterLstat?.(String(path));
|
||||
return result;
|
||||
},
|
||||
openSync: (path: PathLike, flags: string | number, mode?: number) => {
|
||||
const fd = actual.openSync(path, flags, mode);
|
||||
filesystemRaceState.afterOpen?.(String(path));
|
||||
return fd;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { assertCanonicalContainment, readRegularFileSecure } from './secure-file.js';
|
||||
|
||||
describe('secure file reads', () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'mosaic-secure-file-'));
|
||||
filesystemRaceState.afterLstat = undefined;
|
||||
filesystemRaceState.afterOpen = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
filesystemRaceState.afterLstat = undefined;
|
||||
filesystemRaceState.afterOpen = undefined;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects canonical path escape', () => {
|
||||
expect(() => assertCanonicalContainment(root, join(root, '..', 'outside'))).toThrow(
|
||||
'path escapes managed root',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a symlink in a file ancestor', () => {
|
||||
const external = join(root, 'external');
|
||||
mkdirSync(external);
|
||||
writeFileSync(join(external, 'file'), 'external\n');
|
||||
symlinkSync(external, join(root, 'linked'));
|
||||
|
||||
expect(() => readRegularFileSecure(join(root, 'linked', 'file'), { root })).toThrow(
|
||||
'path ancestor is a symbolic link',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a symlink target', () => {
|
||||
const external = join(root, 'external');
|
||||
writeFileSync(external, 'external\n');
|
||||
symlinkSync(external, join(root, 'linked-file'));
|
||||
|
||||
expect(() => readRegularFileSecure(join(root, 'linked-file'), { root })).toThrow(
|
||||
'file is a symbolic link',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps ancestor traversal bound when an opened directory is substituted', () => {
|
||||
const tools = join(root, 'tools');
|
||||
const displacedTools = join(root, 'tools.displaced');
|
||||
const external = join(root, 'external');
|
||||
const helper = join(tools, 'helper.sh');
|
||||
mkdirSync(tools);
|
||||
mkdirSync(external);
|
||||
writeFileSync(helper, 'trusted\n', { mode: 0o755 });
|
||||
writeFileSync(join(external, 'helper.sh'), 'external marker\n', { mode: 0o755 });
|
||||
|
||||
let substituted = false;
|
||||
filesystemRaceState.afterOpen = (openedPath: string): void => {
|
||||
if (substituted || !openedPath.startsWith('/proc/self/fd/')) return;
|
||||
if (openedPath.split('/').at(-1) !== 'tools') return;
|
||||
substituted = true;
|
||||
renameSync(tools, displacedTools);
|
||||
symlinkSync(external, tools);
|
||||
};
|
||||
|
||||
const result = readRegularFileSecure(helper, { root, executable: true });
|
||||
expect(substituted).toBe(true);
|
||||
expect(result.content.toString('utf8')).toBe('trusted\n');
|
||||
});
|
||||
|
||||
it('keeps root selection bound when the opened root is substituted', () => {
|
||||
const displacedRoot = `${root}.displaced`;
|
||||
const externalRoot = `${root}.external`;
|
||||
const helper = join(root, 'helper.sh');
|
||||
mkdirSync(externalRoot);
|
||||
writeFileSync(helper, 'trusted root\n', { mode: 0o755 });
|
||||
writeFileSync(join(externalRoot, 'helper.sh'), 'external root marker\n', { mode: 0o755 });
|
||||
|
||||
let substituted = false;
|
||||
filesystemRaceState.afterOpen = (openedPath: string): void => {
|
||||
if (substituted || !openedPath.startsWith('/proc/self/fd/')) return;
|
||||
const match = openedPath.match(/\/([^/]+)$/);
|
||||
if (match?.[1] !== root.split('/').filter(Boolean).at(-1)) return;
|
||||
substituted = true;
|
||||
renameSync(root, displacedRoot);
|
||||
symlinkSync(externalRoot, root);
|
||||
};
|
||||
|
||||
const result = readRegularFileSecure(helper, { root, executable: true });
|
||||
expect(substituted).toBe(true);
|
||||
expect(result.content.toString('utf8')).toBe('trusted root\n');
|
||||
filesystemRaceState.afterOpen = undefined;
|
||||
rmSync(root);
|
||||
renameSync(displacedRoot, root);
|
||||
});
|
||||
|
||||
it('keeps target read and execute validation bound to the opened file', () => {
|
||||
const file = join(root, 'helper.sh');
|
||||
const displaced = join(root, 'helper.displaced.sh');
|
||||
const external = join(root, 'external-helper.sh');
|
||||
writeFileSync(file, 'trusted target\n', { mode: 0o755 });
|
||||
writeFileSync(external, 'external target marker\n', { mode: 0o755 });
|
||||
|
||||
let substituted = false;
|
||||
filesystemRaceState.afterOpen = (openedPath: string): void => {
|
||||
if (substituted || !openedPath.startsWith('/proc/self/fd/')) return;
|
||||
if (openedPath.split('/').at(-1) !== 'helper.sh') return;
|
||||
substituted = true;
|
||||
renameSync(file, displaced);
|
||||
symlinkSync(external, file);
|
||||
};
|
||||
|
||||
const result = readRegularFileSecure(file, { root, executable: true });
|
||||
expect(substituted).toBe(true);
|
||||
expect(result.content.toString('utf8')).toBe('trusted target\n');
|
||||
});
|
||||
|
||||
it('uses a stable redacted executable error while retaining the error code', () => {
|
||||
const file = join(root, 'helper.sh');
|
||||
writeFileSync(file, '#!/bin/sh\n', { mode: 0o644 });
|
||||
|
||||
try {
|
||||
readRegularFileSecure(file, { root, executable: true });
|
||||
throw new Error('expected executable validation to fail');
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ message: 'managed file is not executable', code: 'EACCES' });
|
||||
expect(String(error)).not.toContain('/proc/self/fd/');
|
||||
expect(String(error)).not.toContain(root);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses effective-identity execute access after regular-file validation', () => {
|
||||
const file = join(root, 'helper.sh');
|
||||
writeFileSync(file, '#!/bin/sh\n', { mode: 0o644 });
|
||||
expect(() => readRegularFileSecure(file, { root, executable: true })).toThrow();
|
||||
|
||||
chmodSync(file, 0o755);
|
||||
expect(readRegularFileSecure(file, { root, executable: true }).content.toString()).toBe(
|
||||
'#!/bin/sh\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,242 +0,0 @@
|
||||
import {
|
||||
accessSync,
|
||||
closeSync,
|
||||
constants,
|
||||
fstatSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
} from 'node:fs';
|
||||
import { platform } from 'node:os';
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export interface SecureFileReadOptions {
|
||||
root: string;
|
||||
maxBytes?: number;
|
||||
executable?: boolean;
|
||||
}
|
||||
|
||||
export interface SecureFileSnapshot {
|
||||
content: Buffer;
|
||||
mode: number;
|
||||
dev: number | bigint;
|
||||
ino: number | bigint;
|
||||
}
|
||||
|
||||
function sameIdentity(
|
||||
left: { dev: number | bigint; ino: number | bigint },
|
||||
right: { dev: number | bigint; ino: number | bigint },
|
||||
): boolean {
|
||||
return left.dev === right.dev && left.ino === right.ino;
|
||||
}
|
||||
|
||||
function secureFilesystemError(message: string, cause: unknown): Error {
|
||||
const error = new Error(message);
|
||||
if (cause instanceof Error && 'code' in cause && typeof cause.code === 'string') {
|
||||
Object.defineProperty(error, 'code', { value: cause.code, enumerable: true });
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
function closeDescriptors(descriptors: number[]): void {
|
||||
for (const fd of descriptors.reverse()) {
|
||||
try {
|
||||
closeSync(fd);
|
||||
} catch {
|
||||
// Best-effort cleanup must not replace the security decision already made.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function procDescriptorPath(fd: number, component?: string): string {
|
||||
const descriptor = `/proc/self/fd/${fd}`;
|
||||
return component === undefined ? descriptor : `${descriptor}/${component}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold each directory while opening its child through Linux proc-fd. The only
|
||||
* symlink followed is the kernel-owned descriptor link; O_NOFOLLOW protects
|
||||
* every appended filesystem component from substitution.
|
||||
*/
|
||||
function openDirectoryChain(absoluteDirectory: string): { fd: number; descriptors: number[] } {
|
||||
if (platform() !== 'linux') {
|
||||
throw new Error('secure descriptor traversal is unsupported on this platform');
|
||||
}
|
||||
|
||||
const descriptors: number[] = [];
|
||||
try {
|
||||
let fd = openSync(sep, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
descriptors.push(fd);
|
||||
for (const component of absoluteDirectory.split(sep).filter(Boolean)) {
|
||||
fd = openSync(
|
||||
procDescriptorPath(fd, component),
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
descriptors.push(fd);
|
||||
if (!fstatSync(fd).isDirectory()) {
|
||||
throw new Error('secure descriptor traversal encountered a non-directory component');
|
||||
}
|
||||
}
|
||||
return { fd, descriptors };
|
||||
} catch (error) {
|
||||
closeDescriptors(descriptors);
|
||||
throw secureFilesystemError(
|
||||
'secure descriptor traversal failed: symbolic link, unavailable, or not a directory',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function openFileBeneathRoot(root: string, target: string): { fd: number; descriptors: number[] } {
|
||||
const canonicalRoot = resolve(root);
|
||||
const canonicalTarget = resolve(target);
|
||||
assertCanonicalContainment(canonicalRoot, canonicalTarget);
|
||||
const components = relative(canonicalRoot, canonicalTarget).split(sep).filter(Boolean);
|
||||
const fileName = components.pop();
|
||||
if (fileName === undefined) throw new Error('managed file path names the managed root');
|
||||
|
||||
const rootChain = openDirectoryChain(canonicalRoot);
|
||||
try {
|
||||
let parentFd = rootChain.fd;
|
||||
for (const component of components) {
|
||||
try {
|
||||
parentFd = openSync(
|
||||
procDescriptorPath(parentFd, component),
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch (error) {
|
||||
throw secureFilesystemError(
|
||||
'path ancestor is a symbolic link, unavailable, or not a directory',
|
||||
error,
|
||||
);
|
||||
}
|
||||
rootChain.descriptors.push(parentFd);
|
||||
if (!fstatSync(parentFd).isDirectory()) {
|
||||
throw new Error('path ancestor is a symbolic link or not a directory');
|
||||
}
|
||||
}
|
||||
let fd: number;
|
||||
try {
|
||||
fd = openSync(
|
||||
procDescriptorPath(parentFd, fileName),
|
||||
constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch (error) {
|
||||
throw secureFilesystemError('file is a symbolic link or unavailable', error);
|
||||
}
|
||||
rootChain.descriptors.push(fd);
|
||||
return { fd, descriptors: rootChain.descriptors };
|
||||
} catch (error) {
|
||||
closeDescriptors(rootChain.descriptors);
|
||||
if (error instanceof Error) throw error;
|
||||
throw new Error('secure managed file open failed');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCanonicalContainment(root: string, target: string): void {
|
||||
const canonicalRoot = resolve(root);
|
||||
const canonicalTarget = resolve(target);
|
||||
const rel = relative(canonicalRoot, canonicalTarget);
|
||||
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
||||
throw new Error(`path escapes managed root ${canonicalRoot}: ${canonicalTarget}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject every symlink from the filesystem root through the target's parent. */
|
||||
export function assertNoSymlinkAncestors(target: string): void {
|
||||
const absolute = resolve(target);
|
||||
const parent = dirname(absolute);
|
||||
const pieces = parent.split(sep).filter(Boolean);
|
||||
let cursor: string = sep;
|
||||
for (const piece of pieces) {
|
||||
cursor = resolve(cursor, piece);
|
||||
const stat = lstatSync(cursor);
|
||||
if (stat.isSymbolicLink()) throw new Error(`path ancestor is a symbolic link: ${cursor}`);
|
||||
if (!stat.isDirectory()) throw new Error(`path ancestor is not a directory: ${cursor}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureManagedDirectory(root: string, directory: string): void {
|
||||
assertCanonicalContainment(root, directory);
|
||||
const canonicalRoot = resolve(root);
|
||||
const canonicalDirectory = resolve(directory);
|
||||
assertNoSymlinkAncestors(canonicalRoot);
|
||||
try {
|
||||
const rootStat = lstatSync(canonicalRoot);
|
||||
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
||||
throw new Error(`managed root is not a real directory: ${canonicalRoot}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
|
||||
mkdirSync(canonicalRoot, { mode: 0o700 });
|
||||
const rootStat = lstatSync(canonicalRoot);
|
||||
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
||||
throw new Error(`managed root creation was redirected: ${canonicalRoot}`);
|
||||
}
|
||||
}
|
||||
const rel = relative(canonicalRoot, canonicalDirectory);
|
||||
let cursor = canonicalRoot;
|
||||
for (const piece of rel.split(sep).filter(Boolean)) {
|
||||
cursor = resolve(cursor, piece);
|
||||
try {
|
||||
const stat = lstatSync(cursor);
|
||||
if (stat.isSymbolicLink()) throw new Error(`path ancestor is a symbolic link: ${cursor}`);
|
||||
if (!stat.isDirectory()) throw new Error(`path ancestor is not a directory: ${cursor}`);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
|
||||
mkdirSync(cursor, { mode: 0o700 });
|
||||
const created = lstatSync(cursor);
|
||||
if (!created.isDirectory() || created.isSymbolicLink()) {
|
||||
throw new Error(`managed directory creation was redirected: ${cursor}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a regular file through an O_NOFOLLOW descriptor. The inode is checked
|
||||
* before and after access/read, and executable access is tested against the
|
||||
* already-open descriptor so path replacement cannot redirect the check.
|
||||
*/
|
||||
export function readRegularFileSecure(
|
||||
path: string,
|
||||
options: SecureFileReadOptions,
|
||||
): SecureFileSnapshot {
|
||||
const openedFile = openFileBeneathRoot(options.root, path);
|
||||
try {
|
||||
const opened = fstatSync(openedFile.fd);
|
||||
if (!opened.isFile()) throw new Error('managed file is not a regular file');
|
||||
if (options.maxBytes !== undefined && opened.size > options.maxBytes) {
|
||||
throw new Error(`managed file exceeds ${options.maxBytes} bytes`);
|
||||
}
|
||||
if (options.executable) {
|
||||
try {
|
||||
accessSync(procDescriptorPath(openedFile.fd), constants.X_OK);
|
||||
} catch (error) {
|
||||
throw secureFilesystemError('managed file is not executable', error);
|
||||
}
|
||||
const afterAccess = fstatSync(openedFile.fd);
|
||||
if (!afterAccess.isFile() || !sameIdentity(opened, afterAccess)) {
|
||||
throw new Error('managed file changed during executable access check');
|
||||
}
|
||||
}
|
||||
|
||||
const content = readFileSync(openedFile.fd);
|
||||
const after = fstatSync(openedFile.fd);
|
||||
if (!after.isFile() || !sameIdentity(opened, after)) {
|
||||
throw new Error('managed file changed during secure read');
|
||||
}
|
||||
if (options.maxBytes !== undefined && content.byteLength > options.maxBytes) {
|
||||
throw new Error(`managed file exceeds ${options.maxBytes} bytes`);
|
||||
}
|
||||
return {
|
||||
content,
|
||||
mode: Number(opened.mode),
|
||||
dev: opened.dev,
|
||||
ino: opened.ino,
|
||||
};
|
||||
} finally {
|
||||
closeDescriptors(openedFile.descriptors);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
@@ -24,8 +11,8 @@ import {
|
||||
readInstalledFrameworkVersion,
|
||||
readBundledFrameworkVersion,
|
||||
checkFrameworkDrift,
|
||||
repairFleetCommsTools,
|
||||
} from './update-checker.js';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
|
||||
@@ -79,7 +66,6 @@ describe('readRosterAgentNames', () => {
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: pi',
|
||||
@@ -91,212 +77,6 @@ describe('readRosterAgentNames', () => {
|
||||
);
|
||||
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0', 'reviewer-1']);
|
||||
});
|
||||
|
||||
it('extracts agent names from a JSON-only roster', () => {
|
||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, 'fleet', 'roster.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [
|
||||
{ name: 'orchestrator', runtime: 'pi', class: 'orchestrator' },
|
||||
{ name: 'coder0', runtime: 'claude', class: 'worker' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repairFleetCommsTools', () => {
|
||||
let root: string;
|
||||
let framework: string;
|
||||
let home: string;
|
||||
const toolsContent = '# tools\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
const helperContent = '#!/bin/sh\nexit 0\n';
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'mosaic-tools-repair-'));
|
||||
framework = join(root, 'framework');
|
||||
home = join(root, 'home');
|
||||
mkdirSync(join(framework, 'defaults'), { recursive: true });
|
||||
mkdirSync(join(framework, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(framework, 'defaults', 'TOOLS.md'), toolsContent);
|
||||
const helper = join(framework, 'tools', 'tmux', 'agent-send.sh');
|
||||
writeFileSync(helper, helperContent);
|
||||
chmodSync(helper, 0o755);
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
it('restores a partially deleted current-version installation without package updates', () => {
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'TOOLS.md'), toolsContent);
|
||||
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: true, changed: true });
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(toolsContent);
|
||||
expect(readFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 'utf8')).toBe(helperContent);
|
||||
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).mode & 0o111).not.toBe(0);
|
||||
});
|
||||
|
||||
it('creates a digest-qualified no-clobber backup and is idempotent', () => {
|
||||
mkdirSync(home, { recursive: true });
|
||||
const stale = '# user tools\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), stale);
|
||||
|
||||
const first = repairFleetCommsTools(framework, home);
|
||||
expect(first).toMatchObject({ ok: true, changed: true });
|
||||
expect(first.backupPath).toMatch(/\.pre-fleet-comms-[a-f0-9]{16}\.bak$/);
|
||||
expect(readFileSync(first.backupPath!, 'utf8')).toBe(stale);
|
||||
|
||||
const second = repairFleetCommsTools(framework, home);
|
||||
expect(second).toEqual({ ok: true, changed: false, backupPath: undefined });
|
||||
expect(readFileSync(first.backupPath!, 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('rejects an installed helper symlink without modifying its target', () => {
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'TOOLS.md'), toolsContent);
|
||||
const target = join(root, 'external-helper');
|
||||
writeFileSync(target, 'do not touch\n');
|
||||
symlinkSync(target, join(home, 'tools', 'tmux', 'agent-send.sh'));
|
||||
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('symbolic link');
|
||||
expect(readFileSync(target, 'utf8')).toBe('do not touch\n');
|
||||
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a helper directory before replacing stale TOOLS content', () => {
|
||||
mkdirSync(join(home, 'tools', 'tmux', 'agent-send.sh'), { recursive: true });
|
||||
const stale = '# user tools\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), stale);
|
||||
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('not a regular file');
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('refuses a pre-existing digest backup whose bytes do not match', () => {
|
||||
mkdirSync(home, { recursive: true });
|
||||
const stale = '# user tools\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), stale);
|
||||
const digest = createHash('sha256').update(stale).digest('hex').slice(0, 16);
|
||||
writeFileSync(join(home, `TOOLS.md.pre-fleet-comms-${digest}.bak`), 'collision\n');
|
||||
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('backup collision');
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('rejects a symlink in each installed destination ancestor without external writes', () => {
|
||||
const cases = [
|
||||
{ name: 'home', prefix: join(root, 'linked-home'), suffix: '' },
|
||||
{ name: 'tools', prefix: join(root, 'real-home'), suffix: 'tools' },
|
||||
{ name: 'tmux', prefix: join(root, 'real-home'), suffix: join('tools', 'tmux') },
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const external = join(root, `external-${testCase.name}`);
|
||||
mkdirSync(external, { recursive: true });
|
||||
const targetHome =
|
||||
testCase.name === 'home' ? testCase.prefix : join(root, `installed-${testCase.name}`);
|
||||
if (testCase.name === 'home') {
|
||||
symlinkSync(external, targetHome);
|
||||
} else {
|
||||
mkdirSync(targetHome, { recursive: true });
|
||||
const linkPath = join(targetHome, testCase.suffix);
|
||||
mkdirSync(join(linkPath, '..'), { recursive: true });
|
||||
symlinkSync(external, linkPath);
|
||||
}
|
||||
|
||||
const result = repairFleetCommsTools(framework, targetHome);
|
||||
|
||||
expect(result, testCase.name).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason, testCase.name).toContain('symbolic link');
|
||||
expect(readdirSync(external), testCase.name).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rolls back the backup and exact TOOLS bytes/mode when helper commit fails', () => {
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
const staleTools = '# user tools\n';
|
||||
const staleHelper = '#!/bin/sh\nexit 17\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), staleTools, { mode: 0o640 });
|
||||
writeFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), staleHelper, { mode: 0o710 });
|
||||
|
||||
const result = repairFleetCommsTools(framework, home, {
|
||||
beforeCommit(which) {
|
||||
if (which === 'helper') throw new Error('injected helper commit failure');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.backupPath).toBeUndefined();
|
||||
expect(result.reason).toContain('injected helper commit failure');
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(staleTools);
|
||||
expect(statSync(join(home, 'TOOLS.md')).mode & 0o777).toBe(0o640);
|
||||
expect(readFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 'utf8')).toBe(staleHelper);
|
||||
expect(statSync(join(home, 'tools', 'tmux', 'agent-send.sh')).mode & 0o777).toBe(0o710);
|
||||
expect(readdirSync(home).filter((name) => name.includes('pre-fleet-comms'))).toEqual([]);
|
||||
expect(
|
||||
readdirSync(home).some((name) => name.includes('.repair-')) ||
|
||||
readdirSync(join(home, 'tools', 'tmux')).some((name) => name.includes('.repair-')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rolls back initially absent destinations and created directories on commit failure', () => {
|
||||
const result = repairFleetCommsTools(framework, home, {
|
||||
beforeCommit(which) {
|
||||
if (which === 'helper') throw new Error('injected absent helper failure');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('injected absent helper failure');
|
||||
expect(existsSync(home)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not persist a backup or replacement when backup commit fails', () => {
|
||||
mkdirSync(home, { recursive: true });
|
||||
const stale = '# user tools\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), stale, { mode: 0o640 });
|
||||
|
||||
const result = repairFleetCommsTools(framework, home, {
|
||||
beforeCommit(which) {
|
||||
if (which === 'backup') throw new Error('injected backup commit failure');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
|
||||
expect(statSync(join(home, 'TOOLS.md')).mode & 0o777).toBe(0o640);
|
||||
expect(readdirSync(home).filter((name) => name.includes('pre-fleet-comms'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails before writes when bundled source paths traverse a symlink ancestor', () => {
|
||||
const external = join(root, 'external-source');
|
||||
mkdirSync(join(external, 'defaults'), { recursive: true });
|
||||
mkdirSync(join(external, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(external, 'defaults', 'TOOLS.md'), toolsContent);
|
||||
writeFileSync(join(external, 'tools', 'tmux', 'agent-send.sh'), helperContent, { mode: 0o755 });
|
||||
const linkedFramework = join(root, 'linked-framework');
|
||||
symlinkSync(external, linkedFramework);
|
||||
|
||||
const result = repairFleetCommsTools(linkedFramework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('symbolic link');
|
||||
expect(existsSync(home)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runFrameworkReseed', () => {
|
||||
|
||||
@@ -15,34 +15,16 @@
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import {
|
||||
closeSync,
|
||||
constants,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
fchmodSync,
|
||||
fsyncSync,
|
||||
linkSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmdirSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
readdirSync,
|
||||
copyFileSync,
|
||||
} from 'node:fs';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseFleetRosterV1, resolveInstalledFleetRosterPath } from '../fleet/fleet-roster-v1.js';
|
||||
import {
|
||||
assertCanonicalContainment,
|
||||
assertNoSymlinkAncestors,
|
||||
ensureManagedDirectory,
|
||||
readRegularFileSecure,
|
||||
} from '../fleet/secure-file.js';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -72,10 +54,6 @@ const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const NETWORK_TIMEOUT_MS = 5_000;
|
||||
|
||||
function isNodeErrorCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function npmExec(args: string, timeoutMs = NETWORK_TIMEOUT_MS): string {
|
||||
@@ -522,346 +500,6 @@ export function buildReseedCommand(
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolsRepairResult {
|
||||
ok: boolean;
|
||||
changed: boolean;
|
||||
backupPath?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ToolsRepairHooks {
|
||||
beforeCommit?: (which: 'backup' | 'tools' | 'helper') => void;
|
||||
}
|
||||
|
||||
function optionalSecureFile(
|
||||
path: string,
|
||||
root: string,
|
||||
): ReturnType<typeof readRegularFileSecure> | undefined {
|
||||
try {
|
||||
return readRegularFileSecure(path, { root });
|
||||
} catch (error) {
|
||||
if (isNodeErrorCode(error, 'ENOENT')) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function stageManagedFile(
|
||||
root: string,
|
||||
directory: string,
|
||||
target: string,
|
||||
content: Buffer,
|
||||
mode: number,
|
||||
): string {
|
||||
assertCanonicalContainment(root, target);
|
||||
ensureManagedDirectory(root, directory);
|
||||
assertNoSymlinkAncestors(target);
|
||||
const staged = join(directory, `.${basename(target)}.repair-${process.pid}-${cryptoRandom()}`);
|
||||
assertCanonicalContainment(root, staged);
|
||||
const fd = openSync(
|
||||
staged,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
writeFileSync(fd, content);
|
||||
fchmodSync(fd, mode);
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
return staged;
|
||||
}
|
||||
|
||||
function cryptoRandom(): string {
|
||||
return randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
interface ManagedOriginal {
|
||||
path: string;
|
||||
snapshot?: ReturnType<typeof readRegularFileSecure>;
|
||||
}
|
||||
|
||||
function assertManagedOriginalUnchanged(original: ManagedOriginal, root: string): void {
|
||||
if (!original.snapshot) {
|
||||
try {
|
||||
lstatSync(original.path);
|
||||
throw new Error(`repair destination appeared during staging: ${original.path}`);
|
||||
} catch (error) {
|
||||
if (isNodeErrorCode(error, 'ENOENT')) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const current = readRegularFileSecure(original.path, { root });
|
||||
if (
|
||||
current.dev !== original.snapshot.dev ||
|
||||
current.ino !== original.snapshot.ino ||
|
||||
current.mode !== original.snapshot.mode ||
|
||||
!current.content.equals(original.snapshot.content)
|
||||
) {
|
||||
throw new Error(`repair destination changed during staging: ${original.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function installBackupNoClobber(staged: string, target: string, root: string): void {
|
||||
assertCanonicalContainment(root, target);
|
||||
assertNoSymlinkAncestors(target);
|
||||
try {
|
||||
lstatSync(target);
|
||||
throw new Error(`digest-qualified backup collision at ${target}`);
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
linkSync(staged, target);
|
||||
unlinkSync(staged);
|
||||
}
|
||||
|
||||
function atomicInstall(staged: string, target: string, root: string): void {
|
||||
assertCanonicalContainment(root, target);
|
||||
assertNoSymlinkAncestors(target);
|
||||
try {
|
||||
const current = lstatSync(target);
|
||||
if (current.isSymbolicLink() || !current.isFile()) {
|
||||
throw new Error(`repair destination is not a regular file: ${target}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
renameSync(staged, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly repair the user-owned TOOLS contract and required helper from the
|
||||
* bundled current framework. Existing divergent TOOLS content is preserved in
|
||||
* a digest-qualified no-clobber backup; repeated repairs are idempotent.
|
||||
*/
|
||||
export function repairFleetCommsTools(
|
||||
frameworkRoot = resolveBundledFrameworkRoot(),
|
||||
mosaicHome = join(homedir(), '.config', 'mosaic'),
|
||||
hooks: ToolsRepairHooks = {},
|
||||
): ToolsRepairResult {
|
||||
const sourceTools = join(frameworkRoot, 'defaults', 'TOOLS.md');
|
||||
const sourceHelper = join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh');
|
||||
const installedTools = join(mosaicHome, 'TOOLS.md');
|
||||
const helperDirectory = join(mosaicHome, 'tools', 'tmux');
|
||||
const installedHelper = join(helperDirectory, 'agent-send.sh');
|
||||
let stagedBackup: string | undefined;
|
||||
let stagedTools: string | undefined;
|
||||
let stagedHelper: string | undefined;
|
||||
let rollbackTools: string | undefined;
|
||||
let rollbackHelper: string | undefined;
|
||||
let committedBackup = false;
|
||||
let committedTools = false;
|
||||
let committedHelper = false;
|
||||
let createdHome = false;
|
||||
let createdToolsDirectory = false;
|
||||
let createdHelperDirectory = false;
|
||||
let backupPath: string | undefined;
|
||||
let toolsOriginal: ManagedOriginal | undefined;
|
||||
let helperOriginal: ManagedOriginal | undefined;
|
||||
try {
|
||||
const sourceToolsSnapshot = readRegularFileSecure(sourceTools, { root: frameworkRoot });
|
||||
const sourceHelperSnapshot = readRegularFileSecure(sourceHelper, {
|
||||
root: frameworkRoot,
|
||||
executable: true,
|
||||
});
|
||||
if (!sourceToolsSnapshot.content.includes('<!-- fleet-comms-contract: 1 -->')) {
|
||||
return { ok: false, changed: false, reason: 'bundled TOOLS contract has wrong version' };
|
||||
}
|
||||
|
||||
assertCanonicalContainment(mosaicHome, installedTools);
|
||||
assertCanonicalContainment(mosaicHome, installedHelper);
|
||||
assertNoSymlinkAncestors(mosaicHome);
|
||||
const homeExisted = existsSync(mosaicHome);
|
||||
const toolsDirectory = dirname(helperDirectory);
|
||||
const toolsDirectoryExisted = existsSync(toolsDirectory);
|
||||
const helperDirectoryExisted = existsSync(helperDirectory);
|
||||
if (homeExisted) {
|
||||
const homeStat = lstatSync(mosaicHome);
|
||||
if (homeStat.isSymbolicLink()) {
|
||||
throw new Error(`managed root is a symbolic link: ${mosaicHome}`);
|
||||
}
|
||||
if (!homeStat.isDirectory()) {
|
||||
throw new Error(`managed root is not a real directory: ${mosaicHome}`);
|
||||
}
|
||||
}
|
||||
|
||||
const installedToolsSnapshot = homeExisted
|
||||
? optionalSecureFile(installedTools, mosaicHome)
|
||||
: undefined;
|
||||
let installedHelperSnapshot: ReturnType<typeof readRegularFileSecure> | undefined;
|
||||
let installedHelperExecutable = false;
|
||||
if (homeExisted) {
|
||||
try {
|
||||
installedHelperSnapshot = readRegularFileSecure(installedHelper, {
|
||||
root: mosaicHome,
|
||||
executable: true,
|
||||
});
|
||||
installedHelperExecutable = true;
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT') && !isNodeErrorCode(error, 'EACCES')) throw error;
|
||||
if (isNodeErrorCode(error, 'EACCES')) {
|
||||
installedHelperSnapshot = optionalSecureFile(installedHelper, mosaicHome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toolsChanged = !installedToolsSnapshot?.content.equals(sourceToolsSnapshot.content);
|
||||
const helperChanged =
|
||||
!installedHelperExecutable ||
|
||||
!installedHelperSnapshot?.content.equals(sourceHelperSnapshot.content);
|
||||
if (!toolsChanged && !helperChanged) return { ok: true, changed: false };
|
||||
|
||||
toolsOriginal = { path: installedTools, snapshot: installedToolsSnapshot };
|
||||
helperOriginal = { path: installedHelper, snapshot: installedHelperSnapshot };
|
||||
|
||||
ensureManagedDirectory(dirname(mosaicHome), mosaicHome);
|
||||
createdHome = !homeExisted;
|
||||
ensureManagedDirectory(mosaicHome, helperDirectory);
|
||||
createdToolsDirectory = !toolsDirectoryExisted;
|
||||
createdHelperDirectory = !helperDirectoryExisted;
|
||||
|
||||
if (toolsChanged) {
|
||||
stagedTools = stageManagedFile(
|
||||
mosaicHome,
|
||||
mosaicHome,
|
||||
installedTools,
|
||||
sourceToolsSnapshot.content,
|
||||
sourceToolsSnapshot.mode & 0o777,
|
||||
);
|
||||
if (installedToolsSnapshot) {
|
||||
const digest = createHash('sha256')
|
||||
.update(installedToolsSnapshot.content)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
backupPath = `${installedTools}.pre-fleet-comms-${digest}.bak`;
|
||||
const existingBackup = optionalSecureFile(backupPath, mosaicHome);
|
||||
if (existingBackup && !existingBackup.content.equals(installedToolsSnapshot.content)) {
|
||||
throw new Error(`digest-qualified backup collision at ${backupPath}`);
|
||||
}
|
||||
if (!existingBackup) {
|
||||
stagedBackup = stageManagedFile(
|
||||
mosaicHome,
|
||||
mosaicHome,
|
||||
backupPath,
|
||||
installedToolsSnapshot.content,
|
||||
installedToolsSnapshot.mode & 0o777,
|
||||
);
|
||||
}
|
||||
rollbackTools = stageManagedFile(
|
||||
mosaicHome,
|
||||
mosaicHome,
|
||||
installedTools,
|
||||
installedToolsSnapshot.content,
|
||||
installedToolsSnapshot.mode & 0o777,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (helperChanged) {
|
||||
stagedHelper = stageManagedFile(
|
||||
mosaicHome,
|
||||
helperDirectory,
|
||||
installedHelper,
|
||||
sourceHelperSnapshot.content,
|
||||
sourceHelperSnapshot.mode & 0o777,
|
||||
);
|
||||
if (installedHelperSnapshot) {
|
||||
rollbackHelper = stageManagedFile(
|
||||
mosaicHome,
|
||||
helperDirectory,
|
||||
installedHelper,
|
||||
installedHelperSnapshot.content,
|
||||
installedHelperSnapshot.mode & 0o777,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||
if (stagedBackup && backupPath) {
|
||||
hooks.beforeCommit?.('backup');
|
||||
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||
installBackupNoClobber(stagedBackup, backupPath, mosaicHome);
|
||||
stagedBackup = undefined;
|
||||
committedBackup = true;
|
||||
}
|
||||
if (stagedTools) {
|
||||
hooks.beforeCommit?.('tools');
|
||||
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||
atomicInstall(stagedTools, installedTools, mosaicHome);
|
||||
stagedTools = undefined;
|
||||
committedTools = true;
|
||||
}
|
||||
if (stagedHelper) {
|
||||
hooks.beforeCommit?.('helper');
|
||||
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||
atomicInstall(stagedHelper, installedHelper, mosaicHome);
|
||||
stagedHelper = undefined;
|
||||
committedHelper = true;
|
||||
}
|
||||
if (rollbackTools) unlinkSync(rollbackTools);
|
||||
if (rollbackHelper) unlinkSync(rollbackHelper);
|
||||
return { ok: true, changed: true, backupPath };
|
||||
} catch (error) {
|
||||
const failures: string[] = [];
|
||||
try {
|
||||
if (committedHelper) {
|
||||
if (rollbackHelper) atomicInstall(rollbackHelper, installedHelper, mosaicHome);
|
||||
else unlinkSync(installedHelper);
|
||||
rollbackHelper = undefined;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
failures.push(`helper rollback failed: ${String(rollbackError)}`);
|
||||
}
|
||||
try {
|
||||
if (committedTools) {
|
||||
if (rollbackTools) atomicInstall(rollbackTools, installedTools, mosaicHome);
|
||||
else unlinkSync(installedTools);
|
||||
rollbackTools = undefined;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
failures.push(`TOOLS rollback failed: ${String(rollbackError)}`);
|
||||
}
|
||||
try {
|
||||
if (committedBackup && backupPath) {
|
||||
unlinkSync(backupPath);
|
||||
committedBackup = false;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
failures.push(`backup rollback failed: ${String(rollbackError)}`);
|
||||
}
|
||||
for (const staged of [stagedBackup, stagedTools, stagedHelper, rollbackTools, rollbackHelper]) {
|
||||
if (!staged) continue;
|
||||
try {
|
||||
unlinkSync(staged);
|
||||
} catch {
|
||||
failures.push(`staging cleanup failed: ${staged}`);
|
||||
}
|
||||
}
|
||||
for (const [created, directory] of [
|
||||
[createdHelperDirectory, helperDirectory],
|
||||
[createdToolsDirectory, dirname(helperDirectory)],
|
||||
[createdHome, mosaicHome],
|
||||
] as const) {
|
||||
if (!created) continue;
|
||||
try {
|
||||
rmdirSync(directory);
|
||||
} catch (cleanupError) {
|
||||
if (!isNodeErrorCode(cleanupError, 'ENOENT')) {
|
||||
failures.push(`directory cleanup failed: ${directory}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
ok: false,
|
||||
changed: failures.length > 0,
|
||||
backupPath: committedBackup ? backupPath : undefined,
|
||||
reason: failures.length > 0 ? `${reason}; ${failures.join('; ')}` : reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seed the framework from the freshly-installed package. Returns a result
|
||||
* describing what happened (so callers can message + decide on relaunch).
|
||||
@@ -953,20 +591,25 @@ export function checkFrameworkDrift(
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonically parse the installed fleet roster for relaunch targets. JSON is
|
||||
* considered only when roster.yaml is genuinely absent; all other failures
|
||||
* return no targets rather than guessing.
|
||||
* Best-effort parse of the fleet roster for agent names (used to relaunch
|
||||
* durable agents after a re-seed). Returns [] when no roster exists.
|
||||
*/
|
||||
export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] {
|
||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
if (!existsSync(rosterPath)) return [];
|
||||
let text: string;
|
||||
try {
|
||||
const rosterPath = resolveInstalledFleetRosterPath(mosaicHome);
|
||||
const source = readFileSync(rosterPath, 'utf8');
|
||||
return parseFleetRosterV1(source, rosterPath.endsWith('.json') ? 'json' : 'yaml').agents.map(
|
||||
(agent) => agent.name,
|
||||
);
|
||||
text = readFileSync(rosterPath, 'utf-8');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
// Roster agents are listed as `- name: <id>` entries under `agents:`.
|
||||
const names: string[] = [];
|
||||
for (const line of text.split('\n')) {
|
||||
const m = line.match(/^\s*-?\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
|
||||
if (m && m[1]) names.push(m[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user