Compare commits

..

2 Commits

Author SHA1 Message Date
Jarvis
58af3deb47 test(fleet): correct socket boundary coverage
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:34:54 -05:00
Jarvis
307b07d027 test(fleet): cover reconciler lifecycle gates
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:43:13 -05:00
42 changed files with 919 additions and 7226 deletions

View File

@@ -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) ## KBN-101 Database Runtime/Migration Role Split (#771)
### Problem and objective ### Problem and objective

View File

@@ -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 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. the shell sidecar only provides fallback state when the native marker is stale or absent.
`mosaic agent comms-block <exact-member>` can inspect that exact roster member's resolved Fleet-Comms `mosaic fleet comms-block <role>` can inspect the role's resolved Fleet-Comms block. It is a read-only
block. It is a read-only inspection tool and fails loudly for an unknown exact member or missing roster. inspection tool and fails loudly for an unknown role 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.
## Current M2 boundary ## Current M2 boundary

View File

@@ -41,27 +41,10 @@ artifact can be removed.
| `profiles/software-delivery.yaml` | Canonical profile | shared profile/persona resolver | Retains the governance profile; authority validation remains FCM-M1-002 evidence. | | `profiles/software-delivery.yaml` | Canonical profile | shared profile/persona resolver | Retains the governance profile; authority validation remains FCM-M1-002 evidence. |
| `services/operator-interaction.yaml` | Canonical service policy | service-policy reader/provisioner | Generic provisioning supplies the instance name; the policy itself never names Tess. | | `services/operator-interaction.yaml` | Canonical service policy | service-policy reader/provisioner | Generic provisioning supplies the instance name; the policy itself never names Tess. |
## M4 migration-preview evidence
FCM-M4-001 layers an executable migration posture over the same 13-entry M1 inventory without
changing the retained artifact classification:
- every `v1-fixture` is previewed only with explicit class and lifecycle evidence;
- every `canonical-profile` remains validated by the shared baseline-plus-`roles.local` resolver;
- the canonical service policy remains generic and uses only the approved tool-policy alias.
`validateShippedFleetMigrationDispositions` first runs the existing executable M1 guard, then requires
explicit decisions and lifecycle observations and executes `previewV1ToV2Migration` for every shipped
v1 fixture. `collectShippedFleetMigrationDispositions` derives the 13-entry posture directly from
`SHIPPED_FLEET_ARTIFACT_DISPOSITIONS`, so additions or removals continue to fail the M1 guard rather
than creating a second artifact list. None of these dispositions claims a cutover, canary, or
rollback; those gates belong to FCM-M4-002. See [v1-to-v2 preview](./v1-to-v2.md).
## Running the guard ## Running the guard
```bash ```bash
pnpm --filter @mosaicstack/mosaic test -- v1-v2-migration.spec.ts \ pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts
-t "validates all 13 shipped artifacts and executes ready previews for every v1 fixture"
``` ```
The guard is intentionally limited to shipped assets and validation. It does not generate The guard is intentionally limited to shipped assets and validation. It does not generate

View File

