Compare commits

..

10 Commits

Author SHA1 Message Date
Jarvis
d63bb0206a fix(fleet): preserve exact v1 source strings
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
2026-07-16 07:55:10 -05:00
Jarvis
355571182f fix(mosaic): preserve v1 agent name semantics
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
2026-07-16 07:01:51 -05:00
Jarvis
fce489269a fix(fleet): align migration SSH parity docs
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 06:13:03 -05:00
Jarvis
66ac59af49 fix(fleet): validate remote migration targets
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 05:36:53 -05:00
Jarvis
01d8b16474 fix(fleet): align migration preview and clean CI
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 04:10:38 -05:00
Jarvis
b7eb724c2b Merge origin/main into feat/758-v1-v2-migrator
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 01:15:25 -05:00
Jarvis
04891422b3 feat(fleet): harden v1 migration preview
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:57:33 -05:00
Jarvis
0e814324ca feat(fleet): add reviewed v1-to-v2 migration preview
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:04:51 -05:00
adad486b6f fix(fleet): enforce exact comms authority (#787)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-16 00:32:23 +00:00
c1aecfabe9 test(fleet): cover reconciler lifecycle gates (#786)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-15 16:46:13 +00:00
25 changed files with 5033 additions and 49 deletions

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 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. > the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes | | 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-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-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-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 | 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-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 | 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-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 | 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-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 | 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-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 | 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-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-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-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-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 | | 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 ## Thin-core prompt diet (#528) — feat/contract-thin-core

View File

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

View File

@@ -0,0 +1,86 @@
# 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

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

View File

@@ -11,8 +11,15 @@ mosaic fleet restart [name] --expected-generation <n> [--dry-run]
mosaic fleet status [name] mosaic fleet status [name]
mosaic fleet verify mosaic fleet verify
mosaic fleet doctor mosaic fleet doctor
mosaic fleet migrate-v1 preview --source <path> --decisions <path> --observations <path>
``` ```
`migrate-v1 preview` is non-mutating: it emits value-free v1 inventory, a canonical semantically
validated v2 candidate when ready, sanitized environment dispositions, and non-executable recovery
evidence. It has no write, apply, canary, or rollback option. Missing preview inputs also return one stable
blocked JSON object and a non-zero exit, rather than Commander text. See
[the migration preview contract](../migration/v1-to-v2.md).
`apply` and `reconcile` use roster desired state. `start`, `stop`, and `restart` are exact local one-shot lifecycle effects and never persist a desired-state edit. `status`, `verify`, and `doctor` are observational. `apply` and `reconcile` use roster desired state. `start`, `stop`, and `restart` are exact local one-shot lifecycle effects and never persist a desired-state edit. `status`, `verify`, and `doctor` are observational.
Commands emit one JSON object. Handled precondition errors emit `{ "error": { "code": "..." } }` and exit non-zero. Partial derived/lifecycle effects use explicit `authoritativeRoster`, `projections`, `lifecycle`, and bounded `recovery` fields; they never claim rollback. Any additive `cleanup` diagnostic also exits non-zero, even where known effects are complete: it is not a clean completion and the lock requires inspection before retry. Commands emit one JSON object. Handled precondition errors emit `{ "error": { "code": "..." } }` and exit non-zero. Partial derived/lifecycle effects use explicit `authoritativeRoster`, `projections`, `lifecycle`, and bounded `recovery` fields; they never claim rollback. Any additive `cleanup` diagnostic also exits non-zero, even where known effects are complete: it is not a clean completion and the lock requires inspection before retry.

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
# 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

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

View File

@@ -0,0 +1,280 @@
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

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

View File

@@ -90,6 +90,7 @@ describe('registerFleetCommand', () => {
'init', 'init',
'install', 'install',
'install-systemd', 'install-systemd',
'migrate-v1',
'persona', 'persona',
'plan', 'plan',
'profile', 'profile',
@@ -197,6 +198,26 @@ describe('fleet roster parsing', () => {
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi'); expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
}); });
it('uses /clear for an explicitly declared empty pi runtime config', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'runtimes:',
' pi: {}',
'agents:',
' - name: canary-pi',
' runtime: pi',
].join('\n'),
);
const loaded = await loadFleetRoster(rosterPath);
expect(loaded.runtimes.pi?.resetCommand).toBe('/clear');
});
it('accepts optional agent alias and provider metadata without requiring them', async () => { it('accepts optional agent alias and provider metadata without requiring them', async () => {
cleanup = await tempDir(); cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml'); const rosterPath = join(cleanup, 'roster.yaml');
@@ -270,6 +291,32 @@ describe('fleet roster parsing', () => {
expect(env).toContain('MOSAIC_TMUX_SOCKET=\n'); expect(env).toContain('MOSAIC_TMUX_SOCKET=\n');
}); });
it('preserves home-relative traversal until the shared environment boundary rejects it', async () => {
for (const workingDirectory of ['~/../escape', '~/src/../../escape']) {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
defaults: { working_directory: workingDirectory },
agents: [{ name: 'coder0', runtime: 'pi' }],
}),
);
const roster = await loadFleetRoster(rosterPath);
expect(() => generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toThrow(
expect.objectContaining({
diagnostic: expect.objectContaining({
code: 'unsafe-path',
key: 'MOSAIC_AGENT_WORKDIR',
}),
}),
);
}
});
it('generates deterministic per-agent EnvironmentFile content', async () => { it('generates deterministic per-agent EnvironmentFile content', async () => {
cleanup = await tempDir(); cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json'); const rosterPath = join(cleanup, 'roster.json');

View File

@@ -35,6 +35,10 @@ import {
registerFleetAgentCrudCommands, registerFleetAgentCrudCommands,
type FleetAgentCrudCommandDeps, type FleetAgentCrudCommandDeps,
} from './fleet-agent-crud-command.js'; } from './fleet-agent-crud-command.js';
import {
registerFleetMigrationCommand,
type FleetMigrationCommandDeps,
} from './fleet-migration-command.js';
import { import {
executeReconcilerCommandJson, executeReconcilerCommandJson,
registerFleetReconcilerCommands, registerFleetReconcilerCommands,
@@ -97,6 +101,7 @@ export interface FleetCommandDeps {
isStdinTTY?: boolean; isStdinTTY?: boolean;
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier']; projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps']; reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
migrationDeps?: Omit<FleetMigrationCommandDeps, 'mosaicHome'>;
} }
export interface FleetPaths { export interface FleetPaths {
@@ -480,7 +485,7 @@ function generateAgentEnvValues(
MOSAIC_AGENT_MODEL: agent.modelHint ?? '', MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '', MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '', MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory), MOSAIC_AGENT_WORKDIR: workingDirectory,
MOSAIC_TMUX_SOCKET: agent.socket ?? roster.tmux.socketName, MOSAIC_TMUX_SOCKET: agent.socket ?? roster.tmux.socketName,
}; };
} }
@@ -2048,6 +2053,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// Roster-v2 desired-state mutations belong directly to the fleet control // Roster-v2 desired-state mutations belong directly to the fleet control
// plane; they do not share the root `mosaic agent` gateway-backed surface. // plane; they do not share the root `mosaic agent` gateway-backed surface.
registerFleetAgentCrudCommands(cmd, deps); registerFleetAgentCrudCommands(cmd, deps);
registerFleetMigrationCommand(cmd, {
...deps.migrationDeps,
mosaicHome: deps.mosaicHome,
});
registerFleetReconcilerCommands(cmd, { registerFleetReconcilerCommands(cmd, {
runner, runner,
mosaicHome: deps.mosaicHome, mosaicHome: deps.mosaicHome,
@@ -2411,10 +2420,6 @@ function resolveMosaicHomeFromCommand(command: Command, override?: string): stri
return opts.mosaicHome ?? override ?? defaultMosaicHome(); return opts.mosaicHome ?? override ?? defaultMosaicHome();
} }
function expandHome(path: string): string {
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
}
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> { async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
const failures: string[] = []; const failures: string[] = [];
for (const agentName of agentNames) { for (const agentName of agentNames) {

View File

@@ -0,0 +1,18 @@
/** 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

@@ -0,0 +1,484 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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