Compare commits

..

20 Commits

Author SHA1 Message Date
Hermes Agent
8a13286ea6 docs(#771): hold generic storage migration wrapper
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
2026-07-15 08:02:54 -05:00
Hermes Agent
18e253c879 docs(#771): record runner-route delivery evidence 2026-07-15 07:37:10 -05:00
Hermes Agent
d857463a8a docs(#771): hold unimplemented runner routes 2026-07-15 07:35:40 -05:00
Hermes Agent
be0ebfdc6a docs(#771): record delivery evidence 2026-07-15 07:04:39 -05:00
Hermes Agent
3de18fc834 docs(#771): record final route correction 2026-07-15 07:04:39 -05:00
Hermes Agent
981183eb7e docs(#771): hold unsafe database startup routes 2026-07-15 07:04:39 -05:00
Hermes Agent
b0bc036dfb docs(#771): record MILESTONES delivery 2026-07-15 07:04:39 -05:00
Hermes Agent
1beff936f7 docs(#771): close MILESTONES startup authority 2026-07-15 07:04:39 -05:00
Hermes Agent
fecb1b9d08 docs(#771): harden importer deployment contract 2026-07-15 07:04:39 -05:00
Hermes Agent
87bbb222f6 docs(#771): record attestation remediation delivery 2026-07-15 07:04:39 -05:00
Hermes Agent
b1a4ef5ce7 docs(#771): bind tier importer to verified target 2026-07-15 07:04:39 -05:00
Hermes Agent
fcb73352ed docs(#771): remediate Ultron role and migration findings 2026-07-15 07:04:39 -05:00
Hermes Agent
1f66793d63 docs(#771): record rc.9 delivery evidence 2026-07-15 07:04:39 -05:00
Hermes Agent
cb50965ce1 docs(#771): close role split rc.9 residuals 2026-07-15 07:04:39 -05:00
Hermes Agent
7efe2e29bd docs(#771): record rc.8 delivery evidence 2026-07-15 07:04:39 -05:00
Hermes Agent
dad1179fc7 docs(#771): close role split rc.8 residuals 2026-07-15 07:04:38 -05:00
Hermes Agent
256f9f6222 docs(#771): close role split residuals 2026-07-15 07:04:38 -05:00
Hermes Agent
315f388f4f docs(#771): remediate database role split review 2026-07-15 07:04:38 -05:00
Hermes Agent
4d9f99f76e docs(#771): record role-split review evidence 2026-07-15 07:04:38 -05:00
Hermes Agent
d1b657d2b6 docs(#771): freeze database runtime role split 2026-07-15 07:04:38 -05:00
54 changed files with 960 additions and 12024 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)
### Problem and objective

View File

@@ -52,20 +52,20 @@ Active workstream is **W1 — Federation v1**. Workers should:
> the repository quality gates, independent code and security review, terminal-green CI, and
> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 |
| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation |
| FCM-M1-002 | done | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | #768 squash `a5e8e55`; shared resolver and canonical authority/alias validation delivered |
| FCM-M1-003 | done | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | #770 squash `e9c4aa3`; shipped artifact disposition validation delivered |
| FCM-M2-001 | done | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | #772 squash `191efae`; generated/local boundary and private quarantine delivered |
| FCM-M2-002 | done | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | #773 squash `bc5e736`; generation-guarded atomic CRUD and recovery contracts delivered |
| FCM-M3-001 | done | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | #785 squash `4990905`; exact roster-owned systemd/tmux reconcile and lifecycle contracts delivered |
| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only |
| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference |
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session |
| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral |
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI |
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ |
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 |
| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation |
| FCM-M1-002 | in-progress | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | Started 2026-07-14 from `aa5b43b`; one shared resolver only; validator certificate-only; merge-gate sole merge authority |
| FCM-M1-003 | not-started | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | Every shipped artifact must validate, be versioned v1, or be retired with replacement |
| FCM-M2-001 | not-started | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | No arbitrary command compatibility path; diagnostics expose key names/hashes only |
| FCM-M2-002 | not-started | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | Fresh create persists stopped unless explicit persisted start |
| FCM-M3-001 | not-started | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | Exact systemd/tmux ownership; remote/schema-only entries are inventory only |
| FCM-M3-002 | not-started | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Proves stopped-state preservation and zero fuzzy destructive targeting |
| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference |
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session |
| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral |
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI |
## Thin-core prompt diet (#528) — feat/contract-thin-core

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 shell sidecar only provides fallback state when the native marker is stale or absent.
`mosaic agent comms-block <exact-member>` can inspect that exact roster member's resolved Fleet-Comms
block. It is a read-only inspection tool and fails loudly for an unknown exact member or missing roster.
On Linux, the installed roster, TOOLS contract, and executable helper are opened through a held
descriptor chain rooted at `/`; every managed path component uses no-follow traversal, and content plus
execute validation stay bound to the same opened file. Systems without Linux `/proc/self/fd` support
fail closed rather than falling back to pathname revalidation.
`mosaic fleet comms-block <role>` can inspect the role's resolved Fleet-Comms block. It is a read-only
inspection tool and fails loudly for an unknown role or missing roster.
## Current M2 boundary

View File

@@ -1,23 +0,0 @@
# Safely Reconcile and Control a Local Fleet Agent
Use the canonical local roster-v2 command surface:
```sh
mosaic fleet apply --expected-generation <n> --dry-run
mosaic fleet apply --expected-generation <n>
mosaic fleet reconcile --expected-generation <n>
mosaic fleet start <name> --expected-generation <n>
mosaic fleet stop <name> --expected-generation <n>
mosaic fleet restart <name> --expected-generation <n>
mosaic fleet status [name]
mosaic fleet verify
mosaic fleet doctor
```
Start with `--dry-run`. It validates roster semantics, deterministic projections, private managed paths, exact holder ownership, and named-socket state without changing files or lifecycle state. `apply` and `reconcile` rebuild derived projections and enforce only persisted roster state: enabled `running` agents may start, while stopped or disabled agents are not started.
`start`, `stop`, and `restart` are explicit one-shot exact-service actions. They do not persist a lifecycle change. Roster CRUD is the only way to change persisted desired state.
Every command prints JSON. Observation commands report drift without mutation; `verify` exits non-zero on ownership mismatch, unmanaged sessions, or drift. A failed apply that wrote some derived projections reports `projections: "incomplete"` with bounded recovery to regenerate from the roster. A lifecycle failure after projections reports incomplete lifecycle work; it is never represented as a rollback or no-op.
These commands are local only. Remote/SSH/connector entries are inventory/validation-only. Commands do not accept arbitrary runtime commands, channels, secrets, generated-file desired state, or arbitrary tmux sockets.

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. |
| `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
```bash
pnpm --filter @mosaicstack/mosaic test -- v1-v2-migration.spec.ts \
-t "validates all 13 shipped artifacts and executes ready previews for every v1 fixture"
pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts
```
The guard is intentionally limited to shipped assets and validation. It does not generate

View File

@@ -1,86 +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. The v1 source remains authoritative for socket semantics:
a supported declared socket field, including an explicit empty value for the default tmux server, is
preserved; if both supported root aliases are absent, the production v1 default is the literal empty socket.
A matching `socketName` decision is accepted and an incompatible decision blocks, but a decision never
supplies or repairs a missing source socket. 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 targets with or without an explicit user must agree with `host`; ssh-only, missing fleet-host evidence, or contradictory targets block |
| agent `socket` | Same-host candidate only when it matches the canonical fleet socket; conflicts block for explicit future disposition |
| root `connector` | Inventory-only; never contacted or reconciled |
| unknown fields or snake/camel synonym collisions | Inventoried and block readiness |
| `.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

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

View File

@@ -1,27 +0,0 @@
# Fleet Control-Plane CLI
The local roster-v2 control plane is `mosaic fleet`.
```text
mosaic fleet apply --expected-generation <n> [--dry-run]
mosaic fleet reconcile --expected-generation <n> [--dry-run]
mosaic fleet start [name] --expected-generation <n> [--dry-run]
mosaic fleet stop [name] --expected-generation <n> [--dry-run]
mosaic fleet restart [name] --expected-generation <n> [--dry-run]
mosaic fleet status [name]
mosaic fleet verify
mosaic fleet doctor
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.
Commands emit one JSON object. Handled precondition errors emit `{ "error": { "code": "..." } }` and exit non-zero. Partial derived/lifecycle effects use explicit `authoritativeRoster`, `projections`, `lifecycle`, and bounded `recovery` fields; they never claim rollback. Any additive `cleanup` diagnostic also exits non-zero, even where known effects are complete: it is not a clean completion and the lock requires inspection before retry.
This control plane is separate from the gateway-backed `mosaic agent` catalog. It is local-only and rejects remote/connector lifecycle mutation, arbitrary command/channel/secret input, and unproven tmux ownership.

View File

@@ -1,14 +0,0 @@
# Local Fleet Lifecycle Transitions
FCM-M3-001 uses the roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` fields as the only desired-state authority. Systemd, tmux, generated environment files, and heartbeats are derived or observed state.
| Command | Desired-state write | Runtime effect | Preconditions |
| --------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `fleet apply` / `fleet reconcile` | Never | Rebuilds projections, then starts only enabled agents desired `running`; stops disabled or desired-`stopped` roster agents | Current generation; private managed paths; valid projections; proven holder ownership; no unmanaged named-socket sessions |
| `fleet start <name>` | Never | One-shot exact `mosaic-agent@<name>.service` start | Current generation; exact enabled roster name; proven ownership |
| `fleet stop <name>` | Never | One-shot exact service stop | Current generation; exact roster name; proven ownership |
| `fleet restart <name>` | Never | One-shot exact service restart | Current generation; exact roster name; proven ownership |
A stopped roster agent is never started by `apply` or `reconcile`. Direct lifecycle commands are explicit one-shot actions and do not change persisted desired state. Use roster CRUD with the explicit persisted-start option to change that desired state.
All mutations require `--expected-generation <n>` and acquire one private roster-adjacent reconciliation lock before projection or lifecycle effects. Missing or stale generations and concurrent writers fail before effects; the lock is released after success, partial failure, or thrown lifecycle failure. Stale, ownership, unmanaged-session, unsupported-runtime, path, projection, and lifecycle-precondition failures return stable redacted JSON errors and a non-zero exit. No command targets a fuzzy tmux name, arbitrary socket, arbitrary command, channel, secret, or generated file as authority.

View File

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

View File

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

View File

@@ -1,13 +0,0 @@
# Local Fleet Status and Drift
`mosaic fleet status [name]`, `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, change desired state, start services, stop services, restart services, or mutate tmux.
The report distinguishes:
- `missing-session`: an enabled agent desired `running` has no exact roster-named tmux session.
- `unexpected-session`: a desired-`stopped` agent still has its exact session.
- `disabled-running`: a disabled roster agent has its exact session.
- `unmanagedSessions`: sessions on the configured named socket that are neither the exact holder nor an exact roster agent.
- `holder`: `owned`, `missing`, or `ownership-mismatch` after exact holder, private install identity, and complete global tmux environment validation.
`doctor` and `status` classify rather than adopt, destroy, or repair unmanaged state. `verify` is observational too, but exits non-zero if ownership cannot be proven, unmanaged sessions exist, or drift is present. Reconciliation fails closed under those conditions and never kills or adopts an unmanaged session.

View File

@@ -1,39 +0,0 @@
# FCM-M3-001 — Local roster-owned reconciliation and lifecycle
- **Task / issue:** FCM-M3-001 / #758
- **Branch / base:** `feat/758-local-reconciler` from `origin/main` `bc5e73629e92c56a80fa6a769ebad17c0177f504`
- **Base tree:** `1b9ebe4fa1a90734b6f81118e120bae5290cd350`
- **Scope:** source, isolated fake-adapter tests, and card documentation only. No live fleet/systemd/tmux action.
## Objective
Provide local roster-v2 `apply`/`reconcile` and lifecycle/status contracts. The roster remains desired-state authority; projections and runtime observations are derived state.
## Red-first evidence
The initial focused reconciler test failed because `fleet-reconciler.ts` did not exist. The initial new-worktree test invocation also exposed absent dependencies; `pnpm install --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store` restored local workspace dependencies without changing source.
## Design
- A new `fleet-reconciler.ts` accepts only typed roster-v2 input plus injected command and projection adapters.
- It targets only exact `mosaic-agent@<roster-name>.service` units and the exact configured tmux socket/session.
- It classifies unowned/unmanaged state and fails mutation closed rather than adopting or killing it.
- It validates the private install-derived holder identity and complete expected tmux global environment before mutating lifecycle state.
- REVIEW-1 remediation: RED review evidence found service-level apply could omit generation and had no mutation lock. Every non-observational command now requires an expected generation; a private exclusive roster-adjacent lock is acquired before effects and released on success or partial failure. Tests cover missing/stale values, concurrent denial, no effects, release, and lock-free observation.
- REVIEW-2 remediation: lock acquisition now validates private real `MOSAIC_HOME`/`fleet` ancestors, rejects symlink or unsafe leaves, distinguishes `EEXIST` concurrency from other I/O, and binds release to the created inode plus random ownership token. A replacement lock is retained and reported, not unlinked. A crash may leave a stale lock for inspection; no stale-lock break is claimed.
- REVIEW-3 remediation: a lock cleanup failure now adds bounded `cleanup` diagnostics to a known successful or partial effect result without replacing its projection/lifecycle/recovery truth. Cleanup is not claimed as complete, and the retained lock requires inspection before retry.
- REVIEW-4 remediation: command JSON with an additive cleanup diagnostic now exits non-zero even where known effects completed; clean effect and observational JSON remain zero-exit.
- REVIEW-5 remediation: mutating operations acquire the private lock before rereading canonical `roster.yaml`; the fenced reread, not a caller snapshot, supplies generation validation, plan, projection, and lifecycle authority.
- `apply` starts only enabled agents whose persisted desired state is `running`; stopped/default agents are never started by reconciliation.
- Observational commands produce JSON classification only. Partial projection or lifecycle effects report explicit recovery without values.
## Boundaries
Excluded: live host actions, remote/SSH/connector lifecycle mutation, migrations, canaries, deployment, gateway changes, arbitrary command/channel/secret inputs, `docs/TASKS.md`, and orchestration ledgers.
## Verification
- Focused reconciler/Commander/CRUD/fleet tests: 4 files / 229 tests passed.
- Full `@mosaicstack/mosaic` suite: 56 files / 820 tests passed after REVIEW-5 canonical roster fencing remediation.
- Package and root typecheck/lint, root format check, and `git diff --check`: passed.
- Isolated launcher and systemd template harnesses passed; they use fixtures only. No live fleet, systemd, tmux, session, remote, connector, or runtime action occurred.

View File

@@ -1,84 +0,0 @@
# FCM-M3-002 — Reconciler lifecycle acceptance gates
- **Task / issue:** FCM-M3-002 / mosaicstack/stack#758
- **Branch:** `test/758-reconciler-lifecycle-gates`
- **Required starting head:** `499090508ef1d768660e4d54e7934cbcf13cb1cd`
- **Required starting tree:** `2f1bb7fed48291f3f7ba8b21c2b52491aa14fe2b`
- **Scope:** isolated acceptance coverage and card-required evidence/tracking only; no live fleet, systemd, tmux, session, site, migration, canary, deployment, runtime, connector, or remote action.
- **Budget:** use the task estimate of 25K as the working cap; keep the delta to one coherent acceptance suite plus required task/scratchpad evidence. No production change unless a failing reproducer proves an in-scope defect.
## Intake evidence
- Clean exact local branch/head/tree verified before editing.
- `origin/test/758-reconciler-lifecycle-gates` fetched and verified at the same required head.
- The Mosaic PR wrapper reported no open pull requests, so no open-PR branch collision exists.
- Parent issue #758 is open and remains intentionally open through M5.
- Requirements loaded from `docs/PRD.md` FCM requirements and `AC-FCM-05`, `docs/TASKS.md` FCM DAG, the M3 rows in `docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md`, and the FCM-M3-001 implementation scratchpad.
## Objective
Add broad, behavior-oriented acceptance evidence around the shipped local reconciler contracts. Exercise only injected fake systemd/tmux adapters and temporary filesystem fixtures. Prove exact ownership/targeting, persisted stopped-state safety, truthful partial-failure recovery, rollback behavior, and stable command JSON/exit outcomes without touching live services or sessions.
## Acceptance mapping and evidence
| FCM-M3-002 acceptance concern | Delivered isolated evidence |
| --- | --- |
| Systemd/tmux lifecycle | `fleet-reconciler.acceptance.spec.ts` drives apply, reconcile, stop, restart, status, and recovery reconcile through one stateful injected fake host. The fake models exact systemd effects and tmux session observations; no host commands run. |
| Drift | Canonical roster-v2 YAML drives the Commander `fleet status` boundary and classifies `missing-session`, `unexpected-session`, and `disabled-running`, including combined drift, while asserting observation emits no lifecycle mutation. |
| Exact default/named socket targeting | Canonical v2 requires an explicit non-empty named socket; the parser rejects missing/empty values and the Commander acceptance path asserts exact `-L mosaic-fleet` targeting. A separate canonical legacy-v1 roster loader plus runtime-transport path proves a socket-less compatibility roster targets the literal tmux default server with no `-L`. No unreachable empty-socket v2 fixture is used. |
| Unmanaged-session classification | Stateful fixtures report sorted `coder0-shadow`/`unmanaged` sessions, then prove an exact roster stop leaves both sessions and the near-collision service intact. |
| Crash/partial failure | Injected restart failure is applied after the fake effect to model crash/partial truth: result is `lifecycle: incomplete`, the roster is unchanged, and observed runtime may be active. |
| Rollback/recovery semantics | M3 has no rollback command and explicitly does not claim automatic rollback. The acceptance workflow proves the bounded recovery contract: inspect, then exact reconcile restores the persisted stopped target without a start or fuzzy effect. M4 migration/canary rollback remains outside this card. |
| Stopped-state preservation | Stateful apply and reconcile both stop an initially running observed agent whose persisted target is stopped; failed explicit restart leaves desired state stopped; recovery reconcile restores stopped state. No start call is emitted. |
| Zero fuzzy destructive targeting | Near-collision `coder0-shadow` service/session plus `unmanaged` session remain untouched. The recorded destructive calls contain only exact `mosaic-agent@coder0.service`; no tmux kill action is emitted. |
| Stable JSON/exit behavior | Temporary canonical roster fixture invokes the CLI boundary and asserts exactly one JSON line, exact partial-result shape, and exit code 1. Existing focused command specs continue to cover clean zero-exit and stable error JSON. |
| Redacted truthful recovery | Fake stderr includes `PASSWORD=acceptance-secret`; exact CLI JSON contains only bounded recovery metadata and excludes the key, value, and raw diagnostic. |
## Plan
1. Inventory existing reconciler and command specs against the table above; avoid duplicating narrow assertions already present.
2. Add one acceptance-level spec using only fake/injected adapters and temporary files.
3. If a real defect is exposed, preserve the failing reproducer and make only the smallest FCM-M3-002-required fix; otherwise leave production unchanged.
4. Reconcile `docs/TASKS.md` only for delivered M1/M2/M3-001 truth and mark FCM-M3-002 in progress.
5. Run focused tests, full `@mosaicstack/mosaic` tests, package/root typecheck and lint, Prettier/format and diff checks, plus adversarial fake-runner cases.
6. Record exact evidence and leave the tree uncommitted for independent synthetic-tree review.
## TDD decision
This card adds acceptance coverage to already-delivered behavior. Test-first applies to any product defect discovered: retain a failing reproducer before an in-scope fix. If the shipped behavior already satisfies the acceptance contract, no production code will be changed and the acceptance suite itself is the deliverable.
## Progress
- Intake and immutable baseline verification complete.
- Existing coverage inventory confirmed strong unit coverage but no stateful cross-command lifecycle acceptance harness.
- Added `packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts`: one injected stateful fake systemd/tmux host, temporary canonical v2 and legacy-v1 roster fixtures, and seven acceptance tests.
- Production source is unchanged; no product defect requiring an FCM-M3-002 fix was found.
- `docs/TASKS.md` reconciles only merged M1/M2/M3-001 truth and marks FCM-M3-002 in progress.
## Verification evidence
All commands ran from `/home/jarvis/src/mosaic-stack-local-reconciler` and passed unless explicitly noted.
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/fleet/fleet-reconciler.acceptance.spec.ts` — final remediation run: 1 file, 7 tests passed; canonical v2 named-socket parsing/Commander status, missing/empty v2 rejection, and canonical legacy-v1 default-server runtime targeting are distinct reachable cases.
- Focused reconciler/roster/transport command covering acceptance, reconciler, command, CRUD, v2 parser, and runtime transport specs — 8 files, 304 tests passed.
- `pnpm --filter @mosaicstack/mosaic test` — final remediation run: 57 files, 827 tests passed.
- `pnpm --filter @mosaicstack/mosaic typecheck` — passed.
- `pnpm --filter @mosaicstack/mosaic lint` — passed.
- `pnpm typecheck` — 42/42 Turbo tasks successful.
- `pnpm lint` — 23/23 Turbo tasks successful.
- `pnpm exec prettier --check docs/TASKS.md docs/scratchpads/758-fcm-m3-002-reconciler-lifecycle-gates.md packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts` — passed.
- `pnpm format:check` — all matched files use Prettier style.
- `git diff --check` — passed with no output.
- Initial scoped Prettier check found style drift in the new spec and tracking table; `pnpm exec prettier --write ...` remediated it before all final gates above.
- No live fleet, systemctl, tmux, process, site, migration, canary, deploy, runtime, connector, or remote command was invoked.
## Review boundary
This is an author handoff. No self-review is represented as reviewer-of-record. The uncommitted synthetic tree is intended for independent review.
## Risks / blockers
- M3 truthfully reports incomplete lifecycle effects and bounded recovery; it does not implement or claim an automatic rollback command. This suite proves stopped-state restoration by the documented exact recovery reconcile. M4 retains migration/canary rollback ownership.
- The fake host models only the public systemd/tmux runner contract and temporary roster filesystem boundary. This is intentional under the no-live-effects hold.
- Parent issue closure, commit, push, PR, merge, deployment, and branch cleanup remain explicit holds.
- No residual implementation blocker.

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.
Project-specific tooling belongs in the project's `AGENTS.md`, not here.
## Most-used fleet tools (reach for these first)
## Most-used fleet tools (reach for these FIRST — don't hand-roll)
<!-- fleet-comms-contract: 1 -->
You are a Mosaic fleet agent. These cover the highest-frequency cross-agent and git-provider
tasks — use them before improvising with raw `tmux send-keys`, raw `tea`/`gh`/`glab`, or `curl`.
You are a Mosaic fleet agent. Use the runtime-composed **Fleet Comms — authoritative exact targets**
section for inter-agent messaging. It renders your authoritative local host, exact agent/session, resolved
tmux socket, installed helper path, generation, and one executable command per known peer.
**1. Message another agent**`tools/tmux/agent-send.sh` (NOT raw `tmux send-keys`):
Select only a peer row rendered for your exact roster identity. Never invent, substitute, or fuzzy-match
a host, session, socket, SSH destination, or helper path. If a peer is absent, stop and run the exact
self-scoped discovery command shown in that composed section; report the peer as unknown if it remains
absent. Do not use raw `tmux send-keys` for fleet messaging.
```bash
tools/tmux/agent-send.sh -s <target-session> -m "message" # or -f <file> to send a file's contents
```
**Issues / PRs / milestones**`tools/git/*.sh` wrappers (before raw `tea`/`gh`/`glab`):
The coordinator session is `mos-claude` — send status, findings, and questions there.
**2. Issues / PRs / milestones**`tools/git/*.sh` wrappers (before raw `tea`/`gh`/`glab`):
```bash
tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ...

View File

@@ -94,11 +94,11 @@
"type": "string"
},
"ssh": {
"description": "Explicit SSH target (normally user@host) for a cross-host inventory peer. Exact comms rendering requires this whenever the peer's resolved host differs from the current agent's host; the host value is never substituted as an SSH destination.",
"description": "SSH target (user@host) for a cross-host peer, so onboarding renders the `agent-send.sh -H <user@host>` form. Optional; only needed for agents on a different host than the fleet.",
"type": "string"
},
"socket": {
"description": "Optional compatibility declaration of the fleet-wide tmux socket. When present it must exactly equal tmux.socket_name; independent per-agent sockets are rejected because the local fleet runtime provisions every session on the fleet-wide socket.",
"description": "tmux socket the agent's session runs on. Onboarding renders `-L <socket>` when set; absent = the default socket (no `-L`). Must match the LIVE socket, not blindly inherit the roster's tmux.socket_name.",
"type": "string"
},
"working_directory": {
@@ -150,67 +150,29 @@
"description": "Orchestrator chat connector (F4). Optional — absent means tmux (back-compat). Secrets (access/bot tokens) come from the environment, never this file.",
"type": "object",
"additionalProperties": false,
"oneOf": [
{
"properties": { "kind": { "const": "tmux" } },
"required": ["kind"],
"not": {
"anyOf": [{ "required": ["discord"] }, { "required": ["matrix"] }]
"required": ["kind"],
"properties": {
"kind": {
"enum": ["tmux", "discord", "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": {
"kind": { "const": "discord" },
"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"] }
"channel_id": { "type": "string" }
}
}
],
"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/roster.schema.json (synced normally — every fleet/roles/*.md role contract
# and fleet/profiles/*.yaml system-type profile lands automatically via this sync,
# so no per-file entry is needed; exact preserved roster paths are anchored to
# the top level only and do NOT shadow fleet/profiles/*.yaml). The user's
# so no per-file entry is needed; the preserved "fleet/*.yaml" glob is anchored to
# the top level only and does NOT shadow fleet/profiles/*.yaml). The user's
# own fleet files MUST
# survive `mosaic update` (which runs this sync automatically): the active
# rosters (`fleet/roster.yaml` and `fleet/roster.json`), per-agent env
# roster (`fleet/roster.yaml` + any other `fleet/*.yaml`), per-agent env
# (`fleet/agents/`), heartbeat run dir (`fleet/run/`), and the Mosaic-native
# backlog-of-record store (`fleet/backlog/` — embedded PGlite data dir; see
# packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update
@@ -44,7 +44,7 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# and user-ADDED personas instead live in fleet/roles.local/ and MUST survive
# `mosaic update` — they win over the baseline on merge (AC-NS-7; see
# packages/mosaic/src/commands/fleet-personas.ts).
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/roster.yaml" "fleet/roster.json" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/*.yaml" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
# user must not edit them; a divergent copy is backed up once before overwrite).
@@ -200,8 +200,8 @@ sync_framework() {
return
fi
# Fallback: cp-based sync. Exact top-level preserved paths mirror the
# root-anchored rsync excludes above.
# Fallback: cp-based sync. Glob-aware so entries like "fleet/*.yaml" preserve
# every matching user file (parity with the rsync --exclude path above).
local preserve_tmp=""
if [[ "$INSTALL_MODE" == "keep" ]]; then
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"

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: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
# F6 — keep-mode re-seed (the `mosaic update` path) MUST preserve only the
# exact user-owned roster paths while refreshing framework-owned schema/examples.
# F6 — keep-mode re-seed (the `mosaic update` path) MUST NOT wipe user fleet data.
# Regression for the roster-loss bug: fleet/ was not in PRESERVE_PATHS.
T6=$(mktemp -d); mkdir -p "$T6/fleet/examples" "$T6/fleet/run" "$T6/fleet/agents"
printf '# persona\n' > "$T6/SOUL.md" # makes it a recognized existing install (→ keep mode)
printf 'version: 1\nagents:\n - name: coder0\n' > "$T6/fleet/roster.yaml"
printf '{"version":1,"agents":[{"name":"json-user"}]}\n' > "$T6/fleet/roster.json"
printf 'version: 1\nagents:\n - name: not-active-roster\n' > "$T6/fleet/my-fleet.yaml"
printf 'version: 1\nagents:\n - name: custom\n' > "$T6/fleet/my-fleet.yaml"
printf 'ts=x\n' > "$T6/fleet/run/coder0.hb"
printf 'MOSAIC_AGENT_NAME=coder0\n' > "$T6/fleet/agents/coder0.env"
printf '# stale preset\n' > "$T6/fleet/examples/general.yaml"
printf '{"stale":true}\n' > "$T6/fleet/roster.schema.json"
E6=$(mktemp -d)
cp "$T6/fleet/roster.yaml" "$E6/roster-yaml.expected"
cp "$T6/fleet/roster.json" "$E6/roster-json.expected"
cp "$T6/fleet/run/coder0.hb" "$E6/run.expected"
cp "$T6/fleet/agents/coder0.env" "$E6/agent.expected"
echo 3 > "$T6/.framework-version"
run "$T6" keep
chk "F6 reseed: exact roster.yaml bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.yaml' '$E6/roster-yaml.expected'"
chk "F6 reseed: exact roster.json bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.json' '$E6/roster-json.expected'"
chk "F6 reseed: unrelated fleet YAML is not preserved" "[ ! -f '$T6/fleet/my-fleet.yaml' ]"
chk "F6 reseed: per-agent env bytes survive" "cmp -s '$T6/fleet/agents/coder0.env' '$E6/agent.expected'"
chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'"
chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
chk "F6 reseed: framework roster schema is refreshed" "cmp -s '$T6/fleet/roster.schema.json' '$FW/fleet/roster.schema.json'"
chk "F6 reseed: user roster.yaml SURVIVES keep-mode sync" "grep -q coder0 '$T6/fleet/roster.yaml'"
chk "F6 reseed: other user fleet/*.yaml survives (glob)" "[ -f '$T6/fleet/my-fleet.yaml' ]"
chk "F6 reseed: per-agent env (fleet/agents) survives" "[ -f '$T6/fleet/agents/coder0.env' ]"
chk "F6 reseed: heartbeat run dir (fleet/run) survives" "[ -f '$T6/fleet/run/coder0.hb' ]"
chk "F6 reseed: framework examples ARE refreshed (not preserved stale)" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
chk "F6 reseed: framework roster.schema.json seeded" "[ -f '$T6/fleet/roster.schema.json' ]"
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6" "$E6"
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6"
echo
echo "RESULT: $pass passed, $fail failed"
[ "$fail" -eq 0 ]

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 { Command } from 'commander';
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
// 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]> = [
['auth', registerAuthCommand],
['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', () => {
const program = new Command();
for (const [, register] of REGISTRARS) register(program);

View File

@@ -27,7 +27,6 @@ import {
checkForAllUpdates,
formatAllPackagesTable,
getInstallAllCommand,
repairFleetCommsTools,
runFrameworkReseed,
refreshActiveFleetUnits,
readRosterAgentNames,
@@ -421,142 +420,115 @@ program
'Skip re-seeding framework files into ~/.config/mosaic after the CLI update',
)
.option('--relaunch', 'Restart durable fleet agents so the new launcher/runtime takes effect')
.option(
'--repair-tools',
'Restore the supported current-version TOOLS contract and executable fleet helper',
)
.action(
async (opts: {
check?: boolean;
reseed?: boolean;
relaunch?: boolean;
repairTools?: boolean;
}) => {
if (opts.repairTools) {
const repair = repairFleetCommsTools();
if (!repair.ok) {
console.error(`Fleet communications tools repair failed: ${repair.reason ?? 'unknown'}`);
process.exitCode = 1;
return;
}
console.log(
repair.changed
? 'Fleet communications tools repaired from the supported current framework.'
: 'Fleet communications tools already match the supported current framework.',
.action(async (opts: { check?: boolean; reseed?: boolean; relaunch?: boolean }) => {
// checkForAllUpdates imported statically above
const { execSync } = await import('node:child_process');
// Re-seed the framework from the freshly-installed package, propagate shipped
// systemd unit fixes to the active units, and (opt-in) relaunch durable
// agents. Shared by the "packages updated" and the "framework drift" paths.
const reseedFramework = (reason: string): void => {
console.log(reason);
const reseed = runFrameworkReseed();
if (!reseed.ok) {
console.error(
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
);
if (repair.backupPath) console.log(`Preserved previous TOOLS.md at ${repair.backupPath}.`);
console.log('No active context or session was rewritten.');
return;
}
// checkForAllUpdates imported statically above
const { execSync } = await import('node:child_process');
// Re-seed the framework from the freshly-installed package, propagate shipped
// systemd unit fixes to the active units, and (opt-in) relaunch durable
// agents. Shared by the "packages updated" and the "framework drift" paths.
const reseedFramework = (reason: string): void => {
console.log(reason);
const reseed = runFrameworkReseed();
if (!reseed.ok) {
console.error(
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
);
return;
}
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('✔ 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('Checking for updates…');
const results = checkForAllUpdates({ skipCache: true });
console.log('');
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;
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>)',
);
}
};
if (opts.check) {
process.exit(2); // Signal to callers that an update exists
}
console.log('Checking for updates…');
const results = checkForAllUpdates({ skipCache: true });
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');
console.log('');
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);
}
// 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,
);
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 ((mosaicUpdated || drift.drifted) && opts.reseed !== false) {
if (drift.drifted && opts.reseed !== false) {
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 ─────────────────────────────────────────────────────────────

View File

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

View File

@@ -1,700 +0,0 @@
/**
* Claudex proxy preflight + lifecycle (P1 of `mosaic yolo claudex`).
*
* `raine/claude-code-proxy` runs a local server on 127.0.0.1:18765 that speaks
* the Anthropic Messages API and translates to the ChatGPT/Codex backend using
* ChatGPT-subscription OAuth. This module owns the *preflight* and *lifecycle*
* concerns for the launcher: is the binary present, is OAuth valid, is the proxy
* listening, and — if not — bring it up (systemd user unit preferred, nohup
* fallback).
*
* Design: every function is pure or dependency-injected so the launch path is
* fully unit-testable without touching a real process, socket, or the OAuth
* token. Nothing here reads `~/.config/claude-code-proxy/codex/auth.json`; the
* proxy holds the real credential and Claude Code only ever sees
* `ANTHROPIC_AUTH_TOKEN=unused`. Parsed auth status is deliberately coarse
* (state + optional expiry) so no token material can be retained or surfaced.
*/
import { execFileSync, spawn, spawnSync } from 'node:child_process';
import { mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
// ─── Endpoint / command constants (spec table) ──────────────────────────────
export const CLAUDEX_PROXY_HOST = '127.0.0.1';
export const CLAUDEX_PROXY_PORT = 18765;
export const CLAUDEX_PROXY_URL = `http://${CLAUDEX_PROXY_HOST}:${CLAUDEX_PROXY_PORT}`;
export const CLAUDEX_PROXY_BINARY = 'claude-code-proxy';
export const CLAUDEX_SYSTEMD_UNIT = 'claude-code-proxy.service';
/**
* The proxy's dedicated liveness endpoint. We probe this — NOT the root path —
* for two reasons: (1) the root returns non-2xx (spec gotcha #1), which is why
* the original `curl -f` check spawned duplicate proxies; `/healthz` returns 2xx
* when the proxy is healthy. (2) It is a *proxy-specific* contract, so a 2xx here
* is a much stronger signal that the responder on :18765 is actually our proxy
* and not some other local process squatting the port (CWE-345).
*/
export const CLAUDEX_HEALTH_PATH = '/healthz';
export const CLAUDEX_HEALTH_URL = `${CLAUDEX_PROXY_URL}${CLAUDEX_HEALTH_PATH}`;
/** argv for `claude-code-proxy codex auth status`. */
export function buildAuthStatusArgs(): string[] {
return ['codex', 'auth', 'status'];
}
/** argv for `claude-code-proxy codex auth device` (device-code re-auth flow). */
export function buildDeviceAuthArgs(): string[] {
return ['codex', 'auth', 'device'];
}
/** argv for `claude-code-proxy serve --no-monitor`. */
export function buildServeArgs(): string[] {
return ['serve', '--no-monitor'];
}
// ─── Types ──────────────────────────────────────────────────────────────────
export type AuthState = 'valid' | 'expired' | 'unauthenticated' | 'unknown';
/**
* Coarse OAuth status. Intentionally carries NO token material — only a state
* and an optional best-effort expiry-in-days for user-facing messaging.
*/
export interface AuthStatus {
state: AuthState;
expiresInDays?: number;
}
export interface ProxyRunResult {
status: number | null;
stdout: string;
stderr: string;
}
/** Runs a command synchronously and returns its captured result. */
export type CommandRunner = (cmd: string, args: string[]) => ProxyRunResult;
/** Minimal fetch shape used for the liveness probe (any HTTP response = alive). */
export type FetchLike = (
url: string,
init?: { signal?: AbortSignal },
) => Promise<{ status?: number }>;
// ─── Binary presence ─────────────────────────────────────────────────────────
function defaultWhich(cmd: string): string | null {
try {
return execFileSync('which', [cmd], { encoding: 'utf8' }).trim() || null;
} catch {
return null;
}
}
export function checkProxyBinary(resolve: (cmd: string) => string | null = defaultWhich): {
present: boolean;
path: string | null;
} {
const path = resolve(CLAUDEX_PROXY_BINARY);
return { present: path !== null && path !== '', path: path || null };
}
// ─── Auth status ─────────────────────────────────────────────────────────────
/**
* Parse `claude-code-proxy codex auth status` output into a coarse state.
*
* The proxy's exact wording is not contractually pinned, so this matches
* tolerantly on well-known markers and falls back on the exit code. It never
* copies the raw output onto the result — only a state and an optional expiry —
* so token-shaped strings in the output cannot leak downstream.
*/
export function parseAuthStatus(result: ProxyRunResult): AuthStatus {
const text = `${result.stdout}\n${result.stderr}`.toLowerCase();
const expired = /\bexpired\b|token has expired|expires?d? \d+ days? ago/.test(text);
const unauth =
/not authenticated|not logged in|no (?:auth|credentials|token)|please (?:log ?in|authenticate)|run .*auth device/.test(
text,
);
const authed = /\bauthenticated\b|logged in|token valid|valid until|expires? in/.test(text);
let state: AuthState;
if (expired) {
state = 'expired';
} else if (unauth) {
state = 'unauthenticated';
} else if (authed && result.status === 0) {
// A `null` status means the check was killed by a signal — an INCOMPLETE
// run. We require a clean exit 0 for `valid`; a partially-flushed auth line
// from a signal-terminated check must never be trusted (finding #3).
state = 'valid';
} else if (result.status === 0) {
state = 'valid';
} else {
state = 'unknown';
}
const status: AuthStatus = { state };
const days = /expires? in (\d+) days?/.exec(text);
if (state === 'valid' && days) {
status.expiresInDays = Number(days[1]);
}
return status;
}
function defaultRun(cmd: string, args: string[]): ProxyRunResult {
const r = spawnSync(cmd, args, { encoding: 'utf8' });
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
}
export function checkAuthStatus(run: CommandRunner = defaultRun): AuthStatus {
return parseAuthStatus(run(CLAUDEX_PROXY_BINARY, buildAuthStatusArgs()));
}
/** Spawn shape for the interactive device re-auth flow. */
export type InheritSpawn = (
cmd: string,
args: string[],
opts: { stdio: 'inherit' },
) => { status: number | null };
function defaultInheritSpawn(cmd: string, args: string[], opts: { stdio: 'inherit' }) {
return spawnSync(cmd, args, opts);
}
/**
* Run the device-code re-auth flow (`claude-code-proxy codex auth device`).
*
* Deliberately `stdio: 'inherit'` so the device code the proxy prints goes
* straight to the user's terminal — the launcher NEVER captures, stores, or logs
* it, and never observes the resulting OAuth token (the proxy persists that to
* its own config). Returns the child's exit status; 1 on an absent binary.
*/
export function runDeviceReauth(spawnImpl: InheritSpawn = defaultInheritSpawn): number {
const r = spawnImpl(CLAUDEX_PROXY_BINARY, buildDeviceAuthArgs(), { stdio: 'inherit' });
return r.status ?? 1;
}
// ─── Liveness (probe the proxy-specific /healthz; require 2xx) ────────────────
/**
* Probe the proxy for liveness by hitting its dedicated `GET /healthz` endpoint
* and requiring a 2xx response.
*
* This is a LIVENESS check only — it answers "is a healthy proxy responding?",
* not "is that responder actually ours?". Requiring a 2xx on the proxy's own
* `/healthz` contract (rather than "any HTTP response = alive") resolves spec
* gotcha #1: the root path returns non-2xx, but `/healthz` returns 2xx when
* healthy, so a live proxy is never mistaken for dead and no duplicate proxy is
* spawned.
*
* Residual risk (CWE-345): the proxy binds loopback with NO client
* authentication, so on a shared host a local process could occupy :18765 and
* serve a 2xx here. A 2xx therefore does NOT by itself establish that the
* listener is our proxy. Identity is verified SEPARATELY and at every trust
* point by {@link verifyListenerIdentity} (OS-level uid + executable check),
* which fails closed when identity can't be established. See
* {@link ensureProxyRunning}. (Broader multi-user hardening — a persistent
* warning when a foreign listener is seen — is tracked for a later phase.)
*/
export async function probeLiveness(
url: string = CLAUDEX_HEALTH_URL,
fetchImpl: FetchLike = fetch as unknown as FetchLike,
timeoutMs = 1500,
): Promise<boolean> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
// Bound the probe with our own timeout race rather than trusting the fetch
// implementation to honor the abort signal — a hung socket (or a fetch that
// ignores the signal) must never wedge the launcher. We still abort() so a
// signal-aware fetch tears the request down promptly.
const timeout = new Promise<boolean>((resolve) => {
timer = setTimeout(() => {
controller.abort();
resolve(false);
}, timeoutMs);
});
const probe = fetchImpl(url, { signal: controller.signal })
.then((res) => typeof res.status === 'number' && res.status >= 200 && res.status < 300)
.catch(() => false); // connection refused / aborted → dead
try {
return await Promise.race([probe, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
// ─── Listener identity (OS-level, CWE-345 mitigation) ─────────────────────────
/**
* The result of verifying who actually owns the :18765 listener.
* - `ok` — same-user process running the expected proxy binary.
* - `foreign-user` — a process owned by a DIFFERENT uid holds the port.
* - `wrong-exe` — same-user, but the executable is not the proxy.
* - `unknown` — identity could not be established (fail closed).
*/
export type ListenerVerdict = 'ok' | 'foreign-user' | 'wrong-exe' | 'unknown';
/** OS-level identity of the process bound to the proxy port. */
export interface ListenerIdentity {
pid: number;
uid: number;
/** Absolute path of the process executable, or null if unreadable. */
exePath: string | null;
}
export interface VerifyListenerDeps {
/** Resolve the process bound to the proxy port (null → unidentifiable). */
identify?: () => ListenerIdentity | null;
/** The current process uid (-1 when unavailable, e.g. non-posix). */
currentUid?: () => number;
/** The expected proxy executable path (null when it can't be resolved). */
expectedExe?: () => string | null;
/** Canonicalize a path (resolve symlinks); null when it can't be resolved. */
canonicalize?: (p: string) => string | null;
}
/** Resolve a path through symlinks to its canonical form; null on any failure. */
function defaultCanonicalize(p: string): string | null {
try {
return realpathSync(p);
} catch {
return null;
}
}
/**
* Identify the process listening on the proxy port via `ss` + `/proc`. Every
* failure path returns null so the caller fails closed. Reads no credential
* material — only pid/uid/exe path of the listener.
*/
function defaultIdentifyListener(port: number = CLAUDEX_PROXY_PORT): ListenerIdentity | null {
try {
const out = execFileSync('ss', ['-H', '-ltnp', `sport = :${port}`], { encoding: 'utf8' });
const pidMatch = /pid=(\d+)/.exec(out);
if (!pidMatch) return null;
const pid = Number(pidMatch[1]);
if (!Number.isInteger(pid) || pid <= 0) return null;
const status = readFileSync(`/proc/${pid}/status`, 'utf8');
const uidLine = /^Uid:\s*(\d+)/m.exec(status);
if (!uidLine) return null;
const uid = Number(uidLine[1]);
let exePath: string | null = null;
try {
exePath = readlinkSync(`/proc/${pid}/exe`);
} catch {
exePath = null;
}
return { pid, uid, exePath };
} catch {
return null;
}
}
/**
* Verify that the process owning :18765 is genuinely OUR proxy before trusting
* it. The proxy binds loopback with NO client authentication, so on a shared
* host any local process could squat the port and a liveness 2xx alone does not
* prove identity (CWE-345). We FAIL CLOSED (`unknown`) whenever identity cannot
* be established. This needs no upstream shared-secret/unix-socket support from
* `claude-code-proxy`.
*
* The executable path is the trust boundary that matters: on a shared-uid host
* (every agent session runs as the same operator) same-uid is NOT sufficient, so
* we require an EXACT canonical-path match against our resolved proxy binary and
* canonicalize both sides for symlinks. There is deliberately NO basename
* fallback — a same-uid process running `/tmp/claude-code-proxy` (right name,
* wrong path) must never be trusted. If our own binary path can't be resolved,
* or either path can't be canonicalized, we fail closed rather than downgrade to
* a weaker check.
*/
export function verifyListenerIdentity(deps: VerifyListenerDeps = {}): ListenerVerdict {
const identify = deps.identify ?? (() => defaultIdentifyListener());
const currentUid =
deps.currentUid ?? (() => (typeof process.getuid === 'function' ? process.getuid() : -1));
const expectedExe = deps.expectedExe ?? (() => checkProxyBinary().path);
const canonicalize = deps.canonicalize ?? defaultCanonicalize;
const id = identify();
if (!id) return 'unknown'; // can't see the listener → don't trust it
const uid = currentUid();
if (uid < 0) return 'unknown'; // can't establish our own identity → fail closed
if (id.uid !== uid) return 'foreign-user'; // someone else's process holds the port
if (!id.exePath) return 'unknown'; // can't confirm the executable → fail closed
const expected = expectedExe();
if (!expected) return 'unknown'; // can't resolve our own binary → fail closed
const expectedReal = canonicalize(expected);
const actualReal = canonicalize(id.exePath);
if (!expectedReal || !actualReal) return 'unknown'; // uncanonicalizable → fail closed
return actualReal === expectedReal ? 'ok' : 'wrong-exe';
}
// ─── systemd user unit ───────────────────────────────────────────────────────
export function systemdUnitPath(home: string = homedir()): string {
return join(home, '.config', 'systemd', 'user', CLAUDEX_SYSTEMD_UNIT);
}
/**
* Validate a path destined for a systemd `ExecStart=` line. A raw newline (or
* other control character) in the path would let an attacker inject arbitrary
* unit directives (e.g. an extra `ExecStartPost=`), a CWE-74 command injection.
* We require a plain absolute path and reject any control character outright.
*/
function validateExecPath(binaryPath: string): string {
if (typeof binaryPath !== 'string' || binaryPath.length === 0) {
throw new Error('systemd ExecStart: binary path is empty');
}
if (!binaryPath.startsWith('/')) {
throw new Error(
`systemd ExecStart: binary path must be absolute: ${JSON.stringify(binaryPath)}`,
);
}
if (/[\x00-\x1f\x7f]/.test(binaryPath)) {
throw new Error('systemd ExecStart: binary path contains control characters');
}
return binaryPath;
}
/**
* Encode a validated path for a systemd `ExecStart=` token. systemd only needs
* quoting when the token carries whitespace or quote/backslash characters; a
* clean path is emitted verbatim. When quoting, we escape backslashes and double
* quotes per systemd's C-style rules so the token cannot be terminated early.
*/
function systemdQuoteExec(path: string): string {
if (!/[\s"'\\]/.test(path)) {
return path;
}
const escaped = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return `"${escaped}"`;
}
/**
* Render the `claude-code-proxy.service` user unit. Contains no credential
* material — the proxy reads its own OAuth token from its config dir at runtime.
* The binary path is validated (absolute, no control characters) and systemd-
* quoted so it cannot inject unit directives.
*/
export function buildSystemdUnitContent(binaryPath: string): string {
const exec = `${systemdQuoteExec(validateExecPath(binaryPath))} ${buildServeArgs().join(' ')}`;
return [
'[Unit]',
'Description=claude-code-proxy (Anthropic->Codex translation proxy for mosaic claudex)',
'After=network-online.target',
'Wants=network-online.target',
'',
'[Service]',
'Type=simple',
`ExecStart=${exec}`,
'Restart=on-failure',
'RestartSec=2',
'',
'[Install]',
'WantedBy=default.target',
'',
].join('\n');
}
/**
* Write the user unit and reload the systemd --user daemon. Returns false when
* systemd --user is unavailable (the caller then falls back to nohup).
*/
export function installSystemdUnit(
binaryPath: string,
deps: {
home?: string;
writeUnit?: (path: string, content: string) => void;
run?: CommandRunner;
} = {},
): boolean {
const home = deps.home ?? homedir();
const write =
deps.writeUnit ??
((path: string, content: string) => {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content);
});
const run = deps.run ?? defaultRun;
try {
write(systemdUnitPath(home), buildSystemdUnitContent(binaryPath));
const reload = run('systemctl', ['--user', 'daemon-reload']);
return reload.status === 0;
} catch {
return false;
}
}
// ─── Preflight report ────────────────────────────────────────────────────────
export interface PreflightReport {
binaryPresent: boolean;
binaryPath: string | null;
auth: AuthStatus;
live: boolean;
/** OS-level identity verdict for the :18765 listener (`unknown` when dead). */
listenerVerdict: ListenerVerdict;
needsReauth: boolean;
ok: boolean;
problems: string[];
}
export interface PreflightDeps {
checkBinary?: () => { present: boolean; path: string | null };
checkAuth?: () => AuthStatus;
probe?: () => Promise<boolean>;
verifyListener?: () => ListenerVerdict;
}
/**
* Compose the preflight checks into a single structured report. `ok` is true
* only when the binary is present, OAuth is valid, the proxy responds, AND the
* responding listener's OS-level identity verifies as our proxy.
*
* The identity gate lives here too, not only in {@link ensureProxyRunning}: any
* consumer of this report (notably the phase-2 launch path) would otherwise
* treat a `/healthz`-2xx squatter as healthy and route Claude traffic to it
* (CWE-345). A liveness 2xx is necessary but not sufficient — a live responder
* that fails identity fails the preflight.
*/
export async function runProxyPreflight(deps: PreflightDeps = {}): Promise<PreflightReport> {
const checkBinary = deps.checkBinary ?? (() => checkProxyBinary());
const checkAuth = deps.checkAuth ?? (() => checkAuthStatus());
const probe = deps.probe ?? (() => probeLiveness());
const verifyListener = deps.verifyListener ?? (() => verifyListenerIdentity());
const bin = checkBinary();
const auth = checkAuth();
const live = await probe();
// Only meaningful when something is actually responding; a dead port has no
// listener identity to establish.
const listenerVerdict: ListenerVerdict = live ? verifyListener() : 'unknown';
const problems: string[] = [];
if (!bin.present) {
problems.push(
`claude-code-proxy binary not found in PATH. Install it before launching claudex.`,
);
}
const needsReauth = auth.state === 'expired' || auth.state === 'unauthenticated';
if (needsReauth) {
problems.push(
`claude-code-proxy OAuth is ${auth.state}. Re-auth with: ${CLAUDEX_PROXY_BINARY} ${buildDeviceAuthArgs().join(' ')}`,
);
} else if (auth.state === 'unknown') {
problems.push('Could not determine claude-code-proxy OAuth status.');
}
if (!live) {
problems.push(`No proxy responding on ${CLAUDEX_PROXY_URL}.`);
} else if (listenerVerdict !== 'ok') {
// Non-sensitive: names the port and the verdict only — never any listener
// command line, token, or other process detail.
problems.push(
`A process is listening on ${CLAUDEX_PROXY_URL} but its identity could not be verified as ${CLAUDEX_PROXY_BINARY} (${listenerVerdict}). Refusing to trust it.`,
);
}
const ok = bin.present && auth.state === 'valid' && live && listenerVerdict === 'ok';
return {
binaryPresent: bin.present,
binaryPath: bin.path,
auth,
live,
listenerVerdict,
needsReauth,
ok,
problems,
};
}
// ─── Lifecycle: ensure the proxy is running ──────────────────────────────────
export type ProxyStartMethod = 'already' | 'systemd' | 'nohup' | 'untrusted' | 'failed';
export interface EnsureProxyResult {
live: boolean;
method: ProxyStartMethod;
}
/** Minimal spawned-child shape used by the nohup fallback (testable seam). */
export interface SpawnedChild {
once(event: string, listener: (arg?: unknown) => void): unknown;
unref(): void;
}
/** Spawn shape for the detached fallback process. */
export type SpawnLike = (
cmd: string,
args: string[],
opts: { detached: boolean; stdio: 'ignore' },
) => SpawnedChild;
export interface StartNohupDeps {
resolveBin?: () => string;
spawnImpl?: SpawnLike;
}
/**
* Start the proxy as a detached background process (the fallback when no systemd
* user unit is available).
*
* `spawn()` reports launch failures (ENOENT/EACCES) ASYNCHRONOUSLY via the
* child's `error` event, which a `try/catch` cannot see. If left unhandled that
* event throws and crashes the launcher. So we: (1) attach the `error` listener
* BEFORE `unref()`, capturing a failed launch as a non-zero result instead of a
* crash; and (2) resolve success only after the child's `spawn` event fires —
* never optimistically before the process is known to have started.
*/
export function startNohupProxy(deps: StartNohupDeps = {}): Promise<ProxyRunResult> {
const resolveBin = deps.resolveBin ?? (() => checkProxyBinary().path ?? CLAUDEX_PROXY_BINARY);
const spawnImpl =
deps.spawnImpl ?? ((cmd, args, opts) => spawn(cmd, args, opts) as unknown as SpawnedChild);
return new Promise<ProxyRunResult>((resolve) => {
let settled = false;
const finish = (r: ProxyRunResult) => {
if (!settled) {
settled = true;
resolve(r);
}
};
let child: SpawnedChild;
try {
child = spawnImpl(resolveBin(), buildServeArgs(), { detached: true, stdio: 'ignore' });
} catch (err) {
finish({ status: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) });
return;
}
// Register error handling BEFORE unref so an async spawn failure is caught.
child.once('error', (err) => {
finish({
status: 1,
stdout: '',
stderr: err instanceof Error ? err.message : String(err),
});
});
child.once('spawn', () => {
child.unref();
finish({ status: 0, stdout: '', stderr: '' });
});
});
}
export interface EnsureProxyDeps {
probe?: () => Promise<boolean>;
/** OS-level identity check for the process holding the proxy port. */
verifyListener?: () => ListenerVerdict;
startSystemd?: () => ProxyRunResult;
startNohup?: () => Promise<ProxyRunResult>;
waitMs?: (ms: number) => Promise<void>;
/** Interval between liveness polls while waiting for a start to bind. */
settleMs?: number;
/** Total budget to wait for a started proxy to bind its socket. */
startupDeadlineMs?: number;
}
function defaultWait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function defaultStartSystemd(): ProxyRunResult {
return defaultRun('systemctl', ['--user', 'start', CLAUDEX_SYSTEMD_UNIT]);
}
/**
* Poll for a TRUSTED-live proxy up to a bounded startup deadline. A start command
* returning 0 only means the job was ACCEPTED, not that the socket is bound — so
* we keep probing at `intervalMs` until either the deadline elapses or the port
* both responds AND passes the OS-level identity check. Liveness alone is not
* enough: a responder that fails identity (a squatter) must never be trusted.
*/
async function waitForTrusted(
probe: () => Promise<boolean>,
verifyListener: () => ListenerVerdict,
waitMs: (ms: number) => Promise<void>,
intervalMs: number,
deadlineMs: number,
): Promise<boolean> {
let elapsed = 0;
while (elapsed < deadlineMs) {
await waitMs(intervalMs);
elapsed += intervalMs;
if ((await probe()) && verifyListener() === 'ok') {
return true;
}
}
return false;
}
/**
* Ensure a proxy is listening. No-op when already live. Otherwise prefer the
* systemd user unit, then fall back to a detached background process.
*
* Every trust point is gated on OS-level listener identity, not just liveness:
* the proxy has no client authentication, so on a shared host a local process
* could squat :18765 and a 2xx `/healthz` alone would not prove it is our proxy
* (CWE-345, finding #2). We only trust a responder whose owning process is the
* current uid running the expected proxy binary; otherwise we fail closed.
*
* If a responder is already present but its identity does NOT verify, we return
* `untrusted` WITHOUT starting anything — the port is taken, so spawning would
* only create contention, and we must never route Claude traffic through an
* unverified listener.
*
* After a start command is accepted we poll to a bounded startup deadline before
* giving up: `systemctl start` exit 0 means the job was accepted, not that the
* socket bound within one probe interval. Critically, once systemd ACCEPTS the
* job we do NOT fall back to nohup even if it never becomes trusted-live in the
* deadline (finding #1): the accepted unit may bind late or be restarted by
* systemd, and a second proxy would then contend for :18765 — the very
* duplicate-proxy outcome this function exists to prevent. nohup is reachable
* only when systemd never accepted the job at all.
*/
export async function ensureProxyRunning(deps: EnsureProxyDeps = {}): Promise<EnsureProxyResult> {
const probe = deps.probe ?? (() => probeLiveness());
const verifyListener = deps.verifyListener ?? (() => verifyListenerIdentity());
const startSystemd = deps.startSystemd ?? defaultStartSystemd;
const startNohup = deps.startNohup ?? (() => startNohupProxy());
const waitMs = deps.waitMs ?? defaultWait;
const settleMs = deps.settleMs ?? 500;
const startupDeadlineMs = deps.startupDeadlineMs ?? 5000;
if (await probe()) {
// Something answers on :18765 — trust it ONLY if it is provably our proxy.
return verifyListener() === 'ok'
? { live: true, method: 'already' }
: { live: false, method: 'untrusted' };
}
const systemd = startSystemd();
if (systemd.status === 0) {
// systemd accepted the job. Wait for a trusted-live bind, but never fall
// back to nohup afterward — that would risk a duplicate proxy (finding #1).
if (await waitForTrusted(probe, verifyListener, waitMs, settleMs, startupDeadlineMs)) {
return { live: true, method: 'systemd' };
}
return { live: false, method: 'failed' };
}
const nohup = await startNohup();
if (nohup.status === 0) {
if (await waitForTrusted(probe, verifyListener, waitMs, settleMs, startupDeadlineMs)) {
return { live: true, method: 'nohup' };
}
}
return { live: false, method: 'failed' };
}

View File

@@ -1,20 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
accessSync,
chmodSync,
constants,
mkdtempSync,
mkdirSync,
writeFileSync,
rmSync,
readFileSync,
renameSync,
symlinkSync,
} from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { FileConfigAdapter } from '../config/file-adapter.js';
import { composeContract } from './launch.js';
/**
@@ -35,9 +22,7 @@ import { composeContract } from './launch.js';
const CONSTITUTION = '# CONSTITUTION\n\nGATE-1: the non-negotiable law.\n';
const AGENTS = '# Mosaic Agent Dispatcher\n\nLoad order + guide router.\n';
const USER = '# operator\n\nName: Test Operator\n';
const TOOLS = '# tools index\n\n<!-- fleet-comms-contract: 1 -->\n';
const FRAMEWORK_SOURCE = fileURLToPath(new URL('../../framework', import.meta.url));
const SOURCE_TOOLS_PATH = join(FRAMEWORK_SOURCE, 'defaults', 'TOOLS.md');
const TOOLS = '# tools index\n';
function makeHome(): { home: string; root: string } {
const root = mkdtempSync(join(tmpdir(), 'mosaic-compose-'));
@@ -46,12 +31,6 @@ function makeHome(): { home: string; root: string } {
mkdirSync(join(home, 'runtime', h), { recursive: true });
writeFileSync(join(home, 'runtime', h, 'RUNTIME.md'), `# ${h} runtime contract\n`);
}
mkdirSync(join(home, 'defaults'), { recursive: true });
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
writeFileSync(join(home, 'defaults', 'TOOLS.md'), TOOLS);
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
writeFileSync(helper, '#!/bin/sh\n');
chmodSync(helper, 0o755);
writeFileSync(join(home, 'CONSTITUTION.md'), CONSTITUTION);
writeFileSync(join(home, 'AGENTS.md'), AGENTS);
writeFileSync(join(home, 'USER.md'), USER);
@@ -63,31 +42,16 @@ describe('composeContract — overlay composer', () => {
let fixture: ReturnType<typeof makeHome>;
let prevCwd: string;
let cwdDir: string;
let prevAgentName: string | undefined;
let prevAgentClass: string | undefined;
let prevAgentToolPolicy: string | undefined;
beforeEach(() => {
fixture = makeHome();
prevCwd = process.cwd();
prevAgentName = process.env['MOSAIC_AGENT_NAME'];
prevAgentClass = process.env['MOSAIC_AGENT_CLASS'];
prevAgentToolPolicy = process.env['MOSAIC_AGENT_TOOL_POLICY'];
delete process.env['MOSAIC_AGENT_NAME'];
delete process.env['MOSAIC_AGENT_CLASS'];
delete process.env['MOSAIC_AGENT_TOOL_POLICY'];
cwdDir = mkdtempSync(join(tmpdir(), 'mosaic-cwd-'));
process.chdir(cwdDir); // neutralize cwd-relative mission/PRD blocks
});
afterEach(() => {
process.chdir(prevCwd);
if (prevAgentName === undefined) delete process.env['MOSAIC_AGENT_NAME'];
else process.env['MOSAIC_AGENT_NAME'] = prevAgentName;
if (prevAgentClass === undefined) delete process.env['MOSAIC_AGENT_CLASS'];
else process.env['MOSAIC_AGENT_CLASS'] = prevAgentClass;
if (prevAgentToolPolicy === undefined) delete process.env['MOSAIC_AGENT_TOOL_POLICY'];
else process.env['MOSAIC_AGENT_TOOL_POLICY'] = prevAgentToolPolicy;
rmSync(fixture.root, { recursive: true, force: true });
rmSync(cwdDir, { recursive: true, force: true });
});
@@ -100,17 +64,13 @@ describe('composeContract — overlay composer', () => {
[
'version: 1',
'transport: tmux',
'tmux:',
' socket_name: mosaic-fleet',
'agents:',
' - name: orchestrator',
' runtime: claude',
' class: orchestrator',
' host: w-jarvis',
' - name: enhancer',
' runtime: claude',
' class: enhancer',
' host: w-jarvis',
' - name: coder0-0',
' runtime: claude',
' class: implementer',
@@ -122,266 +82,19 @@ describe('composeContract — overlay composer', () => {
const prev = process.env['MOSAIC_AGENT_NAME'];
try {
process.env['MOSAIC_AGENT_NAME'] = 'enhancer';
const outputs = (['claude', 'codex', 'opencode', 'pi'] as const).map((runtime) =>
composeContract(runtime, fixture.home),
);
for (const out of outputs) {
expect(out).toContain('# Fleet Comms');
expect(out).toContain('Host: `w-jarvis`');
expect(out).toContain('Agent/session: `enhancer`');
expect(out).toContain('tmux socket: `mosaic-fleet`');
expect(out).toContain(
`Helper: \`${join(fixture.home, 'tools', 'tmux', 'agent-send.sh')}\``,
);
expect(out).toContain('-L mosaic-fleet -s orchestrator -m "…"');
expect(out).toContain('-L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"');
expect(out).not.toContain('-H jwoltje@10.1.10.37 -s orchestrator');
}
const commsSection = (out: string): string => out.slice(out.indexOf('# Fleet Comms'));
const authoritative = commsSection(outputs[0]!);
expect(outputs.map(commsSection)).toEqual([
authoritative,
authoritative,
authoritative,
authoritative,
]);
const out = composeContract('claude', fixture.home);
expect(out).toContain('# Fleet Comms');
expect(out).toMatch(/`\[[^\]]+:enhancer\]`/); // own [host:session] identity (host machine-dependent)
// local peer → no -H; cross-host peer → -H ssh
expect(out).toContain('-s orchestrator -m "…"');
expect(out).toContain('-H jwoltje@10.1.10.37 -s coder0-0 -m "…"');
expect(out).not.toContain('-H jwoltje@10.1.10.37 -s orchestrator'); // local stays local
} finally {
if (prev === undefined) delete process.env['MOSAIC_AGENT_NAME'];
else process.env['MOSAIC_AGENT_NAME'] = prev;
}
});
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
'derives canonical class, persona, tool policy, and comms from one roster member for %s',
(runtime) => {
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
writeFileSync(
join(fixture.home, 'fleet', 'roles', 'code.md'),
'# Code\n\n(`class: code`)\n\nCANONICAL-CODE-MANDATE.\n',
);
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: exact-coder',
` runtime: ${runtime}`,
' class: implementer',
' tool_policy: operator-interaction',
'',
].join('\n'),
);
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
process.env['MOSAIC_AGENT_CLASS'] = 'implementer';
process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'ambient-policy-must-not-win';
const out = composeContract(runtime, fixture.home);
expect(out).toContain('# Persona Contract (code)');
expect(out).toContain('CANONICAL-CODE-MANDATE');
expect(out).toContain('Role/class: `code`');
expect(out).toContain('# Fleet Tool Policy (operator-interaction)');
expect(out).not.toContain('ambient-policy-must-not-win');
expect(out.indexOf('# Persona Contract')).toBeLessThan(out.indexOf('# Fleet Comms'));
},
);
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
'does not inherit ambient tool policy when canonical member omits tool_policy for %s',
(runtime) => {
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
writeFileSync(
join(fixture.home, 'fleet', 'roles', 'code.md'),
'# Code\n\n(`class: code`)\n\nCANONICAL-CODE-MANDATE.\n',
);
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: exact-coder',
` runtime: ${runtime}`,
' class: implementer',
'',
].join('\n'),
);
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
process.env['MOSAIC_AGENT_CLASS'] = 'implementer';
process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'operator-interaction';
const out = composeContract(runtime, fixture.home);
expect(out).toContain('# Persona Contract (code)');
expect(out).toContain('Role/class: `code`');
expect(out).toContain('# Fleet Comms');
expect(out).not.toContain('# Fleet Tool Policy (operator-interaction)');
},
);
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
'rejects an ambient class that mismatches the canonical roster member for %s',
(runtime) => {
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: exact-coder',
` runtime: ${runtime}`,
' class: implementer',
'',
].join('\n'),
);
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
process.env['MOSAIC_AGENT_CLASS'] = 'reviewer';
expect(() => composeContract(runtime, fixture.home)).toThrow(
/ambient MOSAIC_AGENT_CLASS.*review.*canonical roster.*code/i,
);
},
);
it('composes solo role mandate and boundaries before explicit no-peer authority', () => {
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
writeFileSync(
join(fixture.home, 'fleet', 'roles', 'orchestrator.md'),
'# Orchestrator\n\n## Mandate\n\nCoordinate exact work.\n\n## Boundaries\n\nDo not infer authority.\n',
);
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: solo',
' runtime: claude',
' class: orchestrator',
'',
].join('\n'),
);
process.env['MOSAIC_AGENT_NAME'] = 'solo';
process.env['MOSAIC_AGENT_CLASS'] = 'orchestrator';
const out = composeContract('claude', fixture.home);
expect(out).toContain('## Mandate');
expect(out).toContain('## Boundaries');
expect(out).toContain('Role/class: `orchestrator`');
expect(out).toContain('## Solo authority boundaries');
expect(out.indexOf('## Mandate')).toBeLessThan(out.indexOf('# Fleet Comms'));
expect(out.indexOf('## Boundaries')).toBeLessThan(out.indexOf('# Fleet Comms'));
expect(out).toContain('no peer, orchestrator, or remote communication authority');
});
it('proves real source TOOLS.md through fresh install, executable helper, and final composition', async () => {
const installRoot = mkdtempSync(join(tmpdir(), 'mosaic-real-contract-'));
const installedHome = join(installRoot, 'mosaic-home');
mkdirSync(installedHome, { recursive: true });
const previous = process.env['MOSAIC_AGENT_NAME'];
try {
const adapter = new FileConfigAdapter(installedHome, FRAMEWORK_SOURCE);
await adapter.syncFramework('fresh');
const sourceTools = readFileSync(SOURCE_TOOLS_PATH, 'utf8');
const installedToolsPath = join(installedHome, 'TOOLS.md');
expect(readFileSync(installedToolsPath, 'utf8')).toBe(sourceTools);
expect(sourceTools).toContain('fleet-comms-contract: 1');
expect(sourceTools).not.toMatch(
/<(?:user@host|src_host|src_session|dst_host|dst_session|target-session)>/,
);
const helper = join(installedHome, 'tools', 'tmux', 'agent-send.sh');
expect(() => accessSync(helper, constants.X_OK)).not.toThrow();
mkdirSync(join(installedHome, 'fleet'), { recursive: true });
writeFileSync(
join(installedHome, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'tmux:',
' socket_name: exact-socket',
'agents:',
' - name: exact-self',
' runtime: pi',
' class: orchestrator',
' host: local-host',
' - name: exact-peer',
' runtime: claude',
' class: implementer',
' host: local-host',
'',
].join('\n'),
);
process.env['MOSAIC_AGENT_NAME'] = 'exact-self';
const composed = composeContract('pi', installedHome);
expect(composed).toContain(sourceTools);
expect(composed).toContain(`Helper: \`${helper}\``);
expect(composed).toContain(`${helper} -L exact-socket -s exact-peer -m "…"`);
expect(composed).not.toContain('# Fleet Comms Installation Status');
} finally {
if (previous === undefined) delete process.env['MOSAIC_AGENT_NAME'];
else process.env['MOSAIC_AGENT_NAME'] = previous;
rmSync(installRoot, { recursive: true, force: true });
}
});
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
'never injects installed TOOLS.md through a target symlink for %s',
(runtime) => {
const toolsPath = join(fixture.home, 'TOOLS.md');
const external = join(fixture.root, 'attacker-tools.md');
writeFileSync(external, 'UNSAFE-TARGET-SYMLINK-CONTENT\n');
rmSync(toolsPath);
symlinkSync(external, toolsPath);
const out = composeContract(runtime, fixture.home);
expect(out).not.toContain('UNSAFE-TARGET-SYMLINK-CONTENT');
expect(out).toContain('# Fleet Comms Installation Status');
expect(out).toContain('unavailable');
expect(readFileSync(external, 'utf8')).toBe('UNSAFE-TARGET-SYMLINK-CONTENT\n');
},
);
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
'never injects installed TOOLS.md through an ancestor symlink for %s',
(runtime) => {
const realHome = join(fixture.root, 'real-mosaic-home');
renameSync(fixture.home, realHome);
symlinkSync(realHome, fixture.home, 'dir');
writeFileSync(join(realHome, 'TOOLS.md'), 'UNSAFE-ANCESTOR-SYMLINK-CONTENT\n');
const out = composeContract(runtime, fixture.home);
expect(out).not.toContain('UNSAFE-ANCESTOR-SYMLINK-CONTENT');
expect(out).toContain('# Fleet Comms Installation Status');
expect(out).toContain('unavailable');
expect(readFileSync(join(realHome, 'TOOLS.md'), 'utf8')).toBe(
'UNSAFE-ANCESTOR-SYMLINK-CONTENT\n',
);
},
);
it('reports stale preserved TOOLS.md without rewriting it', () => {
const toolsPath = join(fixture.home, 'TOOLS.md');
const stale = '# user-customized tools without fleet contract marker\n';
writeFileSync(toolsPath, stale);
const out = composeContract('pi', fixture.home);
expect(out).toContain('# Fleet Comms Installation Status');
expect(out).toContain('does not byte-match');
expect(out).toContain('active context was not rewritten');
expect(readFileSync(toolsPath, 'utf8')).toBe(stale);
});
it('does NOT inject fleet comms when MOSAIC_AGENT_NAME is unset (non-fleet launch)', () => {
const prev = process.env['MOSAIC_AGENT_NAME'];
try {
@@ -392,24 +105,6 @@ describe('composeContract — overlay composer', () => {
}
});
it('fails closed when an explicitly requested fleet identity is unknown', () => {
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
['version: 1', 'transport: tmux', 'agents:', ' - name: exact-agent', ' runtime: pi'].join(
'\n',
),
);
const previous = process.env['MOSAIC_AGENT_NAME'];
try {
process.env['MOSAIC_AGENT_NAME'] = 'invented-agent';
expect(() => composeContract('pi', fixture.home)).toThrow(/known exact names: exact-agent/i);
} finally {
if (previous === undefined) delete process.env['MOSAIC_AGENT_NAME'];
else process.env['MOSAIC_AGENT_NAME'] = previous;
}
});
it('includes the per-tier anchors and the selected harness runtime', () => {
const out = composeContract('claude', fixture.home);
expect(out).toContain('GATE-1: the non-negotiable law.'); // L0
@@ -545,14 +240,10 @@ describe('composeContract — overlay composer', () => {
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: orchestrator',
' runtime: claude',
' class: orchestrator',
' - name: enhancer',
' runtime: claude',
' class: enhancer',
'',
].join('\n'),

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

@@ -1,257 +0,0 @@
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { type FleetReconcileDeps } from '../fleet/fleet-reconciler.js';
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
const roster = `
version: 2
generation: 7
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: /srv/mosaic
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: coder0
alias: Coder 0
class: code
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: high
tool_policy: code
working_directory: /srv/mosaic
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
`;
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (cleanup) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
async function fleetHome(): Promise<string> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-reconciler-command-'));
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
await mkdir(join(cleanup, directory), { recursive: true, mode: 0o700 });
await chmod(join(cleanup, directory), 0o700);
}
await chmod(cleanup, 0o700);
await writeFile(join(cleanup, 'fleet', 'roster.yaml'), roster, { mode: 0o600 });
await writeFile(join(cleanup, 'fleet', 'roles', 'code.md'), '# code\n\n(`class: code`)\n', {
mode: 0o600,
});
return cleanup;
}
function program(
mosaicHome: string,
runner: FleetCommandDeps['runner'],
reconcileOverrides: Partial<FleetReconcileDeps> = {},
): Command {
const result = new Command();
result.exitOverride();
registerFleetCommand(result, {
mosaicHome,
runner,
reconcileDeps: {
homeDirectory: '/home/mosaic',
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
validateRoster: async () => undefined,
prepareProjections: async () => [{ agentName: 'coder0' }],
applyProjection: async () => undefined,
...reconcileOverrides,
},
});
return result;
}
function capture(): string[] {
const lines: string[] = [];
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
lines.push(value);
});
return lines;
}
function ownedRunner(
calls: string[][],
): (command: string, args: string[]) => Promise<CommandResult> {
return async (command: string, args: string[]): Promise<CommandResult> => {
calls.push([command, ...args]);
if (command === 'tmux' && args.includes('list-sessions')) {
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
}
if (command === 'tmux' && args.includes('show-environment')) {
return {
stdout:
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
};
}
describe('mosaic fleet reconciler commands', (): void => {
it('plans apply as stable JSON without applying projections or lifecycle effects', async (): Promise<void> => {
const home = await fleetHome();
const calls: string[][] = [];
const lines = capture();
await program(home, ownedRunner(calls)).parseAsync([
'node',
'mosaic',
'fleet',
'apply',
'--expected-generation',
'7',
'--dry-run',
]);
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
applied: false,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
lifecycle: 'not-applied',
});
expect(calls).not.toContainEqual([
'systemctl',
'--user',
'start',
'mosaic-agent@coder0.service',
]);
});
it('uses direct fleet status and doctor as observational JSON commands', async (): Promise<void> => {
const home = await fleetHome();
const lines = capture();
const calls: string[][] = [];
const cli = program(home, ownedRunner(calls));
await cli.parseAsync(['node', 'mosaic', 'fleet', 'status', 'coder0']);
await cli.parseAsync(['node', 'mosaic', 'fleet', 'doctor']);
expect(lines.map((line: string): unknown => JSON.parse(line))).toMatchObject([
{ applied: false, lifecycle: 'not-applied' },
{ applied: false, lifecycle: 'not-applied' },
]);
expect(
calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
).toBe(true);
});
it.each(['start', 'stop', 'restart'] as const)(
'uses exact roster-owned systemd targeting for %s',
async (operation: 'start' | 'stop' | 'restart'): Promise<void> => {
const home = await fleetHome();
const calls: string[][] = [];
const lines = capture();
await program(home, ownedRunner(calls)).parseAsync([
'node',
'mosaic',
'fleet',
operation,
'coder0',
'--expected-generation',
'7',
]);
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({ applied: true, lifecycle: 'complete' });
expect(calls).toContainEqual([
'systemctl',
'--user',
operation,
'mosaic-agent@coder0.service',
]);
expect(calls.some((call: string[]): boolean => call.join(' ').includes('coder0-extra'))).toBe(
false,
);
},
);
it('makes cleanup-incomplete effect JSON non-zero without losing effect truth', async (): Promise<void> => {
const home = await fleetHome();
const lines = capture();
await program(home, ownedRunner([]), {
acquireMutationLock: async () => async () => {
throw new Error('cleanup failure');
},
}).parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
applied: true,
projections: 'complete',
lifecycle: 'complete',
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
});
expect(process.exitCode).toBe(1);
});
it('keeps primary partial recovery JSON and exits non-zero when cleanup is also incomplete', async (): Promise<void> => {
const cleanupFailure = async () => async () => {
throw new Error('cleanup failure');
};
const projectionHome = await fleetHome();
const projectionLines = capture();
await program(projectionHome, ownedRunner([]), {
applyProjection: async () => {
throw new Error('projection failure');
},
acquireMutationLock: cleanupFailure,
}).parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
expect(JSON.parse(projectionLines.pop() ?? '')).toMatchObject({
projections: 'incomplete',
lifecycle: 'not-applied',
recovery: { code: 'projection-apply-failed' },
cleanup: { code: 'lock-cleanup-failed' },
});
expect(process.exitCode).toBe(1);
});
it('keeps clean effect and observational commands at zero exit', async (): Promise<void> => {
const home = await fleetHome();
const cli = program(home, ownedRunner([]));
await cli.parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
expect(process.exitCode).toBe(0);
process.exitCode = undefined;
await cli.parseAsync(['node', 'mosaic', 'fleet', 'status']);
expect(process.exitCode).toBe(0);
});
it('rejects stale apply generations as non-zero redacted JSON', async (): Promise<void> => {
const home = await fleetHome();
const lines = capture();
await program(home, ownedRunner([])).parseAsync([
'node',
'mosaic',
'fleet',
'apply',
'--expected-generation',
'6',
]);
expect(JSON.parse(lines.pop() ?? '')).toEqual({ error: { code: 'stale-generation' } });
expect(process.exitCode).toBe(1);
});
});

View File

@@ -1,146 +0,0 @@
import { readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import type { Command } from 'commander';
import type { CommandRunner } from './fleet.js';
import {
executeFleetReconcile,
FleetReconcileError,
type FleetReconcileCommand,
type FleetReconcileDeps,
} from '../fleet/fleet-reconciler.js';
import { parseRosterV2 } from '../fleet/roster-v2.js';
export interface FleetReconcilerCommandDeps {
readonly runner: CommandRunner;
readonly mosaicHome?: string;
readonly reconcileDeps?: Omit<FleetReconcileDeps, 'runner' | 'mosaicHome'>;
}
interface ReconcileOptions {
readonly expectedGeneration?: string;
readonly dryRun?: boolean;
}
/** Registers roster-v2 reconciliation commands on the canonical fleet control plane. */
export function registerFleetReconcilerCommands(
fleetCommand: Command,
deps: FleetReconcilerCommandDeps,
): void {
for (const operation of ['apply', 'reconcile'] as const) {
fleetCommand
.command(operation)
.description(`${operation} local roster-owned projections and desired lifecycle`)
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
.option('--dry-run', 'Plan and preflight without writing projections or lifecycle state')
.action(async (opts: ReconcileOptions): Promise<void> => {
await writeOutcome(async (): Promise<void> => {
await executeReconcilerCommand(fleetCommand, deps, operation, opts);
});
});
}
fleetCommand
.command('doctor')
.description('Classify local roster-owned drift without mutation')
.action(async (): Promise<void> => {
await writeOutcome(async (): Promise<void> => {
await executeReconcilerCommand(fleetCommand, deps, 'doctor', {});
});
});
}
export async function executeReconcilerCommandJson(
fleetCommand: Command,
deps: FleetReconcilerCommandDeps,
operation: FleetReconcileCommand,
opts: ReconcileOptions,
agentName?: string,
): Promise<void> {
await writeOutcome(async (): Promise<void> => {
await executeReconcilerCommand(fleetCommand, deps, operation, opts, agentName);
});
}
export async function executeReconcilerCommand(
fleetCommand: Command,
deps: FleetReconcilerCommandDeps,
operation: FleetReconcileCommand,
opts: ReconcileOptions,
agentName?: string,
): Promise<void> {
const mosaicHome = resolveMosaicHome(fleetCommand, deps);
const rosterPath = resolveRosterPath(fleetCommand, mosaicHome);
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
const mutating = operation === 'apply' || operation === 'reconcile' || isLifecycle(operation);
const expectedGeneration = mutating
? parseExpectedGeneration(opts.expectedGeneration)
: undefined;
const result = await executeFleetReconcile({
roster,
command: opts.dryRun === true ? 'plan' : operation,
...(agentName === undefined ? {} : { agentName }),
...(expectedGeneration === undefined ? {} : { expectedGeneration }),
deps: {
runner: async (command: string, args: readonly string[]) => deps.runner(command, [...args]),
mosaicHome,
rolesDir: join(mosaicHome, 'fleet', 'roles'),
overrideDir: join(mosaicHome, 'fleet', 'roles.local'),
readRoster: async (): Promise<typeof roster> =>
parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml'),
...(deps.reconcileDeps ?? {}),
},
});
printJson(result);
process.exitCode = result.recovery === undefined && result.cleanup === undefined ? 0 : 1;
}
function isLifecycle(operation: FleetReconcileCommand): boolean {
return operation === 'start' || operation === 'stop' || operation === 'restart';
}
function parseExpectedGeneration(value: string | undefined): number {
const generation = Number(value);
if (!Number.isSafeInteger(generation) || generation < 1) {
throw new FleetReconcileError(
'lifecycle-precondition-failed',
'--expected-generation must be a positive safe integer.',
);
}
return generation;
}
function resolveMosaicHome(fleetCommand: Command, deps: FleetReconcilerCommandDeps): string {
const options = fleetCommand.optsWithGlobals<{ mosaicHome?: string }>();
return (
options.mosaicHome ?? deps.mosaicHome ?? join(process.env['HOME'] ?? '', '.config', 'mosaic')
);
}
function resolveRosterPath(fleetCommand: Command, mosaicHome: string): string {
const options = fleetCommand.optsWithGlobals<{ roster?: string }>();
const canonical = join(mosaicHome, 'fleet', 'roster.yaml');
if (options.roster !== undefined && resolve(options.roster) !== resolve(canonical)) {
throw new FleetReconcileError(
'lifecycle-precondition-failed',
'Roster-v2 reconciliation requires the canonical roster path.',
);
}
return canonical;
}
async function writeOutcome(action: () => Promise<void>): Promise<void> {
try {
await action();
} catch (error: unknown) {
process.exitCode = 1;
printJson({
error: {
code: error instanceof FleetReconcileError ? error.code : 'reconcile-failed',
},
});
}
}
function printJson(value: object): void {
console.log(JSON.stringify(value));
}

View File

@@ -81,22 +81,18 @@ describe('registerFleetCommand', () => {
expect(fleet).toBeDefined();
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
'add',
'apply',
'backlog',
'create',
'delete',
'doctor',
'get',
'init',
'install',
'install-systemd',
'migrate-v1',
'persona',
'plan',
'profile',
'provision',
'ps',
'reconcile',
'remove',
'restart',
'start',
@@ -198,26 +194,6 @@ describe('fleet roster parsing', () => {
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 () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
@@ -291,32 +267,6 @@ describe('fleet roster parsing', () => {
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 () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
@@ -335,8 +285,8 @@ describe('fleet roster parsing', () => {
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
[
'MOSAIC_AGENT_NAME=coder0',
// Reflects the roster's canonicalized compatibility class (A3a).
'MOSAIC_AGENT_CLASS=code',
// Reflects the roster's non-default `class: implementer` (A3a).
'MOSAIC_AGENT_CLASS=implementer',
'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_MODEL=',
'MOSAIC_AGENT_REASONING=',
@@ -540,13 +490,7 @@ describe('fleet command construction', () => {
]);
});
it('runs legacy fleet status through injected runner without touching tmux in tests', async () => {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
'version: 1\ntransport: tmux\nagents: []\n',
);
it('runs fleet status through injected runner without touching tmux in tests', async () => {
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
@@ -554,14 +498,11 @@ describe('fleet command construction', () => {
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
registerFleetCommand(program, { runner });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
} finally {
await rm(home, { recursive: true, force: true });
}
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
});
it('verifies liveness with tmux has-session and does not trust systemd active exited', async () => {
@@ -677,19 +618,13 @@ describe('fleet command construction', () => {
).rejects.toThrow('Unsupported fleet profile');
});
it('sets process exitCode when legacy status runner fails', async () => {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
join(home, 'fleet', 'roster.yaml'),
'version: 1\ntransport: tmux\nagents: []\n',
);
it('sets process exitCode when status runner fails', async () => {
const originalExitCode = process.exitCode;
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const runner: CommandRunner = async () => ({ stdout: '', stderr: 'missing\n', exitCode: 3 });
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
registerFleetCommand(program, { runner });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
@@ -697,7 +632,6 @@ describe('fleet command construction', () => {
} finally {
process.exitCode = originalExitCode;
stderrSpy.mockRestore();
await rm(home, { recursive: true, force: true });
}
});
@@ -3552,66 +3486,7 @@ describe('fleet add/remove — pure helpers', () => {
}
});
it.each([
['tmux', { kind: 'tmux' }],
['discord', { kind: 'discord', discord: { channelId: '1234567890' } }],
[
'matrix',
{
kind: 'matrix',
matrix: {
homeserverUrl: 'https://matrix.example.test',
userId: '@mosaic:example.test',
roomId: '!fleet:example.test',
},
},
],
] as const)('round-trips the supported %s connector through YAML', async (_kind, connector) => {
const yaml = serializeRosterToYaml({ ...baseRoster, connector });
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-connector-'));
const rosterPath = join(dir, 'roster.yaml');
try {
await writeFile(rosterPath, yaml);
expect((await loadFleetRoster(rosterPath)).connector).toEqual(connector);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it.each([
['tmux', { kind: 'tmux' }],
['discord', { kind: 'discord', discord: { channel_id: '1234567890' } }],
[
'matrix',
{
kind: 'matrix',
matrix: {
homeserver_url: 'https://matrix.example.test',
user_id: '@mosaic:example.test',
room_id: '!fleet:example.test',
},
},
],
] as const)('parses the supported %s connector from JSON', async (_kind, connector) => {
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-connector-'));
const rosterPath = join(dir, 'roster.json');
try {
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'orchestrator', runtime: 'claude', class: 'orchestrator' }],
connector,
}),
);
expect((await loadFleetRoster(rosterPath)).connector?.kind).toBe(_kind);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('serializeRosterToYaml round-trips optional fields and exact comms targets', async () => {
it('serializeRosterToYaml round-trips optional fields (modelHint, workingDirectory)', async () => {
const rosterWithOptionals: FleetRoster = {
...baseRoster,
agents: [
@@ -3623,9 +3498,6 @@ describe('fleet add/remove — pure helpers', () => {
workingDirectory: '/tmp/work',
persistentPersona: true,
resetBetweenTasks: false,
host: '10.1.10.37',
ssh: 'jwoltje@10.1.10.37',
socket: 'mosaic-fleet',
},
],
};
@@ -3633,9 +3505,6 @@ describe('fleet add/remove — pure helpers', () => {
expect(yaml).toContain('model_hint: claude-3-5-sonnet');
expect(yaml).toContain('working_directory: /tmp/work');
expect(yaml).toContain('persistent_persona: true');
expect(yaml).toContain('host: 10.1.10.37');
expect(yaml).toContain('ssh: jwoltje@10.1.10.37');
expect(yaml).toContain('socket: mosaic-fleet');
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
const rosterPath = join(dir, 'roster.yaml');
@@ -3645,9 +3514,6 @@ describe('fleet add/remove — pure helpers', () => {
expect(loaded.agents[0]!.modelHint).toBe('claude-3-5-sonnet');
expect(loaded.agents[0]!.workingDirectory).toBe('/tmp/work');
expect(loaded.agents[0]!.persistentPersona).toBe(true);
expect(loaded.agents[0]!.host).toBe('10.1.10.37');
expect(loaded.agents[0]!.ssh).toBe('jwoltje@10.1.10.37');
expect(loaded.agents[0]!.socket).toBe('mosaic-fleet');
} finally {
await rm(dir, { recursive: true, force: true });
}

View File

@@ -18,32 +18,10 @@ import { spawn } from 'node:child_process';
import * as readline from 'node:readline';
import type { Command } from 'commander';
import YAML from 'yaml';
import {
getRosterAgent,
loadFleetRoster,
resolveInstalledFleetRosterPath,
type FleetAgent,
type FleetRoster,
} from '../fleet/fleet-roster-v1.js';
export {
getRosterAgent,
loadFleetRoster,
resolveInstalledFleetRosterPath,
} from '../fleet/fleet-roster-v1.js';
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
import {
registerFleetAgentCrudCommands,
type FleetAgentCrudCommandDeps,
} from './fleet-agent-crud-command.js';
import {
registerFleetMigrationCommand,
type FleetMigrationCommandDeps,
} from './fleet-migration-command.js';
import {
executeReconcilerCommandJson,
registerFleetReconcilerCommands,
type FleetReconcilerCommandDeps,
} from './fleet-reconciler-command.js';
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
import {
applyPreparedAgentEnvironmentProjection,
@@ -100,8 +78,72 @@ export interface FleetCommandDeps {
*/
isStdinTTY?: boolean;
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
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 {
@@ -122,6 +164,8 @@ type FleetServiceAction = 'start' | 'stop' | 'restart' | 'status';
* fallback for a socket-less roster (that now resolves to the default socket).
*/
export const DEFAULT_SOCKET_NAME = 'mosaic-fleet';
const DEFAULT_HOLDER_SESSION = '_holder';
const DEFAULT_WORKING_DIRECTORY = '~/src';
/**
* tmux `-L` args for a socket name. An empty/absent socket ⇒ the LITERAL default
@@ -145,6 +189,13 @@ export const VERIFY_POLL_INTERVAL_MS = 400;
* Configurable via `--verify-timeout <ms>` on `agent send`.
*/
export const VERIFY_DEFAULT_TIMEOUT_MS = 6_000;
const DEFAULT_RUNTIME_RESETS: Record<string, { resetCommand: string }> = {
claude: { resetCommand: '/clear' },
codex: { resetCommand: '/clear' },
opencode: { resetCommand: '/clear' },
pi: { resetCommand: '/new' },
};
export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths {
return {
mosaicHome,
@@ -172,6 +223,20 @@ function assertDefaultMosaicHomeForSystemd(mosaicHome: string): void {
}
}
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
const rawText = await readFile(path, 'utf8');
const parsed = parseRosterText(rawText, path);
return normalizeRoster(parsed);
}
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
const agent = roster.agents.find((candidate) => candidate.name === name);
if (!agent) {
throw new Error(`Agent "${name}" is not in the fleet roster.`);
}
return agent;
}
// ---------------------------------------------------------------------------
// NORTH_STAR — machine-readable fleet planning source + Markdown projection
//
@@ -485,8 +550,8 @@ function generateAgentEnvValues(
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
MOSAIC_AGENT_WORKDIR: workingDirectory,
MOSAIC_TMUX_SOCKET: agent.socket ?? roster.tmux.socketName,
MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory),
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
};
}
@@ -1541,70 +1606,53 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
cmd
.command(`${action} [agent]`)
.description(`${action} the fleet holder or one agent`)
.option('--expected-generation <number>', 'Authoritative roster generation for roster-v2')
.option('--dry-run', 'Plan roster-v2 lifecycle effects without mutation')
.action(
async (
agent: string | undefined,
opts: { expectedGeneration?: string; dryRun?: boolean },
) => {
if (await usesRosterV2ControlPlane(cmd)) {
await executeReconcilerCommandJson(
cmd,
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
action,
opts,
agent,
);
return;
}
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const roster = await loadRosterForCommand(cmd);
if (agent) {
getRosterAgent(roster, agent);
// Single-agent restart is guarded too: it can race a full restart that
// is tearing the shared holder down.
if (action === 'restart') {
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
try {
await runChecked(runner, buildFleetServiceCommand(action, agent));
} finally {
await guard.release();
}
return;
}
await runChecked(runner, buildFleetServiceCommand(action, agent));
return;
}
if (action === 'stop') {
await stopFleetBestEffort(
runner,
roster.agents.map((rosterAgent) => rosterAgent.name),
);
return;
}
.action(async (agent?: string) => {
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const roster = await loadRosterForCommand(cmd);
if (agent) {
getRosterAgent(roster, agent);
// Single-agent restart is guarded too: it can race a full restart that
// is tearing the shared holder down.
if (action === 'restart') {
// Serialize the holder+agents teardown/relaunch behind the restart lock
// so a re-entrant restart waits for clean shutdown before relaunching,
// instead of racing a half-torn-down holder into a tight loop.
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
try {
await runChecked(runner, buildFleetServiceCommand(action));
for (const rosterAgent of roster.agents) {
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
}
await runChecked(runner, buildFleetServiceCommand(action, agent));
} finally {
await guard.release();
}
return;
}
await runChecked(runner, buildFleetServiceCommand(action));
for (const rosterAgent of roster.agents) {
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
await runChecked(runner, buildFleetServiceCommand(action, agent));
return;
}
if (action === 'stop') {
await stopFleetBestEffort(
runner,
roster.agents.map((rosterAgent) => rosterAgent.name),
);
return;
}
if (action === 'restart') {
// Serialize the holder+agents teardown/relaunch behind the restart lock
// so a re-entrant restart waits for clean shutdown before relaunching,
// instead of racing a half-torn-down holder into a tight loop.
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
try {
await runChecked(runner, buildFleetServiceCommand(action));
for (const rosterAgent of roster.agents) {
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
}
} finally {
await guard.release();
}
},
);
return;
}
await runChecked(runner, buildFleetServiceCommand(action));
for (const rosterAgent of roster.agents) {
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
}
});
}
cmd
@@ -1612,16 +1660,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.description('Show fleet holder or agent systemd status')
.option('--json', 'Print JSON status')
.action(async (agent: string | undefined, opts: { json?: boolean }) => {
if (await usesRosterV2ControlPlane(cmd)) {
await executeReconcilerCommandJson(
cmd,
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
'status',
{},
agent,
);
return;
}
if (agent) {
const roster = await loadRosterForCommand(cmd);
getRosterAgent(roster, agent);
@@ -1645,15 +1683,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.command('verify')
.description('Verify the local canary holder and roster sessions on the isolated socket')
.action(async () => {
if (await usesRosterV2ControlPlane(cmd)) {
await executeReconcilerCommandJson(
cmd,
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
'verify',
{},
);
return;
}
const roster = await loadRosterForCommand(cmd);
const socketName = roster.tmux.socketName;
await runChecked(runner, [
@@ -2053,15 +2082,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// Roster-v2 desired-state mutations belong directly to the fleet control
// plane; they do not share the root `mosaic agent` gateway-backed surface.
registerFleetAgentCrudCommands(cmd, deps);
registerFleetMigrationCommand(cmd, {
...deps.migrationDeps,
mosaicHome: deps.mosaicHome,
});
registerFleetReconcilerCommands(cmd, {
runner,
mosaicHome: deps.mosaicHome,
reconcileDeps: deps.reconcileDeps,
});
return cmd;
}
@@ -2090,11 +2110,14 @@ export function registerFleetAgentCommands(
});
agentCommand
.command('comms-block <exact-member>')
.description('Print the Fleet Comms contract for one exact roster member')
.action((exactMember: string) => {
.command('comms-block <role>')
.description(
"Print the Fleet Comms cheat-sheet for a roster role (preview a peer's peer-reach view)",
)
.option('--host <host>', 'Override the fleet host (preview a cross-host peer view)')
.action((role: string, opts: { host?: string }) => {
const mosaicHome = resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome);
const res = resolveCommsBlock(mosaicHome, exactMember);
const res = resolveCommsBlock(mosaicHome, role, opts.host);
if (!res.ok) {
console.error(`[mosaic] comms-block: ${res.error}`);
process.exitCode = 1;
@@ -2392,20 +2415,6 @@ async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
}
/** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */
async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
return (
typeof parsed === 'object' &&
parsed !== null &&
!Array.isArray(parsed) &&
'version' in parsed &&
parsed.version === 2
);
}
async function loadRosterFromAgentCommand(
command: Command,
mosaicHomeOverride?: string,
@@ -2420,6 +2429,253 @@ function resolveMosaicHomeFromCommand(command: Command, override?: string): stri
return opts.mosaicHome ?? override ?? defaultMosaicHome();
}
function parseRosterText(text: string, path: string): RawFleetRoster {
const trimmed = text.trim();
if (path.endsWith('.json')) {
return JSON.parse(trimmed) as RawFleetRoster;
}
return YAML.parse(trimmed) as RawFleetRoster;
}
function normalizeRoster(raw: RawFleetRoster): FleetRoster {
assertObject(raw, 'Fleet roster');
assertKnownKeys(raw, 'Fleet roster', [
'version',
'transport',
'tmux',
'defaults',
'runtimes',
'agents',
]);
if (raw.tmux !== undefined) {
assertObject(raw.tmux, 'Fleet roster tmux');
assertKnownKeys(raw.tmux, 'Fleet roster tmux', [
'socket_name',
'socketName',
'holder_session',
'holderSession',
]);
}
if (raw.defaults !== undefined) {
assertObject(raw.defaults, 'Fleet roster defaults');
assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
'working_directory',
'workingDirectory',
]);
}
if (raw.runtimes !== undefined) {
assertObject(raw.runtimes, 'Fleet roster runtimes');
for (const [runtime, config] of Object.entries(raw.runtimes)) {
assertObject(config, `Fleet roster runtime "${runtime}"`);
assertKnownKeys(config, `Fleet roster runtime "${runtime}"`, [
'reset_command',
'resetCommand',
]);
}
}
if (raw.version !== 1) {
throw new Error('Fleet roster version must be 1.');
}
if (raw.transport !== 'tmux') {
throw new Error('Fleet roster transport must be "tmux".');
}
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
throw new Error('Fleet roster must define at least one agent.');
}
const agents = raw.agents.map(normalizeAgent);
assertUniqueAgentNames(agents);
return {
version: 1,
transport: 'tmux',
tmux: {
// Absent socket_name ⇒ '' (the literal default tmux socket, no -L) — NOT
// mosaic-fleet. Shipped presets set socket_name explicitly, so they are
// unaffected; only socket-less rosters get default-socket behavior.
socketName: stringValue(
raw.tmux?.socket_name ?? raw.tmux?.socketName,
'',
'Fleet roster tmux socket_name',
),
holderSession: stringValue(
raw.tmux?.holder_session ?? raw.tmux?.holderSession,
DEFAULT_HOLDER_SESSION,
'Fleet roster tmux holder_session',
),
},
defaults: {
workingDirectory: stringValue(
raw.defaults?.working_directory ?? raw.defaults?.workingDirectory,
DEFAULT_WORKING_DIRECTORY,
'Fleet roster defaults working_directory',
),
},
runtimes: normalizeRuntimes(raw.runtimes as RawFleetRoster['runtimes']),
agents,
};
}
function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): FleetAgent {
assertObject(raw, 'Fleet roster agent');
assertKnownKeys(raw, 'Fleet roster agent', [
'name',
'alias',
'provider',
'runtime',
'class',
'working_directory',
'workingDirectory',
'model_hint',
'modelHint',
'reasoning_level',
'reasoningLevel',
'tool_policy',
'toolPolicy',
'persistent_persona',
'persistentPersona',
'reset_between_tasks',
'resetBetweenTasks',
'kickstart_template',
'kickstartTemplate',
]);
const name = stringValue(raw.name, '', 'Fleet roster agent name');
const runtime = stringValue(
raw.runtime,
'',
`Fleet roster agent "${name || '<unknown>'}" runtime`,
);
if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
throw new Error(`Invalid fleet agent name: ${name || '<empty>'}`);
}
if (!runtime) {
throw new Error(`Fleet agent "${name}" must define a runtime.`);
}
return {
name,
alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`),
provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`),
runtime,
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
workingDirectory: optionalString(
raw.working_directory ?? raw.workingDirectory,
`Fleet roster agent "${name}" working_directory`,
),
modelHint: optionalString(
raw.model_hint ?? raw.modelHint,
`Fleet roster agent "${name}" model_hint`,
),
reasoningLevel: optionalString(
raw.reasoning_level ?? raw.reasoningLevel,
`Fleet roster agent "${name}" reasoning_level`,
),
toolPolicy: optionalString(
raw.tool_policy ?? raw.toolPolicy,
`Fleet roster agent "${name}" tool_policy`,
),
persistentPersona: optionalBooleanOrString(
raw.persistent_persona ?? raw.persistentPersona,
`Fleet roster agent "${name}" persistent_persona`,
),
resetBetweenTasks: optionalBoolean(
raw.reset_between_tasks ?? raw.resetBetweenTasks,
`Fleet roster agent "${name}" reset_between_tasks`,
),
kickstartTemplate: optionalString(
raw.kickstart_template ?? raw.kickstartTemplate,
`Fleet roster agent "${name}" kickstart_template`,
),
};
}
function normalizeRuntimes(
raw: RawFleetRoster['runtimes'] | undefined,
): Record<string, { resetCommand: string }> {
const result: Record<string, { resetCommand: string }> = { ...DEFAULT_RUNTIME_RESETS };
for (const [runtime, config] of Object.entries(raw ?? {})) {
result[runtime] = {
resetCommand: stringValue(
config.reset_command ?? config.resetCommand,
'/clear',
`Fleet roster runtime "${runtime}" reset_command`,
),
};
}
return result;
}
function assertObject(value: unknown, label: string): asserts value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} must be an object.`);
}
}
function assertKnownKeys(
value: Record<string, unknown>,
label: string,
allowedKeys: readonly string[],
): void {
const allowed = new Set(allowedKeys);
const unknownKeys = Object.keys(value).filter((key) => !allowed.has(key));
if (unknownKeys.length > 0) {
throw new Error(`${label} has unknown field(s): ${unknownKeys.join(', ')}.`);
}
}
function assertUniqueAgentNames(agents: FleetAgent[]): void {
const seen = new Set<string>();
for (const agent of agents) {
if (seen.has(agent.name)) {
throw new Error(`Fleet roster has duplicate agent name: ${agent.name}.`);
}
seen.add(agent.name);
}
}
function stringValue(value: unknown, fallback = '', label = 'Value'): string {
if (value === undefined) {
return fallback;
}
if (typeof value !== 'string') {
throw new Error(`${label} must be a string.`);
}
return value;
}
function optionalString(value: unknown, label = 'Value'): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string') {
throw new Error(`${label} must be a string.`);
}
return value;
}
function optionalBoolean(value: unknown, label = 'Value'): boolean | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'boolean') {
throw new Error(`${label} must be a boolean.`);
}
return value;
}
function optionalBooleanOrString(value: unknown, label = 'Value'): boolean | string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'boolean' && typeof value !== 'string') {
throw new Error(`${label} must be a boolean or string.`);
}
return value;
}
function expandHome(path: string): string {
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
}
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
const failures: string[] = [];
for (const agentName of agentNames) {
@@ -2572,23 +2828,6 @@ export function removeAgentFromRoster(roster: FleetRoster, name: string): FleetR
};
}
function serializeConnector(
connector: NonNullable<FleetRoster['connector']>,
): Record<string, unknown> {
if (connector.kind === 'tmux') return { kind: 'tmux' };
if (connector.kind === 'discord') {
return { kind: 'discord', discord: { channel_id: connector.discord.channelId } };
}
return {
kind: 'matrix',
matrix: {
homeserver_url: connector.matrix.homeserverUrl,
user_id: connector.matrix.userId,
room_id: connector.matrix.roomId,
},
};
}
/**
* Serialize a FleetRoster to YAML text (snake_case keys).
* The output is parseable by loadFleetRoster.
@@ -2606,15 +2845,6 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
if (agent.provider !== undefined) {
raw['provider'] = agent.provider;
}
if (agent.host !== undefined) {
raw['host'] = agent.host;
}
if (agent.ssh !== undefined) {
raw['ssh'] = agent.ssh;
}
if (agent.socket !== undefined) {
raw['socket'] = agent.socket;
}
if (agent.workingDirectory !== undefined) {
raw['working_directory'] = agent.workingDirectory;
}
@@ -2656,7 +2886,6 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
},
runtimes,
agents,
...(roster.connector ? { connector: serializeConnector(roster.connector) } : {}),
};
return YAML.stringify(raw);
@@ -2803,5 +3032,6 @@ export async function resolveRosterPath(
if (await canRead(yamlPath)) {
return yamlPath;
}
return resolveInstalledFleetRosterPath(mosaicHome);
const jsonPath = join(mosaicHome, 'fleet', 'roster.json');
return jsonPath;
}

View File

@@ -19,17 +19,10 @@ import { createRequire } from 'node:module';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import type { Command } from 'commander';
import {
buildResolvedFleetCommsBlock,
renderToolsContractStatus,
resolveFleetIdentity,
} from '../fleet/comms-onboarding.js';
import { readRegularFileSecure } from '../fleet/secure-file.js';
import { readFleetCommsBlock } from '../fleet/comms-onboarding.js';
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
import { canonicalizeRoleClass } from './fleet-personas.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
@@ -192,17 +185,6 @@ function readOptional(path: string): string {
}
}
function readInstalledToolsSecure(mosaicHome: string): string {
try {
return readRegularFileSecure(join(mosaicHome, 'TOOLS.md'), {
root: mosaicHome,
maxBytes: MAX_INSTALLED_TOOLS_BYTES,
}).content.toString('utf8');
} catch {
return '';
}
}
function readJson(path: string): Record<string, unknown> | null {
try {
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
@@ -379,25 +361,9 @@ For required push/merge/issue-close/release actions, execute without routine con
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
}
const fleetIdentity = resolveFleetIdentity(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
if (!fleetIdentity.ok) {
throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`);
}
const canonicalMember = fleetIdentity.identity?.member;
if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) {
const ambientClass = canonicalizeRoleClass(process.env['MOSAIC_AGENT_CLASS']).canonicalClass;
if (ambientClass !== canonicalMember.className) {
throw new Error(
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`,
);
}
}
// TOOLS.md
const tools = readInstalledToolsSecure(mosaicHome);
const tools = readOptional(join(mosaicHome, 'TOOLS.md'));
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
const toolsContractStatus = renderToolsContractStatus(mosaicHome);
if (toolsContractStatus) parts.push('\n\n' + toolsContractStatus);
// Operator overlays whose base layers are load-on-demand (SOUL, STANDARDS):
// inject only the small `.local` delta by value so the customization reaches
@@ -419,23 +385,24 @@ For required push/merge/issue-close/release actions, execute without routine con
// Runtime-specific contract
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
// Fleet launches derive every identity projection from the one canonical roster
// member resolved above. Non-fleet launches retain the legacy ambient persona
// and tool-policy behavior.
const personaClass = canonicalMember?.className ?? process.env['MOSAIC_AGENT_CLASS'];
const persona = readPersonaContractBlock(mosaicHome, personaClass);
// Persona contract (A3b): when this agent was spawned with a class
// (MOSAIC_AGENT_CLASS, exported into the pane env by A3a), inject its resolved
// role contract so its identity (mandate + boundaries) is resident from the
// first turn. Override-aware via the persona resolver: a user-customized
// persona in fleet/roles.local/ wins over the baseline (AC-NS-7 launch proof).
// Placed BEFORE fleet comms: identity first, then how-to-reach-peers. No-ops
// silently when the class is unset/unknown (mirrors the comms block).
const persona = readPersonaContractBlock(mosaicHome, process.env['MOSAIC_AGENT_CLASS']);
if (persona) parts.push('\n\n' + persona);
const toolPolicyName = canonicalMember
? canonicalMember.toolPolicy
: process.env['MOSAIC_AGENT_TOOL_POLICY'];
const toolPolicy = readFleetToolPolicyBlock(toolPolicyName);
const toolPolicy = readFleetToolPolicyBlock(process.env['MOSAIC_AGENT_TOOL_POLICY']);
if (toolPolicy) parts.push('\n\n' + toolPolicy);
if (fleetIdentity.identity) {
const fleetComms = buildResolvedFleetCommsBlock(fleetIdentity.identity);
if (fleetComms) parts.push('\n\n' + fleetComms);
}
// Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set
// and present in the roster), inject a comms cheat-sheet + peer roster so it
// knows how to reach the orchestrator and its peers from its first turn.
const fleetComms = readFleetCommsBlock(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
if (fleetComms) parts.push('\n\n' + fleetComms);
return parts.join('\n');
}

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

View File

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

View File

@@ -1,43 +1,30 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
chmodSync,
mkdtempSync,
mkdirSync,
writeFileSync,
rmSync,
readFileSync,
symlinkSync,
} from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parseFleetRosterV1, type FleetRoster, type FleetAgent } from './fleet-roster-v1.js';
import {
parseRosterAgents,
buildFleetCommsBlock,
renderPeerReach,
readFleetCommsBlock,
resolveCommsBlock,
resolvePeerCommand,
renderToolsContractStatus,
type CommsPeer,
} from './comms-onboarding.js';
const ROSTER = [
'version: 1',
'transport: tmux',
'tmux:',
' socket_name: mosaic-fleet',
'agents:',
' - name: orchestrator',
' runtime: claude',
' class: orchestrator',
' host: w-jarvis',
' - name: enhancer',
' runtime: claude',
' class: enhancer',
' host: w-jarvis',
' - name: coder0',
' runtime: pi',
' class: implementer',
' host: w-jarvis',
' # a manually-listed cross-host peer (pre-federation stopgap)',
' - name: coder0-0',
' runtime: claude',
' class: implementer',
@@ -46,687 +33,206 @@ const ROSTER = [
'',
].join('\n');
function roster(source = ROSTER): FleetRoster {
return parseFleetRosterV1(source, 'yaml');
}
describe('shared fleet roster v1 resolver', () => {
it('resolves comms fields and the global socket through the canonical roster contract', () => {
const resolved = roster();
expect(resolved.tmux.socketName).toBe('mosaic-fleet');
expect(resolved.agents.find((agent) => agent.name === 'coder0-0')).toMatchObject({
className: 'code',
describe('parseRosterAgents', () => {
it('parses name + class + optional host/ssh', () => {
const peers = parseRosterAgents(ROSTER);
expect(peers.map((p) => p.name)).toEqual(['orchestrator', 'enhancer', 'coder0', 'coder0-0']);
expect(peers.find((p) => p.name === 'coder0')).toMatchObject({ className: 'implementer' });
expect(peers.find((p) => p.name === 'coder0-0')).toMatchObject({
className: 'implementer',
host: '10.1.10.37',
ssh: 'jwoltje@10.1.10.37',
});
// local agents have no host/ssh
expect(peers.find((p) => p.name === 'orchestrator')!.host).toBeUndefined();
});
it('rejects unknown fields instead of leniently constructing a second roster view', () => {
expect(() => parseFleetRosterV1(`${ROSTER}\nunknown: value\n`, 'yaml')).toThrow(
/unknown field/i,
it('parses an optional per-agent socket', () => {
const peers = parseRosterAgents(
['agents:', ' - name: a', ' class: worker', ' socket: mosaic-fleet'].join('\n'),
);
expect(peers[0]).toMatchObject({ name: 'a', socket: 'mosaic-fleet' });
});
it('rejects an unsupported independent per-agent socket instead of targeting a nonexistent session', () => {
expect(() =>
roster(
ROSTER.replace(
' host: w-jarvis\n - name: coder0',
' host: w-jarvis\n socket: other-socket\n - name: coder0',
),
it('stops at the next top-level key', () => {
const peers = parseRosterAgents(
['agents:', ' - name: a', ' class: worker', 'defaults:', ' working_directory: ~'].join(
'\n',
),
).toThrow(/independent per-agent sockets are not supported/i);
);
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 base: FleetAgent = {
name: 'peer',
runtime: 'claude',
className: 'worker',
};
it('renders the global named socket and omits -H for a same-host peer', () => {
expect(renderPeerReach(base, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe(
`${send} -L mosaic-fleet -s peer -m "…"`,
);
it('renders the short form for a same-host peer', () => {
const peer: CommsPeer = { name: 'enhancer', className: 'enhancer' };
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s enhancer -m "…"`);
});
it('uses only the explicit roster ssh target for a cross-host peer', () => {
const peer: FleetAgent = {
...base,
it('renders the -H form for a cross-host peer using ssh', () => {
const peer: CommsPeer = {
name: 'coder0-0',
className: 'implementer',
host: '10.1.10.37',
ssh: 'jwoltje@10.1.10.37',
};
expect(renderPeerReach(peer, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe(
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
`${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`,
);
});
it('falls back to host when a cross-host peer has no ssh', () => {
const peer: CommsPeer = { name: 'x', className: 'worker', host: '10.0.0.9' };
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -H 10.0.0.9 -s x -m "…"`);
});
it('treats a peer whose host equals the fleet host as same-host', () => {
const peer: CommsPeer = { name: 'y', className: 'worker', host: 'w-jarvis' };
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s y -m "…"`);
});
it('emits NO -L for an unset/default socket', () => {
const peer: CommsPeer = { name: 'lead', className: 'orchestrator' };
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s lead -m "…"`);
});
it('emits -L <socket> for a named socket', () => {
const peer: CommsPeer = { name: 'coder0', className: 'implementer', socket: 'mosaic-fleet' };
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
`${send} -L mosaic-fleet -s coder0 -m "…"`,
);
});
it('combines -L (named socket) and -H (cross-host) in order', () => {
const peer: CommsPeer = {
name: 'coder0-0',
className: 'implementer',
host: '10.1.10.37',
ssh: 'jwoltje@10.1.10.37',
socket: 'mosaic-fleet',
};
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
`${send} -L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`,
);
});
it('fails closed when a cross-host peer has no explicit roster ssh target', () => {
const peer: FleetAgent = { ...base, name: 'x', host: '10.0.0.9' };
expect(() => renderPeerReach(peer, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toThrow(
/explicit roster ssh target/i,
);
});
it('renders only the fleet-wide supported socket', () => {
const peer: FleetAgent = { ...base, socket: 'mosaic-fleet' };
expect(renderPeerReach(peer, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe(
`${send} -L mosaic-fleet -s peer -m "…"`,
);
});
it('resolves hostless peers against the stable fleet-host baseline, not the viewer host', () => {
const peer: FleetAgent = { ...base, ssh: 'fleet-user@w-jarvis' };
expect(renderPeerReach(peer, 'remote-host', 'w-jarvis', 'mosaic-fleet', send)).toBe(
`${send} -L mosaic-fleet -H fleet-user@w-jarvis -s peer -m "…"`,
);
});
it('shell-quotes an exact helper path that contains spaces', () => {
expect(
renderPeerReach(base, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', '/home/test user/send.sh'),
).toBe(`'/home/test user/send.sh' -L mosaic-fleet -s peer -m "…"`);
});
it('omits -L only for the literal default socket', () => {
expect(renderPeerReach(base, 'w-jarvis', 'w-jarvis', '', send)).toBe(`${send} -s peer -m "…"`);
});
});
describe('buildFleetCommsBlock', () => {
const send = '/h/.config/mosaic/tools/tmux/agent-send.sh';
const agents = parseRosterAgents(ROSTER);
it('renders authoritative identity, exact rows, generation, and no operational metavariables', () => {
it('excludes self, lists peers, flags the orchestrator, and emits both address forms', () => {
const block = buildFleetCommsBlock({
selfName: 'enhancer',
roster: roster(),
localHost: 'ignored-process-host',
agents,
fleetHost: 'w-jarvis',
agentSendPath: send,
});
expect(block).toContain('# Fleet Comms');
expect(block).toContain('Host: `w-jarvis`');
expect(block).toContain('Agent/session: `enhancer`');
expect(block).toContain('tmux socket: `mosaic-fleet`');
expect(block).toContain(`Helper: \`${send}\``);
expect(block).toMatch(/Comms generation: `[a-f0-9]{64}`/);
expect(block).toContain('You are **enhancer**');
// criterion 1: agent's own [host:session] identity
expect(block).toContain('`[w-jarvis:enhancer]`');
// self excluded
expect(block).not.toMatch(/\|\s*enhancer\s*\|/);
expect(block).toContain(`${send} -L mosaic-fleet -s orchestrator -m "…"`);
expect(block).toContain(`${send} -L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`);
expect(block).toContain(`mosaic agent comms-block enhancer`);
expect(block).toMatch(/Never invent, substitute, or fuzzy-match/i);
expect(block).not.toMatch(
/<(?:user@host|src_host|src_session|dst_host|dst_session|target-session)>/,
);
expect(block).not.toContain('FLIP the preamble');
// peers present
expect(block).toContain('| orchestrator |');
expect(block).toContain('point of contact');
// same-host peer short form
expect(block).toContain(`${send} -s coder0 -m "…"`);
// cross-host peer -H form + host annotation
expect(block).toContain(`${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`);
expect(block).toContain('host `10.1.10.37`');
// conventions
expect(block).toContain('FLIP the preamble');
expect(block).toContain('ACCEPTED');
});
it('changes the generation when a rendered peer role changes', () => {
const generation = (block: string) => block.match(/Comms generation: `([a-f0-9]{64})`/)?.[1];
const before = buildFleetCommsBlock({
selfName: 'enhancer',
roster: roster(),
localHost: 'w-jarvis',
agentSendPath: send,
});
const changedRoster = roster(ROSTER.replace('class: implementer', 'class: reviewer'));
const after = buildFleetCommsBlock({
selfName: 'enhancer',
roster: changedRoster,
localHost: 'w-jarvis',
agentSendPath: send,
});
expect(generation(before)).toMatch(/^[a-f0-9]{64}$/);
expect(generation(after)).not.toBe(generation(before));
});
it('fails closed when any rendered cross-host row lacks ssh', () => {
const bad = roster(ROSTER.replace(' ssh: jwoltje@10.1.10.37\n', ''));
expect(() =>
it('returns empty when the agent has no peers', () => {
expect(
buildFleetCommsBlock({
selfName: 'enhancer',
roster: bad,
localHost: 'w-jarvis',
selfName: 'solo',
agents: [{ name: 'solo', className: 'orchestrator' }],
fleetHost: 'h',
agentSendPath: send,
}),
).toThrow(/explicit roster ssh target/i);
});
it('still renders authoritative local identity when the agent has no peers', () => {
const solo = roster(
[
'version: 1',
'transport: tmux',
'agents:',
' - name: solo',
' runtime: claude',
' class: orchestrator',
].join('\n'),
);
const block = buildFleetCommsBlock({
selfName: 'solo',
roster: solo,
localHost: 'h',
agentSendPath: send,
});
expect(block).toContain('Host: `h`');
expect(block).toContain('Agent/session: `solo`');
expect(block).toContain('Role/class: `orchestrator`');
expect(block).toMatch(/Comms generation: `[a-f0-9]{64}`/);
expect(block).toContain('This roster has no peers');
expect(block).toContain('## Solo authority boundaries');
expect(block).toContain('no peer, orchestrator, or remote communication authority');
expect(block).toContain('Do not send, infer a target, or claim fleet coordination');
).toBe('');
});
});
describe('resolvePeerCommand', () => {
const send = '/h/.config/mosaic/tools/tmux/agent-send.sh';
it('returns one exact known-peer row', () => {
const result = resolvePeerCommand(roster(), 'enhancer', 'coder0-0', 'w-jarvis', send);
expect(result.ok).toBe(true);
expect(result.command).toContain('-H jwoltje@10.1.10.37 -s coder0-0');
});
it('fails closed for an unknown peer with exact-name discovery guidance', () => {
const result = resolvePeerCommand(roster(), 'enhancer', 'invented-host', 'w-jarvis', send);
expect(result.ok).toBe(false);
expect(result.command).toBe('');
expect(result.error).toContain('invented-host');
expect(result.error).toContain('orchestrator, coder0, coder0-0');
expect(result.error).toContain('mosaic agent comms-block enhancer');
expect(result.error).not.toContain('tmux ls');
});
});
describe('readFleetCommsBlock — spawned-agent context', () => {
describe('readFleetCommsBlock — situational (the context a spawned agent gets)', () => {
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'mosaic-comms-'));
mkdirSync(join(home, 'fleet'), { recursive: true });
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER);
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
writeFileSync(helper, '#!/bin/sh\n');
chmodSync(helper, 0o755);
});
afterEach(() => rmSync(home, { recursive: true, force: true }));
it('uses the authoritative self host and global socket from the shared roster resolver', () => {
const result = readFleetCommsBlock(home, 'enhancer', 'process-host-must-not-win');
expect(result.ok).toBe(true);
expect(result.output).toContain('Host: `w-jarvis`');
expect(result.output).toContain('tmux socket: `mosaic-fleet`');
expect(result.output).toContain('-L mosaic-fleet -s orchestrator');
it('builds the cheat-sheet with correct peer addresses for a fleet member', () => {
const block = readFleetCommsBlock(home, 'orchestrator', 'w-jarvis');
expect(block).toContain('# Fleet Comms');
expect(block).toContain('| enhancer |');
expect(block).toContain(`${join(home, 'tools', 'tmux', 'agent-send.sh')} -s coder0 -m "…"`);
expect(block).toContain('-H jwoltje@10.1.10.37 -s coder0-0');
expect(block).not.toMatch(/\|\s*orchestrator\s*\|/); // self excluded
});
it('fails closed for a requested fleet identity that is absent', () => {
const result = readFleetCommsBlock(home, 'stranger', 'w-jarvis');
expect(result.ok).toBe(false);
expect(result.output).toBe('');
expect(result.error).toContain('Known exact names');
});
it('resolves a supported JSON-only installed roster', () => {
rmSync(join(home, 'fleet', 'roster.yaml'));
writeFileSync(
join(home, 'fleet', 'roster.json'),
JSON.stringify({
version: 1,
transport: 'tmux',
tmux: { socket_name: 'mosaic-fleet' },
agents: [
{ name: 'enhancer', runtime: 'claude', class: 'enhancer', host: 'w-jarvis' },
{ name: 'orchestrator', runtime: 'claude', class: 'orchestrator', host: 'w-jarvis' },
],
}),
);
const result = readFleetCommsBlock(home, 'enhancer', 'process-host-must-not-win');
expect(result.ok).toBe(true);
expect(result.output).toContain('-L mosaic-fleet -s orchestrator');
});
it('fails closed on a YAML I/O error instead of falling back to JSON', () => {
rmSync(join(home, 'fleet', 'roster.yaml'));
mkdirSync(join(home, 'fleet', 'roster.yaml'));
writeFileSync(
join(home, 'fleet', 'roster.json'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'enhancer', runtime: 'claude', class: 'enhancer' }],
}),
);
const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
expect(result.ok).toBe(false);
expect(result.error).toContain('invalid fleet roster at');
expect(result.error).toContain('roster.yaml');
});
it.each([
['missing', () => rmSync(join(home, 'tools', 'tmux', 'agent-send.sh'))],
[
'directory',
() => {
rmSync(join(home, 'tools', 'tmux', 'agent-send.sh'));
mkdirSync(join(home, 'tools', 'tmux', 'agent-send.sh'));
},
],
[
'symlink',
() => {
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
rmSync(helper);
writeFileSync(join(home, 'real-send.sh'), '#!/bin/sh\n');
symlinkSync(join(home, 'real-send.sh'), helper);
},
],
['non-executable', () => chmodSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 0o644)],
])('fails closed for a %s helper with deterministic repair guidance', (_case, mutate) => {
mutate();
const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
expect(result.ok).toBe(false);
expect(result.output).toBe('');
expect(result.error).toContain('mosaic update --repair-tools');
expect(result.error).toContain('no active context or session was rewritten');
});
it('does not rewrite the roster while resolving context', () => {
const path = join(home, 'fleet', 'roster.yaml');
const before = readFileSync(path, 'utf8');
readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
expect(readFileSync(path, 'utf8')).toBe(before);
it('returns empty when MOSAIC_AGENT_NAME is unset, no roster, or agent not a member', () => {
expect(readFleetCommsBlock(home, undefined, 'w-jarvis')).toBe('');
expect(readFleetCommsBlock(home, 'stranger', 'w-jarvis')).toBe('');
expect(readFleetCommsBlock(mkdtempSync(join(tmpdir(), 'noroster-')), 'orchestrator')).toBe('');
});
});
describe('renderToolsContractStatus — non-mutating install drift', () => {
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'mosaic-tools-status-'));
mkdirSync(join(home, 'defaults'), { recursive: true });
writeFileSync(
join(home, 'defaults', 'TOOLS.md'),
'# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n',
);
});
afterEach(() => rmSync(home, { recursive: true, force: true }));
it('uses the supported repair command when installed TOOLS.md is missing', () => {
const status = renderToolsContractStatus(home);
expect(status).toContain('mosaic update --repair-tools');
expect(status).not.toContain('--reseed');
expect(status).toContain('authorized operator');
});
it('reports stale preserved content without rewriting it', () => {
const path = join(home, 'TOOLS.md');
const stale = '# customized tools\n';
writeFileSync(path, stale);
const status = renderToolsContractStatus(home);
expect(status).toContain('fleet-comms-contract: 1');
expect(status).toContain('digest-qualified backup');
expect(status).toContain('mosaic update --repair-tools');
expect(status).toContain('active context was not rewritten');
expect(readFileSync(path, 'utf8')).toBe(stale);
});
it('does not accept marker-only customized content as current', () => {
const path = join(home, 'TOOLS.md');
writeFileSync(path, '<!-- fleet-comms-contract: 1 -->\ncorrupt\n');
expect(renderToolsContractStatus(home)).toContain('does not byte-match');
});
it('rejects markerless byte-equal source and installed content', () => {
const content = '# markerless but equal\n';
writeFileSync(join(home, 'defaults', 'TOOLS.md'), content);
writeFileSync(join(home, 'TOOLS.md'), content);
const status = renderToolsContractStatus(home);
expect(status).toContain('source contract');
expect(status).toContain('does not declare the expected');
});
it.each([
['source', join('defaults', 'TOOLS.md')],
['installed', 'TOOLS.md'],
])('rejects a wrong contract version in %s content', (_case, relativePath) => {
const current = '# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n';
writeFileSync(join(home, 'TOOLS.md'), current);
writeFileSync(join(home, 'defaults', 'TOOLS.md'), current);
writeFileSync(join(home, relativePath), current.replace('contract: 1', 'contract: 2'));
expect(renderToolsContractStatus(home)).not.toBe('');
});
it('treats installed TOOLS.md symlinks as stale without following or rewriting them', () => {
const external = join(home, 'external-tools.md');
const externalContent = '# external\n<!-- fleet-comms-contract: 1 -->\n';
writeFileSync(external, externalContent);
symlinkSync(external, join(home, 'TOOLS.md'));
const status = renderToolsContractStatus(home);
expect(status).toContain('unavailable');
expect(status).toContain('mosaic update --repair-tools');
expect(readFileSync(external, 'utf8')).toBe(externalContent);
});
it('treats source TOOLS.md symlinks as unavailable without following them', () => {
const external = join(home, 'external-source.md');
const content = '# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n';
writeFileSync(external, content);
rmSync(join(home, 'defaults', 'TOOLS.md'));
symlinkSync(external, join(home, 'defaults', 'TOOLS.md'));
writeFileSync(join(home, 'TOOLS.md'), content);
const status = renderToolsContractStatus(home);
expect(status).toContain('source contract');
expect(status).toContain('unavailable');
expect(readFileSync(external, 'utf8')).toBe(content);
});
it('accepts byte-equal bounded source and installed contracts', () => {
const source = readFileSync(join(home, 'defaults', 'TOOLS.md'), 'utf8');
writeFileSync(join(home, 'TOOLS.md'), source);
expect(renderToolsContractStatus(home)).toBe('');
});
});
describe('resolveCommsBlock — mosaic agent comms-block', () => {
describe('resolveCommsBlock — `mosaic fleet comms-block <role>` emitter semantics', () => {
// The emitter wraps readFleetCommsBlock but must NEVER print an empty string silently:
// an unknown role / missing roster has to fail loud (caller maps !ok → stderr + exit 1)
// so `mosaic fleet comms-block bogus` is a visible error, not a confusing no-op. The
// success path returns the block verbatim for `mosaic fleet comms-block <peer>` previews.
let home: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'mosaic-commsblk-'));
mkdirSync(join(home, 'fleet'), { recursive: true });
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER);
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
writeFileSync(helper, '#!/bin/sh\n');
chmodSync(helper, 0o755);
});
afterEach(() => rmSync(home, { recursive: true, force: true }));
it('returns the exact contract for a roster member', () => {
const result = resolveCommsBlock(home, 'enhancer');
expect(result.ok).toBe(true);
expect(result.output).toContain('Host: `w-jarvis`');
expect(result.error).toBeUndefined();
it('returns ok + the cheat-sheet for a roster member', () => {
const res = resolveCommsBlock(home, 'orchestrator', 'w-jarvis');
expect(res.ok).toBe(true);
expect(res.output).toContain('# Fleet Comms');
expect(res.output).toContain('| enhancer |');
expect(res.error).toBeUndefined();
});
it('fails loud and lists known exact names for a non-member', () => {
const result = resolveCommsBlock(home, 'stranger');
expect(result.ok).toBe(false);
expect(result.output).toBe('');
expect(result.error).toContain('stranger');
expect(result.error).toContain('orchestrator');
expect(result.error).toContain('enhancer');
it('fails loud (not ok + error naming the role) for a non-member — never silently empty', () => {
const res = resolveCommsBlock(home, 'stranger', 'w-jarvis');
expect(res.ok).toBe(false);
expect(res.output).toBe('');
expect(res.error).toContain('stranger');
});
it('fails loud when no roster exists', () => {
it('fails loud when no roster exists at the mosaic home', () => {
const noRoster = mkdtempSync(join(tmpdir(), 'mosaic-noroster-'));
mkdirSync(join(noRoster, 'tools', 'tmux'), { recursive: true });
const helper = join(noRoster, 'tools', 'tmux', 'agent-send.sh');
writeFileSync(helper, '#!/bin/sh\n');
chmodSync(helper, 0o755);
const result = resolveCommsBlock(noRoster, 'orchestrator');
expect(result.ok).toBe(false);
expect(result.error).toContain('no fleet roster');
const res = resolveCommsBlock(noRoster, 'orchestrator', 'w-jarvis');
expect(res.ok).toBe(false);
expect(res.error).toBeTruthy();
rmSync(noRoster, { recursive: true, force: true });
});
it('fails loud for a missing role argument', () => {
const result = resolveCommsBlock(home, undefined);
expect(result.ok).toBe(false);
expect(result.error).toContain('requires');
const res = resolveCommsBlock(home, undefined, 'w-jarvis');
expect(res.ok).toBe(false);
expect(res.error).toBeTruthy();
});
it('honors a host override so a peer can preview its own cross-host view', () => {
// coder0-0 viewing with its own host → its self-identity line uses that host.
const res = resolveCommsBlock(home, 'coder0-0', '10.1.10.37');
expect(res.ok).toBe(true);
expect(res.output).toContain('`[10.1.10.37:coder0-0]`');
});
});

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
* v1 roster resolver. This module never probes tmux, guesses an SSH target, or
* mutates an active session.
* Fleet agents are born not knowing how to reach their peers — the root cause of
* a spawned agent's failed first send. When an agent boots via `mosaic yolo
* <runtime>` (→ composeContract → system prompt), we append a comms cheat-sheet
* + peer roster so it can talk to the orchestrator and other agents immediately.
*
* Cross-host aware: a peer may carry `host`/`ssh` (a deliberate pre-federation
* stopgap — manual cross-host listing; federation/W1 auto-discovers later), so a
* w-jarvis agent is born knowing the exact `-H` command to reach a dragon-lin
* peer. Same-host peers render the short form.
*
* Standalone (no fleet.ts import) to keep launch.ts's prompt path free of the
* heavy fleet command module. The roster is parsed leniently — the cheat-sheet
* is best-effort onboarding, never a hard dependency.
*/
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { homedir, hostname } from 'node:os';
import { join } from 'node:path';
import { readRegularFileSecure } from './secure-file.js';
import {
parseFleetRosterV1,
resolveInstalledFleetRosterPath,
getRosterAgent,
type FleetAgent,
type FleetRoster,
} from './fleet-roster-v1.js';
export interface CommsPeer {
name: string;
/** Roster `class` (orchestrator | enhancer | implementer | worker | …). */
className: string;
/** Host the peer runs on; absent ⇒ the fleet host (same host). */
host?: string;
/** SSH target (user@host) for a cross-host peer; renders the `-H` form. */
ssh?: string;
/** tmux socket the peer's session lives on; absent ⇒ default socket (no `-L`). */
socket?: string;
}
/**
* Lenient parse of a fleet `roster.yaml` for agent name/class/host/ssh. Avoids a
* dependency on the full fleet roster parser; the format is `- name:` list items
* with `class:`/`host:`/`ssh:` siblings under `agents:`.
*/
export function parseRosterAgents(yamlText: string): CommsPeer[] {
const peers: CommsPeer[] = [];
let current: CommsPeer | null = null;
let inAgents = false;
const scalar = (line: string, key: string): string | null => {
const m = line.match(new RegExp(`^\\s*${key}:\\s*["']?([^"'#]+?)["']?\\s*$`));
return m ? (m[1] as string).trim() : null;
};
for (const rawLine of yamlText.split('\n')) {
const line = rawLine.replace(/\s+$/, '');
if (/^agents:\s*$/.test(line)) {
inAgents = true;
continue;
}
if (!inAgents) continue;
// A new top-level key (no leading space) ends the agents block.
if (/^\S/.test(line)) break;
const nameMatch = line.match(/^\s*-\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
if (nameMatch) {
if (current) peers.push(current);
current = { name: nameMatch[1] as string, className: 'worker' };
continue;
}
if (!current) continue;
const cls = scalar(line, 'class');
if (cls) current.className = cls;
const host = scalar(line, 'host');
if (host) current.host = host;
const ssh = scalar(line, 'ssh');
if (ssh) current.ssh = ssh;
const socket = scalar(line, 'socket');
if (socket) current.socket = socket;
}
if (current) peers.push(current);
return peers;
}
export interface FleetCommsOptions {
/** Exact current roster member. */
/** This agent's name (it is excluded from its own peer list). */
selfName: string;
/** Canonically resolved roster. */
roster: FleetRoster;
/** Stable fleet-host baseline for members whose roster host is absent. */
localHost: string;
/** Absolute helper path in this installation. */
/** All roster agents (including self; filtered out internally). */
agents: CommsPeer[];
/** Host the fleet runs on (short hostname) — the same-host baseline. */
fleetHost: string;
/** Absolute path to agent-send.sh in this install. */
agentSendPath: string;
}
export interface CommsBlockResult {
ok: boolean;
output: string;
error?: string;
}
export interface ResolvedFleetIdentity {
readonly roster: FleetRoster;
readonly member: FleetAgent;
readonly requestedName: string;
readonly agentSendPath: string;
readonly localHost: string;
}
export interface FleetIdentityResult {
ok: boolean;
identity?: ResolvedFleetIdentity;
error?: string;
}
export interface PeerCommandResult {
ok: boolean;
command: string;
error?: string;
}
export const FLEET_COMMS_TOOLS_CONTRACT = 'fleet-comms-contract: 1';
const MAX_TOOLS_CONTRACT_BYTES = 256 * 1024;
function shortHostname(): string {
return hostname().split('.')[0] || 'localhost';
}
function resolvedHost(agent: FleetAgent, fleetHost: string): string {
return agent.host ?? fleetHost;
}
function displaySocket(socket: string): string {
return socket || '(default)';
}
function knownNames(roster: FleetRoster, except?: string): string {
return roster.agents
.filter((agent) => agent.name !== except)
.map((agent) => agent.name)
.join(', ');
}
function missingMemberError(roster: FleetRoster, selfName: string): string {
return `Agent "${selfName}" is not in the fleet roster. Known exact names: ${knownNames(roster)}. Select an exact roster name; do not infer or fuzzy-match a tmux session.`;
}
/** Render one shell argument without changing already-safe exact values. */
function shellArg(value: string): string {
if (/^[A-Za-z0-9_./:@=+-]+$/.test(value)) return value;
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
/** Render the exact command for one peer. Throws rather than guessing. */
export function renderPeerReach(
peer: FleetAgent,
selfHost: string,
fleetHost: string,
rosterSocket: string,
agentSendPath: string,
): string {
const parts = [shellArg(agentSendPath)];
if (rosterSocket) parts.push('-L', shellArg(rosterSocket));
const peerHost = resolvedHost(peer, fleetHost);
if (peerHost !== selfHost) {
if (!peer.ssh) {
throw new Error(
`Cross-host peer "${peer.name}" (${peerHost}) requires an explicit roster ssh target; refusing to substitute its host value.`,
);
}
parts.push('-H', shellArg(peer.ssh));
}
parts.push('-s', shellArg(peer.name), '-m', '"…"');
return parts.join(' ');
}
/** Resolve one requested peer without fuzzy lookup. */
export function resolvePeerCommand(
roster: FleetRoster,
selfName: string,
peerName: string,
localHost: string,
agentSendPath: string,
): PeerCommandResult {
const self = roster.agents.find((agent) => agent.name === selfName);
if (!self) return { ok: false, command: '', error: missingMemberError(roster, selfName) };
const peer = roster.agents.find((agent) => agent.name === peerName && agent.name !== selfName);
if (!peer) {
return {
ok: false,
command: '',
error:
`Peer "${peerName}" is absent from the fleet roster. Known exact peer names: ${knownNames(roster, selfName)}. ` +
`Run \`mosaic agent comms-block ${selfName}\` to rediscover exact rendered rows; do not infer or fuzzy-match a tmux session.`,
};
}
try {
return {
ok: true,
command: renderPeerReach(
peer,
resolvedHost(self, localHost),
localHost,
roster.tmux.socketName,
agentSendPath,
),
};
} catch (error) {
return {
ok: false,
command: '',
error: error instanceof Error ? error.message : String(error),
};
}
}
interface ResolvedRow {
readonly peer: FleetAgent;
readonly host: string;
readonly socket: string;
readonly command: string;
}
function resolveRows(opts: FleetCommsOptions, self: FleetAgent): readonly ResolvedRow[] {
const selfHost = resolvedHost(self, opts.localHost);
return opts.roster.agents
.filter((agent) => agent.name !== opts.selfName)
.map(
(peer): ResolvedRow => ({
peer,
host: resolvedHost(peer, opts.localHost),
socket: opts.roster.tmux.socketName,
command: renderPeerReach(
peer,
selfHost,
opts.localHost,
opts.roster.tmux.socketName,
opts.agentSendPath,
),
}),
);
}
function commsGeneration(
self: FleetAgent,
selfHost: string,
selfSocket: string,
helper: string,
rows: readonly ResolvedRow[],
): string {
const canonical = JSON.stringify({
self: { ...self, resolvedHost: selfHost, resolvedSocket: selfSocket, helper },
peers: rows.map((row) => ({
...row.peer,
resolvedHost: row.host,
resolvedSocket: row.socket,
exactCommand: row.command,
})),
});
return createHash('sha256').update(canonical).digest('hex');
}
/** Build the authoritative Markdown contract for one exact roster member. */
export function buildFleetCommsBlock(opts: FleetCommsOptions): string {
const self = opts.roster.agents.find((agent) => agent.name === opts.selfName);
if (!self) throw new Error(missingMemberError(opts.roster, opts.selfName));
const rows = resolveRows(opts, self);
const selfHost = resolvedHost(self, opts.localHost);
const selfSocket = opts.roster.tmux.socketName;
const generation = commsGeneration(self, selfHost, selfSocket, opts.agentSendPath, rows);
const orchestrator = rows.find((row) => row.peer.className === 'orchestrator');
const peerSection =
rows.length === 0
? 'This roster has no peers. Do not invent a target.'
: `| Agent | Role | Host | Socket | Exact command |
| ----- | ---- | ---- | ------ | ------------- |
${rows
.map((row) => {
const pointOfContact = row.peer.className === 'orchestrator' ? ' ← point of contact' : '';
return `| ${row.peer.name} | ${row.peer.className}${pointOfContact} | ${row.host} | ${displaySocket(row.socket)} | \`${row.command}\` |`;
})
.join('\n')}`;
const contact = orchestrator
? `Your point of contact is **${orchestrator.peer.name}**. Select that exact peer row for status, questions, and decisions.`
: rows.length === 0
? 'No peer coordination target exists in this roster.'
: 'This roster has no orchestrator. Select an exact peer row for coordination.';
const soloAuthority =
rows.length === 0
? `\n## Solo authority boundaries\n\nThis member is normalized as role/class **${self.className}**. The roster grants no peer, orchestrator, or remote communication authority. Do not send, infer a target, or claim fleet coordination until an exact peer is added to the canonical roster and this block is recomposed.\n`
: '';
return `# Fleet Comms — authoritative exact targets
## Local identity
- Host: \`${selfHost}\`
- Agent/session: \`${self.name}\`
- Role/class: \`${self.className}\`
- tmux socket: \`${displaySocket(selfSocket)}\`
- Helper: \`${opts.agentSendPath}\`
- Comms generation: \`${generation}\`
The roster-resolved rows below are the only valid operational targets. Select the row whose Agent value
exactly matches the requested peer. Never invent, substitute, or fuzzy-match host, session, socket, SSH,
or helper-path values. If the peer is absent, stop and run \`mosaic agent comms-block ${self.name}\` to
rediscover this exact member's rows; if it is still absent, report the unknown peer.
## Peers
${peerSection}
${contact}
${soloAuthority}
## Context freshness
This block is a snapshot; Mosaic does not rewrite an active agent's context. Compare its Comms generation
with fresh output from \`mosaic agent comms-block ${self.name}\`. If they differ, report stale composed
context and have an authorized operator relaunch only this exact roster member with
\`mosaic fleet restart ${self.name}\`. Do not restart or mutate a session automatically.`;
}
function validateAgentSendHelper(path: string, mosaicHome: string): string | undefined {
try {
readRegularFileSecure(path, { root: mosaicHome, executable: true });
return undefined;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return `helper is unavailable or unsafe: ${path} (${reason})`;
}
}
function helperFailureGuidance(reason: string): string {
return `${reason}. Run \`mosaic update --repair-tools\` to restore the supported current-version helper and TOOLS contract, then retry exact-member composition; no active context or session was rewritten.`;
}
export function resolveFleetIdentity(
mosaicHome: string,
requestedName: string | undefined,
localHost: string = shortHostname(),
): FleetIdentityResult {
if (!requestedName) return { ok: true };
const agentSendPath = join(mosaicHome, 'tools', 'tmux', 'agent-send.sh');
const helperError = validateAgentSendHelper(agentSendPath, mosaicHome);
if (helperError) return { ok: false, error: helperFailureGuidance(helperError) };
let rosterPath: string;
try {
rosterPath = resolveInstalledFleetRosterPath(mosaicHome);
} catch (error) {
return {
ok: false,
error: `cannot inspect fleet roster.yaml: ${error instanceof Error ? error.message : String(error)}; refusing JSON fallback because fallback is allowed only when YAML is absent`,
};
}
if (!existsSync(rosterPath)) {
return {
ok: false,
error: `no fleet roster at ${join(mosaicHome, 'fleet', 'roster.yaml')} or ${join(mosaicHome, 'fleet', 'roster.json')}`,
};
}
let roster: FleetRoster;
try {
roster = parseFleetRosterV1(
readRegularFileSecure(rosterPath, { root: mosaicHome }).content.toString('utf8'),
rosterPath.endsWith('.json') ? 'json' : 'yaml',
);
} catch (error) {
return {
ok: false,
error: `invalid fleet roster at ${rosterPath}: ${error instanceof Error ? error.message : String(error)}`,
};
}
try {
return {
ok: true,
identity: {
roster,
member: getRosterAgent(roster, requestedName),
requestedName,
agentSendPath,
localHost,
},
};
} catch {
return { ok: false, error: missingMemberError(roster, requestedName) };
}
}
/** Render Fleet Comms from one already-resolved canonical member identity. */
export function buildResolvedFleetCommsBlock(identity: ResolvedFleetIdentity): string {
return buildFleetCommsBlock({
selfName: identity.member.name,
roster: identity.roster,
localHost: identity.localHost,
agentSendPath: identity.agentSendPath,
});
/** Is this peer on a different host than the fleet baseline? */
function isRemote(peer: CommsPeer, fleetHost: string): boolean {
return peer.host !== undefined && peer.host !== fleetHost;
}
/**
* Read and resolve the installed roster for runtime composition. A requested
* fleet identity fails closed; only a genuinely non-fleet launch (no selfName)
* is a quiet no-op.
* Render the exact agent-send command to reach a peer (session = agent name).
* Data-driven per peer: a named `socket` → `-L <socket>`; an unset socket → the
* default tmux socket (no `-L`). A cross-host peer adds `-H <ssh|host>`.
*/
export function renderPeerReach(peer: CommsPeer, fleetHost: string, agentSendPath: string): string {
const parts = [agentSendPath];
if (peer.socket) parts.push('-L', peer.socket); // unset ⇒ default socket, no -L
if (isRemote(peer, fleetHost)) parts.push('-H', peer.ssh ?? (peer.host as string));
parts.push('-s', peer.name, '-m', '"…"');
return parts.join(' ');
}
/**
* Build the `# Fleet Comms` onboarding block (pure markdown). Returns '' when
* the agent has no peers (a single-agent roster has no one to talk to).
*/
export function buildFleetCommsBlock(opts: FleetCommsOptions): string {
const peers = opts.agents.filter((a) => a.name !== opts.selfName);
if (peers.length === 0) return '';
const orchestrator = peers.find((p) => p.className === 'orchestrator');
const rows = peers
.map((p) => {
const where = isRemote(p, opts.fleetHost)
? `${p.className} · host \`${p.host}\``
: p.className;
const role = p.className === 'orchestrator' ? `${where} ← point of contact` : where;
return `| ${p.name} | ${role} | \`${renderPeerReach(p, opts.fleetHost, opts.agentSendPath)}\` |`;
})
.join('\n');
const orchLine = orchestrator
? `Your point of contact is **${orchestrator.name}** (the orchestrator) — route questions, ` +
`status, and decisions there.`
: `This fleet has no orchestrator in its roster; coordinate with your peers directly.`;
return `# Fleet Comms — reach your peers
You are **${opts.selfName}** in this fleet. Your comms identity is \`[${opts.fleetHost}:${opts.selfName}]\`
that is the \`<src>\` other agents see and reply to. Reach other agents (durable tmux sessions) with the
Mosaic comms tool at \`${opts.agentSendPath}\`. The **Reach** column below is the exact command per peer:
same-host peers use the short form (no \`-H\`); cross-host peers include \`-H <user@host>\`.
## Peers
| Agent | Role | Reach (session = agent name) |
| ----- | ---- | ---------------------------- |
${rows}
${orchLine}
## Conventions
- Every message carries a self-identifying preamble \`[<src_host>:<src_session> -> <dst_host>:<dst_session>]\`\`agent-send.sh\` adds it automatically.
- **To reply, FLIP the preamble:** address your reply to the sender's \`src\` (their host:session becomes your \`-s\`/\`-H\`).
- \`agent-send.sh\` (a.k.a. \`agent send --verify\`) confirms the message was **ACCEPTED** at the destination prompt — not merely injected. Prefer it for anything that matters.`;
}
/**
* Read the fleet roster from `mosaicHome` and build the comms block for
* `selfName`. Returns '' when there is no roster, the agent is not in it, or
* there are no peers — onboarding is best-effort and never throws.
*/
export function readFleetCommsBlock(
mosaicHome: string,
selfName: string | undefined,
localHost: string = shortHostname(),
): CommsBlockResult {
const resolved = resolveFleetIdentity(mosaicHome, selfName, localHost);
if (!resolved.ok) return { ok: false, output: '', error: resolved.error };
if (!resolved.identity) return { ok: true, output: '' };
fleetHost: string = hostname().split('.')[0] || 'localhost',
): string {
if (!selfName) return '';
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
if (!existsSync(rosterPath)) return '';
let text: string;
try {
return { ok: true, output: buildResolvedFleetCommsBlock(resolved.identity) };
} catch (error) {
return {
ok: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
text = readFileSync(rosterPath, 'utf-8');
} catch {
return '';
}
const agents = parseRosterAgents(text);
if (!agents.some((a) => a.name === selfName)) return ''; // not a member of this fleet
return buildFleetCommsBlock({
selfName,
agents,
fleetHost,
agentSendPath: join(mosaicHome, 'tools', 'tmux', 'agent-send.sh'),
});
}
/** Backing resolver for `mosaic agent comms-block <exact-member>`. */
/** Result of resolving a comms-block emit request — see `mosaic fleet comms-block`. */
export interface CommsBlockResult {
/** True when a cheat-sheet was produced; false maps to stderr + non-zero exit. */
ok: boolean;
/** The Fleet-Comms cheat-sheet (empty unless ok). */
output: string;
/** Operator-facing reason when !ok. */
error?: string;
}
/**
* Resolve the Fleet-Comms cheat-sheet for an explicit <role>, backing the
* `mosaic fleet comms-block <role>` command. Unlike readFleetCommsBlock — which
* returns '' on any miss so composeContract can no-op silently during a launch —
* this NEVER silently emits empty: an unknown role or missing roster yields
* ok:false + an operator-facing reason, so the CLI surfaces it (stderr + exit 1)
* rather than printing nothing. That makes it safe to preview any peer's view,
* e.g. `mosaic fleet comms-block coder0-0`.
*/
export function resolveCommsBlock(
mosaicHome: string,
exactMember: string | undefined,
role: string | undefined,
fleetHost?: string,
): CommsBlockResult {
if (!exactMember) {
if (!role) {
return { ok: false, output: '', error: 'comms-block requires a <role> argument' };
}
const block = fleetHost
? readFleetCommsBlock(mosaicHome, role, fleetHost)
: readFleetCommsBlock(mosaicHome, role);
if (!block) {
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
return {
ok: false,
output: '',
error: 'comms-block requires an exact <exact-member> argument',
error: existsSync(rosterPath)
? `role "${role}" is not a member of the fleet roster at ${rosterPath}`
: `no fleet roster at ${rosterPath}`,
};
}
return readFleetCommsBlock(mosaicHome, exactMember);
}
function expectedContractVersion(content: Buffer | string): boolean {
return content.toString().includes(`<!-- ${FLEET_COMMS_TOOLS_CONTRACT} -->`);
}
function boundedContractDigest(
path: string,
mosaicHome: string,
): { digest?: string; versionOk: boolean } {
try {
const content = readRegularFileSecure(path, {
root: mosaicHome,
maxBytes: MAX_TOOLS_CONTRACT_BYTES,
}).content;
return {
digest: createHash('sha256').update(content).digest('hex'),
versionOk: expectedContractVersion(content),
};
} catch {
return { versionOk: false };
}
}
function replacementGuidance(): string {
return `Run \`mosaic update --repair-tools\` to make a digest-qualified backup and restore the supported current-version TOOLS contract, then have an authorized operator explicitly relaunch the exact roster member. The active context was not rewritten.`;
}
/** Detect preserved installed TOOLS.md drift without changing it. */
export function renderToolsContractStatus(mosaicHome: string): string {
const installedPath = join(mosaicHome, 'TOOLS.md');
const sourcePath = join(mosaicHome, 'defaults', 'TOOLS.md');
if (!existsSync(installedPath)) {
return `# Fleet Comms Installation Status\n\nInstalled TOOLS.md is missing at \`${installedPath}\`. ${replacementGuidance()}`;
}
const installed = boundedContractDigest(installedPath, mosaicHome);
const source = boundedContractDigest(sourcePath, mosaicHome);
if (!source.digest || !source.versionOk) {
return `# Fleet Comms Installation Status\n\nThe bounded framework source contract at \`${sourcePath}\` is unavailable or does not declare the expected \`${FLEET_COMMS_TOOLS_CONTRACT}\` version. Run \`mosaic update\` to restore framework source data, verify again, then have an authorized operator explicitly relaunch the exact roster member. The installed file and active context were not rewritten.`;
}
if (installed.versionOk && installed.digest === source.digest) return '';
return `# Fleet Comms Installation Status\n\nInstalled TOOLS.md is unavailable, has the wrong contract version, or does not byte-match the bounded framework source contract \`${FLEET_COMMS_TOOLS_CONTRACT}\`. ${replacementGuidance()}`;
return { ok: true, output: block };
}
/** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */
export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');

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

@@ -1,484 +0,0 @@
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerFleetCommand, type CommandResult, type CommandRunner } from '../commands/fleet.js';
import {
executeFleetReconcile,
type FleetReconcileCommandResult,
type FleetReconcileDeps,
type FleetReconcileResult,
} from './fleet-reconciler.js';
import {
parseRosterV2,
renderRosterV2Yaml,
type FleetRosterV2,
type FleetRosterV2Agent,
} from './roster-v2.js';
import { FleetTmuxRuntimeTransport } from './tmux-runtime-transport.js';
const holderIdentity = '11111111-1111-4111-8111-111111111111';
const stoppedAgent: FleetRosterV2Agent = {
name: 'coder0',
alias: 'Coder 0',
className: 'code',
runtime: 'pi',
provider: 'openai',
model: 'gpt-5.6-sol',
reasoning: 'high',
toolPolicy: 'code',
workingDirectory: '/srv/mosaic',
persistentPersona: false,
resetBetweenTasks: true,
lifecycle: { enabled: true, desiredState: 'stopped' },
launch: { yolo: true },
};
const baseRoster: FleetRosterV2 = {
version: 2,
generation: 7,
transport: 'tmux',
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
defaults: { workingDirectory: '/srv/mosaic', runtime: 'pi' },
runtimes: { pi: { resetCommand: '/new' } },
agents: [stoppedAgent],
};
interface InjectedLifecycleFailure {
readonly action: 'start' | 'stop' | 'restart';
readonly service: string;
readonly diagnostic: string;
}
class FakeLifecycleHost {
readonly calls: string[][] = [];
readonly sessions = new Set<string>();
readonly activeServices = new Set<string>();
private failure: InjectedLifecycleFailure | undefined;
constructor(readonly roster: FleetRosterV2) {
this.sessions.add(roster.tmux.holderSession);
}
injectFailure(failure: InjectedLifecycleFailure): void {
this.failure = failure;
}
readonly run = async (
command: string,
args: readonly string[],
): Promise<FleetReconcileCommandResult> => {
this.calls.push([command, ...args]);
if (command === 'tmux') return this.runTmux(args);
if (command === 'systemctl') return this.runSystemctl(args);
return { stdout: '', stderr: 'unsupported fake command', exitCode: 127 };
};
private runTmux(args: readonly string[]): FleetReconcileCommandResult {
if (args.includes('list-sessions')) {
return { stdout: `${[...this.sessions].join('\n')}\n`, stderr: '', exitCode: 0 };
}
if (args.includes('has-session')) {
const targetArgument = args[args.indexOf('-t') + 1];
const sessionName = targetArgument?.replace(/^=/, '').split(':')[0];
return {
stdout: '',
stderr: '',
exitCode: sessionName !== undefined && this.sessions.has(sessionName) ? 0 : 1,
};
}
if (args.includes('show-environment')) {
return {
stdout: [
'HOME=/home/mosaic',
`MOSAIC_FLEET_OWNER=${holderIdentity}`,
`MOSAIC_TMUX_HOLDER=${this.roster.tmux.holderSession}`,
`MOSAIC_TMUX_SOCKET=${this.roster.tmux.socketName}`,
'PATH=/usr/bin:/bin',
'PWD=/home/mosaic',
'',
].join('\n'),
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: 'destructive tmux action rejected by fake', exitCode: 125 };
}
private runSystemctl(args: readonly string[]): FleetReconcileCommandResult {
const action = args[1];
const service = args[2];
if (action === 'show' && service !== undefined) {
return {
stdout: `ActiveState=${this.activeServices.has(service) ? 'active' : 'inactive'}\n`,
stderr: '',
exitCode: 0,
};
}
if (
(action === 'start' || action === 'stop' || action === 'restart') &&
service !== undefined
) {
this.applyLifecycleEffect(action, service);
if (this.failure?.action === action && this.failure.service === service) {
const diagnostic = this.failure.diagnostic;
this.failure = undefined;
return { stdout: '', stderr: diagnostic, exitCode: 1 };
}
return { stdout: '', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: 'unsupported fake systemctl action', exitCode: 125 };
}
private applyLifecycleEffect(action: 'start' | 'stop' | 'restart', service: string): void {
if (service === 'mosaic-tmux-holder.service') return;
const match = /^mosaic-agent@(.+)\.service$/.exec(service);
if (!match) return;
const agentName = match[1];
if (agentName === undefined) return;
if (action === 'stop') {
this.activeServices.delete(service);
this.sessions.delete(agentName);
return;
}
this.activeServices.add(service);
this.sessions.add(agentName);
}
}
const cleanupDirectories: string[] = [];
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
await Promise.all(
cleanupDirectories.splice(0).map(async (directory: string): Promise<void> => {
await rm(directory, { recursive: true, force: true });
}),
);
});
function reconcileDeps(host: FakeLifecycleHost): FleetReconcileDeps {
return {
runner: host.run,
homeDirectory: '/home/mosaic',
readHolderIdentity: async () => holderIdentity,
validateRoster: async () => undefined,
prepareProjections: async () => [{ agentName: 'coder0' }],
applyProjection: async () => undefined,
readRoster: async () => host.roster,
acquireMutationLock: async () => async () => undefined,
};
}
async function execute(
host: FakeLifecycleHost,
command: 'apply' | 'reconcile' | 'restart' | 'status' | 'stop',
agentName?: string,
): Promise<FleetReconcileResult> {
return executeFleetReconcile({
roster: host.roster,
command,
...(agentName === undefined ? {} : { agentName }),
...(command === 'status' ? {} : { expectedGeneration: host.roster.generation }),
deps: reconcileDeps(host),
});
}
async function fixtureHome(roster: FleetRosterV2): Promise<string> {
const home = await mkdtemp(join(tmpdir(), 'mosaic-reconciler-acceptance-'));
cleanupDirectories.push(home);
const fleetDirectory = join(home, 'fleet');
await mkdir(fleetDirectory, { mode: 0o700 });
await chmod(home, 0o700);
await chmod(fleetDirectory, 0o700);
await writeFile(join(fleetDirectory, 'roster.yaml'), renderRosterV2Yaml(roster), { mode: 0o600 });
return home;
}
async function fixtureLegacyRoster(): Promise<string> {
const home = await mkdtemp(join(tmpdir(), 'mosaic-reconciler-acceptance-v1-'));
cleanupDirectories.push(home);
const fleetDirectory = join(home, 'fleet');
await mkdir(fleetDirectory, { mode: 0o700 });
await chmod(home, 0o700);
await chmod(fleetDirectory, 0o700);
const rosterPath = join(fleetDirectory, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'tmux:',
' holder_session: _holder',
'agents:',
' - name: coder0',
' runtime: pi',
' class: code',
'',
].join('\n'),
{ mode: 0o600 },
);
return rosterPath;
}
function cliProgram(home: string, host: FakeLifecycleHost): Command {
const program = new Command();
program.exitOverride();
registerFleetCommand(program, {
mosaicHome: home,
runner: async (command: string, args: string[]): Promise<CommandResult> =>
host.run(command, args),
reconcileDeps: reconcileDeps(host),
});
return program;
}
function captureJson(): string[] {
const output: string[] = [];
vi.spyOn(console, 'log').mockImplementation((line: string): void => {
output.push(line);
});
return output;
}
describe('FCM-M3-002 reconciler lifecycle acceptance', (): void => {
it('observes named-socket drift through canonical roster-v2 parsing without runtime mutation', async (): Promise<void> => {
const roster: FleetRosterV2 = {
...baseRoster,
agents: [
stoppedAgent,
{
...stoppedAgent,
name: 'reviewer0',
alias: 'Reviewer 0',
lifecycle: { enabled: true, desiredState: 'running' },
},
{
...stoppedAgent,
name: 'validator0',
alias: 'Validator 0',
lifecycle: { enabled: false, desiredState: 'stopped' },
},
],
};
const home = await fixtureHome(roster);
const host = new FakeLifecycleHost(roster);
host.sessions.add('coder0');
host.sessions.add('validator0');
host.sessions.add('coder0-shadow');
const output = captureJson();
await cliProgram(home, host).parseAsync(['node', 'mosaic', 'fleet', 'status']);
expect(output).toHaveLength(1);
expect(JSON.parse(output[0] ?? '{}')).toMatchObject({
plan: {
agents: [
{ name: 'coder0', drift: ['unexpected-session'] },
{ name: 'reviewer0', drift: ['missing-session'] },
{ name: 'validator0', drift: ['unexpected-session', 'disabled-running'] },
],
unmanagedSessions: ['coder0-shadow'],
},
});
expect(host.calls[0]).toEqual([
'tmux',
'-L',
'mosaic-fleet',
'list-sessions',
'-F',
'#{session_name}',
]);
expect(
host.calls.every(
(call: string[]): boolean =>
call[0] !== 'tmux' || (call[1] === '-L' && call[2] === 'mosaic-fleet'),
),
).toBe(true);
expect(
host.calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
).toBe(true);
expect(process.exitCode).toBe(0);
});
it('rejects a missing canonical roster-v2 tmux socket through parseRosterV2', (): void => {
const source = renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*\n/m, '');
expect(() => parseRosterV2(source, 'yaml')).toThrow(
'Roster v2 tmux socket_name is required and must be a string.',
);
});
it('accepts an explicit empty canonical roster-v2 socket as the literal default server', (): 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 runner = vi.fn<CommandRunner>(
async (): Promise<CommandResult> => ({
stdout: '111 pi 0 0 0 0\n',
stderr: '',
exitCode: 0,
}),
);
const transport = new FleetTmuxRuntimeTransport({
mosaicHome: '/unused',
rosterPath,
runner,
});
await expect(transport.verifySession('coder0')).resolves.toEqual({
id: 'coder0',
runtimeId: 'pi',
socketName: '',
});
expect(runner).toHaveBeenCalledTimes(1);
expect(runner).toHaveBeenCalledWith('tmux', [
'list-panes',
'-t',
'=coder0:0.0',
'-F',
'#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity} #{window_activity} #{session_activity}',
]);
expect(runner.mock.calls[0]?.[1]).not.toContain('-L');
});
it('classifies unmanaged near-collisions and stops only the exact roster-owned service', async (): Promise<void> => {
const host = new FakeLifecycleHost(baseRoster);
host.sessions.add('coder0');
host.sessions.add('coder0-shadow');
host.sessions.add('unmanaged');
host.activeServices.add('mosaic-agent@coder0.service');
host.activeServices.add('mosaic-agent@coder0-shadow.service');
const observed = await execute(host, 'status');
const stopped = await execute(host, 'stop', 'coder0');
expect(observed.plan.unmanagedSessions).toEqual(['coder0-shadow', 'unmanaged']);
expect(stopped).toMatchObject({ applied: true, lifecycle: 'complete' });
expect(host.activeServices.has('mosaic-agent@coder0.service')).toBe(false);
expect(host.activeServices.has('mosaic-agent@coder0-shadow.service')).toBe(true);
expect(host.sessions.has('coder0-shadow')).toBe(true);
expect(host.sessions.has('unmanaged')).toBe(true);
expect(host.calls).toContainEqual([
'systemctl',
'--user',
'stop',
'mosaic-agent@coder0.service',
]);
expect(
host.calls.some(
(call: string[]): boolean =>
call.includes('kill-session') ||
call.includes('coder0-shadow.service') ||
call.includes('unmanaged.service'),
),
).toBe(false);
});
it('preserves persisted stopped state through apply, reconcile, restart failure, and recovery reconcile', async (): Promise<void> => {
const host = new FakeLifecycleHost(baseRoster);
host.sessions.add('coder0');
host.activeServices.add('mosaic-agent@coder0.service');
const applied = await execute(host, 'apply');
const reconciled = await execute(host, 'reconcile');
host.injectFailure({
action: 'restart',
service: 'mosaic-agent@coder0.service',
diagnostic: 'crash after effect: TOKEN=acceptance-secret',
});
const partialRestart = await execute(host, 'restart', 'coder0');
expect(applied).toMatchObject({ applied: true, lifecycle: 'complete' });
expect(reconciled).toMatchObject({ applied: true, lifecycle: 'complete' });
expect(host.roster.agents[0]?.lifecycle.desiredState).toBe('stopped');
expect(partialRestart).toMatchObject({
applied: false,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
lifecycle: 'incomplete',
recovery: {
code: 'lifecycle-apply-failed',
action: 'rerun-after-inspecting-owned-resources',
},
});
expect(host.activeServices.has('mosaic-agent@coder0.service')).toBe(true);
const recovered = await execute(host, 'reconcile');
expect(recovered).toMatchObject({ applied: true, lifecycle: 'complete' });
expect(host.roster.agents[0]?.lifecycle.desiredState).toBe('stopped');
expect(host.activeServices.has('mosaic-agent@coder0.service')).toBe(false);
expect(host.sessions.has('coder0')).toBe(false);
const destructiveCalls = host.calls.filter(
(call: string[]): boolean =>
call[0] === 'systemctl' && ['start', 'stop', 'restart'].includes(call[2] ?? ''),
);
expect(
destructiveCalls.every(
(call: string[]): boolean => call[3] === 'mosaic-agent@coder0.service',
),
).toBe(true);
expect(destructiveCalls.some((call: string[]): boolean => call[2] === 'start')).toBe(false);
});
it('emits stable non-zero redacted JSON for a partial lifecycle effect', async (): Promise<void> => {
const home = await fixtureHome(baseRoster);
const host = new FakeLifecycleHost(baseRoster);
host.injectFailure({
action: 'restart',
service: 'mosaic-agent@coder0.service',
diagnostic: 'simulated runner stderr with PASSWORD=acceptance-secret',
});
const output = captureJson();
await cliProgram(home, host).parseAsync([
'node',
'mosaic',
'fleet',
'restart',
'coder0',
'--expected-generation',
'7',
]);
const line = output.at(-1) ?? '';
expect(output).toHaveLength(1);
expect(JSON.parse(line)).toEqual({
applied: false,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
lifecycle: 'incomplete',
plan: {
generation: 7,
holder: 'owned',
agents: [
{
name: 'coder0',
desiredState: 'stopped',
enabled: true,
systemd: 'inactive',
tmux: 'missing',
drift: [],
},
],
unmanagedSessions: [],
},
recovery: {
code: 'lifecycle-apply-failed',
action: 'rerun-after-inspecting-owned-resources',
},
});
expect(process.exitCode).toBe(1);
expect(line).not.toContain('PASSWORD');
expect(line).not.toContain('acceptance-secret');
expect(line).not.toContain('simulated runner stderr');
});
});

View File

@@ -1,529 +0,0 @@
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
acquirePrivateReconcileLock,
FleetReconcileError,
executeFleetReconcile,
type FleetReconcileCommand,
type FleetReconcileDeps,
} from './fleet-reconciler.js';
import type { FleetRosterV2 } from './roster-v2.js';
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
if (cleanup) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
async function lockHome(): Promise<string> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-reconcile-lock-'));
const fleet = join(cleanup, 'fleet');
await mkdir(fleet, { mode: 0o700 });
await chmod(cleanup, 0o700);
await chmod(fleet, 0o700);
return cleanup;
}
const roster: FleetRosterV2 = {
version: 2,
generation: 7,
transport: 'tmux',
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
defaults: { workingDirectory: '/srv/mosaic', runtime: 'pi' },
runtimes: { pi: { resetCommand: '/new' } },
agents: [
{
name: 'coder0',
alias: 'Coder 0',
className: 'code',
runtime: 'pi',
provider: 'openai',
model: 'gpt-5.6-sol',
reasoning: 'high',
toolPolicy: 'code',
workingDirectory: '/srv/mosaic',
persistentPersona: false,
resetBetweenTasks: true,
lifecycle: { enabled: true, desiredState: 'stopped' },
launch: { yolo: true },
},
],
};
function deps(overrides: Partial<FleetReconcileDeps> = {}): FleetReconcileDeps {
return {
runner: async (command, args) => {
if (command === 'tmux' && args.includes('list-sessions')) {
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
}
if (command === 'tmux' && args.includes('show-environment')) {
return {
stdout:
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
},
homeDirectory: '/home/mosaic',
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
validateRoster: async () => undefined,
prepareProjections: async () => [{ agentName: 'coder0' }],
applyProjection: async () => undefined,
readRoster: async () => roster,
acquireMutationLock: async () => async () => undefined,
...overrides,
};
}
async function run(command: FleetReconcileCommand, overrides: Partial<FleetReconcileDeps> = {}) {
return executeFleetReconcile({
roster,
command,
...(command === 'status' || command === 'verify' || command === 'doctor'
? {}
: { expectedGeneration: 7 }),
deps: deps({ readRoster: async () => roster, ...overrides }),
});
}
describe('fleet roster-owned reconciler', (): void => {
it('fails closed on a symlinked fleet ancestor without touching its target', async (): Promise<void> => {
const home = await lockHome();
const fleet = join(home, 'fleet');
const attacker = await mkdtemp(join(tmpdir(), 'mosaic-reconcile-attacker-'));
await writeFile(join(attacker, 'sentinel'), 'unchanged\n', { mode: 0o600 });
try {
await rm(fleet, { recursive: true });
await symlink(attacker, fleet, 'dir');
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
code: 'unsafe-managed-path',
});
expect(await readFile(join(attacker, 'sentinel'), 'utf8')).toBe('unchanged\n');
} finally {
await rm(attacker, { recursive: true, force: true });
}
});
it('rejects unsafe ancestors, symlink leaves, EEXIST, and non-EEXIST lock creation failures', async (): Promise<void> => {
const home = await lockHome();
const fleet = join(home, 'fleet');
const lockPath = join(fleet, 'roster.yaml.reconcile.lock');
await chmod(fleet, 0o770);
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
code: 'unsafe-managed-path',
});
await chmod(fleet, 0o700);
const target = join(home, 'target');
await writeFile(target, 'target\n', { mode: 0o600 });
await symlink(target, lockPath);
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
code: 'unsafe-lock',
});
await rm(lockPath);
await writeFile(lockPath, 'other\n', { mode: 0o600 });
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
code: 'concurrent-mutation',
});
await rm(lockPath);
const ioFailure = Object.assign(new Error('injected I/O failure'), { code: 'EIO' });
await expect(
acquirePrivateReconcileLock(home, async () => Promise.reject(ioFailure))(),
).rejects.toMatchObject({ code: 'lock-io-failed' });
});
it('does not unlink a replacement lock and normally releases its own lock', async (): Promise<void> => {
const home = await lockHome();
const lockPath = join(home, 'fleet', 'roster.yaml.reconcile.lock');
const release = await acquirePrivateReconcileLock(home)();
await rm(lockPath);
await writeFile(lockPath, 'replacement\n', { mode: 0o600 });
await expect(release()).rejects.toMatchObject({ code: 'lock-cleanup-failed' });
expect(await readFile(lockPath, 'utf8')).toBe('replacement\n');
await rm(lockPath);
const normalRelease = await acquirePrivateReconcileLock(home)();
await normalRelease();
await expect(readFile(lockPath, 'utf8')).rejects.toThrow();
});
it('requires a generation before any mutating preflight or effect', async (): Promise<void> => {
let effects = 0;
await expect(
executeFleetReconcile({
roster,
command: 'apply',
deps: deps({
validateRoster: async () => {
effects += 1;
},
}),
}),
).rejects.toMatchObject({ code: 'missing-generation' });
expect(effects).toBe(0);
});
it('fences mutation against the canonical roster reread under lock', async (): Promise<void> => {
let effects = 0;
await expect(
executeFleetReconcile({
roster,
command: 'apply',
expectedGeneration: 7,
deps: deps({
readRoster: async () => ({ ...roster, generation: 8 }),
runner: async () => {
effects += 1;
return { stdout: '', stderr: '', exitCode: 0 };
},
applyProjection: async () => {
effects += 1;
},
}),
}),
).rejects.toMatchObject({ code: 'stale-generation' });
expect(effects).toBe(0);
});
it('rejects stale generation before projection or lifecycle effects', async (): Promise<void> => {
let effects = 0;
await expect(
executeFleetReconcile({
roster,
command: 'apply',
expectedGeneration: 6,
deps: deps({
validateRoster: async () => {
effects += 1;
},
}),
}),
).rejects.toMatchObject({ code: 'stale-generation' });
expect(effects).toBe(0);
});
it('denies a concurrent mutation lock without effects and leaves observations lock-free', async (): Promise<void> => {
let effects = 0;
const busy = async (): Promise<() => Promise<void>> => {
throw new FleetReconcileError('concurrent-mutation', 'busy');
};
await expect(
run('apply', {
acquireMutationLock: busy,
applyProjection: async () => {
effects += 1;
},
}),
).rejects.toMatchObject({ code: 'concurrent-mutation' });
expect(effects).toBe(0);
await expect(run('doctor', { acquireMutationLock: busy })).resolves.toMatchObject({
applied: false,
lifecycle: 'not-applied',
});
});
it('always releases the mutation lock after success and partial failure', async (): Promise<void> => {
let releases = 0;
const lock = async (): Promise<() => Promise<void>> => async (): Promise<void> => {
releases += 1;
};
await run('apply', { acquireMutationLock: lock });
await run('apply', {
acquireMutationLock: lock,
applyProjection: async () => {
throw new Error('injected failure');
},
});
expect(releases).toBe(2);
});
it('preserves stopped desired state during apply', async (): Promise<void> => {
const calls: string[][] = [];
const result = await run('apply', {
runner: async (command, args) => {
calls.push([command, ...args]);
if (command === 'tmux' && args.includes('list-sessions')) {
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
}
if (command === 'tmux' && args.includes('show-environment')) {
return {
stdout:
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
},
});
expect(result.lifecycle).toBe('complete');
expect(calls).not.toContainEqual([
'systemctl',
'--user',
'start',
'mosaic-agent@coder0.service',
]);
});
it.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> => {
const calls: string[][] = [];
const runningRoster: FleetRosterV2 = {
...roster,
agents: [{ ...roster.agents[0]!, lifecycle: { enabled: true, desiredState: 'running' } }],
};
const result = await executeFleetReconcile({
roster: runningRoster,
command: 'apply',
expectedGeneration: 7,
deps: deps({
readRoster: async () => runningRoster,
runner: async (command, args) => {
calls.push([command, ...args]);
if (command === 'tmux' && args.includes('list-sessions')) {
return { stdout: '', stderr: '', exitCode: 1 };
}
return { stdout: '', stderr: '', exitCode: 0 };
},
}),
});
expect(result.lifecycle).toBe('complete');
expect(calls).toContainEqual(['systemctl', '--user', 'start', 'mosaic-tmux-holder.service']);
expect(calls).toContainEqual(['systemctl', '--user', 'start', 'mosaic-agent@coder0.service']);
});
it('rejects a fake holder with contaminated global state before projection application', async (): Promise<void> => {
let projectionApplied = false;
await expect(
run('apply', {
runner: async (command, args) => {
if (command === 'tmux' && args.includes('list-sessions')) {
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
}
if (command === 'tmux' && args.includes('show-environment')) {
return { stdout: 'MOSAIC_FLEET_OWNER=forged\n', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: '', exitCode: 0 };
},
applyProjection: async () => {
projectionApplied = true;
},
}),
).rejects.toMatchObject({ code: 'ownership-mismatch' });
expect(projectionApplied).toBe(false);
});
it('reports but never adopts unmanaged sessions', async (): Promise<void> => {
await expect(
run('apply', {
runner: async (command, args) => {
if (command === 'tmux' && args.includes('list-sessions')) {
return { stdout: '_holder\ncoder0\nother\n', stderr: '', exitCode: 0 };
}
if (command === 'tmux' && args.includes('show-environment')) {
return {
stdout:
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
},
}),
).rejects.toMatchObject({ code: 'unmanaged-session' } satisfies Partial<FleetReconcileError>);
});
it('rejects remote inventory before a local lifecycle command is constructed', async (): Promise<void> => {
const calls: string[][] = [];
const remoteRoster = {
...roster,
agents: [{ ...roster.agents[0]!, remote: { host: 'inventory-only' } }],
} as unknown as FleetRosterV2;
await expect(
executeFleetReconcile({
roster: remoteRoster,
command: 'apply',
expectedGeneration: 7,
deps: deps({
readRoster: async () => remoteRoster,
runner: async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
},
}),
}),
).rejects.toMatchObject({ code: 'lifecycle-precondition-failed' });
expect(calls).toEqual([]);
});
it('plans without applying projections or lifecycle effects', async (): Promise<void> => {
let applied = false;
const result = await run('plan', {
applyProjection: async () => {
applied = true;
},
});
expect(result.applied).toBe(false);
expect(result.lifecycle).toBe('not-applied');
expect(applied).toBe(false);
});
it('reports a truthful partial result if projection application fails', async (): Promise<void> => {
const result = await run('apply', {
applyProjection: async () => {
throw new Error('injected failure');
},
});
expect(result).toMatchObject({
applied: false,
projections: 'incomplete',
lifecycle: 'not-applied',
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
});
});
it('adds cleanup diagnostics without masking projection or lifecycle partial truth', async (): Promise<void> => {
const failingRelease = async (): Promise<never> => {
throw new FleetReconcileError('lock-cleanup-failed', 'injected');
};
const projectionPartial = await run('apply', {
applyProjection: async () => {
throw new Error('injected projection failure');
},
acquireMutationLock: async () => failingRelease,
});
expect(projectionPartial).toMatchObject({
projections: 'incomplete',
lifecycle: 'not-applied',
recovery: { code: 'projection-apply-failed' },
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
});
const lifecyclePartial = await run('apply', {
runner: async () => ({ stdout: '', stderr: '', exitCode: 1 }),
acquireMutationLock: async () => failingRelease,
});
expect(lifecyclePartial).toMatchObject({
projections: 'complete',
lifecycle: 'incomplete',
recovery: { code: 'lifecycle-apply-failed' },
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
});
});
it('adds cleanup diagnostics after successful effects without changing effect completion', async (): Promise<void> => {
const result = await run('apply', {
acquireMutationLock: async () => async () => {
throw new FleetReconcileError('lock-cleanup-failed', 'injected');
},
});
expect(result).toMatchObject({
applied: true,
projections: 'complete',
lifecycle: 'complete',
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
});
});
});

View File

@@ -1,799 +0,0 @@
import { constants } from 'node:fs';
import { lstat, open, readFile, unlink, type FileHandle } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { homedir } from 'node:os';
import { join } from 'node:path';
import {
applyPreparedAgentEnvironmentProjection,
prepareAgentEnvironmentProjection,
type PreparedAgentEnvironmentProjection,
} from './generated-env-boundary.js';
import {
validateRosterV2Semantics,
type FleetRosterV2,
type FleetRosterV2Agent,
} from './roster-v2.js';
export type FleetReconcileCommand =
| 'plan'
| 'apply'
| 'reconcile'
| 'start'
| 'stop'
| 'restart'
| 'status'
| 'verify'
| 'doctor';
export interface FleetReconcileCommandResult {
readonly stdout: string;
readonly stderr: string;
readonly exitCode: number;
}
export type FleetReconcileRunner = (
command: string,
args: readonly string[],
) => Promise<FleetReconcileCommandResult>;
export interface FleetReconcileDeps {
readonly runner: FleetReconcileRunner;
readonly mosaicHome?: string;
readonly rolesDir?: string;
readonly overrideDir?: string;
readonly homeDirectory?: string;
readonly readHolderIdentity?: () => Promise<string>;
readonly validateRoster?: (roster: FleetRosterV2) => Promise<void>;
readonly prepareProjections?: (roster: FleetRosterV2) => Promise<readonly unknown[]>;
readonly applyProjection?: (prepared: unknown) => Promise<unknown>;
/** Canonical roster reader; mutation authority is reread under the private lock. */
readonly readRoster?: () => Promise<FleetRosterV2>;
/** Test seam; production uses a private exclusive roster-adjacent lock. */
readonly acquireMutationLock?: () => Promise<() => Promise<void>>;
/** Internal recursion guard for the under-lock canonical roster read. */
readonly lockAlreadyHeld?: boolean;
}
export interface FleetReconcileRequest {
readonly roster: FleetRosterV2;
readonly command: FleetReconcileCommand;
readonly agentName?: string;
readonly expectedGeneration?: number;
readonly deps: FleetReconcileDeps;
}
export interface FleetReconcileObservedAgent {
readonly name: string;
readonly desiredState: 'running' | 'stopped';
readonly enabled: boolean;
readonly systemd: 'active' | 'inactive' | 'unknown';
readonly tmux: 'present' | 'missing';
readonly drift: readonly ('missing-session' | 'unexpected-session' | 'disabled-running')[];
}
export interface FleetReconcilePlan {
readonly generation: number;
readonly holder: 'owned' | 'missing' | 'ownership-mismatch';
readonly agents: readonly FleetReconcileObservedAgent[];
readonly unmanagedSessions: readonly string[];
}
export type FleetReconcileProjectionState = 'not-applied' | 'complete' | 'incomplete';
export type FleetReconcileLifecycleState = 'not-applied' | 'complete' | 'incomplete';
export interface FleetReconcileResult {
readonly applied: boolean;
readonly authoritativeRoster: 'unchanged';
readonly projections: FleetReconcileProjectionState;
readonly lifecycle: FleetReconcileLifecycleState;
readonly plan: FleetReconcilePlan;
readonly recovery?: {
readonly code: 'projection-apply-failed' | 'lifecycle-apply-failed';
readonly action:
| 'regenerate-projections-from-roster'
| 'rerun-after-inspecting-owned-resources';
};
/** Additive: effects remain truthful when private lock cleanup cannot be proven. */
readonly cleanup?: {
readonly code: 'lock-cleanup-failed';
readonly action: 'inspect-lock-before-retry';
};
}
export class FleetReconcileError extends Error {
constructor(
readonly code:
| 'missing-generation'
| 'stale-generation'
| 'concurrent-mutation'
| 'unsafe-managed-path'
| 'unsafe-lock'
| 'lock-io-failed'
| 'lock-cleanup-failed'
| 'agent-not-found'
| 'disabled-agent'
| 'ownership-mismatch'
| 'unmanaged-session'
| 'lifecycle-precondition-failed',
message: string,
) {
super(message);
this.name = FleetReconcileError.name;
}
}
/**
* Reconciles only local roster-owned projections and exact service targets.
* The roster is read-only desired state: no observed runtime result ever writes it.
*/
export async function executeFleetReconcile(
request: FleetReconcileRequest,
): Promise<FleetReconcileResult> {
const mutating =
request.command === 'apply' ||
request.command === 'reconcile' ||
isLifecycleCommand(request.command);
if (mutating && !request.deps.lockAlreadyHeld) {
if (request.expectedGeneration === undefined) assertExpectedGeneration(request);
const acquire =
request.deps.acquireMutationLock ?? acquirePrivateReconcileLock(mosaicHomeFor(request.deps));
const release = await acquire();
let result: FleetReconcileResult | undefined;
let primaryError: unknown;
try {
if (!request.deps.readRoster) {
throw new FleetReconcileError(
'lifecycle-precondition-failed',
'Canonical roster state cannot be read for mutation.',
);
}
const canonicalRoster = await request.deps.readRoster();
result = await executeFleetReconcile({
...request,
roster: canonicalRoster,
deps: { ...request.deps, lockAlreadyHeld: true },
});
} catch (error: unknown) {
primaryError = error;
}
try {
await release();
} catch (cleanupError: unknown) {
if (result) {
return {
...result,
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
};
}
if (primaryError) throw primaryError;
throw cleanupError;
}
if (primaryError) throw primaryError;
return result as FleetReconcileResult;
}
assertExpectedGeneration(request);
assertLocalRosterOnly(request.roster);
const validateRoster = request.deps.validateRoster ?? defaultValidateRoster(request);
await validateRoster(request.roster);
assertLifecycleSocketAuthority(request);
const plan = scopePlan(await observeFleet(request.roster, request.deps), request.agentName);
if (request.command === 'status' || request.command === 'doctor') {
return {
applied: false,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
lifecycle: 'not-applied',
plan,
};
}
if (request.command === 'verify') {
assertVerificationSafe(plan);
return {
applied: false,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
lifecycle: 'not-applied',
plan,
};
}
assertMutationSafe(plan, request.command);
const prepareProjections = request.deps.prepareProjections ?? defaultPrepareProjections(request);
const prepared = await prepareProjections(request.roster);
if (request.command === 'plan') {
return {
applied: false,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
lifecycle: 'not-applied',
plan,
};
}
const targetAgents = targetAgentsFor(request.roster, request.agentName);
const release = async (): Promise<void> => undefined;
let result: FleetReconcileResult | undefined;
let primaryError: unknown;
try {
if (request.command !== 'apply' && request.command !== 'reconcile') {
result = await executeExplicitLifecycle(request, plan, targetAgents);
} else {
const applyProjection = request.deps.applyProjection ?? defaultApplyProjection;
try {
for (const projection of prepared) await applyProjection(projection);
} catch {
result = {
applied: false,
authoritativeRoster: 'unchanged',
projections: 'incomplete',
lifecycle: 'not-applied',
plan,
recovery: {
code: 'projection-apply-failed',
action: 'regenerate-projections-from-roster',
},
};
}
if (!result) {
try {
await applyDesiredLifecycle(request.roster, plan, request.deps);
result = {
applied: true,
authoritativeRoster: 'unchanged',
projections: 'complete',
lifecycle: 'complete',
plan,
};
} catch {
result = {
applied: false,
authoritativeRoster: 'unchanged',
projections: 'complete',
lifecycle: 'incomplete',
plan,
recovery: {
code: 'lifecycle-apply-failed',
action: 'rerun-after-inspecting-owned-resources',
},
};
}
}
}
} catch (error: unknown) {
primaryError = error;
}
try {
await release();
} catch (cleanupError: unknown) {
if (result) {
return {
...result,
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
};
}
if (primaryError) throw primaryError;
throw cleanupError;
}
if (primaryError) throw primaryError;
return result as FleetReconcileResult;
}
function assertLocalRosterOnly(roster: FleetRosterV2): void {
for (const agent of roster.agents) {
const untypedAgent = agent as unknown as Record<string, unknown>;
if (
Object.hasOwn(untypedAgent, 'remote') ||
Object.hasOwn(untypedAgent, 'ssh') ||
Object.hasOwn(untypedAgent, 'connector') ||
Object.hasOwn(untypedAgent, 'host')
) {
throw new FleetReconcileError(
'lifecycle-precondition-failed',
'Remote or connector inventory cannot receive local lifecycle actions.',
);
}
}
}
function assertExpectedGeneration(request: FleetReconcileRequest): void {
if (isObservational(request.command)) return;
if (request.expectedGeneration === undefined) {
throw new FleetReconcileError(
'missing-generation',
'A roster generation is required for mutation.',
);
}
if (request.expectedGeneration !== request.roster.generation) {
throw new FleetReconcileError('stale-generation', 'The roster generation is stale.');
}
}
function isObservational(command: FleetReconcileCommand): boolean {
return command === 'plan' || command === 'status' || command === 'verify' || command === 'doctor';
}
async function observeFleet(
roster: FleetRosterV2,
deps: FleetReconcileDeps,
): Promise<FleetReconcilePlan> {
const sessionsResult = await run(deps, 'tmux', [
...tmuxSocketArgs(roster.tmux.socketName),
'list-sessions',
'-F',
'#{session_name}',
]);
if (sessionsResult.exitCode !== 0) {
return {
generation: roster.generation,
holder: 'missing',
agents: await observeAgents(roster, deps, new Set<string>()),
unmanagedSessions: [],
};
}
const sessions = new Set(
sessionsResult.stdout
.split('\n')
.map((value: string): string => value.trim())
.filter((value: string): boolean => value.length > 0),
);
const knownSessions = new Set([
roster.tmux.holderSession,
...roster.agents.map((agent) => agent.name),
]);
const unmanagedSessions = [...sessions].filter(
(session: string): boolean => !knownSessions.has(session),
);
const holder = await observeHolder(roster, deps, sessions);
return {
generation: roster.generation,
holder,
agents: await observeAgents(roster, deps, sessions),
unmanagedSessions: Object.freeze(unmanagedSessions.sort()),
};
}
async function observeHolder(
roster: FleetRosterV2,
deps: FleetReconcileDeps,
sessions: ReadonlySet<string>,
): Promise<FleetReconcilePlan['holder']> {
if (!sessions.has(roster.tmux.holderSession)) return 'ownership-mismatch';
let owner: string;
try {
owner = await (deps.readHolderIdentity ?? defaultReadHolderIdentity(mosaicHomeFor(deps)))();
} catch {
return 'ownership-mismatch';
}
const environment = await run(deps, 'tmux', [
...tmuxSocketArgs(roster.tmux.socketName),
'show-environment',
'-g',
]);
if (environment.exitCode !== 0) return 'ownership-mismatch';
const homeDirectory = deps.homeDirectory ?? homedir();
const expected = [
`HOME=${homeDirectory}`,
`MOSAIC_FLEET_OWNER=${owner}`,
`MOSAIC_TMUX_HOLDER=${roster.tmux.holderSession}`,
`MOSAIC_TMUX_SOCKET=${roster.tmux.socketName}`,
'PATH=/usr/bin:/bin',
`PWD=${homeDirectory}`,
].sort();
const actual = environment.stdout
.split('\n')
.filter((line: string): boolean => line.length > 0)
.sort();
return sameStringArray(actual, expected) ? 'owned' : 'ownership-mismatch';
}
async function observeAgents(
roster: FleetRosterV2,
deps: FleetReconcileDeps,
sessions: ReadonlySet<string>,
): Promise<readonly FleetReconcileObservedAgent[]> {
return Promise.all(
roster.agents.map(async (agent: FleetRosterV2Agent): Promise<FleetReconcileObservedAgent> => {
const service = await run(deps, 'systemctl', [
'--user',
'show',
`mosaic-agent@${agent.name}.service`,
'-p',
'ActiveState',
]);
const active = /^ActiveState=active$/m.test(service.stdout)
? 'active'
: service.exitCode === 0
? 'inactive'
: 'unknown';
const tmux = sessions.has(agent.name) ? 'present' : 'missing';
const drift: Array<'missing-session' | 'unexpected-session' | 'disabled-running'> = [];
if (
agent.lifecycle.enabled &&
agent.lifecycle.desiredState === 'running' &&
tmux === 'missing'
) {
drift.push('missing-session');
}
if (agent.lifecycle.desiredState === 'stopped' && tmux === 'present')
drift.push('unexpected-session');
if (!agent.lifecycle.enabled && tmux === 'present') drift.push('disabled-running');
return {
name: agent.name,
desiredState: agent.lifecycle.desiredState,
enabled: agent.lifecycle.enabled,
systemd: active,
tmux,
drift: Object.freeze(drift),
};
}),
);
}
function assertMutationSafe(plan: FleetReconcilePlan, command: FleetReconcileCommand): void {
if (plan.holder === 'ownership-mismatch') {
throw new FleetReconcileError(
'ownership-mismatch',
'The named tmux server ownership cannot be proven.',
);
}
if (plan.unmanagedSessions.length > 0 && affectsHolder(command)) {
throw new FleetReconcileError(
'unmanaged-session',
'Unmanaged sessions are present on the named socket.',
);
}
}
function affectsHolder(command: FleetReconcileCommand): boolean {
return (
command === 'apply' || command === 'reconcile' || command === 'start' || command === 'restart'
);
}
function scopePlan(plan: FleetReconcilePlan, agentName?: string): FleetReconcilePlan {
if (agentName === undefined) return plan;
const agent = plan.agents.find(
(candidate: FleetReconcileObservedAgent): boolean => candidate.name === agentName,
);
if (!agent)
throw new FleetReconcileError('agent-not-found', 'The lifecycle target is not roster-owned.');
return { ...plan, agents: [agent] };
}
function assertVerificationSafe(plan: FleetReconcilePlan): void {
if (plan.holder !== 'owned' || plan.unmanagedSessions.length > 0) {
throw new FleetReconcileError(
'lifecycle-precondition-failed',
'Fleet ownership cannot be verified.',
);
}
if (plan.agents.some((agent: FleetReconcileObservedAgent): boolean => agent.drift.length > 0)) {
throw new FleetReconcileError(
'lifecycle-precondition-failed',
'Fleet drift prevents verification.',
);
}
}
function 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[] {
if (agentName === undefined) return roster.agents;
const agent = roster.agents.find(
(candidate: FleetRosterV2Agent): boolean => candidate.name === agentName,
);
if (!agent)
throw new FleetReconcileError('agent-not-found', 'The lifecycle target is not roster-owned.');
return [agent];
}
async function executeExplicitLifecycle(
request: FleetReconcileRequest,
plan: FleetReconcilePlan,
agents: readonly FleetRosterV2Agent[],
): Promise<FleetReconcileResult> {
if (request.command === 'start') {
for (const agent of agents) {
if (!agent.lifecycle.enabled) {
throw new FleetReconcileError(
'disabled-agent',
'A disabled roster agent cannot be started.',
);
}
}
}
try {
if (request.command === 'start' && plan.holder === 'missing') {
await runChecked(request.deps, 'systemctl', [
'--user',
'start',
'mosaic-tmux-holder.service',
]);
}
for (const agent of agents) {
await runChecked(request.deps, 'systemctl', [
'--user',
request.command,
`mosaic-agent@${agent.name}.service`,
]);
}
} catch {
return {
applied: false,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
lifecycle: 'incomplete',
plan,
recovery: {
code: 'lifecycle-apply-failed',
action: 'rerun-after-inspecting-owned-resources',
},
};
}
return {
applied: true,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
lifecycle: 'complete',
plan,
};
}
async function applyDesiredLifecycle(
roster: FleetRosterV2,
plan: FleetReconcilePlan,
deps: FleetReconcileDeps,
): Promise<void> {
const needsRunningAgent = roster.agents.some(
(agent: FleetRosterV2Agent): boolean =>
agent.lifecycle.enabled && agent.lifecycle.desiredState === 'running',
);
if (needsRunningAgent && plan.holder === 'missing') {
await runChecked(deps, 'systemctl', ['--user', 'start', 'mosaic-tmux-holder.service']);
}
for (const agent of roster.agents) {
const action =
agent.lifecycle.enabled && agent.lifecycle.desiredState === 'running' ? 'start' : 'stop';
await runChecked(deps, 'systemctl', ['--user', action, `mosaic-agent@${agent.name}.service`]);
}
}
function defaultValidateRoster(
request: FleetReconcileRequest,
): (roster: FleetRosterV2) => Promise<void> {
return async (roster: FleetRosterV2): Promise<void> => {
const mosaicHome = mosaicHomeFor(request.deps);
await validateRosterV2Semantics(roster, {
rolesDir: request.deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
overrideDir: request.deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
});
};
}
function defaultPrepareProjections(
request: FleetReconcileRequest,
): (roster: FleetRosterV2) => Promise<readonly PreparedAgentEnvironmentProjection[]> {
return async (roster: FleetRosterV2): Promise<readonly PreparedAgentEnvironmentProjection[]> => {
const mosaicHome = mosaicHomeFor(request.deps);
return Promise.all(
roster.agents.map(
(agent: FleetRosterV2Agent): Promise<PreparedAgentEnvironmentProjection> =>
prepareAgentEnvironmentProjection({
mosaicHome,
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
agentName: agent.name,
generated: {
MOSAIC_AGENT_NAME: agent.name,
MOSAIC_AGENT_CLASS: agent.className,
MOSAIC_AGENT_RUNTIME: agent.runtime,
MOSAIC_AGENT_MODEL: agent.model,
MOSAIC_AGENT_REASONING: agent.reasoning,
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
MOSAIC_AGENT_WORKDIR: agent.workingDirectory,
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
},
}),
),
);
};
}
async function defaultApplyProjection(prepared: unknown): Promise<unknown> {
return applyPreparedAgentEnvironmentProjection(prepared as PreparedAgentEnvironmentProjection);
}
function mosaicHomeFor(deps: FleetReconcileDeps): string {
return deps.mosaicHome ?? join(homedir(), '.config', 'mosaic');
}
/** Acquires a private lock only after proving the canonical managed path. */
export function acquirePrivateReconcileLock(
mosaicHome: string,
openLock: typeof open = open,
): () => Promise<() => Promise<void>> {
const fleetDir = join(mosaicHome, 'fleet');
const lockPath = join(fleetDir, 'roster.yaml.reconcile.lock');
return async (): Promise<() => Promise<void>> => {
await assertPrivateManagedDirectory(mosaicHome);
await assertPrivateManagedDirectory(fleetDir);
await assertSafeLockLeafIfPresent(lockPath);
let handle: FileHandle;
try {
handle = await openLock(lockPath, 'wx', 0o600);
} catch (error: unknown) {
if (isCode(error, 'EEXIST')) {
await assertSafeLockLeafIfPresent(lockPath);
throw new FleetReconcileError(
'concurrent-mutation',
'Another roster reconciliation is in progress.',
);
}
throw new FleetReconcileError('lock-io-failed', 'The reconciliation lock cannot be created.');
}
const token = randomUUID();
try {
await handle.writeFile(`${token}\n`, 'utf8');
const opened = await handle.stat();
await handle.close();
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'unsafe-lock');
return async (): Promise<void> => {
try {
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
await unlink(lockPath);
} catch (error: unknown) {
if (error instanceof FleetReconcileError) throw error;
throw new FleetReconcileError(
'lock-cleanup-failed',
'The reconciliation lock cleanup failed.',
);
}
};
} catch (error: unknown) {
await handle.close().catch((): void => {});
if (error instanceof FleetReconcileError) throw error;
throw new FleetReconcileError(
'lock-io-failed',
'The reconciliation lock cannot be initialized.',
);
}
};
}
async function assertPrivateManagedDirectory(path: string): Promise<void> {
try {
const metadata = await lstat(path);
if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
throw new FleetReconcileError('unsafe-managed-path', 'The managed lock ancestor is unsafe.');
}
} catch (error: unknown) {
if (error instanceof FleetReconcileError) throw error;
throw new FleetReconcileError(
'unsafe-managed-path',
'The managed lock ancestor is unavailable.',
);
}
}
async function assertSafeLockLeafIfPresent(lockPath: string): Promise<void> {
try {
const metadata = await lstat(lockPath);
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
throw new FleetReconcileError('unsafe-lock', 'The reconciliation lock path is unsafe.');
}
} catch (error: unknown) {
if (isCode(error, 'ENOENT')) return;
if (error instanceof FleetReconcileError) throw error;
throw new FleetReconcileError('unsafe-lock', 'The reconciliation lock path is unavailable.');
}
}
async function assertLockOwnership(
lockPath: string,
device: number,
inode: number,
token: string,
failureCode: 'unsafe-lock' | 'lock-cleanup-failed',
): Promise<void> {
try {
const metadata = await lstat(lockPath);
if (
!metadata.isFile() ||
metadata.isSymbolicLink() ||
(metadata.mode & 0o077) !== 0 ||
metadata.dev !== device ||
metadata.ino !== inode
) {
throw new FleetReconcileError(failureCode, 'The reconciliation lock ownership changed.');
}
const handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const opened = await handle.stat();
const contents = await handle.readFile({ encoding: 'utf8' });
if (opened.dev !== device || opened.ino !== inode || contents !== `${token}\n`) {
throw new FleetReconcileError(failureCode, 'The reconciliation lock ownership changed.');
}
} finally {
await handle.close();
}
} catch (error: unknown) {
if (error instanceof FleetReconcileError) throw error;
throw new FleetReconcileError(
failureCode,
'The reconciliation lock ownership cannot be proven.',
);
}
}
function isLifecycleCommand(command: FleetReconcileCommand): boolean {
return command === 'start' || command === 'stop' || command === 'restart';
}
function isCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}
function defaultReadHolderIdentity(mosaicHome: string): () => Promise<string> {
return async (): Promise<string> => {
const fleetDir = join(mosaicHome, 'fleet');
const runDir = join(fleetDir, 'run');
for (const directory of [mosaicHome, fleetDir, runDir]) {
const metadata = await lstat(directory);
if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
throw new Error('Unsafe holder identity ancestor.');
}
}
const identityPath = join(runDir, 'holder-owner');
const metadata = await lstat(identityPath);
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
throw new Error('Unsafe holder identity.');
}
const value = (await readFile(identityPath, 'utf8')).trim();
if (!/^[a-f0-9-]{36}$/.test(value)) throw new Error('Malformed holder identity.');
return value;
};
}
async function run(
deps: FleetReconcileDeps,
command: string,
args: readonly string[],
): Promise<FleetReconcileCommandResult> {
return deps.runner(command, args);
}
async function runChecked(
deps: FleetReconcileDeps,
command: string,
args: readonly string[],
): Promise<void> {
const result = await run(deps, command, args);
if (result.exitCode !== 0) throw new Error('Lifecycle action failed.');
}
function tmuxSocketArgs(socketName: string): readonly string[] {
return socketName === '' ? [] : ['-L', socketName];
}
function sameStringArray(left: readonly string[], right: readonly string[]): boolean {
return (
left.length === right.length &&
left.every((value: string, index: number): boolean => value === right[index])
);
}

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,
writeFile,
} from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
AgentEnvBoundaryError,
parseAgentEnvironment,
previewAgentEnvironmentProjection,
renderGeneratedAgentEnvironment,
writeAgentEnvironmentProjection,
} from './generated-env-boundary.js';
@@ -87,77 +86,6 @@ describe('generated fleet agent environment boundary', (): void => {
}).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> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic');

View File

@@ -1,8 +1,6 @@
import { createHash, randomUUID } from 'node:crypto';
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 { compareCodePoints } from './deterministic-order.js';
export type AgentEnvironmentKind = 'generated' | 'local';
@@ -32,15 +30,6 @@ export interface AgentEnvironmentProjectionResult {
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. */
export interface PreparedAgentEnvironmentProjection {
readonly mosaicHome: string;
@@ -51,7 +40,6 @@ export interface PreparedAgentEnvironmentProjection {
readonly generated: string;
readonly local: string;
readonly legacy?: string;
readonly legacyRelocatedKeys: readonly string[];
readonly quarantinePath?: string;
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
}
@@ -198,7 +186,6 @@ export async function prepareAgentEnvironmentProjection(
generated,
local,
...(legacy === undefined ? {} : { legacy }),
legacyRelocatedKeys: Object.keys(legacyDisposition.localValues).sort(compareCodePoints),
...(quarantinePath === undefined ? {} : { quarantinePath }),
diagnostics: legacyDisposition.diagnostics,
};
@@ -221,33 +208,6 @@ export async function prepareAgentGeneratedProjectionDeletion(
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. */
export async function applyPreparedAgentEnvironmentProjection(
prepared: PreparedAgentEnvironmentProjection,
@@ -319,7 +279,7 @@ function normalizeGeneratedValues(
for (const key of GENERATED_AGENT_ENV_KEYS) {
const value = values[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)) {
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
@@ -328,23 +288,17 @@ function normalizeGeneratedValues(
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 {
if (Object.keys(values).length === 0) return '';
const parsed = parseAgentEnvironment(
Object.entries(values)
.sort(([left], [right]): number => compareCodePoints(left, right))
.sort(([left], [right]): number => left.localeCompare(right))
.map(([key, value]): string => `${key}=${value}`)
.join('\n'),
'local',
);
return `${Object.entries(parsed)
.sort(([left], [right]): number => compareCodePoints(left, right))
.sort(([left], [right]): number => left.localeCompare(right))
.map(([key, value]): string => `${key}=${value}`)
.join('\n')}\n`;
}

View File

@@ -45,12 +45,6 @@ agents:
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> => {
if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true });
semanticTmp = undefined;

View File

@@ -10,7 +10,6 @@ import {
type PersonaResolution,
type RoleAuthority,
} 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_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
@@ -173,7 +172,7 @@ export const ROSTER_V2_JSON_SCHEMA: JsonSchema = {
additionalProperties: false,
required: ['socket_name', 'holder_session'],
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_.-]+$' },
},
},
@@ -273,7 +272,6 @@ const AGENT_KEYS = [
const LIFECYCLE_KEYS = ['enabled', 'desired_state'];
const LAUNCH_KEYS = ['yolo'];
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 POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/;
@@ -329,7 +327,7 @@ function normalizeTmux(value: unknown): FleetRosterV2Tmux {
const raw = requiredObject(value, 'Roster v2 tmux');
assertKnownKeys(raw, 'Roster v2 tmux', TMUX_KEYS);
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'),
};
}
@@ -350,7 +348,7 @@ function normalizeRuntimes(value: unknown): Readonly<Record<string, FleetRosterV
throw new RosterV2ValidationError('Roster v2 runtimes must not be empty.');
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 config = requiredObject(raw[name], `Roster v2 runtime "${runtime}"`);
assertKnownKeys(config, `Roster v2 runtime "${runtime}"`, RUNTIME_KEYS);
@@ -418,7 +416,7 @@ function normalizeAgents(
};
});
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;
}
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 {
const result = requiredString(value, label);
if (!TMUX_IDENTIFIER.test(result))
@@ -537,7 +524,7 @@ function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
},
runtimes: Object.fromEntries(
Object.entries(roster.runtimes)
.sort(([left], [right]): number => compareCodePoints(left, right))
.sort(([left], [right]): number => left.localeCompare(right))
.map(([name, runtime]): [string, unknown] => [
name,
{ reset_command: runtime.resetCommand },
@@ -545,7 +532,7 @@ function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
),
agents: [...roster.agents]
.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
compareCodePoints(left.name, right.name),
left.name.localeCompare(right.name),
)
.map(
(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 {
chmodSync,
existsSync,
lstatSync,
mkdtempSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { createHash } from 'node:crypto';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
@@ -24,8 +11,8 @@ import {
readInstalledFrameworkVersion,
readBundledFrameworkVersion,
checkFrameworkDrift,
repairFleetCommsTools,
} from './update-checker.js';
import { existsSync, readFileSync } from 'node:fs';
/**
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
@@ -79,7 +66,6 @@ describe('readRosterAgentNames', () => {
join(home, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: orchestrator',
' runtime: pi',
@@ -91,212 +77,6 @@ describe('readRosterAgentNames', () => {
);
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0', 'reviewer-1']);
});
it('extracts agent names from a JSON-only roster', () => {
mkdirSync(join(home, 'fleet'), { recursive: true });
writeFileSync(
join(home, 'fleet', 'roster.json'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [
{ name: 'orchestrator', runtime: 'pi', class: 'orchestrator' },
{ name: 'coder0', runtime: 'claude', class: 'worker' },
],
}),
);
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0']);
});
});
describe('repairFleetCommsTools', () => {
let root: string;
let framework: string;
let home: string;
const toolsContent = '# tools\n<!-- fleet-comms-contract: 1 -->\n';
const helperContent = '#!/bin/sh\nexit 0\n';
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'mosaic-tools-repair-'));
framework = join(root, 'framework');
home = join(root, 'home');
mkdirSync(join(framework, 'defaults'), { recursive: true });
mkdirSync(join(framework, 'tools', 'tmux'), { recursive: true });
writeFileSync(join(framework, 'defaults', 'TOOLS.md'), toolsContent);
const helper = join(framework, 'tools', 'tmux', 'agent-send.sh');
writeFileSync(helper, helperContent);
chmodSync(helper, 0o755);
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
it('restores a partially deleted current-version installation without package updates', () => {
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
writeFileSync(join(home, 'TOOLS.md'), toolsContent);
const result = repairFleetCommsTools(framework, home);
expect(result).toMatchObject({ ok: true, changed: true });
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(toolsContent);
expect(readFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 'utf8')).toBe(helperContent);
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).mode & 0o111).not.toBe(0);
});
it('creates a digest-qualified no-clobber backup and is idempotent', () => {
mkdirSync(home, { recursive: true });
const stale = '# user tools\n';
writeFileSync(join(home, 'TOOLS.md'), stale);
const first = repairFleetCommsTools(framework, home);
expect(first).toMatchObject({ ok: true, changed: true });
expect(first.backupPath).toMatch(/\.pre-fleet-comms-[a-f0-9]{16}\.bak$/);
expect(readFileSync(first.backupPath!, 'utf8')).toBe(stale);
const second = repairFleetCommsTools(framework, home);
expect(second).toEqual({ ok: true, changed: false, backupPath: undefined });
expect(readFileSync(first.backupPath!, 'utf8')).toBe(stale);
});
it('rejects an installed helper symlink without modifying its target', () => {
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
writeFileSync(join(home, 'TOOLS.md'), toolsContent);
const target = join(root, 'external-helper');
writeFileSync(target, 'do not touch\n');
symlinkSync(target, join(home, 'tools', 'tmux', 'agent-send.sh'));
const result = repairFleetCommsTools(framework, home);
expect(result).toMatchObject({ ok: false, changed: false });
expect(result.reason).toContain('symbolic link');
expect(readFileSync(target, 'utf8')).toBe('do not touch\n');
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).isSymbolicLink()).toBe(true);
});
it('refuses a helper directory before replacing stale TOOLS content', () => {
mkdirSync(join(home, 'tools', 'tmux', 'agent-send.sh'), { recursive: true });
const stale = '# user tools\n';
writeFileSync(join(home, 'TOOLS.md'), stale);
const result = repairFleetCommsTools(framework, home);
expect(result).toMatchObject({ ok: false, changed: false });
expect(result.reason).toContain('not a regular file');
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
});
it('refuses a pre-existing digest backup whose bytes do not match', () => {
mkdirSync(home, { recursive: true });
const stale = '# user tools\n';
writeFileSync(join(home, 'TOOLS.md'), stale);
const digest = createHash('sha256').update(stale).digest('hex').slice(0, 16);
writeFileSync(join(home, `TOOLS.md.pre-fleet-comms-${digest}.bak`), 'collision\n');
const result = repairFleetCommsTools(framework, home);
expect(result).toMatchObject({ ok: false, changed: false });
expect(result.reason).toContain('backup collision');
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
});
it('rejects a symlink in each installed destination ancestor without external writes', () => {
const cases = [
{ name: 'home', prefix: join(root, 'linked-home'), suffix: '' },
{ name: 'tools', prefix: join(root, 'real-home'), suffix: 'tools' },
{ name: 'tmux', prefix: join(root, 'real-home'), suffix: join('tools', 'tmux') },
];
for (const testCase of cases) {
const external = join(root, `external-${testCase.name}`);
mkdirSync(external, { recursive: true });
const targetHome =
testCase.name === 'home' ? testCase.prefix : join(root, `installed-${testCase.name}`);
if (testCase.name === 'home') {
symlinkSync(external, targetHome);
} else {
mkdirSync(targetHome, { recursive: true });
const linkPath = join(targetHome, testCase.suffix);
mkdirSync(join(linkPath, '..'), { recursive: true });
symlinkSync(external, linkPath);
}
const result = repairFleetCommsTools(framework, targetHome);
expect(result, testCase.name).toMatchObject({ ok: false, changed: false });
expect(result.reason, testCase.name).toContain('symbolic link');
expect(readdirSync(external), testCase.name).toEqual([]);
}
});
it('rolls back the backup and exact TOOLS bytes/mode when helper commit fails', () => {
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
const staleTools = '# user tools\n';
const staleHelper = '#!/bin/sh\nexit 17\n';
writeFileSync(join(home, 'TOOLS.md'), staleTools, { mode: 0o640 });
writeFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), staleHelper, { mode: 0o710 });
const result = repairFleetCommsTools(framework, home, {
beforeCommit(which) {
if (which === 'helper') throw new Error('injected helper commit failure');
},
});
expect(result).toMatchObject({ ok: false, changed: false });
expect(result.backupPath).toBeUndefined();
expect(result.reason).toContain('injected helper commit failure');
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(staleTools);
expect(statSync(join(home, 'TOOLS.md')).mode & 0o777).toBe(0o640);
expect(readFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 'utf8')).toBe(staleHelper);
expect(statSync(join(home, 'tools', 'tmux', 'agent-send.sh')).mode & 0o777).toBe(0o710);
expect(readdirSync(home).filter((name) => name.includes('pre-fleet-comms'))).toEqual([]);
expect(
readdirSync(home).some((name) => name.includes('.repair-')) ||
readdirSync(join(home, 'tools', 'tmux')).some((name) => name.includes('.repair-')),
).toBe(false);
});
it('rolls back initially absent destinations and created directories on commit failure', () => {
const result = repairFleetCommsTools(framework, home, {
beforeCommit(which) {
if (which === 'helper') throw new Error('injected absent helper failure');
},
});
expect(result).toMatchObject({ ok: false, changed: false });
expect(result.reason).toContain('injected absent helper failure');
expect(existsSync(home)).toBe(false);
});
it('does not persist a backup or replacement when backup commit fails', () => {
mkdirSync(home, { recursive: true });
const stale = '# user tools\n';
writeFileSync(join(home, 'TOOLS.md'), stale, { mode: 0o640 });
const result = repairFleetCommsTools(framework, home, {
beforeCommit(which) {
if (which === 'backup') throw new Error('injected backup commit failure');
},
});
expect(result).toMatchObject({ ok: false, changed: false });
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
expect(statSync(join(home, 'TOOLS.md')).mode & 0o777).toBe(0o640);
expect(readdirSync(home).filter((name) => name.includes('pre-fleet-comms'))).toEqual([]);
});
it('fails before writes when bundled source paths traverse a symlink ancestor', () => {
const external = join(root, 'external-source');
mkdirSync(join(external, 'defaults'), { recursive: true });
mkdirSync(join(external, 'tools', 'tmux'), { recursive: true });
writeFileSync(join(external, 'defaults', 'TOOLS.md'), toolsContent);
writeFileSync(join(external, 'tools', 'tmux', 'agent-send.sh'), helperContent, { mode: 0o755 });
const linkedFramework = join(root, 'linked-framework');
symlinkSync(external, linkedFramework);
const result = repairFleetCommsTools(linkedFramework, home);
expect(result).toMatchObject({ ok: false, changed: false });
expect(result.reason).toContain('symbolic link');
expect(existsSync(home)).toBe(false);
});
});
describe('runFrameworkReseed', () => {

View File

@@ -15,34 +15,16 @@
import { execSync } from 'node:child_process';
import {
closeSync,
constants,
copyFileSync,
existsSync,
fchmodSync,
fsyncSync,
linkSync,
lstatSync,
mkdirSync,
openSync,
readFileSync,
readdirSync,
renameSync,
rmdirSync,
unlinkSync,
writeFileSync,
readdirSync,
copyFileSync,
} from 'node:fs';
import { createHash, randomBytes } from 'node:crypto';
import { homedir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseFleetRosterV1, resolveInstalledFleetRosterPath } from '../fleet/fleet-roster-v1.js';
import {
assertCanonicalContainment,
assertNoSymlinkAncestors,
ensureManagedDirectory,
readRegularFileSecure,
} from '../fleet/secure-file.js';
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -72,10 +54,6 @@ const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const NETWORK_TIMEOUT_MS = 5_000;
function isNodeErrorCode(error: unknown, code: string): boolean {
return error instanceof Error && 'code' in error && error.code === code;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function npmExec(args: string, timeoutMs = NETWORK_TIMEOUT_MS): string {
@@ -522,346 +500,6 @@ export function buildReseedCommand(
};
}
export interface ToolsRepairResult {
ok: boolean;
changed: boolean;
backupPath?: string;
reason?: string;
}
export interface ToolsRepairHooks {
beforeCommit?: (which: 'backup' | 'tools' | 'helper') => void;
}
function optionalSecureFile(
path: string,
root: string,
): ReturnType<typeof readRegularFileSecure> | undefined {
try {
return readRegularFileSecure(path, { root });
} catch (error) {
if (isNodeErrorCode(error, 'ENOENT')) return undefined;
throw error;
}
}
function stageManagedFile(
root: string,
directory: string,
target: string,
content: Buffer,
mode: number,
): string {
assertCanonicalContainment(root, target);
ensureManagedDirectory(root, directory);
assertNoSymlinkAncestors(target);
const staged = join(directory, `.${basename(target)}.repair-${process.pid}-${cryptoRandom()}`);
assertCanonicalContainment(root, staged);
const fd = openSync(
staged,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
0o600,
);
try {
writeFileSync(fd, content);
fchmodSync(fd, mode);
fsyncSync(fd);
} finally {
closeSync(fd);
}
return staged;
}
function cryptoRandom(): string {
return randomBytes(8).toString('hex');
}
interface ManagedOriginal {
path: string;
snapshot?: ReturnType<typeof readRegularFileSecure>;
}
function assertManagedOriginalUnchanged(original: ManagedOriginal, root: string): void {
if (!original.snapshot) {
try {
lstatSync(original.path);
throw new Error(`repair destination appeared during staging: ${original.path}`);
} catch (error) {
if (isNodeErrorCode(error, 'ENOENT')) return;
throw error;
}
}
const current = readRegularFileSecure(original.path, { root });
if (
current.dev !== original.snapshot.dev ||
current.ino !== original.snapshot.ino ||
current.mode !== original.snapshot.mode ||
!current.content.equals(original.snapshot.content)
) {
throw new Error(`repair destination changed during staging: ${original.path}`);
}
}
function installBackupNoClobber(staged: string, target: string, root: string): void {
assertCanonicalContainment(root, target);
assertNoSymlinkAncestors(target);
try {
lstatSync(target);
throw new Error(`digest-qualified backup collision at ${target}`);
} catch (error) {
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
}
linkSync(staged, target);
unlinkSync(staged);
}
function atomicInstall(staged: string, target: string, root: string): void {
assertCanonicalContainment(root, target);
assertNoSymlinkAncestors(target);
try {
const current = lstatSync(target);
if (current.isSymbolicLink() || !current.isFile()) {
throw new Error(`repair destination is not a regular file: ${target}`);
}
} catch (error) {
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
}
renameSync(staged, target);
}
/**
* Explicitly repair the user-owned TOOLS contract and required helper from the
* bundled current framework. Existing divergent TOOLS content is preserved in
* a digest-qualified no-clobber backup; repeated repairs are idempotent.
*/
export function repairFleetCommsTools(
frameworkRoot = resolveBundledFrameworkRoot(),
mosaicHome = join(homedir(), '.config', 'mosaic'),
hooks: ToolsRepairHooks = {},
): ToolsRepairResult {
const sourceTools = join(frameworkRoot, 'defaults', 'TOOLS.md');
const sourceHelper = join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh');
const installedTools = join(mosaicHome, 'TOOLS.md');
const helperDirectory = join(mosaicHome, 'tools', 'tmux');
const installedHelper = join(helperDirectory, 'agent-send.sh');
let stagedBackup: string | undefined;
let stagedTools: string | undefined;
let stagedHelper: string | undefined;
let rollbackTools: string | undefined;
let rollbackHelper: string | undefined;
let committedBackup = false;
let committedTools = false;
let committedHelper = false;
let createdHome = false;
let createdToolsDirectory = false;
let createdHelperDirectory = false;
let backupPath: string | undefined;
let toolsOriginal: ManagedOriginal | undefined;
let helperOriginal: ManagedOriginal | undefined;
try {
const sourceToolsSnapshot = readRegularFileSecure(sourceTools, { root: frameworkRoot });
const sourceHelperSnapshot = readRegularFileSecure(sourceHelper, {
root: frameworkRoot,
executable: true,
});
if (!sourceToolsSnapshot.content.includes('<!-- fleet-comms-contract: 1 -->')) {
return { ok: false, changed: false, reason: 'bundled TOOLS contract has wrong version' };
}
assertCanonicalContainment(mosaicHome, installedTools);
assertCanonicalContainment(mosaicHome, installedHelper);
assertNoSymlinkAncestors(mosaicHome);
const homeExisted = existsSync(mosaicHome);
const toolsDirectory = dirname(helperDirectory);
const toolsDirectoryExisted = existsSync(toolsDirectory);
const helperDirectoryExisted = existsSync(helperDirectory);
if (homeExisted) {
const homeStat = lstatSync(mosaicHome);
if (homeStat.isSymbolicLink()) {
throw new Error(`managed root is a symbolic link: ${mosaicHome}`);
}
if (!homeStat.isDirectory()) {
throw new Error(`managed root is not a real directory: ${mosaicHome}`);
}
}
const installedToolsSnapshot = homeExisted
? optionalSecureFile(installedTools, mosaicHome)
: undefined;
let installedHelperSnapshot: ReturnType<typeof readRegularFileSecure> | undefined;
let installedHelperExecutable = false;
if (homeExisted) {
try {
installedHelperSnapshot = readRegularFileSecure(installedHelper, {
root: mosaicHome,
executable: true,
});
installedHelperExecutable = true;
} catch (error) {
if (!isNodeErrorCode(error, 'ENOENT') && !isNodeErrorCode(error, 'EACCES')) throw error;
if (isNodeErrorCode(error, 'EACCES')) {
installedHelperSnapshot = optionalSecureFile(installedHelper, mosaicHome);
}
}
}
const toolsChanged = !installedToolsSnapshot?.content.equals(sourceToolsSnapshot.content);
const helperChanged =
!installedHelperExecutable ||
!installedHelperSnapshot?.content.equals(sourceHelperSnapshot.content);
if (!toolsChanged && !helperChanged) return { ok: true, changed: false };
toolsOriginal = { path: installedTools, snapshot: installedToolsSnapshot };
helperOriginal = { path: installedHelper, snapshot: installedHelperSnapshot };
ensureManagedDirectory(dirname(mosaicHome), mosaicHome);
createdHome = !homeExisted;
ensureManagedDirectory(mosaicHome, helperDirectory);
createdToolsDirectory = !toolsDirectoryExisted;
createdHelperDirectory = !helperDirectoryExisted;
if (toolsChanged) {
stagedTools = stageManagedFile(
mosaicHome,
mosaicHome,
installedTools,
sourceToolsSnapshot.content,
sourceToolsSnapshot.mode & 0o777,
);
if (installedToolsSnapshot) {
const digest = createHash('sha256')
.update(installedToolsSnapshot.content)
.digest('hex')
.slice(0, 16);
backupPath = `${installedTools}.pre-fleet-comms-${digest}.bak`;
const existingBackup = optionalSecureFile(backupPath, mosaicHome);
if (existingBackup && !existingBackup.content.equals(installedToolsSnapshot.content)) {
throw new Error(`digest-qualified backup collision at ${backupPath}`);
}
if (!existingBackup) {
stagedBackup = stageManagedFile(
mosaicHome,
mosaicHome,
backupPath,
installedToolsSnapshot.content,
installedToolsSnapshot.mode & 0o777,
);
}
rollbackTools = stageManagedFile(
mosaicHome,
mosaicHome,
installedTools,
installedToolsSnapshot.content,
installedToolsSnapshot.mode & 0o777,
);
}
}
if (helperChanged) {
stagedHelper = stageManagedFile(
mosaicHome,
helperDirectory,
installedHelper,
sourceHelperSnapshot.content,
sourceHelperSnapshot.mode & 0o777,
);
if (installedHelperSnapshot) {
rollbackHelper = stageManagedFile(
mosaicHome,
helperDirectory,
installedHelper,
installedHelperSnapshot.content,
installedHelperSnapshot.mode & 0o777,
);
}
}
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
if (stagedBackup && backupPath) {
hooks.beforeCommit?.('backup');
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
installBackupNoClobber(stagedBackup, backupPath, mosaicHome);
stagedBackup = undefined;
committedBackup = true;
}
if (stagedTools) {
hooks.beforeCommit?.('tools');
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
atomicInstall(stagedTools, installedTools, mosaicHome);
stagedTools = undefined;
committedTools = true;
}
if (stagedHelper) {
hooks.beforeCommit?.('helper');
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
atomicInstall(stagedHelper, installedHelper, mosaicHome);
stagedHelper = undefined;
committedHelper = true;
}
if (rollbackTools) unlinkSync(rollbackTools);
if (rollbackHelper) unlinkSync(rollbackHelper);
return { ok: true, changed: true, backupPath };
} catch (error) {
const failures: string[] = [];
try {
if (committedHelper) {
if (rollbackHelper) atomicInstall(rollbackHelper, installedHelper, mosaicHome);
else unlinkSync(installedHelper);
rollbackHelper = undefined;
}
} catch (rollbackError) {
failures.push(`helper rollback failed: ${String(rollbackError)}`);
}
try {
if (committedTools) {
if (rollbackTools) atomicInstall(rollbackTools, installedTools, mosaicHome);
else unlinkSync(installedTools);
rollbackTools = undefined;
}
} catch (rollbackError) {
failures.push(`TOOLS rollback failed: ${String(rollbackError)}`);
}
try {
if (committedBackup && backupPath) {
unlinkSync(backupPath);
committedBackup = false;
}
} catch (rollbackError) {
failures.push(`backup rollback failed: ${String(rollbackError)}`);
}
for (const staged of [stagedBackup, stagedTools, stagedHelper, rollbackTools, rollbackHelper]) {
if (!staged) continue;
try {
unlinkSync(staged);
} catch {
failures.push(`staging cleanup failed: ${staged}`);
}
}
for (const [created, directory] of [
[createdHelperDirectory, helperDirectory],
[createdToolsDirectory, dirname(helperDirectory)],
[createdHome, mosaicHome],
] as const) {
if (!created) continue;
try {
rmdirSync(directory);
} catch (cleanupError) {
if (!isNodeErrorCode(cleanupError, 'ENOENT')) {
failures.push(`directory cleanup failed: ${directory}`);
}
}
}
const reason = error instanceof Error ? error.message : String(error);
return {
ok: false,
changed: failures.length > 0,
backupPath: committedBackup ? backupPath : undefined,
reason: failures.length > 0 ? `${reason}; ${failures.join('; ')}` : reason,
};
}
}
/**
* Re-seed the framework from the freshly-installed package. Returns a result
* describing what happened (so callers can message + decide on relaunch).
@@ -953,20 +591,25 @@ export function checkFrameworkDrift(
}
/**
* Canonically parse the installed fleet roster for relaunch targets. JSON is
* considered only when roster.yaml is genuinely absent; all other failures
* return no targets rather than guessing.
* Best-effort parse of the fleet roster for agent names (used to relaunch
* durable agents after a re-seed). Returns [] when no roster exists.
*/
export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] {
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
if (!existsSync(rosterPath)) return [];
let text: string;
try {
const rosterPath = resolveInstalledFleetRosterPath(mosaicHome);
const source = readFileSync(rosterPath, 'utf8');
return parseFleetRosterV1(source, rosterPath.endsWith('.json') ? 'json' : 'yaml').agents.map(
(agent) => agent.name,
);
text = readFileSync(rosterPath, 'utf-8');
} catch {
return [];
}
// Roster agents are listed as `- name: <id>` entries under `agents:`.
const names: string[] = [];
for (const line of text.split('\n')) {
const m = line.match(/^\s*-?\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
if (m && m[1]) names.push(m[1]);
}
return names;
}
/**

View File

@@ -1,8 +0,0 @@
{
"extends": ["//"],
"tasks": {
"test": {
"dependsOn": ["^build", "build"]
}
}
}