@@ -1,85 +0,0 @@
# Previewing a Fleet Roster v1-to-v2 Migration
**Issue:** #758 · **Card:** FCM-M4-001 · **Effect boundary:** preview only
`mosaic fleet migrate-v1 preview` inventories a v1 roster and emits a canonical v2 candidate plus
recovery evidence. It does not write a roster, apply environment projections, invoke systemd or
`tmux`, contact connectors or remote hosts, launch an agent, run a canary, or execute rollback.
FCM-M4-002 owns reversible cutover and rollback.
## Inputs
```bash
mosaic fleet migrate-v1 preview \
--source roster-v1.yaml \
--decisions migration-decisions.json \
--observations reviewed-observations.json
```
The command emits one JSON object and exits nonzero when the preview is blocked, including when any of
`--source`, `--decisions`, or `--observations` is omitted, passed without a path value, or passed an empty
path value. These request-shape failures are reported before any input file is read. Decision and
observation JSON is validated fail-closed: unknown fields, malformed values, and records for non-local
agents are rejected. Decisions must supply a positive v2 `generation`, a reviewed `fleetHost` whenever
v1 agents include `host` or `ssh`, explicit `defaultRuntime`, and per-local-agent provider, model,
reasoning, enabled state, and launch policy. A supported socket field declared by v1 remains authoritative,
including an explicit empty value representing the default tmux server; a matching `socketName` decision is
accepted, an incompatible decision blocks, and a decision supplies the socket only when both supported v1
socket fields are genuinely absent. If v1 omitted `tool_policy`, decisions must supply an
explicit replacement; it is never derived from `class`. `model_hint` is never split or treated as
authority.
Observations are separate reviewed evidence keyed by local agent name:
```json
{
"coder0": { "systemd": "inactive", "tmux": "missing" }
}
```
Only `active` plus `present` maps to `running`; only `inactive` plus `missing` maps to `stopped`.
Missing, extra, unknown, or contradictory evidence blocks output. An observed-running agent cannot
be marked disabled. Observed-stopped agents always remain stopped.
## Field disposition
| v1 field | v2 disposition |
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version`, `transport`, `tmux`, `defaults`, `runtimes` | Inventoried and structurally compiled; omitted runtimes retain v1 built-in defaults, while each explicitly declared runtime without a reset field follows the production v1 `/clear` fallback; present-empty holder/work-directory/reset values block |
| agent `name`, `alias`, `runtime`, working directory, persona/reset flags | Copied or explicitly defaulted only when absent; present-empty alias/work-directory values block for explicit disposition. Canonical `~`/`~/...` values stay unchanged in roster evidence and traversal-free forms expand only at the shared production environment-projection boundary before unchanged absolute-path validation |
| `provider`, `model_hint`, `reasoning_level` | Explicit provider/model/reasoning decisions; no model-hint inference |
| `class`, `tool_policy` | Only approved aliases canonicalize automatically; other classes require explicit preserve/replace disposition and shared-resolver validation |
| `kickstart_template` | No v2 field; explicit inventory-only disposition required |
| agent `host`, `ssh` | `host != fleetHost` is demonstrably remote and inventory-only; `host == fleetHost` stays local; ssh-only, missing fleet-host evidence, or contradictory targets block |
| agent `socket` | Same-host candidate only when it matches the canonical fleet socket; conflicts block for explicit future disposition |
| root `connector` | Inventory-only; never contacted or reconciled |
| unknown fields or snake/camel synonym collisions | Inventoried and block readiness |
| `.env.generated` | Rebuild from canonical roster data |
| no legacy `.env` | `absent`; no legacy action required |
| legacy `.env` containing generated keys only | `regenerate-only`; replace later from canonical roster data |
| legacy `.env` containing strict local keys | `relocate-local`; preserve those keys in `.env.local` during a later reviewed cutover |
| legacy `.env` containing forbidden/unsafe/sensitive/malformed keys | `quarantine`; private input only, with diagnostics limited to code, key, and SHA-256 |
The only automatic aliases are `implementer → code`, `reviewer → review`, and
`operator-interaction → interaction`. Similar or domain-specific names are never inferred. Automatic
classes do not accept competing disposition records. Semantic validation delegates to the existing
baseline-plus-`roles.local` resolver after the candidate is compiled by the existing v2 compiler.
## Evidence and recovery boundary
Ready output includes source and candidate SHA-256 identities, value-free field inventory, excluded
remote/connector entries, explicit environment dispositions with sanitized diagnostics, and the lifecycle
evidence used for each local candidate. Canonical lifecycle and remote-exclusion evidence ordering compares
Unicode code points directly and does not depend on source-agent order or process locale. Source field
inventory remains position-addressed evidence of the exact input. Recovery is marked non-executable and
assigns the executable gate to FCM-M4-002.
Before any later cutover, preserve these artifacts:
1. authoritative v1 roster backup;
2. agent environment backup, including `.env.local` and private quarantine inputs;
3. reviewed lifecycle observations;
4. canonical candidate v2 roster and its SHA-256.
See [backup and restore](../operations/backup-restore.md). Preview output is migration-readiness
evidence, not proof that migration, canary, or rollback occurred.

View File

@@ -1,40 +0,0 @@
# Fleet Configuration Backup and Restore Boundary
**Issue:** #758 · **Card:** FCM-M4-001
This page defines evidence that must exist before a roster v1-to-v2 cutover. FCM-M4-001 lists these
prerequisites in non-executable recovery evidence but does not validate that backups exist and performs
no backup, migration, canary, or restore. FCM-M4-002 owns the executable reversible canary and rollback
gates.
## Preserve before cutover
- The authoritative v1 roster, byte-for-byte, with a SHA-256 identity.
- Existing per-agent legacy `.env`, strict `.env.local`, and quarantine files under private
permissions.
- Reviewed per-local-agent systemd and exact-socket tmux observations.
- The canonical v2 candidate and its SHA-256 identity.
- Inventory-only remote agents and connector configuration as evidence, not local control-plane input.
`.env.generated` is a rebuildable projection and is not restored as authority. It must be regenerated
from the selected authoritative roster. `.env.local` is operator-owned strict data and must not be
overwritten or absorbed into generated output. Quarantined source remains private evidence; public
diagnostics expose only rule code, key name, and SHA-256.
## Restore requirements
A later rollback implementation must restore the authoritative roster and operator-owned environment
files, regenerate managed projections, and preserve each reviewed pre-cutover stopped/running state.
It must never start an agent observed stopped and must never reconcile an inventory-only remote or
connector entry.
The preview evidence deliberately records:
- `executable: false`;
- required backup artifacts;
- source and candidate identities;
- lifecycle observations and resulting desired states;
- environment relocation/quarantine dispositions;
- FCM-M4-002 as the executable rollback gate owner.
Do not interpret a ready preview as a completed backup, migration, canary, or rollback.

View File

@@ -11,15 +11,8 @@ mosaic fleet restart [name] --expected-generation <n> [--dry-run]
mosaic fleet status [name] mosaic fleet status [name]
mosaic fleet verify mosaic fleet verify
mosaic fleet doctor mosaic fleet doctor
mosaic fleet migrate-v1 preview --source <path> --decisions <path> --observations <path>
``` ```
`migrate-v1 preview` is non-mutating: it emits value-free v1 inventory, a canonical semantically
validated v2 candidate when ready, sanitized environment dispositions, and non-executable recovery
evidence. It has no write, apply, canary, or rollback option. Missing preview inputs also return one stable
blocked JSON object and a non-zero exit, rather than Commander text. See
[the migration preview contract](../migration/v1-to-v2.md).
`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. `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. 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.

View File

@@ -62,24 +62,24 @@ agents:
## Nested fields ## Nested fields
| Path | Required | Constraint | | Path | Required | Constraint |
| ---------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- | | ---------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
| `tmux.socket_name` | yes | `[A-Za-z0-9_.-]*`; empty string means the literal default tmux server, while a non-empty value names a socket | | `tmux.socket_name` | yes | non-empty `[A-Za-z0-9_.-]+`; an explicit named socket prevents default-versus-named socket ambiguity |
| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` | | `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` |
| `defaults.working_directory` | yes | non-empty string | | `defaults.working_directory` | yes | non-empty string |
| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` | | `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| `runtimes.<runtime>.reset_command` | yes | non-empty string; runtime key must be a supported local runtime | | `runtimes.<runtime>.reset_command` | yes | non-empty string; runtime key must be a supported local runtime |
| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity | | `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity |
| `agents[].alias` | yes | non-empty display string | | `agents[].alias` | yes | non-empty display string |
| `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 | | `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 |
| `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` | | `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card | | `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card |
| `agents[].reasoning` | yes | `low`, `medium`, or `high` | | `agents[].reasoning` | yes | `low`, `medium`, or `high` |
| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 | | `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 |
| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans | | `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans |
| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 | | `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 |
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` | | `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch | | `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
## Semantic handoff ## Semantic handoff

View File

@@ -24,7 +24,7 @@
"properties": { "properties": {
"socket_name": { "socket_name": {
"type": "string", "type": "string",
"pattern": "^[A-Za-z0-9_.-]*$" "pattern": "^[A-Za-z0-9_.-]+$"
}, },
"holder_session": { "holder_session": {
"type": "string", "type": "string",

View File

@@ -1,80 +0,0 @@
# FCM-M4-001 — v1-to-v2 inventory, preview, and migrator
- **Task / issue:** FCM-M4-001 / mosaicstack/stack#758
- **Branch / base:** `feat/758-v1-v2-migrator` from `origin/main` `c1aecfabe97a5dc81a72f44910cd4e626f41863f`
- **Base tree:** `46cdfbcdc1d1ff9c7b8b2b9cf3841086590bf774`
- **Scope:** field-complete inventory, non-mutating preview, canonical v2 migration output, and migration/recovery disposition evidence. All effects use injected fakes or temporary fixtures.
- **Budget:** 35K task estimate is the hard working cap. Keep one card/one PR and prefer focused reuse of the v2 compiler, shared role resolver, generated-env boundary, M1 executable disposition inventory, and reconciler observations.
## Objective
Implement preview-first v1 migration that never infers unresolved classes or lifecycle, preserves observed running/stopped state, quarantines forbidden legacy environment inputs with key-name/SHA-256-only diagnostics, inventories remote/connector/schema-only entries without reconciling them, covers every M1-classified shipped artifact, and emits deterministic recovery disposition evidence for the later M4-002 canary/rollback gate.
## Acceptance mapping
1. Field-by-field v1 inventory and no-mutation preview.
2. Canonical output compiled by `roster-v2.ts` and semantically validated by the existing baseline-plus-`roles.local` resolver.
3. Only approved deterministic aliases; every other noncanonical class requires an explicit disposition.
4. Observed stopped/running maps explicitly to persisted lifecycle; stopped observations never produce running targets.
5. Generated env is regenerated; strict local data is relocated; forbidden keys are quarantine inputs reported only by key name and SHA-256.
6. Remote/connector/schema-only entries are inventory-only and excluded from local reconciliation output.
7. Every shipped M1 example/profile/service preset has executable migration disposition evidence.
8. Deterministic migration/recovery evidence records source, output, exclusions, quarantine, and restore prerequisites without executing a canary or rollback.
## Boundaries
Out of scope: FCM-M4-002 executable canary/rollback and host fixture, #766 communications, #636 commands/channels, live fleet/systemd/tmux/session/migration/deploy/connector/remote/gateway effects, `docs/TASKS.md`, parent issue mutation, commit, push, and PR operations.
## TDD plan
Migration rules and redaction are critical data-mutation/security logic, so tests are written red-first for inventory completeness, explicit class disposition, observed-state preservation, quarantine redaction, inventory-only remote/schema entries, compiler/resolver reuse, artifact coverage, and recovery evidence. Production code follows only after the focused tests fail for the missing behavior.
## Plan
1. Map existing v1 loader, v2 compiler/resolver, env quarantine, reconciler observation, and M1 disposition guard.
2. Add behavior-oriented failing migration tests with temporary fixtures and injected observation/filesystem adapters only.
3. Implement the narrow migration module and CLI boundary without a second resolver or live command runner.
4. Add scoped M4 migration/recovery documentation and executable shipped-artifact evidence.
5. Run focused tests, full package tests, package/root typecheck and lint, formatting, diff checks, and adversarial redaction/no-effect verification.
6. Run independent code/security review, remediate findings, reconstruct the synthetic tree using a temporary index, and stop uncommitted.
## Progress
- Collision checks passed: no local/remote branch, worktree, target path, or open PR owned `feat/758-v1-v2-migrator`.
- Dedicated worktree created at the exact green `origin/main` base.
- Required global/repository guides and FCM requirements/evidence loaded.
- No matching migration skill exists under the configured skill directories; no unrelated skill loaded.
- Added a preview-only CLI and migration module that compile with the existing v2 parser/renderer and validate through the shared persona resolver.
- Added value-free raw-v1 inventory, strict unknown-field/synonym/duplicate detection, inventory-only remote and connector handling, and explicit class/tool-policy decisions.
- Added separate reviewed lifecycle observations with only unambiguous running/stopped mappings.
- Added sanitized, non-mutating environment preflight and recovery evidence explicitly marked non-executable.
- Added executable disposition evidence derived from the exact 13-entry M1 inventory and operator documentation.
- Tightened untrusted decisions/observations to reject unknown keys, invalid types/enums, extra local records, and competing automatic-alias dispositions.
- Remediated independent review findings: v1 runtime/reset defaults are preserved, `~` workdirs expand only at env preflight, malformed/required agent fields fail closed before remote exclusion, and all seven shipped v1 fixtures now execute real previews with explicit evidence.
- Remediated socket and locality authority blockers: socket-only agents stay local; `host == fleetHost` stays local; only `host != fleetHost` is inventory-only; ssh-only, missing reviewed fleet-host identity, and contradictory host/ssh targets block explicitly without lifecycle omission.
- Remediated final exact-tree blockers: a declared v1 root socket cannot be overridden; matching/conflicting socket decisions retain reviewed running evidence; canonical ordering uses a shared locale-independent Unicode code-point comparator; migration evidence preserves all four legacy environment dispositions; backup documentation no longer claims validation that M4-001 does not perform.
- Remediated immutable-review socket-presence blocker: both `socket_name` and `socketName` are field-presence-aware, so explicit empty/default-server declarations remain authoritative and incompatible named decisions block rather than replacing them.
- Remediated the replacement-tree blockers: the shared v2 compiler and reconciler accept an explicit empty socket as literal default-server identity; present-empty holder session, default/agent work directory, runtime reset command, and alias values block rather than defaulting; missing preview inputs emit one stable blocked JSON object with non-zero status. Snake/camel aliases and whitespace-only input have adversarial coverage.
- Remediated the subsequent authority blockers: each present-empty CLI path emits exactly one stable blocked JSON object with exit 1 before file reads, and reconciler `start`/`restart` or desired-state `apply`/`reconcile` fail closed before fixed `mosaic-fleet` systemd services can act on a default-server roster.
- Remediated committed-head review blockers: explicitly declared empty runtime objects use the production v1 `/clear` reset fallback while omitted `pi` retains `/new`; lifecycle observations are sorted by canonical agent name; and bare CLI path flags reach preview validation, emit one stable blocked JSON object with exit 1, and perform zero reads. Built production-CLI subprocess tests cover all three bare flags.
- Remediated late-audit blockers: the documented M4 guard invokes the 13-artifact validator and all seven v1 previews; canonical `~`/`~/...` workdirs remain unchanged in migration evidence and traversal-free forms expand at the shared production projection boundary while ordinary relative and home-relative traversal paths remain rejected; remote inventory and exclusion evidence sort canonically; and every default-server lifecycle-mutating reconciler path fails before observation, projection preparation/application, or fixed-unit effects. Explicit regressions preserve `plan`, `status`, `doctor`, and `verify` as observational default-server commands.
- Remediated exact-tree traversal review: `~/../escape` and `~/src/../../escape` remain unexpanded and fail the unchanged shared `unsafe-path` validation. Both the shared generated-environment boundary and the production v1 environment caller have red-first regressions, preventing earlier caller normalization from bypassing the boundary.
## Verification evidence
- Focused migration/compiler/environment/reconciler/CLI: 8 files, 372 tests passed.
- Documented 13-artifact guard: 1 matching test passed and executed all seven v1 previews.
- Full `@mosaicstack/mosaic`: 59 files, 902 tests passed.
- Workspace build: 23 tasks passed.
- Root typecheck: 42 tasks passed.
- Root lint: 23 tasks passed.
- Root format check and `git diff --check`: passed.
- Built production CLI: canonical `~/src` remains in ready roster/YAML evidence; generated projection preflight succeeds with no blockers; a bare path flag emits one blocked JSON object, exit 1, and no stderr.
- Independent high-effort late-audit review: no blocker remained in the four assigned repair surfaces; separate exact-tree security audits found no qualifying newly introduced vulnerability.
- Exact temporary-index synthetic tree includes every tracked changed path; immutable SHA and duplicate reconstruction are recorded in the final handoff.
## Risks / blockers
- M4-001 emits rollback prerequisites/evidence only; executable rollback/canary and the managed/unmanaged host fixture remain owned by M4-002.
- Remote/connector entries remain inventory-only; later federation or connector reconciliation requires separately reviewed work.
- Existing environment data is only preflighted. Cutover backup, quarantine write, legacy removal, and generated projection application remain later reviewed effects.

View File

@@ -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.

View File

@@ -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. 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. 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** **1. Message another agent**`tools/tmux/agent-send.sh` (NOT raw `tmux send-keys`):
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.
Select only a peer row rendered for your exact roster identity. Never invent, substitute, or fuzzy-match ```bash
a host, session, socket, SSH destination, or helper path. If a peer is absent, stop and run the exact tools/tmux/agent-send.sh -s <target-session> -m "message" # or -f <file> to send a file's contents
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.
**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 ```bash
tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ... tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ...

View File

@@ -94,11 +94,11 @@
"type": "string" "type": "string"
}, },
"ssh": { "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" "type": "string"
}, },
"socket": { "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" "type": "string"
}, },
"working_directory": { "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.", "description": "Orchestrator chat connector (F4). Optional — absent means tmux (back-compat). Secrets (access/bot tokens) come from the environment, never this file.",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"oneOf": [ "required": ["kind"],
{ "properties": {
"properties": { "kind": { "const": "tmux" } }, "kind": {
"required": ["kind"], "enum": ["tmux", "discord", "matrix"]
"not": { },
"anyOf": [{ "required": ["discord"] }, { "required": ["matrix"] }] "matrix": {
"type": "object",
"additionalProperties": false,
"required": ["homeserver_url", "user_id", "room_id"],
"properties": {
"homeserver_url": { "type": "string" },
"user_id": { "type": "string" },
"room_id": { "type": "string" }
} }
}, },
{ "discord": {
"type": "object",
"additionalProperties": false,
"required": ["channel_id"],
"properties": { "properties": {
"kind": { "const": "discord" }, "channel_id": { "type": "string" }
"discord": { }
"type": "object",
"additionalProperties": false,
"required": ["channel_id"],
"properties": {
"channel_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
}
}
},
"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"
}
}
}
},
"required": ["kind", "matrix"],
"not": { "required": ["discord"] }
} }
],
"properties": {
"kind": { "enum": ["tmux", "discord", "matrix"] },
"matrix": { "type": "object" },
"discord": { "type": "object" }
} }
} }
} }

View File

@@ -27,11 +27,11 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# fleet/* — the framework SEEDS fleet/examples, fleet/roles, fleet/profiles, and # fleet/* — the framework SEEDS fleet/examples, fleet/roles, fleet/profiles, and
# fleet/roster.schema.json (synced normally — every fleet/roles/*.md role contract # fleet/roster.schema.json (synced normally — every fleet/roles/*.md role contract
# and fleet/profiles/*.yaml system-type profile lands automatically via this sync, # 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 # so no per-file entry is needed; the preserved "fleet/*.yaml" glob is anchored to
# the top level only and do NOT shadow fleet/profiles/*.yaml). The user's # the top level only and does NOT shadow fleet/profiles/*.yaml). The user's
# own fleet files MUST # own fleet files MUST
# survive `mosaic update` (which runs this sync automatically): the active # 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 # (`fleet/agents/`), heartbeat run dir (`fleet/run/`), and the Mosaic-native
# backlog-of-record store (`fleet/backlog/` — embedded PGlite data dir; see # backlog-of-record store (`fleet/backlog/` — embedded PGlite data dir; see
# packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update # 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 # 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 # `mosaic update` — they win over the baseline on merge (AC-NS-7; see
# packages/mosaic/src/commands/fleet-personas.ts). # 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 # 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). # user must not edit them; a divergent copy is backed up once before overwrite).
@@ -200,8 +200,8 @@ sync_framework() {
return return
fi fi
# Fallback: cp-based sync. Exact top-level preserved paths mirror the # Fallback: cp-based sync. Glob-aware so entries like "fleet/*.yaml" preserve
# root-anchored rsync excludes above. # every matching user file (parity with the rsync --exclude path above).
local preserve_tmp="" local preserve_tmp=""
if [[ "$INSTALL_MODE" == "keep" ]]; then if [[ "$INSTALL_MODE" == "keep" ]]; then
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")" preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"

View File

@@ -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: 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'" 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 # F6 — keep-mode re-seed (the `mosaic update` path) MUST NOT wipe user fleet data.
# exact user-owned roster paths while refreshing framework-owned schema/examples. # 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" 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 '# 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\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: custom\n' > "$T6/fleet/my-fleet.yaml"
printf 'version: 1\nagents:\n - name: not-active-roster\n' > "$T6/fleet/my-fleet.yaml"
printf 'ts=x\n' > "$T6/fleet/run/coder0.hb" printf 'ts=x\n' > "$T6/fleet/run/coder0.hb"
printf 'MOSAIC_AGENT_NAME=coder0\n' > "$T6/fleet/agents/coder0.env" printf 'MOSAIC_AGENT_NAME=coder0\n' > "$T6/fleet/agents/coder0.env"
printf '# stale preset\n' > "$T6/fleet/examples/general.yaml" 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" echo 3 > "$T6/.framework-version"
run "$T6" keep 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: user roster.yaml SURVIVES keep-mode sync" "grep -q coder0 '$T6/fleet/roster.yaml'"
chk "F6 reseed: exact roster.json bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.json' '$E6/roster-json.expected'" chk "F6 reseed: other user fleet/*.yaml survives (glob)" "[ -f '$T6/fleet/my-fleet.yaml' ]"
chk "F6 reseed: unrelated fleet YAML is not preserved" "[ ! -f '$T6/fleet/my-fleet.yaml' ]" chk "F6 reseed: per-agent env (fleet/agents) survives" "[ -f '$T6/fleet/agents/coder0.env' ]"
chk "F6 reseed: per-agent env bytes survive" "cmp -s '$T6/fleet/agents/coder0.env' '$E6/agent.expected'" chk "F6 reseed: heartbeat run dir (fleet/run) survives" "[ -f '$T6/fleet/run/coder0.hb' ]"
chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'" chk "F6 reseed: framework examples ARE refreshed (not preserved stale)" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'" chk "F6 reseed: framework roster.schema.json seeded" "[ -f '$T6/fleet/roster.schema.json' ]"
chk "F6 reseed: framework roster schema is refreshed" "cmp -s '$T6/fleet/roster.schema.json' '$FW/fleet/roster.schema.json'"
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6" "$E6" rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6"
echo echo
echo "RESULT: $pass passed, $fail failed" echo "RESULT: $pass passed, $fail failed"
[ "$fail" -eq 0 ] [ "$fail" -eq 0 ]

View File

@@ -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")

View File

@@ -1,5 +1,3 @@
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { Command } from 'commander'; import { Command } from 'commander';
import { registerBrainCommand } from '@mosaicstack/brain'; import { registerBrainCommand } from '@mosaicstack/brain';
@@ -18,8 +16,6 @@ import { registerConfigCommand } from './commands/config.js';
// without throwing. This is the "mosaic <cmd> --help exits 0" gate that // without throwing. This is the "mosaic <cmd> --help exits 0" gate that
// guards the sub-package CLI surface (CU-05-01..08) from silent breakage. // guards the sub-package CLI surface (CU-05-01..08) from silent breakage.
const CLI_PATH = fileURLToPath(new URL('../dist/cli.js', import.meta.url));
const REGISTRARS: Array<[string, (program: Command) => void]> = [ const REGISTRARS: Array<[string, (program: Command) => void]> = [
['auth', registerAuthCommand], ['auth', registerAuthCommand],
['brain', registerBrainCommand], ['brain', registerBrainCommand],
@@ -50,40 +46,6 @@ describe('sub-package CLI smoke (CU-05-10)', () => {
}); });
} }
it.each(['source', 'decisions', 'observations'] as const)(
'production CLI emits one blocked JSON object for bare --%s',
(bareOption) => {
const args = [
CLI_PATH,
'fleet',
'migrate-v1',
'preview',
'--source=source',
'--decisions=decisions',
'--observations=observations',
];
args[args.findIndex((argument) => argument.startsWith(`--${bareOption}=`))] =
`--${bareOption}`;
const result = spawnSync(process.execPath, args, { encoding: 'utf8' });
const outputLines = result.stdout.trim().split('\n');
expect(result.status).toBe(1);
expect(result.stderr).toBe('');
expect(outputLines).toHaveLength(1);
expect(JSON.parse(outputLines[0]!)).toEqual({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option-value',
path: `request.${bareOption}`,
detail: 'Required migration preview option value is missing.',
},
],
});
},
);
it('all nine sub-package commands coexist on a single program', () => { it('all nine sub-package commands coexist on a single program', () => {
const program = new Command(); const program = new Command();
for (const [, register] of REGISTRARS) register(program); for (const [, register] of REGISTRARS) register(program);

View File

@@ -27,7 +27,6 @@ import {
checkForAllUpdates, checkForAllUpdates,
formatAllPackagesTable, formatAllPackagesTable,
getInstallAllCommand, getInstallAllCommand,
repairFleetCommsTools,
runFrameworkReseed, runFrameworkReseed,
refreshActiveFleetUnits, refreshActiveFleetUnits,
readRosterAgentNames, readRosterAgentNames,
@@ -421,142 +420,115 @@ program
'Skip re-seeding framework files into ~/.config/mosaic after the CLI update', '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('--relaunch', 'Restart durable fleet agents so the new launcher/runtime takes effect')
.option( .action(async (opts: { check?: boolean; reseed?: boolean; relaunch?: boolean }) => {
'--repair-tools', // checkForAllUpdates imported statically above
'Restore the supported current-version TOOLS contract and executable fleet helper', const { execSync } = await import('node:child_process');
)
.action( // Re-seed the framework from the freshly-installed package, propagate shipped
async (opts: { // systemd unit fixes to the active units, and (opt-in) relaunch durable
check?: boolean; // agents. Shared by the "packages updated" and the "framework drift" paths.
reseed?: boolean; const reseedFramework = (reason: string): void => {
relaunch?: boolean; console.log(reason);
repairTools?: boolean; const reseed = runFrameworkReseed();
}) => { if (!reseed.ok) {
if (opts.repairTools) { console.error(
const repair = repairFleetCommsTools(); `\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
if (!repair.ok) { ' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
console.error(`Fleet communications tools repair failed: ${repair.reason ?? 'unknown'}`); '(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
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; return;
} }
// checkForAllUpdates imported statically above console.log('✔ Framework re-seeded.');
const { execSync } = await import('node:child_process'); // Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
// Re-seed the framework from the freshly-installed package, propagate shipped const units = refreshActiveFleetUnits();
// systemd unit fixes to the active units, and (opt-in) relaunch durable if (units.refreshed.length > 0) {
// agents. Shared by the "packages updated" and the "framework drift" paths. console.log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`);
const reseedFramework = (reason: string): void => { }
console.log(reason); const agents = readRosterAgentNames();
const reseed = runFrameworkReseed(); if (agents.length === 0) return;
if (!reseed.ok) { if (opts.relaunch) {
console.error( console.log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`);
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` + for (const restart of buildRelaunchCommands(agents)) {
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' + try {
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)', execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
); } catch {
return; console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
}
console.log('✔ Framework re-seeded.');
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
const units = refreshActiveFleetUnits();
if (units.refreshed.length > 0) {
console.log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`);
}
const agents = readRosterAgentNames();
if (agents.length === 0) return;
if (opts.relaunch) {
console.log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`);
for (const restart of buildRelaunchCommands(agents)) {
try {
execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
} catch {
console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
}
} }
console.log('✔ Agents relaunched.');
} else {
console.log(
`\n ${agents.length} fleet agent(s) are still running the previous runtime. ` +
'Restart them to activate the update:\n mosaic update --relaunch ' +
'(or: mosaic fleet restart <agent>)',
);
} }
}; console.log('✔ Agents relaunched.');
} else {
console.log('Checking for updates…'); console.log(
const results = checkForAllUpdates({ skipCache: true }); `\n ${agents.length} fleet agent(s) are still running the previous runtime. ` +
'Restart them to activate the update:\n mosaic update --relaunch ' +
console.log(''); '(or: mosaic fleet restart <agent>)',
console.log(formatAllPackagesTable(results)); );
const outdated = results.filter((r: { updateAvailable: boolean }) => r.updateAvailable);
if (outdated.length === 0) {
const anyInstalled = results.some((r: { current: string }) => r.current);
if (!anyInstalled) {
console.error('No @mosaicstack/* packages are installed.');
process.exit(1);
}
console.log('\n✔ All packages up to date.');
// #642: the CLI may have been upgraded outside `mosaic update` (e.g. a
// direct `npm i -g`), leaving the framework files stale even though no
// package is reported outdated. Detect that via the framework version and
// re-seed so shipped launcher/runtime fixes still activate.
const drift = checkFrameworkDrift();
if (drift.drifted && opts.reseed !== false) {
reseedFramework(
`\nFramework drift detected (on-disk v${drift.installed} < bundled v${drift.bundled}) — ` +
'the CLI was updated outside `mosaic update`. Re-seeding framework files into ' +
'~/.config/mosaic (data-safe; keeps your edits)…',
);
}
return;
} }
};
if (opts.check) { console.log('Checking for updates…');
process.exit(2); // Signal to callers that an update exists const results = checkForAllUpdates({ skipCache: true });
}
console.log(`\nInstalling ${outdated.length} update(s)…`); console.log('');
try { console.log(formatAllPackagesTable(results));
// Relies on @mosaicstack:registry in ~/.npmrc
const cmd = getInstallAllCommand(outdated); const outdated = results.filter((r: { updateAvailable: boolean }) => r.updateAvailable);
execSync(cmd, { if (outdated.length === 0) {
stdio: 'inherit', const anyInstalled = results.some((r: { current: string }) => r.current);
timeout: 60_000, if (!anyInstalled) {
}); console.error('No @mosaicstack/* packages are installed.');
console.log('\n✔ Updated successfully.');
} catch {
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
process.exit(1); process.exit(1);
} }
console.log('\n✔ All packages up to date.');
// F3-m3 / R13: the CLI is updated, but the framework files in // #642: the CLI may have been upgraded outside `mosaic update` (e.g. a
// ~/.config/mosaic/ are still the previous version. Re-seed them from the // direct `npm i -g`), leaving the framework files stale even though no
// freshly-installed package so shipped launcher/runtime changes ACTIVATE. // package is reported outdated. Detect that via the framework version and
// Re-seed when the framework-bearing package itself updated OR the on-disk // re-seed so shipped launcher/runtime fixes still activate.
// framework is older than the freshly-installed one (#642 — e.g. only
// sibling packages were outdated but the CLI was already ahead).
const mosaicUpdated = outdated.some(
(r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE,
);
const drift = checkFrameworkDrift(); const drift = checkFrameworkDrift();
if ((mosaicUpdated || drift.drifted) && opts.reseed !== false) { if (drift.drifted && opts.reseed !== false) {
reseedFramework( reseedFramework(
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…', `\nFramework drift detected (on-disk v${drift.installed} < bundled v${drift.bundled}) — ` +
'the CLI was updated outside `mosaic update`. Re-seeding framework files into ' +
'~/.config/mosaic (data-safe; keeps your edits)…',
); );
} }
}, return;
); }
if (opts.check) {
process.exit(2); // Signal to callers that an update exists
}
console.log(`\nInstalling ${outdated.length} update(s)…`);
try {
// Relies on @mosaicstack:registry in ~/.npmrc
const cmd = getInstallAllCommand(outdated);
execSync(cmd, {
stdio: 'inherit',
timeout: 60_000,
});
console.log('\n✔ Updated successfully.');
} catch {
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
process.exit(1);
}
// F3-m3 / R13: the CLI is updated, but the framework files in
// ~/.config/mosaic/ are still the previous version. Re-seed them from the
// freshly-installed package so shipped launcher/runtime changes ACTIVATE.
// Re-seed when the framework-bearing package itself updated OR the on-disk
// framework is older than the freshly-installed one (#642 — e.g. only
// sibling packages were outdated but the CLI was already ahead).
const mosaicUpdated = outdated.some(
(r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE,
);
const drift = checkFrameworkDrift();
if ((mosaicUpdated || drift.drifted) && opts.reseed !== false) {
reseedFramework(
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…',
);
}
});
// ─── wizard ───────────────────────────────────────────────────────────── // ─── wizard ─────────────────────────────────────────────────────────────

