feat(fleet): add reviewed v1-to-v2 migration preview (#788)
This commit was merged in pull request #788.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
86
docs/fleet/migration/v1-to-v2.md
Normal file
86
docs/fleet/migration/v1-to-v2.md
Normal 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.
|
||||||
40
docs/fleet/operations/backup-restore.md
Normal file
40
docs/fleet/operations/backup-restore.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ 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` |
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
80
docs/scratchpads/758-fcm-m4-001-v1-v2-migrator.md
Normal file
80
docs/scratchpads/758-fcm-m4-001-v1-v2-migrator.md
Normal 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.
|
||||||
@@ -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);
|
||||||
|
|||||||
280
packages/mosaic/src/commands/fleet-migration-command.spec.ts
Normal file
280
packages/mosaic/src/commands/fleet-migration-command.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
170
packages/mosaic/src/commands/fleet-migration-command.ts
Normal file
170
packages/mosaic/src/commands/fleet-migration-command.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
18
packages/mosaic/src/fleet/deterministic-order.ts
Normal file
18
packages/mosaic/src/fleet/deterministic-order.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -303,25 +303,22 @@ describe('FCM-M3-002 reconciler lifecycle acceptance', (): void => {
|
|||||||
expect(process.exitCode).toBe(0);
|
expect(process.exitCode).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it('rejects a missing canonical roster-v2 tmux socket through parseRosterV2', (): void => {
|
||||||
{
|
const source = renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*\n/m, '');
|
||||||
label: 'missing',
|
|
||||||
source: renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*\n/m, ''),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'empty',
|
|
||||||
source: renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*$/m, ' socket_name: ""'),
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
'rejects a $label canonical roster-v2 tmux socket through parseRosterV2',
|
|
||||||
({ source }): void => {
|
|
||||||
expect(() => parseRosterV2(source, 'yaml')).toThrow(
|
expect(() => parseRosterV2(source, 'yaml')).toThrow(
|
||||||
'Roster v2 tmux socket_name is required and must be a non-empty string.',
|
'Roster v2 tmux socket_name is required and must be a string.',
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses the literal default tmux server only through the legacy-v1 loader and runtime transport boundary', async (): Promise<void> => {
|
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 rosterPath = await fixtureLegacyRoster();
|
||||||
const runner = vi.fn<CommandRunner>(
|
const runner = vi.fn<CommandRunner>(
|
||||||
async (): Promise<CommandResult> => ({
|
async (): Promise<CommandResult> => ({
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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> => ({
|
||||||
|
|||||||
1874
packages/mosaic/src/fleet/v1-v2-migration.spec.ts
Normal file
1874
packages/mosaic/src/fleet/v1-v2-migration.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
1506
packages/mosaic/src/fleet/v1-v2-migration.ts
Normal file
1506
packages/mosaic/src/fleet/v1-v2-migration.ts
Normal file
File diff suppressed because it is too large
Load Diff
8
packages/mosaic/turbo.json
Normal file
8
packages/mosaic/turbo.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": ["//"],
|
||||||
|
"tasks": {
|
||||||
|
"test": {
|
||||||
|
"dependsOn": ["^build", "build"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user