View File

@@ -1,20 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
accessSync,
chmodSync,
constants,
mkdtempSync,
mkdirSync,
writeFileSync,
rmSync,
readFileSync,
renameSync,
symlinkSync,
} from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { FileConfigAdapter } from '../config/file-adapter.js';
import { composeContract } from './launch.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 CONSTITUTION = '# CONSTITUTION\n\nGATE-1: the non-negotiable law.\n';
const AGENTS = '# Mosaic Agent Dispatcher\n\nLoad order + guide router.\n'; const AGENTS = '# Mosaic Agent Dispatcher\n\nLoad order + guide router.\n';
const USER = '# operator\n\nName: Test Operator\n'; const USER = '# operator\n\nName: Test Operator\n';
const TOOLS = '# tools index\n\n<!-- fleet-comms-contract: 1 -->\n'; const TOOLS = '# tools index\n';
const FRAMEWORK_SOURCE = fileURLToPath(new URL('../../framework', import.meta.url));
const SOURCE_TOOLS_PATH = join(FRAMEWORK_SOURCE, 'defaults', 'TOOLS.md');
function makeHome(): { home: string; root: string } { function makeHome(): { home: string; root: string } {
const root = mkdtempSync(join(tmpdir(), 'mosaic-compose-')); const root = mkdtempSync(join(tmpdir(), 'mosaic-compose-'));
@@ -46,12 +31,6 @@ function makeHome(): { home: string; root: string } {
mkdirSync(join(home, 'runtime', h), { recursive: true }); mkdirSync(join(home, 'runtime', h), { recursive: true });
writeFileSync(join(home, 'runtime', h, 'RUNTIME.md'), `# ${h} runtime contract\n`); 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, 'CONSTITUTION.md'), CONSTITUTION);
writeFileSync(join(home, 'AGENTS.md'), AGENTS); writeFileSync(join(home, 'AGENTS.md'), AGENTS);
writeFileSync(join(home, 'USER.md'), USER); writeFileSync(join(home, 'USER.md'), USER);
@@ -63,31 +42,16 @@ describe('composeContract — overlay composer', () => {
let fixture: ReturnType<typeof makeHome>; let fixture: ReturnType<typeof makeHome>;
let prevCwd: string; let prevCwd: string;
let cwdDir: string; let cwdDir: string;
let prevAgentName: string | undefined;
let prevAgentClass: string | undefined;
let prevAgentToolPolicy: string | undefined;
beforeEach(() => { beforeEach(() => {
fixture = makeHome(); fixture = makeHome();
prevCwd = process.cwd(); 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-')); cwdDir = mkdtempSync(join(tmpdir(), 'mosaic-cwd-'));
process.chdir(cwdDir); // neutralize cwd-relative mission/PRD blocks process.chdir(cwdDir); // neutralize cwd-relative mission/PRD blocks
}); });
afterEach(() => { afterEach(() => {
process.chdir(prevCwd); 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(fixture.root, { recursive: true, force: true });
rmSync(cwdDir, { recursive: true, force: true }); rmSync(cwdDir, { recursive: true, force: true });
}); });
@@ -100,17 +64,13 @@ describe('composeContract — overlay composer', () => {
[ [
'version: 1', 'version: 1',
'transport: tmux', 'transport: tmux',
'tmux:',
' socket_name: mosaic-fleet',
'agents:', 'agents:',
' - name: orchestrator', ' - name: orchestrator',
' runtime: claude', ' runtime: claude',
' class: orchestrator', ' class: orchestrator',
' host: w-jarvis',
' - name: enhancer', ' - name: enhancer',
' runtime: claude', ' runtime: claude',
' class: enhancer', ' class: enhancer',
' host: w-jarvis',
' - name: coder0-0', ' - name: coder0-0',
' runtime: claude', ' runtime: claude',
' class: implementer', ' class: implementer',
@@ -122,266 +82,19 @@ describe('composeContract — overlay composer', () => {
const prev = process.env['MOSAIC_AGENT_NAME']; const prev = process.env['MOSAIC_AGENT_NAME'];
try { try {
process.env['MOSAIC_AGENT_NAME'] = 'enhancer'; process.env['MOSAIC_AGENT_NAME'] = 'enhancer';
const outputs = (['claude', 'codex', 'opencode', 'pi'] as const).map((runtime) => const out = composeContract('claude', fixture.home);
composeContract(runtime, fixture.home), expect(out).toContain('# Fleet Comms');
); expect(out).toMatch(/`\[[^\]]+:enhancer\]`/); // own [host:session] identity (host machine-dependent)
for (const out of outputs) { // local peer → no -H; cross-host peer → -H ssh
expect(out).toContain('# Fleet Comms'); expect(out).toContain('-s orchestrator -m "…"');
expect(out).toContain('Host: `w-jarvis`'); expect(out).toContain('-H jwoltje@10.1.10.37 -s coder0-0 -m "…"');
expect(out).toContain('Agent/session: `enhancer`'); expect(out).not.toContain('-H jwoltje@10.1.10.37 -s orchestrator'); // local stays local
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,
]);
} finally { } finally {
if (prev === undefined) delete process.env['MOSAIC_AGENT_NAME']; if (prev === undefined) delete process.env['MOSAIC_AGENT_NAME'];
else process.env['MOSAIC_AGENT_NAME'] = prev; 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)', () => { it('does NOT inject fleet comms when MOSAIC_AGENT_NAME is unset (non-fleet launch)', () => {
const prev = process.env['MOSAIC_AGENT_NAME']; const prev = process.env['MOSAIC_AGENT_NAME'];
try { 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', () => { it('includes the per-tier anchors and the selected harness runtime', () => {
const out = composeContract('claude', fixture.home); const out = composeContract('claude', fixture.home);
expect(out).toContain('GATE-1: the non-negotiable law.'); // L0 expect(out).toContain('GATE-1: the non-negotiable law.'); // L0
@@ -545,14 +240,10 @@ describe('composeContract — overlay composer', () => {
writeFileSync( writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'), join(fixture.home, 'fleet', 'roster.yaml'),
[ [
'version: 1',
'transport: tmux',
'agents:', 'agents:',
' - name: orchestrator', ' - name: orchestrator',
' runtime: claude',
' class: orchestrator', ' class: orchestrator',
' - name: enhancer', ' - name: enhancer',
' runtime: claude',
' class: enhancer', ' class: enhancer',
'', '',
].join('\n'), ].join('\n'),

View File

@@ -1,280 +0,0 @@
import { 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 { registerFleetMigrationCommand } from './fleet-migration-command.js';
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
if (cleanup) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
describe('fleet migrate-v1 preview command', (): void => {
it('emits one ready JSON result without any runtime or file mutation API', async () => {
cleanup = await mkdtemp(join(tmpdir(), 'fleet-migration-command-'));
const rolesDir = join(cleanup, 'roles');
const overrideDir = join(cleanup, 'roles.local');
await mkdir(rolesDir);
await mkdir(overrideDir);
await writeFile(join(rolesDir, 'code.md'), '# code\n');
const files: Record<string, string> = {
source: `version: 1\ntransport: tmux\ntmux:\n socket_name: test\ndefaults:\n working_directory: /srv\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n runtime: pi\n class: implementer\n`,
decisions: JSON.stringify({
generation: 2,
defaultRuntime: 'pi',
agents: {
coder0: {
provider: 'openai',
model: 'gpt-5.6-sol',
reasoning: 'high',
enabled: true,
launchYolo: false,
toolPolicyDisposition: { action: 'replace', className: 'code' },
},
},
}),
observations: JSON.stringify({ coder0: { systemd: 'inactive', tmux: 'missing' } }),
};
const printJson = vi.fn();
const setExitCode = vi.fn();
const program = new Command();
const fleet = program.command('fleet').option('--mosaic-home <path>', '', cleanup);
registerFleetMigrationCommand(fleet, {
mosaicHome: cleanup,
rolesDir,
overrideDir,
readText: async (path): Promise<string> => files[path] ?? '',
printJson,
setExitCode,
});
await program.parseAsync([
'node',
'test',
'fleet',
'migrate-v1',
'preview',
'--source',
'source',
'--decisions',
'decisions',
'--observations',
'observations',
]);
expect(printJson).toHaveBeenCalledTimes(1);
expect(printJson).toHaveBeenCalledWith(expect.objectContaining({ status: 'ready' }));
expect(setExitCode).not.toHaveBeenCalled();
expect(fleet.helpInformation()).toContain('migrate-v1');
const migration = fleet.commands.find((command) => command.name() === 'migrate-v1');
expect(migration?.helpInformation()).not.toMatch(/--write|apply|canary|rollback/);
});
it('emits one stable blocked JSON result when --observations is missing', async () => {
const printJson = vi.fn();
const setExitCode = vi.fn();
const program = new Command().exitOverride();
program.configureOutput({
writeErr: vi.fn(),
writeOut: vi.fn(),
});
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
registerFleetMigrationCommand(fleet, {
mosaicHome: '/unused',
readText: vi.fn(),
printJson,
setExitCode,
});
await expect(
program.parseAsync([
'node',
'test',
'fleet',
'migrate-v1',
'preview',
'--source',
'source',
'--decisions',
'decisions',
]),
).resolves.toBe(program);
expect(printJson).toHaveBeenCalledTimes(1);
expect(printJson).toHaveBeenCalledWith({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option',
path: 'request.observations',
detail: 'Required migration preview option is missing.',
},
],
});
expect(setExitCode).toHaveBeenCalledWith(1);
});
it.each(['source', 'decisions', 'observations'] as const)(
'emits one stable blocked JSON result when bare --%s has no value',
async (bareOption) => {
const printJson = vi.fn();
const setExitCode = vi.fn();
const readText = vi.fn();
const writeErr = vi.fn();
const program = new Command().exitOverride();
program.configureOutput({ writeErr, writeOut: vi.fn() });
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
registerFleetMigrationCommand(fleet, {
mosaicHome: '/unused',
readText,
printJson,
setExitCode,
});
const args = [
'node',
'test',
'fleet',
'migrate-v1',
'preview',
'--source=source',
'--decisions=decisions',
'--observations=observations',
];
args[args.findIndex((argument) => argument.startsWith(`--${bareOption}=`))] =
`--${bareOption}`;
await expect(program.parseAsync(args)).resolves.toBe(program);
expect(printJson).toHaveBeenCalledTimes(1);
expect(printJson).toHaveBeenCalledWith({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option-value',
path: `request.${bareOption}`,
detail: 'Required migration preview option value is missing.',
},
],
});
expect(setExitCode).toHaveBeenCalledTimes(1);
expect(setExitCode).toHaveBeenCalledWith(1);
expect(readText).not.toHaveBeenCalled();
expect(writeErr).not.toHaveBeenCalled();
},
);
it.each(['source', 'decisions', 'observations'] as const)(
'emits one stable blocked JSON result when --%s is present-empty',
async (emptyOption) => {
const printJson = vi.fn();
const setExitCode = vi.fn();
const readText = vi.fn();
const program = new Command().exitOverride();
program.configureOutput({ writeErr: vi.fn(), writeOut: vi.fn() });
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
registerFleetMigrationCommand(fleet, {
mosaicHome: '/unused',
readText,
printJson,
setExitCode,
});
const paths = { source: 'source', decisions: 'decisions', observations: 'observations' };
paths[emptyOption] = '';
await expect(
program.parseAsync([
'node',
'test',
'fleet',
'migrate-v1',
'preview',
`--source=${paths.source}`,
`--decisions=${paths.decisions}`,
`--observations=${paths.observations}`,
]),
).resolves.toBe(program);
expect(printJson).toHaveBeenCalledTimes(1);
expect(printJson).toHaveBeenCalledWith({
status: 'blocked',
blockers: [
{
code: 'empty-migration-preview-option',
path: `request.${emptyOption}`,
detail: 'Required migration preview option must be a non-empty path.',
},
],
});
expect(setExitCode).toHaveBeenCalledTimes(1);
expect(setExitCode).toHaveBeenCalledWith(1);
expect(readText).not.toHaveBeenCalled();
},
);
it('emits a blocked result and sets exit 1 for malformed evidence', async () => {
const printJson = vi.fn();
const setExitCode = vi.fn();
const program = new Command();
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
registerFleetMigrationCommand(fleet, {
mosaicHome: '/unused',
readText: async (path): Promise<string> => (path === 'decisions' ? 'not-json' : '{}'),
printJson,
setExitCode,
});
await program.parseAsync([
'node',
'test',
'fleet',
'migrate-v1',
'preview',
'--source',
'source',
'--decisions',
'decisions',
'--observations',
'observations',
]);
expect(printJson).toHaveBeenCalledWith(
expect.objectContaining({
status: 'blocked',
blockers: [expect.objectContaining({ code: 'migration-preview-failed' })],
}),
);
expect(setExitCode).toHaveBeenCalledWith(1);
});
it('redacts adversarial values from validation failures', async () => {
const secret = 'never-print-command-or-token';
const printJson = vi.fn();
const setExitCode = vi.fn();
const program = new Command();
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
registerFleetMigrationCommand(fleet, {
mosaicHome: '/unused',
readText: async (path): Promise<string> =>
path === 'decisions'
? JSON.stringify({ generation: 2, agents: {}, [secret]: secret })
: '{}',
printJson,
setExitCode,
});
await program.parseAsync([
'node',
'test',
'fleet',
'migrate-v1',
'preview',
'--source',
'source',
'--decisions',
'decisions',
'--observations',
'observations',
]);
expect(JSON.stringify(printJson.mock.calls)).not.toContain(secret);
expect(setExitCode).toHaveBeenCalledWith(1);
});
});

View File

@@ -1,170 +0,0 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { Command } from 'commander';
import {
parseV1MigrationObservations,
parseV1ToV2MigrationDecisions,
previewV1ToV2Migration,
} from '../fleet/v1-v2-migration.js';
export interface FleetMigrationCommandDeps {
readonly mosaicHome?: string;
readonly rolesDir?: string;
readonly overrideDir?: string;
readonly readText?: (path: string) => Promise<string>;
readonly printJson?: (value: unknown) => void;
readonly setExitCode?: (code: number) => void;
}
interface PreviewOptions {
readonly source?: string | boolean;
readonly decisions?: string | boolean;
readonly observations?: string | boolean;
}
/** Registers preview-only v1 migration. This command has no mutation verbs or runners. */
export function registerFleetMigrationCommand(
fleetCommand: Command,
deps: FleetMigrationCommandDeps = {},
): void {
fleetCommand
.command('migrate-v1')
.description('Preview a field-complete v1-to-v2 roster migration')
.command('preview')
.description('Compile migration evidence without writing files or changing runtimes')
.option('--source [path]', 'v1 roster YAML or JSON')
.option('--decisions [path]', 'explicit migration decisions JSON')
.option('--observations [path]', 'reviewed lifecycle observations JSON')
.action(async (options: PreviewOptions): Promise<void> => {
const readText = deps.readText ?? ((path: string): Promise<string> => readFile(path, 'utf8'));
const printJson =
deps.printJson ?? ((value: unknown): void => console.log(JSON.stringify(value)));
const setExitCode =
deps.setExitCode ?? ((code: number): void => void (process.exitCode = code));
try {
const requiredOptionNames = ['source', 'decisions', 'observations'] as const;
const missingOption = requiredOptionNames.find((name) => options[name] === undefined);
if (missingOption !== undefined) {
printJson({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option',
path: `request.${missingOption}`,
detail: 'Required migration preview option is missing.',
},
],
});
setExitCode(1);
return;
}
const missingValueOption = requiredOptionNames.find((name) => options[name] === true);
if (missingValueOption !== undefined) {
printJson({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option-value',
path: `request.${missingValueOption}`,
detail: 'Required migration preview option value is missing.',
},
],
});
setExitCode(1);
return;
}
const emptyOption = requiredOptionNames.find(
(name) => typeof options[name] === 'string' && options[name].trim() === '',
);
if (emptyOption !== undefined) {
printJson({
status: 'blocked',
blockers: [
{
code: 'empty-migration-preview-option',
path: `request.${emptyOption}`,
detail: 'Required migration preview option must be a non-empty path.',
},
],
});
setExitCode(1);
return;
}
const sourcePath = options.source;
const decisionsPath = options.decisions;
const observationsPath = options.observations;
if (
typeof sourcePath !== 'string' ||
typeof decisionsPath !== 'string' ||
typeof observationsPath !== 'string'
) {
throw new Error('Validated migration preview options became unavailable.');
}
const [source, decisionsSource, observationsSource] = await Promise.all([
readText(sourcePath),
readText(decisionsPath),
readText(observationsPath),
]);
const decisions = parseV1ToV2MigrationDecisions(
parseJsonObject(decisionsSource, 'migration decisions'),
);
const observations = parseV1MigrationObservations(
parseJsonObject(observationsSource, 'lifecycle observations'),
);
const mosaicHome =
deps.mosaicHome ?? fleetCommand.opts<{ mosaicHome: string }>().mosaicHome;
const preview = await previewV1ToV2Migration({
source,
sourcePath,
decisions,
observations,
personaDirs: {
rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
},
environment: {
mosaicHome,
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
},
});
printJson(preview);
if (preview.status === 'blocked') setExitCode(1);
} catch (error: unknown) {
printJson({
status: 'blocked',
blockers: [
{
code: 'migration-preview-failed',
path: 'request',
detail: safeErrorDetail(error),
},
],
});
setExitCode(1);
}
});
}
function parseJsonObject(source: string, label: string): unknown {
let value: unknown;
try {
value = JSON.parse(source) as unknown;
} catch {
throw new Error(`${label} must be valid JSON.`);
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${label} must be a JSON object.`);
}
return value;
}
function safeErrorDetail(error: unknown): string {
if (error instanceof Error && isPublishableValidationMessage(error.message)) return error.message;
return 'Migration preview failed without publishable detail.';
}
function isPublishableValidationMessage(message: string): boolean {
return /^(migration decisions|lifecycle observations) must be (valid JSON|a JSON object)\.$/.test(
message,
);
}

View File

@@ -90,7 +90,6 @@ describe('registerFleetCommand', () => {
'init', 'init',
'install', 'install',
'install-systemd', 'install-systemd',
'migrate-v1',
'persona', 'persona',
'plan', 'plan',
'profile', 'profile',
@@ -198,26 +197,6 @@ describe('fleet roster parsing', () => {
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi'); expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
}); });
it('uses /clear for an explicitly declared empty pi runtime config', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'runtimes:',
' pi: {}',
'agents:',
' - name: canary-pi',
' runtime: pi',
].join('\n'),
);
const loaded = await loadFleetRoster(rosterPath);
expect(loaded.runtimes.pi?.resetCommand).toBe('/clear');
});
it('accepts optional agent alias and provider metadata without requiring them', async () => { it('accepts optional agent alias and provider metadata without requiring them', async () => {
cleanup = await tempDir(); cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml'); const rosterPath = join(cleanup, 'roster.yaml');
@@ -291,32 +270,6 @@ describe('fleet roster parsing', () => {
expect(env).toContain('MOSAIC_TMUX_SOCKET=\n'); expect(env).toContain('MOSAIC_TMUX_SOCKET=\n');
}); });
it('preserves home-relative traversal until the shared environment boundary rejects it', async () => {
for (const workingDirectory of ['~/../escape', '~/src/../../escape']) {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
defaults: { working_directory: workingDirectory },
agents: [{ name: 'coder0', runtime: 'pi' }],
}),
);
const roster = await loadFleetRoster(rosterPath);
expect(() => generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toThrow(
expect.objectContaining({
diagnostic: expect.objectContaining({
code: 'unsafe-path',
key: 'MOSAIC_AGENT_WORKDIR',
}),
}),
);
}
});
it('generates deterministic per-agent EnvironmentFile content', async () => { it('generates deterministic per-agent EnvironmentFile content', async () => {
cleanup = await tempDir(); cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json'); const rosterPath = join(cleanup, 'roster.json');
@@ -335,8 +288,8 @@ describe('fleet roster parsing', () => {
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe( expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
[ [
'MOSAIC_AGENT_NAME=coder0', 'MOSAIC_AGENT_NAME=coder0',
// Reflects the roster's canonicalized compatibility class (A3a). // Reflects the roster's non-default `class: implementer` (A3a).
'MOSAIC_AGENT_CLASS=code', 'MOSAIC_AGENT_CLASS=implementer',
'MOSAIC_AGENT_RUNTIME=codex', 'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_MODEL=', 'MOSAIC_AGENT_MODEL=',
'MOSAIC_AGENT_REASONING=', 'MOSAIC_AGENT_REASONING=',
@@ -3552,66 +3505,7 @@ describe('fleet add/remove — pure helpers', () => {
} }
}); });
it.each([ it('serializeRosterToYaml round-trips optional fields (modelHint, workingDirectory)', async () => {
['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 () => {
const rosterWithOptionals: FleetRoster = { const rosterWithOptionals: FleetRoster = {
...baseRoster, ...baseRoster,
agents: [ agents: [
@@ -3623,9 +3517,6 @@ describe('fleet add/remove — pure helpers', () => {
workingDirectory: '/tmp/work', workingDirectory: '/tmp/work',
persistentPersona: true, persistentPersona: true,
resetBetweenTasks: false, resetBetweenTasks: false,
host: '10.1.10.37',
ssh: 'jwoltje@10.1.10.37',
socket: 'mosaic-fleet',
}, },
], ],
}; };
@@ -3633,9 +3524,6 @@ describe('fleet add/remove — pure helpers', () => {
expect(yaml).toContain('model_hint: claude-3-5-sonnet'); expect(yaml).toContain('model_hint: claude-3-5-sonnet');
expect(yaml).toContain('working_directory: /tmp/work'); expect(yaml).toContain('working_directory: /tmp/work');
expect(yaml).toContain('persistent_persona: true'); 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 dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
const rosterPath = join(dir, 'roster.yaml'); const rosterPath = join(dir, 'roster.yaml');
@@ -3645,9 +3533,6 @@ describe('fleet add/remove — pure helpers', () => {
expect(loaded.agents[0]!.modelHint).toBe('claude-3-5-sonnet'); expect(loaded.agents[0]!.modelHint).toBe('claude-3-5-sonnet');
expect(loaded.agents[0]!.workingDirectory).toBe('/tmp/work'); expect(loaded.agents[0]!.workingDirectory).toBe('/tmp/work');
expect(loaded.agents[0]!.persistentPersona).toBe(true); 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 { } finally {
await rm(dir, { recursive: true, force: true }); await rm(dir, { recursive: true, force: true });
} }

View File

@@ -18,27 +18,10 @@ import { spawn } from 'node:child_process';
import * as readline from 'node:readline'; import * as readline from 'node:readline';
import type { Command } from 'commander'; import type { Command } from 'commander';
import YAML from 'yaml'; import YAML from 'yaml';
import {
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 { import {
registerFleetAgentCrudCommands, registerFleetAgentCrudCommands,
type FleetAgentCrudCommandDeps, type FleetAgentCrudCommandDeps,
} from './fleet-agent-crud-command.js'; } from './fleet-agent-crud-command.js';
import {
registerFleetMigrationCommand,
type FleetMigrationCommandDeps,
} from './fleet-migration-command.js';
import { import {
executeReconcilerCommandJson, executeReconcilerCommandJson,
registerFleetReconcilerCommands, registerFleetReconcilerCommands,
@@ -101,7 +84,72 @@ export interface FleetCommandDeps {
isStdinTTY?: boolean; isStdinTTY?: boolean;
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier']; projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps']; reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
migrationDeps?: Omit<FleetMigrationCommandDeps, 'mosaicHome'>; }
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 { export interface FleetPaths {
@@ -122,6 +170,8 @@ type FleetServiceAction = 'start' | 'stop' | 'restart' | 'status';
* fallback for a socket-less roster (that now resolves to the default socket). * fallback for a socket-less roster (that now resolves to the default socket).
*/ */
export const DEFAULT_SOCKET_NAME = 'mosaic-fleet'; 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 * tmux `-L` args for a socket name. An empty/absent socket ⇒ the LITERAL default
@@ -145,6 +195,13 @@ export const VERIFY_POLL_INTERVAL_MS = 400;
* Configurable via `--verify-timeout <ms>` on `agent send`. * Configurable via `--verify-timeout <ms>` on `agent send`.
*/ */
export const VERIFY_DEFAULT_TIMEOUT_MS = 6_000; 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 { export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths {
return { return {
mosaicHome, mosaicHome,
@@ -172,6 +229,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 // NORTH_STAR — machine-readable fleet planning source + Markdown projection
// //
@@ -485,8 +556,8 @@ function generateAgentEnvValues(
MOSAIC_AGENT_MODEL: agent.modelHint ?? '', MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '', MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '', MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
MOSAIC_AGENT_WORKDIR: workingDirectory, MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory),
MOSAIC_TMUX_SOCKET: agent.socket ?? roster.tmux.socketName, MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
}; };
} }
@@ -2053,10 +2124,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// Roster-v2 desired-state mutations belong directly to the fleet control // Roster-v2 desired-state mutations belong directly to the fleet control
// plane; they do not share the root `mosaic agent` gateway-backed surface. // plane; they do not share the root `mosaic agent` gateway-backed surface.
registerFleetAgentCrudCommands(cmd, deps); registerFleetAgentCrudCommands(cmd, deps);
registerFleetMigrationCommand(cmd, {
...deps.migrationDeps,
mosaicHome: deps.mosaicHome,
});
registerFleetReconcilerCommands(cmd, { registerFleetReconcilerCommands(cmd, {
runner, runner,
mosaicHome: deps.mosaicHome, mosaicHome: deps.mosaicHome,
@@ -2090,11 +2157,14 @@ export function registerFleetAgentCommands(
}); });
agentCommand agentCommand
.command('comms-block <exact-member>') .command('comms-block <role>')
.description('Print the Fleet Comms contract for one exact roster member') .description(
.action((exactMember: string) => { "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 mosaicHome = resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome);
const res = resolveCommsBlock(mosaicHome, exactMember); const res = resolveCommsBlock(mosaicHome, role, opts.host);
if (!res.ok) { if (!res.ok) {
console.error(`[mosaic] comms-block: ${res.error}`); console.error(`[mosaic] comms-block: ${res.error}`);
process.exitCode = 1; process.exitCode = 1;
@@ -2420,6 +2490,253 @@ function resolveMosaicHomeFromCommand(command: Command, override?: string): stri
return opts.mosaicHome ?? override ?? defaultMosaicHome(); 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;
}
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> { async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
const failures: string[] = []; const failures: string[] = [];
for (const agentName of agentNames) { for (const agentName of agentNames) {
@@ -2572,23 +2889,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). * Serialize a FleetRoster to YAML text (snake_case keys).
* The output is parseable by loadFleetRoster. * The output is parseable by loadFleetRoster.
@@ -2606,15 +2906,6 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
if (agent.provider !== undefined) { if (agent.provider !== undefined) {
raw['provider'] = agent.provider; 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) { if (agent.workingDirectory !== undefined) {
raw['working_directory'] = agent.workingDirectory; raw['working_directory'] = agent.workingDirectory;
} }
@@ -2656,7 +2947,6 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
}, },
runtimes, runtimes,
agents, agents,
...(roster.connector ? { connector: serializeConnector(roster.connector) } : {}),
}; };
return YAML.stringify(raw); return YAML.stringify(raw);
@@ -2803,5 +3093,6 @@ export async function resolveRosterPath(
if (await canRead(yamlPath)) { if (await canRead(yamlPath)) {
return yamlPath; return yamlPath;
} }
return resolveInstalledFleetRosterPath(mosaicHome); const jsonPath = join(mosaicHome, 'fleet', 'roster.json');
return jsonPath;
} }

View File

@@ -19,17 +19,10 @@ import { createRequire } from 'node:module';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { import { readFleetCommsBlock } from '../fleet/comms-onboarding.js';
buildResolvedFleetCommsBlock,
renderToolsContractStatus,
resolveFleetIdentity,
} from '../fleet/comms-onboarding.js';
import { readRegularFileSecure } from '../fleet/secure-file.js';
import { readPersonaContractBlock } from '../fleet/persona-contract.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 MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi'; 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 { function readJson(path: string): Record<string, unknown> | null {
try { try {
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>; 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); 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 // TOOLS.md
const tools = readInstalledToolsSecure(mosaicHome); const tools = readOptional(join(mosaicHome, 'TOOLS.md'));
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools); 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): // Operator overlays whose base layers are load-on-demand (SOUL, STANDARDS):
// inject only the small `.local` delta by value so the customization reaches // 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 // Runtime-specific contract
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8')); parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
// Fleet launches derive every identity projection from the one canonical roster // Persona contract (A3b): when this agent was spawned with a class
// member resolved above. Non-fleet launches retain the legacy ambient persona // (MOSAIC_AGENT_CLASS, exported into the pane env by A3a), inject its resolved
// and tool-policy behavior. // role contract so its identity (mandate + boundaries) is resident from the
const personaClass = canonicalMember?.className ?? process.env['MOSAIC_AGENT_CLASS']; // first turn. Override-aware via the persona resolver: a user-customized
const persona = readPersonaContractBlock(mosaicHome, personaClass); // 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); if (persona) parts.push('\n\n' + persona);
const toolPolicyName = canonicalMember const toolPolicy = readFleetToolPolicyBlock(process.env['MOSAIC_AGENT_TOOL_POLICY']);
? canonicalMember.toolPolicy
: process.env['MOSAIC_AGENT_TOOL_POLICY'];
const toolPolicy = readFleetToolPolicyBlock(toolPolicyName);
if (toolPolicy) parts.push('\n\n' + toolPolicy); if (toolPolicy) parts.push('\n\n' + toolPolicy);
if (fleetIdentity.identity) { // Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set
const fleetComms = buildResolvedFleetCommsBlock(fleetIdentity.identity); // and present in the roster), inject a comms cheat-sheet + peer roster so it
if (fleetComms) parts.push('\n\n' + fleetComms); // 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'); return parts.join('\n');
} }

View File

@@ -38,10 +38,7 @@ function makeFixture(): { sourceDir: string; mosaicHome: string; defaultsDir: st
writeFileSync(join(defaultsDir, 'CONSTITUTION.md'), '# CONSTITUTION default\n'); writeFileSync(join(defaultsDir, 'CONSTITUTION.md'), '# CONSTITUTION default\n');
writeFileSync(join(defaultsDir, 'AGENTS.md'), '# AGENTS default\n'); writeFileSync(join(defaultsDir, 'AGENTS.md'), '# AGENTS default\n');
writeFileSync(join(defaultsDir, 'STANDARDS.md'), '# STANDARDS default\n'); writeFileSync(join(defaultsDir, 'STANDARDS.md'), '# STANDARDS default\n');
writeFileSync( writeFileSync(join(defaultsDir, 'TOOLS.md'), '# TOOLS default\n');
join(defaultsDir, 'TOOLS.md'),
'# TOOLS default\n\n<!-- fleet-comms-contract: 1 -->\n',
);
// Non-contract files we must NOT seed on first install. // Non-contract files we must NOT seed on first install.
writeFileSync(join(defaultsDir, 'SOUL.md'), '# SOUL default (should not be seeded)\n'); 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) { for (const name of DEFAULT_SEED_FILES) {
expect(existsSync(join(fixture.mosaicHome, name))).toBe(true); 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')).toContain(
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toBe(sourceTools); '# TOOLS default',
);
}); });
it('does NOT seed SOUL.md or USER.md from defaults/ (wizard stages own those)', async () => { 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'); 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 () => { it('preserves user fleet data (roster.yaml, agents/, run/) through a keep-mode sync', async () => {
// Regression for roster loss (#631/#766): user-authored fleet files must // Regression for the roster-loss bug (#631): user-authored fleet files must
// survive the framework re-seed that `mosaic update` runs. // survive the framework re-seed that `mosaic update` runs.
mkdirSync(join(fixture.mosaicHome, 'fleet', 'run'), { recursive: true }); mkdirSync(join(fixture.mosaicHome, 'fleet', 'run'), { recursive: true });
mkdirSync(join(fixture.mosaicHome, 'fleet', 'agents'), { 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.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', 'run', 'a.hb'), 'ts=x\n');
writeFileSync(join(fixture.mosaicHome, 'fleet', 'agents', 'a.env'), 'X=1\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 — it should still seed/refresh.
// The framework ships fleet/examples and roster.schema.json — both refresh.
mkdirSync(join(fixture.sourceDir, 'fleet', 'examples'), { recursive: true }); mkdirSync(join(fixture.sourceDir, 'fleet', 'examples'), { recursive: true });
writeFileSync(join(fixture.sourceDir, 'fleet', 'examples', 'general.yaml'), '# preset\n'); 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); const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
await adapter.syncFramework('keep'); await adapter.syncFramework('keep');
@@ -176,16 +171,10 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.yaml'), 'utf-8')).toBe( expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.yaml'), 'utf-8')).toBe(
'version: 1\nMINE\n', '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', 'run', 'a.hb'))).toBe(true);
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'agents', 'a.env'))).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(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 () => { it('is a no-op for seeding when defaults/ dir does not exist', async () => {

View File

@@ -177,8 +177,7 @@ export class FileConfigAdapter implements ConfigService {
// The framework seeds only fleet/examples + fleet/roles + // The framework seeds only fleet/examples + fleet/roles +
// fleet/roster.schema.json; the operator's roster, per-agent env, and // fleet/roster.schema.json; the operator's roster, per-agent env, and
// heartbeat run dir stay user-owned. (Mirror of install.sh PRESERVE_PATHS.) // heartbeat run dir stay user-owned. (Mirror of install.sh PRESERVE_PATHS.)
'fleet/roster.yaml', 'fleet/*.yaml',
'fleet/roster.json',
'fleet/agents', 'fleet/agents',
'fleet/run', 'fleet/run',
] ]

View File

@@ -1,43 +1,30 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
chmodSync,
mkdtempSync,
mkdirSync,
writeFileSync,
rmSync,
readFileSync,
symlinkSync,
} from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { parseFleetRosterV1, type FleetRoster, type FleetAgent } from './fleet-roster-v1.js';
import { import {
parseRosterAgents,
buildFleetCommsBlock, buildFleetCommsBlock,
renderPeerReach, renderPeerReach,
readFleetCommsBlock, readFleetCommsBlock,
resolveCommsBlock, resolveCommsBlock,
resolvePeerCommand, type CommsPeer,
renderToolsContractStatus,
} from './comms-onboarding.js'; } from './comms-onboarding.js';
const ROSTER = [ const ROSTER = [
'version: 1', 'version: 1',
'transport: tmux', 'transport: tmux',
'tmux:',
' socket_name: mosaic-fleet',
'agents:', 'agents:',
' - name: orchestrator', ' - name: orchestrator',
' runtime: claude', ' runtime: claude',
' class: orchestrator', ' class: orchestrator',
' host: w-jarvis',
' - name: enhancer', ' - name: enhancer',
' runtime: claude', ' runtime: claude',
' class: enhancer', ' class: enhancer',
' host: w-jarvis',
' - name: coder0', ' - name: coder0',
' runtime: pi', ' runtime: pi',
' class: implementer', ' class: implementer',
' host: w-jarvis', ' # a manually-listed cross-host peer (pre-federation stopgap)',
' - name: coder0-0', ' - name: coder0-0',
' runtime: claude', ' runtime: claude',
' class: implementer', ' class: implementer',
@@ -46,687 +33,206 @@ const ROSTER = [
'', '',
].join('\n'); ].join('\n');
function roster(source = ROSTER): FleetRoster { describe('parseRosterAgents', () => {
return parseFleetRosterV1(source, 'yaml'); it('parses name + class + optional host/ssh', () => {
} const peers = parseRosterAgents(ROSTER);
expect(peers.map((p) => p.name)).toEqual(['orchestrator', 'enhancer', 'coder0', 'coder0-0']);
describe('shared fleet roster v1 resolver', () => { expect(peers.find((p) => p.name === 'coder0')).toMatchObject({ className: 'implementer' });
it('resolves comms fields and the global socket through the canonical roster contract', () => { expect(peers.find((p) => p.name === 'coder0-0')).toMatchObject({
const resolved = roster(); className: 'implementer',
expect(resolved.tmux.socketName).toBe('mosaic-fleet');
expect(resolved.agents.find((agent) => agent.name === 'coder0-0')).toMatchObject({
className: 'code',
host: '10.1.10.37', host: '10.1.10.37',
ssh: 'jwoltje@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', () => { it('parses an optional per-agent socket', () => {
expect(() => parseFleetRosterV1(`${ROSTER}\nunknown: value\n`, 'yaml')).toThrow( const peers = parseRosterAgents(
/unknown field/i, ['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', () => { it('stops at the next top-level key', () => {
expect(() => const peers = parseRosterAgents(
roster( ['agents:', ' - name: a', ' class: worker', 'defaults:', ' working_directory: ~'].join(
ROSTER.replace( '\n',
' host: w-jarvis\n - name: coder0',
' host: w-jarvis\n socket: other-socket\n - name: coder0',
),
), ),
).toThrow(/independent per-agent sockets are not supported/i); );
expect(peers.map((p) => p.name)).toEqual(['a']);
}); });
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();
});
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 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', () => { it('renders the short form for a same-host peer', () => {
expect(renderPeerReach(base, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe( const peer: CommsPeer = { name: 'enhancer', className: 'enhancer' };
`${send} -L mosaic-fleet -s peer -m "…"`, expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s enhancer -m "…"`);
);
}); });
it('uses only the explicit roster ssh target for a cross-host peer', () => { it('renders the -H form for a cross-host peer using ssh', () => {
const peer: FleetAgent = { const peer: CommsPeer = {
...base,
name: 'coder0-0', name: 'coder0-0',
className: 'implementer',
host: '10.1.10.37', host: '10.1.10.37',
ssh: 'jwoltje@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 "…"`, `${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', () => { describe('buildFleetCommsBlock', () => {
const send = '/h/.config/mosaic/tools/tmux/agent-send.sh'; 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({ const block = buildFleetCommsBlock({
selfName: 'enhancer', selfName: 'enhancer',
roster: roster(), agents,
localHost: 'ignored-process-host', fleetHost: 'w-jarvis',
agentSendPath: send, agentSendPath: send,
}); });
expect(block).toContain('# Fleet Comms'); expect(block).toContain('# Fleet Comms');
expect(block).toContain('Host: `w-jarvis`'); expect(block).toContain('You are **enhancer**');
expect(block).toContain('Agent/session: `enhancer`'); // criterion 1: agent's own [host:session] identity
expect(block).toContain('tmux socket: `mosaic-fleet`'); expect(block).toContain('`[w-jarvis:enhancer]`');
expect(block).toContain(`Helper: \`${send}\``); // self excluded
expect(block).toMatch(/Comms generation: `[a-f0-9]{64}`/);
expect(block).not.toMatch(/\|\s*enhancer\s*\|/); expect(block).not.toMatch(/\|\s*enhancer\s*\|/);
expect(block).toContain(`${send} -L mosaic-fleet -s orchestrator -m "…"`); // peers present
expect(block).toContain(`${send} -L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`); expect(block).toContain('| orchestrator |');
expect(block).toContain(`mosaic agent comms-block enhancer`); expect(block).toContain('point of contact');
expect(block).toMatch(/Never invent, substitute, or fuzzy-match/i); // same-host peer short form
expect(block).not.toMatch( expect(block).toContain(`${send} -s coder0 -m "…"`);
/<(?:user@host|src_host|src_session|dst_host|dst_session|target-session)>/, // cross-host peer -H form + host annotation
); expect(block).toContain(`${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`);
expect(block).not.toContain('FLIP the preamble'); 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', () => { it('returns empty when the agent has no peers', () => {
const generation = (block: string) => block.match(/Comms generation: `([a-f0-9]{64})`/)?.[1]; expect(
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(() =>
buildFleetCommsBlock({ buildFleetCommsBlock({
selfName: 'enhancer', selfName: 'solo',
roster: bad, agents: [{ name: 'solo', className: 'orchestrator' }],
localHost: 'w-jarvis', fleetHost: 'h',
agentSendPath: send, agentSendPath: send,
}), }),
).toThrow(/explicit roster ssh target/i); ).toBe('');
});
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');
}); });
}); });
describe('resolvePeerCommand', () => { describe('readFleetCommsBlock — situational (the context a spawned agent gets)', () => {
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', () => {
let home: string; let home: string;
beforeEach(() => { beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'mosaic-comms-')); home = mkdtempSync(join(tmpdir(), 'mosaic-comms-'));
mkdirSync(join(home, 'fleet'), { recursive: true }); mkdirSync(join(home, 'fleet'), { recursive: true });
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER); 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 })); afterEach(() => rmSync(home, { recursive: true, force: true }));
it('uses the authoritative self host and global socket from the shared roster resolver', () => { it('builds the cheat-sheet with correct peer addresses for a fleet member', () => {
const result = readFleetCommsBlock(home, 'enhancer', 'process-host-must-not-win'); const block = readFleetCommsBlock(home, 'orchestrator', 'w-jarvis');
expect(result.ok).toBe(true); expect(block).toContain('# Fleet Comms');
expect(result.output).toContain('Host: `w-jarvis`'); expect(block).toContain('| enhancer |');
expect(result.output).toContain('tmux socket: `mosaic-fleet`'); expect(block).toContain(`${join(home, 'tools', 'tmux', 'agent-send.sh')} -s coder0 -m "…"`);
expect(result.output).toContain('-L mosaic-fleet -s orchestrator'); 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', () => { it('returns empty when MOSAIC_AGENT_NAME is unset, no roster, or agent not a member', () => {
const result = readFleetCommsBlock(home, 'stranger', 'w-jarvis'); expect(readFleetCommsBlock(home, undefined, 'w-jarvis')).toBe('');
expect(result.ok).toBe(false); expect(readFleetCommsBlock(home, 'stranger', 'w-jarvis')).toBe('');
expect(result.output).toBe(''); expect(readFleetCommsBlock(mkdtempSync(join(tmpdir(), 'noroster-')), 'orchestrator')).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);
}); });
}); });
describe('renderToolsContractStatus — non-mutating install drift', () => { describe('resolveCommsBlock — `mosaic fleet comms-block <role>` emitter semantics', () => {
let home: string; // 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)
beforeEach(() => { // so `mosaic fleet comms-block bogus` is a visible error, not a confusing no-op. The
home = mkdtempSync(join(tmpdir(), 'mosaic-tools-status-')); // success path returns the block verbatim for `mosaic fleet comms-block <peer>` previews.
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', () => {
let home: string; let home: string;
beforeEach(() => { beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'mosaic-commsblk-')); home = mkdtempSync(join(tmpdir(), 'mosaic-commsblk-'));
mkdirSync(join(home, 'fleet'), { recursive: true }); mkdirSync(join(home, 'fleet'), { recursive: true });
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER); 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 })); afterEach(() => rmSync(home, { recursive: true, force: true }));
it('returns the exact contract for a roster member', () => { it('returns ok + the cheat-sheet for a roster member', () => {
const result = resolveCommsBlock(home, 'enhancer'); const res = resolveCommsBlock(home, 'orchestrator', 'w-jarvis');
expect(result.ok).toBe(true); expect(res.ok).toBe(true);
expect(result.output).toContain('Host: `w-jarvis`'); expect(res.output).toContain('# Fleet Comms');
expect(result.error).toBeUndefined(); expect(res.output).toContain('| enhancer |');
expect(res.error).toBeUndefined();
}); });
it('fails loud and lists known exact names for a non-member', () => { it('fails loud (not ok + error naming the role) for a non-member — never silently empty', () => {
const result = resolveCommsBlock(home, 'stranger'); const res = resolveCommsBlock(home, 'stranger', 'w-jarvis');
expect(result.ok).toBe(false); expect(res.ok).toBe(false);
expect(result.output).toBe(''); expect(res.output).toBe('');
expect(result.error).toContain('stranger'); expect(res.error).toContain('stranger');
expect(result.error).toContain('orchestrator');
expect(result.error).toContain('enhancer');
}); });
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-')); const noRoster = mkdtempSync(join(tmpdir(), 'mosaic-noroster-'));
mkdirSync(join(noRoster, 'tools', 'tmux'), { recursive: true }); const res = resolveCommsBlock(noRoster, 'orchestrator', 'w-jarvis');
const helper = join(noRoster, 'tools', 'tmux', 'agent-send.sh'); expect(res.ok).toBe(false);
writeFileSync(helper, '#!/bin/sh\n'); expect(res.error).toBeTruthy();
chmodSync(helper, 0o755);
const result = resolveCommsBlock(noRoster, 'orchestrator');
expect(result.ok).toBe(false);
expect(result.error).toContain('no fleet roster');
rmSync(noRoster, { recursive: true, force: true }); rmSync(noRoster, { recursive: true, force: true });
}); });
it('fails loud for a missing role argument', () => { it('fails loud for a missing role argument', () => {
const result = resolveCommsBlock(home, undefined); const res = resolveCommsBlock(home, undefined, 'w-jarvis');
expect(result.ok).toBe(false); expect(res.ok).toBe(false);
expect(result.error).toContain('requires'); 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]`');
}); });
}); });

View File

@@ -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 * Fleet agents are born not knowing how to reach their peers — the root cause of
* v1 roster resolver. This module never probes tmux, guesses an SSH target, or * a spawned agent's failed first send. When an agent boots via `mosaic yolo
* mutates an active session. * <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, readFileSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { homedir, hostname } from 'node:os'; import { homedir, hostname } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { readRegularFileSecure } from './secure-file.js';
import { export interface CommsPeer {
parseFleetRosterV1, name: string;
resolveInstalledFleetRosterPath, /** Roster `class` (orchestrator | enhancer | implementer | worker | …). */
getRosterAgent, className: string;
type FleetAgent, /** Host the peer runs on; absent ⇒ the fleet host (same host). */
type FleetRoster, host?: string;
} from './fleet-roster-v1.js'; /** 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 { export interface FleetCommsOptions {
/** Exact current roster member. */ /** This agent's name (it is excluded from its own peer list). */
selfName: string; selfName: string;
/** Canonically resolved roster. */ /** All roster agents (including self; filtered out internally). */
roster: FleetRoster; agents: CommsPeer[];
/** Stable fleet-host baseline for members whose roster host is absent. */ /** Host the fleet runs on (short hostname) — the same-host baseline. */
localHost: string; fleetHost: string;
/** Absolute helper path in this installation. */ /** Absolute path to agent-send.sh in this install. */
agentSendPath: string; agentSendPath: string;
} }
export interface CommsBlockResult { /** Is this peer on a different host than the fleet baseline? */
ok: boolean; function isRemote(peer: CommsPeer, fleetHost: string): boolean {
output: string; return peer.host !== undefined && peer.host !== fleetHost;
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,
});
} }
/** /**
* Read and resolve the installed roster for runtime composition. A requested * Render the exact agent-send command to reach a peer (session = agent name).
* fleet identity fails closed; only a genuinely non-fleet launch (no selfName) * Data-driven per peer: a named `socket` → `-L <socket>`; an unset socket → the
* is a quiet no-op. * 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( export function readFleetCommsBlock(
mosaicHome: string, mosaicHome: string,
selfName: string | undefined, selfName: string | undefined,
localHost: string = shortHostname(), fleetHost: string = hostname().split('.')[0] || 'localhost',
): CommsBlockResult { ): string {
const resolved = resolveFleetIdentity(mosaicHome, selfName, localHost); if (!selfName) return '';
if (!resolved.ok) return { ok: false, output: '', error: resolved.error }; const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
if (!resolved.identity) return { ok: true, output: '' }; if (!existsSync(rosterPath)) return '';
let text: string;
try { try {
return { ok: true, output: buildResolvedFleetCommsBlock(resolved.identity) }; text = readFileSync(rosterPath, 'utf-8');
} catch (error) { } catch {
return { return '';
ok: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
} }
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( export function resolveCommsBlock(
mosaicHome: string, mosaicHome: string,
exactMember: string | undefined, role: string | undefined,
fleetHost?: string,
): CommsBlockResult { ): 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 { return {
ok: false, ok: false,
output: '', 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); return { ok: true, output: block };
}
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()}`;
} }
/** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */
export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic'); export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');

View File

@@ -1,18 +0,0 @@
/** Locale-independent Unicode code-point ordering for canonical fleet evidence. */
export function compareCodePoints(left: string, right: string): number {
const leftPoints = Array.from(left, (character): number => character.codePointAt(0) ?? 0);
const rightPoints = Array.from(right, (character): number => character.codePointAt(0) ?? 0);
const sharedLength = Math.min(leftPoints.length, rightPoints.length);
for (let index = 0; index < sharedLength; index += 1) {
const leftPoint = leftPoints[index];
const rightPoint = rightPoints[index];
if (leftPoint === undefined || rightPoint === undefined) continue;
if (leftPoint !== rightPoint) return leftPoint < rightPoint ? -1 : 1;
}
return leftPoints.length < rightPoints.length
? -1
: leftPoints.length > rightPoints.length
? 1
: 0;
}

View File

@@ -303,22 +303,25 @@ describe('FCM-M3-002 reconciler lifecycle acceptance', (): void => {
expect(process.exitCode).toBe(0); expect(process.exitCode).toBe(0);
}); });
it('rejects a missing canonical roster-v2 tmux socket through parseRosterV2', (): void => { it.each([
const source = renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*\n/m, ''); {
expect(() => parseRosterV2(source, 'yaml')).toThrow( label: 'missing',
'Roster v2 tmux socket_name is required and must be a string.', source: renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*\n/m, ''),
); },
}); {
label: 'empty',
source: renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*$/m, ' socket_name: ""'),
},
])(
'rejects a $label canonical roster-v2 tmux socket through parseRosterV2',
({ source }): void => {
expect(() => parseRosterV2(source, 'yaml')).toThrow(
'Roster v2 tmux socket_name is required and must be a non-empty string.',
);
},
);
it('accepts an explicit empty canonical roster-v2 socket as the literal default server', (): void => { it('uses the literal default tmux server only through the legacy-v1 loader and runtime transport boundary', async (): Promise<void> => {
const source = renderRosterV2Yaml(baseRoster).replace(
/^ socket_name:.*$/m,
' socket_name: ""',
);
expect(parseRosterV2(source, 'yaml').tmux.socketName).toBe('');
});
it('omits -L for the literal default tmux server at the runtime transport boundary', async (): Promise<void> => {
const rosterPath = await fixtureLegacyRoster(); const rosterPath = await fixtureLegacyRoster();
const runner = vi.fn<CommandRunner>( const runner = vi.fn<CommandRunner>(
async (): Promise<CommandResult> => ({ async (): Promise<CommandResult> => ({

View File

@@ -274,95 +274,6 @@ describe('fleet roster-owned reconciler', (): void => {
]); ]);
}); });
it.each(['plan', 'status', 'doctor', 'verify'] as const)(
'keeps default-server %s observational and free of lifecycle effects',
async (command) => {
const calls: string[][] = [];
const defaultServerRoster: FleetRosterV2 = {
...roster,
tmux: { ...roster.tmux, socketName: '' },
};
await expect(
executeFleetReconcile({
roster: defaultServerRoster,
command,
deps: deps({
runner: async (executable, args) => {
calls.push([executable, ...args]);
if (executable === 'tmux' && args.includes('list-sessions')) {
return { stdout: '_holder\n', stderr: '', exitCode: 0 };
}
if (executable === '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=\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: 'ActiveState=inactive\n', stderr: '', exitCode: 0 };
},
}),
}),
).resolves.toMatchObject({ applied: false, lifecycle: 'not-applied' });
expect(
calls.some(
([executable, , action]): boolean =>
executable === 'systemctl' &&
(action === 'start' || action === 'stop' || action === 'restart'),
),
).toBe(false);
},
);
it.each(['start', 'stop', 'restart', 'apply', 'reconcile'] as const)(
'fails closed before %s can route fixed named-socket services for a default-server roster',
async (command) => {
const calls: string[][] = [];
let projectionPrepares = 0;
let projectionApplies = 0;
const defaultServerRoster: FleetRosterV2 = {
...roster,
tmux: { ...roster.tmux, socketName: '' },
agents: [
{
...roster.agents[0]!,
lifecycle: {
enabled: true,
desiredState: command === 'start' || command === 'restart' ? 'running' : 'stopped',
},
},
],
};
await expect(
executeFleetReconcile({
roster: defaultServerRoster,
command,
expectedGeneration: 7,
deps: deps({
readRoster: async () => defaultServerRoster,
prepareProjections: async () => {
projectionPrepares += 1;
return [{ agentName: 'coder0' }];
},
applyProjection: async () => {
projectionApplies += 1;
},
runner: async (executable, args) => {
calls.push([executable, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
},
}),
}),
).rejects.toMatchObject({ code: 'lifecycle-precondition-failed' });
expect(projectionPrepares).toBe(0);
expect(projectionApplies).toBe(0);
expect(calls).toEqual([]);
},
);
it('starts only an explicitly running roster agent with exact systemd targets', async (): Promise<void> => { it('starts only an explicitly running roster agent with exact systemd targets', async (): Promise<void> => {
const calls: string[][] = []; const calls: string[][] = [];
const runningRoster: FleetRosterV2 = { const runningRoster: FleetRosterV2 = {

View File

@@ -176,7 +176,6 @@ export async function executeFleetReconcile(
assertLocalRosterOnly(request.roster); assertLocalRosterOnly(request.roster);
const validateRoster = request.deps.validateRoster ?? defaultValidateRoster(request); const validateRoster = request.deps.validateRoster ?? defaultValidateRoster(request);
await validateRoster(request.roster); await validateRoster(request.roster);
assertLifecycleSocketAuthority(request);
const plan = scopePlan(await observeFleet(request.roster, request.deps), request.agentName); const plan = scopePlan(await observeFleet(request.roster, request.deps), request.agentName);
if (request.command === 'status' || request.command === 'doctor') { if (request.command === 'status' || request.command === 'doctor') {
@@ -478,19 +477,6 @@ function assertVerificationSafe(plan: FleetReconcilePlan): void {
} }
} }
function assertLifecycleSocketAuthority(request: FleetReconcileRequest): void {
const usesFixedLifecycleUnits =
request.command === 'apply' ||
request.command === 'reconcile' ||
isLifecycleCommand(request.command);
if (usesFixedLifecycleUnits && request.roster.tmux.socketName === '') {
throw new FleetReconcileError(
'lifecycle-precondition-failed',
'Default-server lifecycle mutation is unsupported by the fixed named-socket systemd units.',
);
}
}
function targetAgentsFor(roster: FleetRosterV2, agentName?: string): readonly FleetRosterV2Agent[] { function targetAgentsFor(roster: FleetRosterV2, agentName?: string): readonly FleetRosterV2Agent[] {
if (agentName === undefined) return roster.agents; if (agentName === undefined) return roster.agents;
const agent = roster.agents.find( const agent = roster.agents.find(

View File

@@ -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;
}

View File

@@ -9,13 +9,12 @@ import {
symlink, symlink,
writeFile, writeFile,
} from 'node:fs/promises'; } from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { import {
AgentEnvBoundaryError, AgentEnvBoundaryError,
parseAgentEnvironment, parseAgentEnvironment,
previewAgentEnvironmentProjection,
renderGeneratedAgentEnvironment, renderGeneratedAgentEnvironment,
writeAgentEnvironmentProjection, writeAgentEnvironmentProjection,
} from './generated-env-boundary.js'; } from './generated-env-boundary.js';
@@ -87,77 +86,6 @@ describe('generated fleet agent environment boundary', (): void => {
}).toThrow(AgentEnvBoundaryError); }).toThrow(AgentEnvBoundaryError);
}); });
it('rejects traversal in home-relative workdirs before expansion', (): void => {
for (const workingDirectory of ['~/../escape', '~/src/../../escape']) {
expect((): void => {
renderGeneratedAgentEnvironment({
...generatedValues,
MOSAIC_AGENT_WORKDIR: workingDirectory,
});
}).toThrow(
expect.objectContaining({
diagnostic: expect.objectContaining({
code: 'unsafe-path',
key: 'MOSAIC_AGENT_WORKDIR',
}),
}),
);
}
});
it('expands home-relative workdirs before preserving absolute-path validation', (): void => {
expect(
renderGeneratedAgentEnvironment({
...generatedValues,
MOSAIC_AGENT_WORKDIR: '~/src',
}),
).toContain(`MOSAIC_AGENT_WORKDIR=${join(homedir(), 'src')}\n`);
for (const workingDirectory of ['relative/path', '../outside']) {
expect((): void => {
renderGeneratedAgentEnvironment({
...generatedValues,
MOSAIC_AGENT_WORKDIR: workingDirectory,
});
}).toThrow(AgentEnvBoundaryError);
}
});
it('previews legacy relocation and quarantine without exposing content or mutating files', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic');
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
const legacyPath = join(agentEnvDir, 'coder0.env');
const legacy = 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\nMOSAIC_AGENT_COMMAND=never-print-command\n';
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
await writeFile(legacyPath, legacy, { mode: 0o600 });
const preview = await previewAgentEnvironmentProjection({
mosaicHome,
agentEnvDir,
agentName: 'coder0',
generated: generatedValues,
});
expect(preview).toMatchObject({
agentName: 'coder0',
generated: 'rebuild',
legacy: 'quarantine',
relocatedKeys: ['MOSAIC_RUNTIME_BIN'],
diagnostics: [
expect.objectContaining({
code: 'unknown-key',
key: 'MOSAIC_AGENT_COMMAND',
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
}),
],
});
expect(JSON.stringify(preview)).not.toContain('never-print-command');
await expect(readFile(legacyPath, 'utf8')).resolves.toBe(legacy);
await expect(readFile(join(agentEnvDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
await expect(readFile(join(agentEnvDir, 'coder0.env.quarantine'), 'utf8')).rejects.toThrow();
});
it('creates missing managed directories privately before writing a projection', async (): Promise<void> => { it('creates missing managed directories privately before writing a projection', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-')); cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic'); const mosaicHome = join(cleanup, 'mosaic');

View File

@@ -1,8 +1,6 @@
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { compareCodePoints } from './deterministic-order.js';
export type AgentEnvironmentKind = 'generated' | 'local'; export type AgentEnvironmentKind = 'generated' | 'local';
@@ -32,15 +30,6 @@ export interface AgentEnvironmentProjectionResult {
readonly diagnostics: readonly AgentEnvironmentDiagnostic[]; readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
} }
/** Sanitized, non-mutating projection evidence safe for migration output. */
export interface AgentEnvironmentProjectionPreview {
readonly agentName: string;
readonly generated: 'rebuild';
readonly legacy: 'absent' | 'regenerate-only' | 'relocate-local' | 'quarantine';
readonly relocatedKeys: readonly string[];
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
}
/** A projection fully validated without changing managed files. */ /** A projection fully validated without changing managed files. */
export interface PreparedAgentEnvironmentProjection { export interface PreparedAgentEnvironmentProjection {
readonly mosaicHome: string; readonly mosaicHome: string;
@@ -51,7 +40,6 @@ export interface PreparedAgentEnvironmentProjection {
readonly generated: string; readonly generated: string;
readonly local: string; readonly local: string;
readonly legacy?: string; readonly legacy?: string;
readonly legacyRelocatedKeys: readonly string[];
readonly quarantinePath?: string; readonly quarantinePath?: string;
readonly diagnostics: readonly AgentEnvironmentDiagnostic[]; readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
} }
@@ -198,7 +186,6 @@ export async function prepareAgentEnvironmentProjection(
generated, generated,
local, local,
...(legacy === undefined ? {} : { legacy }), ...(legacy === undefined ? {} : { legacy }),
legacyRelocatedKeys: Object.keys(legacyDisposition.localValues).sort(compareCodePoints),
...(quarantinePath === undefined ? {} : { quarantinePath }), ...(quarantinePath === undefined ? {} : { quarantinePath }),
diagnostics: legacyDisposition.diagnostics, diagnostics: legacyDisposition.diagnostics,
}; };
@@ -221,33 +208,6 @@ export async function prepareAgentGeneratedProjectionDeletion(
return generatedPath; return generatedPath;
} }
export async function previewAgentEnvironmentProjection(
options: AgentEnvironmentProjectionOptions,
): Promise<AgentEnvironmentProjectionPreview> {
const prepared = await prepareAgentEnvironmentProjection(options);
const relocatedKeys = prepared.legacyRelocatedKeys;
const legacy =
prepared.legacy === undefined
? 'absent'
: prepared.diagnostics.length > 0
? 'quarantine'
: relocatedKeys.length > 0
? 'relocate-local'
: 'regenerate-only';
return {
agentName: options.agentName,
generated: 'rebuild',
legacy,
relocatedKeys,
diagnostics: [...prepared.diagnostics].sort((left, right): number =>
compareCodePoints(
`${left.key}:${left.code}:${left.sha256}`,
`${right.key}:${right.code}:${right.sha256}`,
),
),
};
}
/** Applies a previously prepared deterministic projection. */ /** Applies a previously prepared deterministic projection. */
export async function applyPreparedAgentEnvironmentProjection( export async function applyPreparedAgentEnvironmentProjection(
prepared: PreparedAgentEnvironmentProjection, prepared: PreparedAgentEnvironmentProjection,
@@ -319,7 +279,7 @@ function normalizeGeneratedValues(
for (const key of GENERATED_AGENT_ENV_KEYS) { for (const key of GENERATED_AGENT_ENV_KEYS) {
const value = values[key]; const value = values[key];
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, ''); if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
normalized[key] = key === 'MOSAIC_AGENT_WORKDIR' ? expandHomeDirectory(value) : value; normalized[key] = value;
} }
for (const [key, value] of Object.entries(values)) { for (const [key, value] of Object.entries(values)) {
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value); if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
@@ -328,23 +288,17 @@ function normalizeGeneratedValues(
return Object.freeze(normalized); return Object.freeze(normalized);
} }
function expandHomeDirectory(path: string): string {
if (path === '~') return homedir();
if (!path.startsWith('~/') || path.split('/').includes('..')) return path;
return join(homedir(), path.slice(2));
}
function renderLocalAgentEnvironment(values: Readonly<Record<string, string>>): string { function renderLocalAgentEnvironment(values: Readonly<Record<string, string>>): string {
if (Object.keys(values).length === 0) return ''; if (Object.keys(values).length === 0) return '';
const parsed = parseAgentEnvironment( const parsed = parseAgentEnvironment(
Object.entries(values) Object.entries(values)
.sort(([left], [right]): number => compareCodePoints(left, right)) .sort(([left], [right]): number => left.localeCompare(right))
.map(([key, value]): string => `${key}=${value}`) .map(([key, value]): string => `${key}=${value}`)
.join('\n'), .join('\n'),
'local', 'local',
); );
return `${Object.entries(parsed) return `${Object.entries(parsed)
.sort(([left], [right]): number => compareCodePoints(left, right)) .sort(([left], [right]): number => left.localeCompare(right))
.map(([key, value]): string => `${key}=${value}`) .map(([key, value]): string => `${key}=${value}`)
.join('\n')}\n`; .join('\n')}\n`;
} }

View File

@@ -45,12 +45,6 @@ agents:
let semanticTmp: string | undefined; let semanticTmp: string | undefined;
it('preserves an explicit empty socket as the literal default tmux server', () => {
const roster = parseRosterV2(validRoster.replace('socket_name: mosaic-fleet', "socket_name: ''"));
expect(roster.tmux.socketName).toBe('');
expect(renderRosterV2Yaml(roster)).toContain('socket_name: ""');
});
afterEach(async (): Promise<void> => { afterEach(async (): Promise<void> => {
if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true }); if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true });
semanticTmp = undefined; semanticTmp = undefined;

View File

@@ -10,7 +10,6 @@ import {
type PersonaResolution, type PersonaResolution,
type RoleAuthority, type RoleAuthority,
} from '../commands/fleet-personas.js'; } from '../commands/fleet-personas.js';
import { compareCodePoints } from './deterministic-order.js';
export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const; export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const;
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const; export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
@@ -173,7 +172,7 @@ export const ROSTER_V2_JSON_SCHEMA: JsonSchema = {
additionalProperties: false, additionalProperties: false,
required: ['socket_name', 'holder_session'], required: ['socket_name', 'holder_session'],
properties: { properties: {
socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]*$' }, socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
holder_session: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' }, holder_session: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
}, },
}, },
@@ -273,7 +272,6 @@ const AGENT_KEYS = [
const LIFECYCLE_KEYS = ['enabled', 'desired_state']; const LIFECYCLE_KEYS = ['enabled', 'desired_state'];
const LAUNCH_KEYS = ['yolo']; const LAUNCH_KEYS = ['yolo'];
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const TMUX_SOCKET_IDENTIFIER = /^[A-Za-z0-9_.-]*$/;
const TMUX_IDENTIFIER = /^[A-Za-z0-9_.-]+$/; const TMUX_IDENTIFIER = /^[A-Za-z0-9_.-]+$/;
const POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/; const POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/;
@@ -329,7 +327,7 @@ function normalizeTmux(value: unknown): FleetRosterV2Tmux {
const raw = requiredObject(value, 'Roster v2 tmux'); const raw = requiredObject(value, 'Roster v2 tmux');
assertKnownKeys(raw, 'Roster v2 tmux', TMUX_KEYS); assertKnownKeys(raw, 'Roster v2 tmux', TMUX_KEYS);
return { return {
socketName: requiredTmuxSocket(raw.socket_name, 'Roster v2 tmux socket_name'), socketName: requiredTmuxIdentifier(raw.socket_name, 'Roster v2 tmux socket_name'),
holderSession: requiredTmuxIdentifier(raw.holder_session, 'Roster v2 tmux holder_session'), holderSession: requiredTmuxIdentifier(raw.holder_session, 'Roster v2 tmux holder_session'),
}; };
} }
@@ -350,7 +348,7 @@ function normalizeRuntimes(value: unknown): Readonly<Record<string, FleetRosterV
throw new RosterV2ValidationError('Roster v2 runtimes must not be empty.'); throw new RosterV2ValidationError('Roster v2 runtimes must not be empty.');
const result: Record<string, FleetRosterV2Runtime> = {}; const result: Record<string, FleetRosterV2Runtime> = {};
for (const name of names.sort(compareCodePoints)) { for (const name of names.sort()) {
const runtime = requiredRuntime(name, 'Roster v2 runtime name'); const runtime = requiredRuntime(name, 'Roster v2 runtime name');
const config = requiredObject(raw[name], `Roster v2 runtime "${runtime}"`); const config = requiredObject(raw[name], `Roster v2 runtime "${runtime}"`);
assertKnownKeys(config, `Roster v2 runtime "${runtime}"`, RUNTIME_KEYS); assertKnownKeys(config, `Roster v2 runtime "${runtime}"`, RUNTIME_KEYS);
@@ -418,7 +416,7 @@ function normalizeAgents(
}; };
}); });
return agents.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number => return agents.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
compareCodePoints(left.name, right.name), left.name.localeCompare(right.name),
); );
} }
@@ -475,17 +473,6 @@ function requiredIdentifier(value: unknown, label: string): string {
return result; return result;
} }
function requiredTmuxSocket(value: unknown, label: string): string {
if (typeof value !== 'string') {
throw new RosterV2ValidationError(`${label} is required and must be a string.`);
}
const result = value.trim();
if (!TMUX_SOCKET_IDENTIFIER.test(result)) {
throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`);
}
return result;
}
function requiredTmuxIdentifier(value: unknown, label: string): string { function requiredTmuxIdentifier(value: unknown, label: string): string {
const result = requiredString(value, label); const result = requiredString(value, label);
if (!TMUX_IDENTIFIER.test(result)) if (!TMUX_IDENTIFIER.test(result))
@@ -537,7 +524,7 @@ function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
}, },
runtimes: Object.fromEntries( runtimes: Object.fromEntries(
Object.entries(roster.runtimes) Object.entries(roster.runtimes)
.sort(([left], [right]): number => compareCodePoints(left, right)) .sort(([left], [right]): number => left.localeCompare(right))
.map(([name, runtime]): [string, unknown] => [ .map(([name, runtime]): [string, unknown] => [
name, name,
{ reset_command: runtime.resetCommand }, { reset_command: runtime.resetCommand },
@@ -545,7 +532,7 @@ function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
), ),
agents: [...roster.agents] agents: [...roster.agents]
.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number => .sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
compareCodePoints(left.name, right.name), left.name.localeCompare(right.name),
) )
.map( .map(
(agent: FleetRosterV2Agent): Record<string, unknown> => ({ (agent: FleetRosterV2Agent): Record<string, unknown> => ({

View File

@@ -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',
);
});
});

View File

@@ -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);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
chmodSync,
existsSync,
lstatSync,
mkdtempSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { import {
@@ -24,8 +11,8 @@ import {
readInstalledFrameworkVersion, readInstalledFrameworkVersion,
readBundledFrameworkVersion, readBundledFrameworkVersion,
checkFrameworkDrift, checkFrameworkDrift,
repairFleetCommsTools,
} from './update-checker.js'; } from './update-checker.js';
import { existsSync, readFileSync } from 'node:fs';
/** /**
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches * F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
@@ -79,7 +66,6 @@ describe('readRosterAgentNames', () => {
join(home, 'fleet', 'roster.yaml'), join(home, 'fleet', 'roster.yaml'),
[ [
'version: 1', 'version: 1',
'transport: tmux',
'agents:', 'agents:',
' - name: orchestrator', ' - name: orchestrator',
' runtime: pi', ' runtime: pi',
@@ -91,212 +77,6 @@ describe('readRosterAgentNames', () => {
); );
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0', 'reviewer-1']); 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', () => { describe('runFrameworkReseed', () => {

View File

@@ -15,34 +15,16 @@
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { import {
closeSync,
constants,
copyFileSync,
existsSync, existsSync,
fchmodSync,
fsyncSync,
linkSync,
lstatSync,
mkdirSync, mkdirSync,
openSync,
readFileSync, readFileSync,
readdirSync,
renameSync,
rmdirSync,
unlinkSync,
writeFileSync, writeFileSync,
readdirSync,
copyFileSync,
} from 'node:fs'; } from 'node:fs';
import { createHash, randomBytes } from 'node:crypto';
import { homedir } from 'node:os'; 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 { fileURLToPath } from 'node:url';
import { parseFleetRosterV1, resolveInstalledFleetRosterPath } from '../fleet/fleet-roster-v1.js';
import {
assertCanonicalContainment,
assertNoSymlinkAncestors,
ensureManagedDirectory,
readRegularFileSecure,
} from '../fleet/secure-file.js';
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
@@ -72,10 +54,6 @@ const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const NETWORK_TIMEOUT_MS = 5_000; const NETWORK_TIMEOUT_MS = 5_000;
function isNodeErrorCode(error: unknown, code: string): boolean {
return error instanceof Error && 'code' in error && error.code === code;
}
// ─── Helpers ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
function npmExec(args: string, timeoutMs = NETWORK_TIMEOUT_MS): string { 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 * Re-seed the framework from the freshly-installed package. Returns a result
* describing what happened (so callers can message + decide on relaunch). * 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 * Best-effort parse of the fleet roster for agent names (used to relaunch
* considered only when roster.yaml is genuinely absent; all other failures * durable agents after a re-seed). Returns [] when no roster exists.
* return no targets rather than guessing.
*/ */
export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] { export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] {
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
if (!existsSync(rosterPath)) return [];
let text: string;
try { try {
const rosterPath = resolveInstalledFleetRosterPath(mosaicHome); text = readFileSync(rosterPath, 'utf-8');
const source = readFileSync(rosterPath, 'utf8');
return parseFleetRosterV1(source, rosterPath.endsWith('.json') ? 'json' : 'yaml').agents.map(
(agent) => agent.name,
);
} catch { } catch {
return []; 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;
} }
/** /**