Compare commits
8 Commits
docs/771-k
...
docs/758-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0db300dce5 | ||
|
|
17aa94ca1a | ||
|
|
0aee2c0981 | ||
| 9745bc3f29 | |||
| adad486b6f | |||
| c1aecfabe9 | |||
| 499090508e | |||
| c593a15ef8 |
70
docs/PRD.md
70
docs/PRD.md
@@ -125,6 +125,76 @@ are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Exact Cross-Harness Fleet Communications Contract (#766)
|
||||||
|
|
||||||
|
### Problem and objective
|
||||||
|
|
||||||
|
Fleet runtime contracts currently combine exact peer rows with generic operational metavariables and
|
||||||
|
independently parsed roster data. Non-Claude harnesses can mistake those metavariables for values to
|
||||||
|
infer, producing incorrect host, session, socket, or helper targets. The objective is one
|
||||||
|
roster-resolved communications contract that every supported harness receives unchanged.
|
||||||
|
|
||||||
|
### Normative requirements
|
||||||
|
|
||||||
|
1. `FCOM-REQ-01`: Fleet commands and runtime composition SHALL use one shared v1 roster structural
|
||||||
|
resolver. A second lenient communications parser is forbidden.
|
||||||
|
2. `FCOM-REQ-02`: The composed contract SHALL render the local roster member's authoritative host,
|
||||||
|
exact agent/session name, resolved tmux socket, exact helper path, and deterministic communications
|
||||||
|
generation.
|
||||||
|
3. `FCOM-REQ-03`: Every known peer SHALL have one exact executable command. Same-host commands SHALL
|
||||||
|
omit `-H`; cross-host commands SHALL use only that peer's explicit roster `ssh` target; the one
|
||||||
|
supported fleet-wide named socket SHALL use `-L` with its exact value. A per-agent socket declaration
|
||||||
|
must equal that fleet-wide value; unsupported independent sockets and missing cross-host SSH data SHALL
|
||||||
|
fail closed.
|
||||||
|
4. `FCOM-REQ-04`: Operational fleet examples SHALL not contain unresolved host, session, socket, or
|
||||||
|
helper-path metavariables. Agents SHALL select an exact rendered peer row and SHALL NOT infer,
|
||||||
|
substitute, or fuzzy-match targeting values.
|
||||||
|
5. `FCOM-REQ-05`: An unknown local member or requested peer SHALL fail closed with exact-name discovery
|
||||||
|
guidance. Runtime composition SHALL not silently omit a requested fleet member's communications
|
||||||
|
contract.
|
||||||
|
6. `FCOM-REQ-06`: Claude Code, Codex, OpenCode, and Pi SHALL receive equivalent authoritative
|
||||||
|
communications data through the common runtime composer.
|
||||||
|
7. `FCOM-REQ-07`: Tests SHALL prove the contract from framework-source `TOOLS.md`, through a fresh
|
||||||
|
installed `TOOLS.md`, to final runtime composition and helper executability. User-owned installed
|
||||||
|
`TOOLS.md` content SHALL remain preserved.
|
||||||
|
8. `FCOM-REQ-08`: Stale installed or active composed context SHALL be reported with deterministic
|
||||||
|
generation/repair/relaunch guidance. Currency requires the expected source and installed contract
|
||||||
|
marker/version plus bounded byte equality. The supported current-version repair SHALL run independently
|
||||||
|
of package updates, preserve divergent `TOOLS.md` bytes in a digest-qualified no-clobber backup, restore
|
||||||
|
a regular executable helper without following symlinks, and be idempotent. Detection and reporting SHALL
|
||||||
|
NOT rewrite active context, restart a session, or mutate a live fleet.
|
||||||
|
9. `FCOM-REQ-09`: The shared resolver SHALL preserve and strictly validate every schema-supported v1
|
||||||
|
connector kind (`tmux`, `discord`, and `matrix`) from YAML and JSON. Every accepted snake/camel alias
|
||||||
|
pair SHALL reject differing dual declarations and accept identical declarations. JSON roster fallback
|
||||||
|
SHALL occur only when `roster.yaml` is absent; all other YAML access failures SHALL fail closed.
|
||||||
|
10. `FCOM-REQ-10`: The communications generation SHALL cover the complete canonical rendered semantic
|
||||||
|
contract, including identity, role/class, resolved host/socket/helper, peer metadata, and exact commands.
|
||||||
|
Installed helpers SHALL be validated with no-follow filesystem inspection as regular executable files.
|
||||||
|
Keep-mode reseed and relaunch discovery SHALL preserve and support both YAML and JSON rosters.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
1. `AC-FCOM-01`: Contract fixtures contain no unresolved operational targeting metavariables; local
|
||||||
|
identity contains exact host/session/socket/helper values.
|
||||||
|
2. `AC-FCOM-02`: Same-host, cross-host, named-socket, literal-default-socket, and missing-SSH tests prove
|
||||||
|
exact targeting and fail-closed behavior.
|
||||||
|
3. `AC-FCOM-03`: Unknown identities and peers report known exact names plus an exact self-scoped
|
||||||
|
discovery command; no fuzzy session selection is emitted.
|
||||||
|
4. `AC-FCOM-04`: Four-harness tests prove byte-equal authoritative communications sections.
|
||||||
|
5. `AC-FCOM-05`: Source, fresh-install, preserved-custom-install, stale-installed, composed-generation,
|
||||||
|
helper executable, agent-send socket isolation, and exact-target tests pass.
|
||||||
|
6. `AC-FCOM-06`: Documentation defines non-mutating stale-context detection and operator-authorized,
|
||||||
|
exact-agent relaunch; no implementation path performs automatic session mutation.
|
||||||
|
7. `AC-FCOM-07`: YAML and JSON fixtures cover every connector kind; all snake/camel aliases cover
|
||||||
|
identical acceptance and conflicting rejection; non-`ENOENT` YAML failures do not fall back.
|
||||||
|
8. `AC-FCOM-08`: Missing, directory, symlink, and non-executable installed helpers fail closed. Explicit
|
||||||
|
current-version repair proves partial-deletion recovery, digest-qualified backup collision safety,
|
||||||
|
symlink-target safety, and repeated-run idempotence.
|
||||||
|
9. `AC-FCOM-09`: Markerless-equal and wrong-version source/installed contracts are stale, and a rendered
|
||||||
|
role/class change produces a different communications generation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## KBN-101 Database Runtime/Migration Role Split (#771)
|
## KBN-101 Database Runtime/Migration Role Split (#771)
|
||||||
|
|
||||||
### Problem and objective
|
### Problem and objective
|
||||||
|
|||||||
@@ -2,10 +2,24 @@
|
|||||||
|
|
||||||
## Fleet configuration management
|
## Fleet configuration management
|
||||||
|
|
||||||
- [Generated environment boundary](fleet/reference/generated-env-boundary.md) — roster-derived launch projection, strict local data, legacy quarantine, and downstream interface evidence.
|
- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
|
||||||
- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — local-tmux schema v2 parsing and structural validation.
|
- [Desired, derived, and observed state](fleet/concepts/desired-vs-observed-state.md) — roster authority, generation, ownership, and drift.
|
||||||
- [Role classes and authority](fleet/reference/role-classes.md) — canonical role resolver and protected authority boundaries.
|
- [Identity, class, and runtime](fleet/concepts/identity-class-runtime.md) — stable name, display alias, class, runtime, provider, and model separation.
|
||||||
- [Executable asset dispositions](fleet/migration/example-profile-disposition.md) — shipped v1 fixture/profile/service validation posture.
|
- [Role authority and leases](fleet/concepts/role-authority-and-leases.md) — validator/merge-gate separation and bounded lease authority.
|
||||||
|
- [Generated launch chain](fleet/concepts/generated-env-launch-chain.md) — strict data parsing, precedence, and quarantine.
|
||||||
|
- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — schema, supported values, required fields, defaults, and constraints.
|
||||||
|
- [Fleet CLI reference](fleet/reference/cli.md) — local desired-state commands, JSON/exit behavior, and gateway-catalog separation.
|
||||||
|
- [Lifecycle transitions](fleet/reference/lifecycle-transitions.md) — create/apply/reboot/migration/rollback boundaries.
|
||||||
|
- [Status and drift](fleet/reference/status-and-drift.md) — desired/managed/observed state and current/future classifications.
|
||||||
|
- [Safe agent CRUD](fleet/how-to/create-update-delete-agent.md) — expected generation, dry-run, and partial-failure recovery.
|
||||||
|
- [Local lifecycle operations](fleet/how-to/start-stop-restart.md) — persisted versus one-shot actions.
|
||||||
|
- [Configurable interaction instance](fleet/how-to/configure-tess-interaction.md) and [validator instance](fleet/how-to/configure-ultron-validator.md) — generic identities and protected limits.
|
||||||
|
- [Reconcile and recover](fleet/operations/reconcile-and-recover.md) — plan/apply lock and recovery behavior.
|
||||||
|
- [Environment quarantine](fleet/operations/env-quarantine.md) — private evidence and value-free diagnostics.
|
||||||
|
- [Systemd/tmux troubleshooting](fleet/operations/systemd-tmux-troubleshooting.md) — socket, holder, unmanaged-session, and lock decisions.
|
||||||
|
- [Backup/restore boundary](fleet/operations/backup-restore.md) and [upgrade-assets hold](fleet/operations/upgrade-assets.md).
|
||||||
|
- [v1-to-v2 migration preview](fleet/migration/v1-to-v2.md) and [executable artifact dispositions](fleet/migration/example-profile-disposition.md).
|
||||||
|
- [FCM M5 closure evidence](reports/documentation/758-fleet-config-ia-closure.md) and [approved deferrals](reports/deferred/758-fleet-config-deferrals.md).
|
||||||
|
|
||||||
## Official channel plugins
|
## Official channel plugins
|
||||||
|
|
||||||
|
|||||||
@@ -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 M0–M5 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 M0–M5 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 | done | 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 | PR #788; final head `d63bb0206a1d312ab8352ec1d3ca3631146b0baa`; tree `4da210da9a71b035130d4160a4a2e691bdfde2da`; squash `9745bc3f29c26b021a478b7ad03cfb494f6c9de3`; descendant-main pipeline 1855 terminal success |
|
||||||
| 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 | HOLD: never starts a previously stopped agent or kills an unproven unmanaged session; not authorized by FCM-M5-001 |
|
||||||
| 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 | in-progress | 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 | Sole owner: this FCM-M5-001 delivery on the recorded branch; 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 | HOLD: final #758 gate; quality, independent code/security review, validator certificate, merge-gate approval, and green CI remain out of M5-001 |
|
||||||
|
|
||||||
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||||
|
|
||||||
|
|||||||
@@ -5,27 +5,27 @@
|
|||||||
This checklist is an acceptance contract for documentation and examples. It does not authorize
|
This checklist is an acceptance contract for documentation and examples. It does not authorize
|
||||||
schema, runtime, systemd, role, profile, or live-fleet changes. An item is complete only when its
|
schema, runtime, systemd, role, profile, or live-fleet changes. An item is complete only when its
|
||||||
named artifact exists, is linked from the fleet documentation entry point, and its evidence is
|
named artifact exists, is linked from the fleet documentation entry point, and its evidence is
|
||||||
recorded in the M0 task/PR.
|
recorded in the M5 closure report and linked deferral evidence.
|
||||||
|
|
||||||
## M0 baseline acceptance
|
## M0 baseline acceptance
|
||||||
|
|
||||||
- [ ] `docs/PRD.md` states the roster as desired-state SSOT; generated environment, systemd,
|
- [x] `docs/PRD.md` states the roster as desired-state SSOT; generated environment, systemd,
|
||||||
tmux, and heartbeat artifacts as non-authoritative projections; and fail-closed handling of
|
tmux, and heartbeat artifacts as non-authoritative projections; and fail-closed handling of
|
||||||
unsupported or quarantined legacy input.
|
unsupported or quarantined legacy input.
|
||||||
- [ ] `docs/PRD.md` defines the required classes and authority boundary: `validator` certifies but
|
- [x] `docs/PRD.md` defines the required classes and authority boundary: `validator` certifies but
|
||||||
does not merge; `merge-gate` remains sole approve-to-land/merge authority; `team-leader`
|
does not merge; `merge-gate` remains sole approve-to-land/merge authority; `team-leader`
|
||||||
capacity is lease-bounded; `interaction` is request/status only; instance names such as Tess
|
capacity is lease-bounded; `interaction` is request/status only; instance names such as Tess
|
||||||
and Ultron remain configurable.
|
and Ultron remain configurable.
|
||||||
- [ ] `docs/PRD.md` defines local lifecycle semantics for `enabled`, persisted desired state, and
|
- [x] `docs/PRD.md` defines local lifecycle semantics for `enabled`, persisted desired state, and
|
||||||
observed state, including stopped-state preservation through migration, apply, and reboot.
|
observed state, including stopped-state preservation through migration, apply, and reboot.
|
||||||
- [ ] `docs/PRD.md` defines the generated-env/local-override boundary, explicitly denies arbitrary
|
- [x] `docs/PRD.md` defines the generated-env/local-override boundary, explicitly denies arbitrary
|
||||||
command overrides in M1–M5, and requires key-name/hash-only quarantine diagnostics.
|
command overrides in M1–M5, and requires key-name/hash-only quarantine diagnostics.
|
||||||
- [ ] `docs/PRD.md` identifies the M1–M5 local-tmux scope and excludes remote reconciliation,
|
- [x] `docs/PRD.md` identifies the M1–M5 local-tmux scope and excludes remote reconciliation,
|
||||||
connector mutation, secret references, arbitrary commands/channels, gateway convergence, and
|
connector mutation, secret references, arbitrary commands/channels, gateway convergence, and
|
||||||
UI configuration storage.
|
UI configuration storage.
|
||||||
- [ ] `docs/TASKS.md` contains the complete M0–M5 one-card/one-PR dependency DAG for #758 with
|
- [x] `docs/TASKS.md` contains the complete M0–M5 one-card/one-PR dependency DAG for #758 with
|
||||||
agent tier, branch, dependency, estimate, and evidence expectations.
|
agent tier, branch, dependency, estimate, and evidence expectations.
|
||||||
- [ ] `docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` classifies every current shipped
|
- [x] `docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` classifies every current shipped
|
||||||
fleet example, profile, and service preset before M1 implementation starts.
|
fleet example, profile, and service preset before M1 implementation starts.
|
||||||
|
|
||||||
## Required documentation IA for M1–M5
|
## Required documentation IA for M1–M5
|
||||||
@@ -72,15 +72,28 @@ recorded in the M0 task/PR.
|
|||||||
|
|
||||||
## Cross-cutting evidence gates
|
## Cross-cutting evidence gates
|
||||||
|
|
||||||
- [ ] Every retained or migrated YAML/JSON example, profile, and service preset validates through the
|
- [x] Every retained or migrated YAML/JSON example, profile, and service preset validates through the
|
||||||
same executable schema and shared baseline-plus-`roles.local` resolver used by the CLI.
|
same declared executable production parser/resolver path recorded by the disposition inventory;
|
||||||
- [ ] Every retired example/profile/service preset has a replacement link and deprecation note; no
|
versioned v1 fixtures are not forced through the v2 compiler.
|
||||||
|
- [x] Every retired example/profile/service preset has a replacement link and deprecation note; no
|
||||||
unresolved legacy class or tool-policy alias remains silently shipped.
|
unresolved legacy class or tool-policy alias remains silently shipped.
|
||||||
- [ ] Documentation examples contain no secret values, arbitrary command override, or product-hardcoded
|
- [x] Documentation examples contain no secret values, arbitrary command override, or product-hardcoded
|
||||||
Tess/Ultron identity.
|
Tess/Ultron identity.
|
||||||
- [ ] CLI snippets distinguish local fleet desired-state commands from the separate gateway-backed
|
- [x] CLI snippets distinguish local fleet desired-state commands from the separate gateway-backed
|
||||||
`mosaic agent` catalog.
|
`mosaic agent` catalog.
|
||||||
- [ ] Migration, quarantine, lifecycle, status, and troubleshooting documentation state that values of
|
- [x] Migration, quarantine, lifecycle, status, and troubleshooting documentation state that values of
|
||||||
legacy sensitive keys are never printed.
|
legacy sensitive keys are never printed.
|
||||||
- [ ] M5 release review verifies links, schema/example validation, and that all checklist rows have
|
- [x] M5 documentation validation verifies required IA paths, local file and heading-fragment links,
|
||||||
owner/evidence or an explicit approved deferral.
|
the canonical roster through the production compiler/resolver, and fenced/canonical-example
|
||||||
|
safety checks.
|
||||||
|
- [ ] FCM-M5-001 does not deterministically assert owner/evidence/deferral metadata for every checklist
|
||||||
|
row. Closure and deferral reports provide human-reviewable evidence only; broader assertion
|
||||||
|
coverage remains unclaimed.
|
||||||
|
|
||||||
|
## Held downstream gates
|
||||||
|
|
||||||
|
These unchecked items are intentionally outside FCM-M5-001 and are not authorized by this checklist:
|
||||||
|
|
||||||
|
- [ ] FCM-M4-002 executes and evidences live cutover, canary, stopped-state preservation, and rollback.
|
||||||
|
- [ ] FCM-M5-002 completes independent exact-head review and issues the validator certificate.
|
||||||
|
- [ ] The exact PR head reaches terminal-green CI after independent review.
|
||||||
|
|||||||
@@ -66,8 +66,12 @@ checks the exact `=<agent-name>` tmux target; it never uses an ambient socket or
|
|||||||
The same strict parser runs before exact-stop behavior. A fresh native Pi heartbeat remains authoritative;
|
The same strict parser runs before exact-stop behavior. A fresh native Pi heartbeat remains authoritative;
|
||||||
the shell sidecar only provides fallback state when the native marker is stale or absent.
|
the shell sidecar only provides fallback state when the native marker is stale or absent.
|
||||||
|
|
||||||
`mosaic fleet comms-block <role>` can inspect the role's resolved Fleet-Comms block. It is a read-only
|
`mosaic agent comms-block <exact-member>` can inspect that exact roster member's resolved Fleet-Comms
|
||||||
inspection tool and fails loudly for an unknown role or missing roster.
|
block. It is a read-only inspection tool and fails loudly for an unknown exact member or missing roster.
|
||||||
|
On Linux, the installed roster, TOOLS contract, and executable helper are opened through a held
|
||||||
|
descriptor chain rooted at `/`; every managed path component uses no-follow traversal, and content plus
|
||||||
|
execute validation stay bound to the same opened file. Systems without Linux `/proc/self/fd` support
|
||||||
|
fail closed rather than falling back to pathname revalidation.
|
||||||
|
|
||||||
## Current M2 boundary
|
## Current M2 boundary
|
||||||
|
|
||||||
|
|||||||
63
docs/fleet/README.md
Normal file
63
docs/fleet/README.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Fleet Configuration Management
|
||||||
|
|
||||||
|
This book documents the local roster-v2 desired-state control plane delivered under issue #758. The normative requirements are the [FCM section of the repository PRD](../PRD.md#fleet-declarative-configuration-management-workstream-fcm-758), not the older fleet-suite or observability planning pages.
|
||||||
|
|
||||||
|
## Authority boundary
|
||||||
|
|
||||||
|
`<MOSAIC_HOME>/fleet/roster.yaml` is the sole writable desired-state authority for local fleet membership, launch policy, and persisted lifecycle. Generated environment files, systemd enablement, tmux sessions, heartbeat files, and status output are derived or observed. Rebuild projections from the roster; never edit them as desired state.
|
||||||
|
|
||||||
|
This control plane is local tmux/systemd only. Remote/SSH entries and connectors are inventory, not reconciliation targets. Arbitrary commands, channels, secret references, gateway catalog convergence, and UI configuration storage are outside this workstream. `mos-comms` is temporary transport glue, not permanent fleet architecture.
|
||||||
|
|
||||||
|
## Choose the right workflow
|
||||||
|
|
||||||
|
1. **Need to inspect intent?** Read the roster and use `mosaic fleet get`; see [desired versus observed state](concepts/desired-vs-observed-state.md).
|
||||||
|
2. **Need to inspect reality?** Use `status` or `doctor`; use `verify` for a strict non-zero drift/ownership gate. These commands do not repair anything.
|
||||||
|
3. **Need to change membership or persisted policy?** Use generation-guarded `plan`, `create`, `update`, or `delete`; see [safe CRUD](how-to/create-update-delete-agent.md).
|
||||||
|
4. **Need a one-time runtime action?** Use `start`, `stop`, or `restart`. These do not change persisted desired state.
|
||||||
|
5. **Need convergence?** Review `apply --dry-run`, resolve blockers, then use `apply` with the same current generation; see [reconcile and recover](operations/reconcile-and-recover.md).
|
||||||
|
6. **Need v1 migration evidence?** Use preview only. Cutover, canary, and rollback remain held for FCM-M4-002.
|
||||||
|
7. **Need the gateway-backed agent catalog?** That is the separate `mosaic agent` surface, not local fleet desired state.
|
||||||
|
|
||||||
|
## Concepts
|
||||||
|
|
||||||
|
- [Desired versus observed state](concepts/desired-vs-observed-state.md)
|
||||||
|
- [Identity, class, runtime, provider, and model](concepts/identity-class-runtime.md)
|
||||||
|
- [Role authority and leases](concepts/role-authority-and-leases.md)
|
||||||
|
- [Generated environment launch chain](concepts/generated-env-launch-chain.md)
|
||||||
|
|
||||||
|
## Operator how-to
|
||||||
|
|
||||||
|
- [Create, inspect, update, and delete](how-to/create-update-delete-agent.md)
|
||||||
|
- [Start, stop, restart, and reconcile](how-to/start-stop-restart.md)
|
||||||
|
- [Configure an interaction instance](how-to/configure-tess-interaction.md)
|
||||||
|
- [Configure a validator instance](how-to/configure-ultron-validator.md)
|
||||||
|
- [Customize roles](how-to/customize-roles.md)
|
||||||
|
|
||||||
|
## Operations and recovery
|
||||||
|
|
||||||
|
- [Reconcile and recover](operations/reconcile-and-recover.md)
|
||||||
|
- [Environment quarantine](operations/env-quarantine.md)
|
||||||
|
- [Systemd/tmux troubleshooting](operations/systemd-tmux-troubleshooting.md)
|
||||||
|
- [Backup and restore boundary](operations/backup-restore.md)
|
||||||
|
- [Upgrade and asset-drift hold](operations/upgrade-assets.md)
|
||||||
|
|
||||||
|
## Reference and migration
|
||||||
|
|
||||||
|
- [Roster v2 fields](reference/roster-v2-fields.md) · [executable JSON Schema](reference/roster-v2.schema.json) · [validated example](examples/roster-v2.yaml)
|
||||||
|
- [CLI and exit codes](reference/cli.md)
|
||||||
|
- [Role classes](reference/role-classes.md)
|
||||||
|
- [Lifecycle transitions](reference/lifecycle-transitions.md)
|
||||||
|
- [Status and drift](reference/status-and-drift.md)
|
||||||
|
- [Generated environment boundary](reference/generated-env-boundary.md)
|
||||||
|
- [v1-to-v2 preview](migration/v1-to-v2.md)
|
||||||
|
- [Example/profile dispositions](migration/example-profile-disposition.md)
|
||||||
|
- [Legacy class aliases](migration/legacy-class-aliases.md)
|
||||||
|
|
||||||
|
## Acceptance evidence and holds
|
||||||
|
|
||||||
|
- [M0/M5 IA checklist](FLEET-CONFIG-DOCS-IA-CHECKLIST.md)
|
||||||
|
- [Legacy example/profile inventory](LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md)
|
||||||
|
- [M5 closure evidence](../reports/documentation/758-fleet-config-ia-closure.md)
|
||||||
|
- [Approved-existing deferrals and live-action holds](../reports/deferred/758-fleet-config-deferrals.md)
|
||||||
|
|
||||||
|
The canonical publishing source remains this repository. This card does not publish externally, run a migration, operate a live fleet, or close parent issue #758.
|
||||||
42
docs/fleet/concepts/desired-vs-observed-state.md
Normal file
42
docs/fleet/concepts/desired-vs-observed-state.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Desired, Derived, and Observed Fleet State
|
||||||
|
|
||||||
|
## One writable authority
|
||||||
|
|
||||||
|
The canonical local v2 roster at `<MOSAIC_HOME>/fleet/roster.yaml` is desired state. Membership, stable identity, class, runtime/provider/model selection, launch policy, enablement, and persisted `running`/`stopped` intent are written only through generation-guarded roster mutations.
|
||||||
|
|
||||||
|
Derived projections are reproducible consequences of that authority:
|
||||||
|
|
||||||
|
- `<name>.env.generated`;
|
||||||
|
- exact roster-named tmux sessions on the configured socket after reconciliation;
|
||||||
|
- systemd service targets managed by installation/reconciliation.
|
||||||
|
|
||||||
|
Current systemd unit enablement is not yet lifecycle-conformant at boot: installation can enable every
|
||||||
|
agent unit, and the launcher projection does not carry `enabled` or `desired_state`. Therefore reboot
|
||||||
|
preservation for stopped/disabled agents remains an FCM-M3-002 acceptance hold, not a guaranteed
|
||||||
|
projection behavior.
|
||||||
|
|
||||||
|
Observed evidence available to current roster-v2 status commands includes systemd active state, tmux
|
||||||
|
presence, holder ownership, and unmanaged sessions. Heartbeat files are observational in the wider fleet,
|
||||||
|
but roster-v2 `status`, `doctor`, and `verify` do not currently read them. Observation never writes back
|
||||||
|
to the roster.
|
||||||
|
|
||||||
|
## Generation and ownership
|
||||||
|
|
||||||
|
`generation` is a positive integer concurrency fence. A mutating request must provide the current value. Successful changed CRUD increments it exactly once; stale or concurrent writers fail before mutation. Apply/reconcile rereads the canonical roster under a private exclusive lock and uses only that generation and content for effects.
|
||||||
|
|
||||||
|
Ownership is exact, never fuzzy. Reconciliation is limited to roster names, the configured socket, the exact holder session, a private installation identity, and private managed paths. An ownership mismatch, unmanaged session, unsafe path, stale generation, or ambiguous lock fails closed.
|
||||||
|
|
||||||
|
## Drift decisions
|
||||||
|
|
||||||
|
| Observation | Interpretation | Safe response |
|
||||||
|
| ---------------------------------------- | ------------------------ | ---------------------------------------------------------------------- |
|
||||||
|
| Generated file differs or is missing | Derived projection drift | Review `apply --dry-run`; regenerate from the roster. |
|
||||||
|
| Desired `running`, exact session missing | `missing-session` | Diagnose ownership/runtime, then reconcile if safe. |
|
||||||
|
| Desired `stopped`, exact session present | `unexpected-session` | Inspect; reconciliation may stop only the proven roster target. |
|
||||||
|
| Disabled agent running | `disabled-running` | Inspect; disabled state wins during explicit safe reconciliation. |
|
||||||
|
| Unknown session on the configured socket | Unmanaged state | Report only. Do not adopt, rename, or kill it. |
|
||||||
|
| Heartbeat stale in the wider fleet | Liveness evidence | Diagnose separately; current roster-v2 status does not read heartbeat. |
|
||||||
|
|
||||||
|
`status` and `doctor` classify. `verify` is also observational but exits non-zero when ownership, drift, or unmanaged-state checks fail. `plan`/`apply --dry-run` validates proposed projection and lifecycle work without mutation. `apply` and `reconcile` converge only after all preconditions pass.
|
||||||
|
|
||||||
|
A partial projection failure does not roll the roster back. Treat the committed roster as authority and regenerate. A lifecycle failure after projection completion preserves both roster and projections for inspection. Sensitive legacy values are never printed; diagnostics are bounded to stable codes, key names where applicable, and hashes.
|
||||||
23
docs/fleet/concepts/generated-env-launch-chain.md
Normal file
23
docs/fleet/concepts/generated-env-launch-chain.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated Environment Launch Chain
|
||||||
|
|
||||||
|
The launcher consumes validated data, not shell configuration.
|
||||||
|
|
||||||
|
1. Read and validate the canonical roster.
|
||||||
|
2. Render deterministic `<name>.env.generated` data from that roster.
|
||||||
|
3. Parse optional `<name>.env.local` through a strict allowlist.
|
||||||
|
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
|
||||||
|
5. Derive the runtime command from validated runtime/model/reasoning data.
|
||||||
|
6. Target only the exact configured tmux socket and roster session after ownership checks.
|
||||||
|
|
||||||
|
## File precedence and ownership
|
||||||
|
|
||||||
|
| File | Owner | Use |
|
||||||
|
| ----------------- | ------------------------ | --------------------------------------------------------------------------- |
|
||||||
|
| `.env.generated` | Mosaic projection writer | Complete deterministic roster projection. Rebuild; do not edit. |
|
||||||
|
| `.env.local` | Operator | Optional, private, strict machine-local data. Cannot shadow generated keys. |
|
||||||
|
| `.env` | Legacy input | One-time migration input only; never launch authority. |
|
||||||
|
| `.env.quarantine` | Private quarantine | Retained unsafe legacy evidence; never loaded by the launcher. |
|
||||||
|
|
||||||
|
Neither systemd nor the launcher sources these files. No `eval`, shell expansion, arbitrary `MOSAIC_AGENT_COMMAND`, channel, or secret-reference compatibility path exists. Safe legacy generated keys are regenerated, allowed local keys are relocated, and unsafe material is quarantined.
|
||||||
|
|
||||||
|
Diagnostics never expose the rejected value, credential material, or command text. They are bounded to stable rule code, key name where safe, and SHA-256 content identity. See [generated environment reference](../reference/generated-env-boundary.md) and [quarantine operations](../operations/env-quarantine.md).
|
||||||
20
docs/fleet/concepts/identity-class-runtime.md
Normal file
20
docs/fleet/concepts/identity-class-runtime.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Fleet Identity, Class, and Runtime
|
||||||
|
|
||||||
|
Each roster field has one job. Do not use names or model strings as authority shortcuts.
|
||||||
|
|
||||||
|
| Concern | Field | Contract |
|
||||||
|
| ----------------------- | ------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||||
|
| Stable machine identity | `agents[].name` | Unique, immutable mutation target and exact service/session name. |
|
||||||
|
| Display identity | `agents[].alias` | Human-facing label only; may be changed and grants no authority. |
|
||||||
|
| Behavioral contract | `agents[].class` | Resolves through the shared baseline plus `roles.local` persona library. |
|
||||||
|
| Tool boundary | `agents[].tool_policy` | Must match protected canonical classes; cannot independently grant authority. |
|
||||||
|
| Harness | `agents[].runtime` | One of `claude`, `codex`, `opencode`, or `pi`, declared in `runtimes`. |
|
||||||
|
| Backend selection | `agents[].provider` and `model` | Explicit non-empty data; capability validity is not inferred from the display name or class. |
|
||||||
|
| Effort | `agents[].reasoning` | `low`, `medium`, or `high`. |
|
||||||
|
| Local placement | `working_directory` | Explicit safe local work path; not remote placement authority. |
|
||||||
|
|
||||||
|
Tess and Ultron are conventional instance/display names only. They are not products, required machine identities, role aliases, or authority-bearing classes. A configurable interaction instance uses `class: interaction`; a configurable validation instance uses `class: validator`. Any stable name and alias satisfying the structural contract may be used.
|
||||||
|
|
||||||
|
Class aliases are deliberately narrow: `implementer → code`, `reviewer → review`, and `operator-interaction → interaction`. No runtime, provider, model, persona prose, or instance name changes this mapping. See [role classes](../reference/role-classes.md) and the [validated generic example](../examples/roster-v2.yaml).
|
||||||
|
|
||||||
|
Roster v2 is local-only. It contains no host/SSH placement, connector, channel, secret-reference, arbitrary-command, per-agent socket, or gateway mapping fields. Those concerns require separate requirements and threat models.
|
||||||
22
docs/fleet/concepts/role-authority-and-leases.md
Normal file
22
docs/fleet/concepts/role-authority-and-leases.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Fleet Role Authority and Leases
|
||||||
|
|
||||||
|
Role content describes behavior; protected authority is immutable code metadata derived only from the canonical class.
|
||||||
|
|
||||||
|
## Required workstream classes
|
||||||
|
|
||||||
|
`code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction` are required FCM classes. `merge-gate` is additionally protected because it remains the sole approve-to-land and merge authority.
|
||||||
|
|
||||||
|
| Class | Authority | Boundary |
|
||||||
|
| -------------------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||||
|
| `merge-gate` | Approve-to-land and merge | Sole merge authority. |
|
||||||
|
| `validator` | Issue independent validation evidence/certificate | Never approves landing or merges. |
|
||||||
|
| `orchestrator` | Orchestrate topology and issue bounded leases | Does not gain merge authority. |
|
||||||
|
| `team-leader` | Use explicitly leased capacity | Cannot issue leases or mutate roster, credentials, topology authority, or merge state. |
|
||||||
|
| `interaction` | Receive requests and report status | Cannot orchestrate, issue leases, mutate configuration, or merge. |
|
||||||
|
| `code`, `review`, `enhancer`, custom classes | No protected authority by default | Persona prose cannot grant protected powers. |
|
||||||
|
|
||||||
|
A lease is capacity authorization from an orchestrator, not ownership. It must identify a bounded task or period and does not alter the leased agent's roster identity, role contract, credentials, authority, or persisted lifecycle. Expiry/revocation returns capacity; it does not rewrite the roster.
|
||||||
|
|
||||||
|
Semantic validation rejects protected class/tool-policy mismatch in either direction. An instance named Ultron with `class: validator` remains validation-only. An instance named Tess with `class: interaction` remains request/status-only. Renaming either instance changes no authority.
|
||||||
|
|
||||||
|
For resolver layering and safe customization, see [role classes](../reference/role-classes.md) and [customize roles](../how-to/customize-roles.md).
|
||||||
61
docs/fleet/examples/roster-v2.yaml
Normal file
61
docs/fleet/examples/roster-v2.yaml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
version: 2
|
||||||
|
generation: 1
|
||||||
|
transport: tmux
|
||||||
|
tmux:
|
||||||
|
socket_name: mosaic-fleet
|
||||||
|
holder_session: _holder
|
||||||
|
defaults:
|
||||||
|
working_directory: ~/src
|
||||||
|
runtime: pi
|
||||||
|
runtimes:
|
||||||
|
pi:
|
||||||
|
reset_command: /new
|
||||||
|
agents:
|
||||||
|
- name: code-example
|
||||||
|
alias: Code Example
|
||||||
|
class: code
|
||||||
|
runtime: pi
|
||||||
|
provider: example-provider
|
||||||
|
model: example-model
|
||||||
|
reasoning: medium
|
||||||
|
tool_policy: code
|
||||||
|
working_directory: ~/src
|
||||||
|
persistent_persona: false
|
||||||
|
reset_between_tasks: true
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
desired_state: stopped
|
||||||
|
launch:
|
||||||
|
yolo: false
|
||||||
|
- name: interaction-example
|
||||||
|
alias: Interaction Example
|
||||||
|
class: interaction
|
||||||
|
runtime: pi
|
||||||
|
provider: example-provider
|
||||||
|
model: example-model
|
||||||
|
reasoning: low
|
||||||
|
tool_policy: interaction
|
||||||
|
working_directory: ~/src
|
||||||
|
persistent_persona: true
|
||||||
|
reset_between_tasks: false
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
desired_state: stopped
|
||||||
|
launch:
|
||||||
|
yolo: false
|
||||||
|
- name: validator-example
|
||||||
|
alias: Validator Example
|
||||||
|
class: validator
|
||||||
|
runtime: pi
|
||||||
|
provider: example-provider
|
||||||
|
model: example-model
|
||||||
|
reasoning: high
|
||||||
|
tool_policy: validator
|
||||||
|
working_directory: ~/src
|
||||||
|
persistent_persona: false
|
||||||
|
reset_between_tasks: true
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
desired_state: stopped
|
||||||
|
launch:
|
||||||
|
yolo: false
|
||||||
21
docs/fleet/how-to/configure-tess-interaction.md
Normal file
21
docs/fleet/how-to/configure-tess-interaction.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Configure an Interaction Instance
|
||||||
|
|
||||||
|
An interaction instance is a configurable local roster member with canonical `class: interaction` and matching `tool_policy: interaction`. “Tess” may be used as a display alias, but neither that alias nor the stable name is required or authority-bearing.
|
||||||
|
|
||||||
|
Use the [validated generic roster](../examples/roster-v2.yaml) as the safe shape. Choose a unique stable `name`, any descriptive `alias`, a supported declared runtime, explicit provider/model/reasoning, and a safe work directory. Start with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: interaction-example
|
||||||
|
alias: Interaction Example
|
||||||
|
class: interaction
|
||||||
|
tool_policy: interaction
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
desired_state: stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
Plan the complete agent payload with the current roster generation, then create it without `--persisted-start`. Creation defaults to enabled/stopped and performs no runtime action. Review the resulting roster and projection plan before any later lifecycle decision.
|
||||||
|
|
||||||
|
The interaction class is request/status only. It cannot orchestrate, issue leases, mutate the roster/configuration, grant credentials, certify validation, approve landing, or merge. Connector and channel configuration are outside roster v2; do not add connector, channel, secret, command, remote-host, or gateway fields.
|
||||||
|
|
||||||
|
See [safe CRUD](create-update-delete-agent.md), [identity separation](../concepts/identity-class-runtime.md), and [role authority](../concepts/role-authority-and-leases.md).
|
||||||
21
docs/fleet/how-to/configure-ultron-validator.md
Normal file
21
docs/fleet/how-to/configure-ultron-validator.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Configure a Validator Instance
|
||||||
|
|
||||||
|
A validator instance is a configurable local roster member with canonical `class: validator` and matching `tool_policy: validator`. “Ultron” may be used as a display alias, but it is not a required identity, class alias, product name, or source of authority.
|
||||||
|
|
||||||
|
Use the [validated generic roster](../examples/roster-v2.yaml) as the safe shape. Choose a unique stable name and explicit supported runtime/provider/model/reasoning values. Start stopped:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: validator-example
|
||||||
|
alias: Validator Example
|
||||||
|
class: validator
|
||||||
|
tool_policy: validator
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
desired_state: stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
Plan the full payload with the current generation and create without `--persisted-start`. Creation writes desired state and projections only; it does not launch a validator.
|
||||||
|
|
||||||
|
`validator` may issue independent validation evidence or a certificate. It has no approve-to-land or merge authority. `merge-gate` remains the sole protected merge authority, and changing the validator's name, alias, persona prose, runtime, provider, model, or tool-policy text cannot elevate it.
|
||||||
|
|
||||||
|
Certificate consumption and final release evidence remain FCM-M5-002 gates. This page does not create a certificate or authorize merge. See [safe CRUD](create-update-delete-agent.md) and [role authority](../concepts/role-authority-and-leases.md).
|
||||||
23
docs/fleet/how-to/start-stop-restart.md
Normal file
23
docs/fleet/how-to/start-stop-restart.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Safely Reconcile and Control a Local Fleet Agent
|
||||||
|
|
||||||
|
Use the canonical local roster-v2 command surface:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mosaic fleet apply --expected-generation <n> --dry-run
|
||||||
|
mosaic fleet apply --expected-generation <n>
|
||||||
|
mosaic fleet reconcile --expected-generation <n>
|
||||||
|
mosaic fleet start <name> --expected-generation <n>
|
||||||
|
mosaic fleet stop <name> --expected-generation <n>
|
||||||
|
mosaic fleet restart <name> --expected-generation <n>
|
||||||
|
mosaic fleet status [name]
|
||||||
|
mosaic fleet verify
|
||||||
|
mosaic fleet doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
Start with `--dry-run`. It validates roster semantics, deterministic projections, private managed paths, exact holder ownership, and named-socket state without changing files or lifecycle state. Explicit `apply` and `reconcile` rebuild derived projections and enforce persisted roster state: enabled `running` agents may start, while stopped or disabled agents are not started. This guarantee does not extend to reboot/service activation yet; boot preservation remains an FCM-M3-002 hold.
|
||||||
|
|
||||||
|
`start`, `stop`, and `restart` are explicit one-shot exact-service actions. They do not persist a lifecycle change. `update` preserves the agent's existing lifecycle, and no delivered operation changes durable lifecycle after creation.
|
||||||
|
|
||||||
|
Every command prints JSON. Observation commands report drift without mutation; `verify` exits non-zero on ownership mismatch, unmanaged sessions, or drift. A failed apply that wrote some derived projections reports `projections: "incomplete"` with bounded recovery to regenerate from the roster. A lifecycle failure after projections reports incomplete lifecycle work; it is never represented as a rollback or no-op.
|
||||||
|
|
||||||
|
These commands are local only. Remote/SSH/connector entries are inventory/validation-only. Commands do not accept arbitrary runtime commands, channels, secrets, generated-file desired state, or arbitrary tmux sockets.
|
||||||
@@ -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.
|
||||||
20
docs/fleet/operations/env-quarantine.md
Normal file
20
docs/fleet/operations/env-quarantine.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Environment Quarantine Operations
|
||||||
|
|
||||||
|
Legacy `<name>.env` is input evidence, never current launch authority. Projection preparation classifies it deterministically:
|
||||||
|
|
||||||
|
- generated roster keys → discard and regenerate;
|
||||||
|
- allowed strict local keys → relocate to private `.env.local`;
|
||||||
|
- malformed, duplicate, unknown, sensitive-looking, shell-bearing, unsafe, or command-override entries → move the legacy input to private `.env.quarantine`.
|
||||||
|
|
||||||
|
## Safe response
|
||||||
|
|
||||||
|
1. Stop and read the stable error code and reported key name/hash. Do not request or paste the value.
|
||||||
|
2. Confirm the canonical roster contains the intended non-sensitive desired state.
|
||||||
|
3. If the key is an allowed local machine-data field, place only its validated data form in `.env.local` under private permissions.
|
||||||
|
4. Remove unsupported intent rather than translating it into commands, channels, secret references, or unknown `MOSAIC_AGENT_*` keys.
|
||||||
|
5. Regenerate `.env.generated` from the roster and rerun a dry-run/verification gate.
|
||||||
|
6. Retain quarantine evidence privately until the operator's normal retention process permits removal.
|
||||||
|
|
||||||
|
The launcher never reads quarantine. Public/JSON diagnostics expose stable code, key name where safe, and SHA-256 only—never a legacy sensitive value, credential, rejected command, or full line. Quarantine does not prove remediation, backup, migration, or rollback.
|
||||||
|
|
||||||
|
See [generated launch chain](../concepts/generated-env-launch-chain.md), [generated environment boundary](../reference/generated-env-boundary.md), and [migration field disposition](../migration/v1-to-v2.md#field-disposition).
|
||||||
28
docs/fleet/operations/reconcile-and-recover.md
Normal file
28
docs/fleet/operations/reconcile-and-recover.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Reconcile and Recover a Local Fleet
|
||||||
|
|
||||||
|
## Safe sequence
|
||||||
|
|
||||||
|
1. Read `mosaic fleet doctor` and `mosaic fleet status`.
|
||||||
|
2. Run `mosaic fleet apply --expected-generation <n> --dry-run`.
|
||||||
|
3. Resolve stale generation, ownership mismatch, unsafe path, projection validation, or unmanaged-session findings before applying.
|
||||||
|
4. Run `mosaic fleet apply --expected-generation <n>` only after the plan is understood.
|
||||||
|
|
||||||
|
This is per-generation convergence, not a rolling canary. Executable canary cutover/rollback remains held for FCM-M4-002; rolling local release evidence remains FCM-M5-002. Do not approximate either with repeated live apply commands.
|
||||||
|
|
||||||
|
The reconciler uses the exact roster tmux socket, exact holder session, private installation holder identity, and the complete expected global environment. For mutations it acquires its exclusive lock before rereading the canonical roster and fencing its generation; only that under-lock roster drives validation, planning, projections, and lifecycle effects. Before effects, its exclusive lock proves real private `MOSAIC_HOME` and `fleet` ancestors, uses a private `0600` lock leaf, and binds cleanup to the created file identity and ownership token. A fake holder, contaminated global environment, missing identity, unsafe lock path, or unmanaged session fails closed. It does not adopt, kill, or rename any unproven session. A crash can leave a stale lock for explicit operator inspection; reconciliation deliberately does not guess ownership or remove it.
|
||||||
|
|
||||||
|
## Partial results
|
||||||
|
|
||||||
|
The roster is never changed by reconciliation. If derived projection application partially fails, JSON reports:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"applied": false,
|
||||||
|
"authoritativeRoster": "unchanged",
|
||||||
|
"projections": "incomplete",
|
||||||
|
"lifecycle": "not-applied",
|
||||||
|
"recovery": { "code": "projection-apply-failed", "action": "regenerate-projections-from-roster" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If projections completed but lifecycle work failed, JSON reports `projections: "complete"`, `lifecycle: "incomplete"`, and the bounded action `rerun-after-inspecting-owned-resources`. If lock cleanup cannot be proven after an effect result, it adds `cleanup: { "code": "lock-cleanup-failed", "action": "inspect-lock-before-retry" }` without changing the known projection, lifecycle, or primary recovery truth. Inspect the retained lock before retrying; no rollback, release, or stale-lock removal is implied. Results do not include environment values, secrets, or privileged command content.
|
||||||
24
docs/fleet/operations/systemd-tmux-troubleshooting.md
Normal file
24
docs/fleet/operations/systemd-tmux-troubleshooting.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Systemd and tmux Troubleshooting
|
||||||
|
|
||||||
|
Start with read-only `mosaic fleet status`, `doctor`, and `verify`. Do not manually adopt, rename, terminate, or recreate sessions while ownership is ambiguous.
|
||||||
|
|
||||||
|
## Decision table
|
||||||
|
|
||||||
|
| Finding | Meaning | Safe next step |
|
||||||
|
| ------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||||
|
| Empty roster `tmux.socket_name` | Literal default tmux server | Do not substitute the named `mosaic-fleet` socket. Use roster-derived commands only. |
|
||||||
|
| Non-empty socket | Exact named socket | Never target another socket or infer a per-agent socket. |
|
||||||
|
| `holder: missing` | Required exact holder absent | Inspect installation/projection readiness; do not create an unproven holder manually. |
|
||||||
|
| `ownership-mismatch` | Holder identity or global environment differs | Stop. Verify private install identity and managed paths before retry. |
|
||||||
|
| `missing-session` | Desired-running roster agent lacks exact session | Check service/runtime preconditions; review apply dry-run. |
|
||||||
|
| `unexpected-session` | Desired-stopped roster agent still has exact session | Confirm ownership; only reconciler may target the exact proven roster member. |
|
||||||
|
| `disabled-running` | Disabled roster member is observed running | Inspect and reconcile only after ownership proof. |
|
||||||
|
| `unmanagedSessions` | Unknown session exists on configured named socket | Report and investigate separately. Reconciler will not kill or adopt it. |
|
||||||
|
| stale/concurrent generation | Desired state changed since plan | Reload roster/generation and recompute the plan. |
|
||||||
|
| stale or ambiguous lock | Prior writer/cleanup cannot be proven | Inspect ownership; do not blindly remove the lock. |
|
||||||
|
| projection failure | Derived files incomplete | Keep roster as authority and regenerate projections. |
|
||||||
|
| lifecycle failure | Projections complete, runtime convergence incomplete | Inspect the exact owned resource, then rerun with current generation. |
|
||||||
|
|
||||||
|
Systemd state, tmux state, heartbeat, and generated files are observations/projections, not alternate desired state. Explicit apply/reconcile honors stopped/disabled intent, but current unit enablement and launcher projections do not yet prove lifecycle-safe reboot; inspect unit enablement before reboot and treat stopped/disabled boot preservation as an FCM-M3-002 hold. Current roster-v2 status commands also do not read heartbeat files. Executable gates do not provide site cutover/rollback or package asset-revision repair.
|
||||||
|
|
||||||
|
Errors and troubleshooting output never print legacy sensitive values, credential contents, or privileged command text. Use stable codes, key names/hashes, exact roster identities, and bounded recovery actions. See [status and drift](../reference/status-and-drift.md) and [reconcile and recover](reconcile-and-recover.md).
|
||||||
18
docs/fleet/operations/upgrade-assets.md
Normal file
18
docs/fleet/operations/upgrade-assets.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Upgrade and Installed-Asset Drift
|
||||||
|
|
||||||
|
Fleet source assets and installed assets can differ after an update, but FCM-M5-001 does not add a trustworthy source-versus-installed revision detector or refresh command. Do not infer freshness from checkout presence, timestamps, generated environment files, running sessions, or a ready migration preview.
|
||||||
|
|
||||||
|
## Current safe boundary
|
||||||
|
|
||||||
|
- The canonical roster remains authority and must survive package/framework refresh.
|
||||||
|
- Generated projections are rebuilt from that roster after the installed contract is independently verified.
|
||||||
|
- Operator `roles.local`, `.env.local`, and private quarantine evidence are not generated assets and must not be overwritten.
|
||||||
|
- Baseline roles, schemas, examples, service presets, launcher helpers, and systemd templates must move as one reviewed release set.
|
||||||
|
- Remote/connector inventory and `mos-comms` are not promoted into permanent architecture by an update.
|
||||||
|
- No update may start an agent persisted stopped, adopt an unmanaged session, or bypass generation/ownership checks.
|
||||||
|
|
||||||
|
## Explicit hold
|
||||||
|
|
||||||
|
FCM-M5-002 owns deterministic asset-drift checks, safe package/update refresh evidence, rolling local canary, independent validation certificate, and release evidence. Until that card lands, this page is an operational hold rather than an executable procedure: use the repository/release review path, preserve backups, and do not claim source/installed parity without exact revision evidence from the future validator.
|
||||||
|
|
||||||
|
See [approved deferrals](../../reports/deferred/758-fleet-config-deferrals.md) and [backup/restore boundary](backup-restore.md).
|
||||||
46
docs/fleet/reference/cli.md
Normal file
46
docs/fleet/reference/cli.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Fleet Control-Plane CLI
|
||||||
|
|
||||||
|
The local desired-state surface is `mosaic fleet`. It is distinct from the gateway-backed `mosaic agent` catalog and from legacy compatibility commands that act on roster v1.
|
||||||
|
|
||||||
|
## Roster-v2 desired-state commands
|
||||||
|
|
||||||
|
| Command | Effect | Generation | Output |
|
||||||
|
| ---------------------------------------------------------- | ---------------------------------------------- | ---------- | -------------------------- |
|
||||||
|
| `mosaic fleet get <name>` | Read one authoritative agent | no | One JSON object |
|
||||||
|
| `mosaic fleet plan <create\|update\|delete> ...` | Validate proposed CRUD and projections | required | One JSON object; no writes |
|
||||||
|
| `mosaic fleet create ... [--dry-run] [--persisted-start]` | Add desired state; default enabled/stopped | required | One JSON object |
|
||||||
|
| `mosaic fleet update <name> ... [--dry-run]` | Replace mutable agent fields | required | One JSON object |
|
||||||
|
| `mosaic fleet delete <name> ... [--dry-run]` | Remove roster member/generated projection | required | One JSON object |
|
||||||
|
| `mosaic fleet apply ... [--dry-run]` | Plan or converge projections/lifecycle | required | One JSON object |
|
||||||
|
| `mosaic fleet reconcile ... [--dry-run]` | Alias of the same convergence contract | required | One JSON object |
|
||||||
|
| `mosaic fleet start\|stop\|restart [name] ... [--dry-run]` | Exact one-shot lifecycle action | required | One JSON object |
|
||||||
|
| `mosaic fleet status [name]` | Observe desired/managed/runtime state | no | One JSON object |
|
||||||
|
| `mosaic fleet verify` | Strict observational drift/ownership gate | no | One JSON object |
|
||||||
|
| `mosaic fleet doctor` | Classify local drift and recovery context | no | One JSON object |
|
||||||
|
| `mosaic fleet migrate-v1 preview ...` | Non-mutating field-complete migration evidence | no | One JSON object |
|
||||||
|
|
||||||
|
CRUD syntax and full payload shape are documented in [agent mutations](agent-mutations.md). Reconciliation syntax:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mosaic fleet apply --expected-generation <n> [--dry-run]
|
||||||
|
mosaic fleet reconcile --expected-generation <n> [--dry-run]
|
||||||
|
mosaic fleet start [name] --expected-generation <n> [--dry-run]
|
||||||
|
mosaic fleet stop [name] --expected-generation <n> [--dry-run]
|
||||||
|
mosaic fleet restart [name] --expected-generation <n> [--dry-run]
|
||||||
|
mosaic fleet status [name]
|
||||||
|
mosaic fleet verify
|
||||||
|
mosaic fleet doctor
|
||||||
|
mosaic fleet migrate-v1 preview --source <path> --decisions <path> --observations <path>
|
||||||
|
```
|
||||||
|
|
||||||
|
`get` is the read/show operation for one v2 agent. Full roster parsing and semantic validation occur on every v2 mutation/reconcile path; there is no separate mutable “config store.” The executable JSON Schema and validated example provide offline structural evidence. The PRD requires an explicit programmatic `mosaic fleet validate` operation, but the current CLI does not expose one; do not substitute another command or claim that requirement is delivered. This remains an implementation gap for #758.
|
||||||
|
|
||||||
|
## JSON and exit behavior
|
||||||
|
|
||||||
|
Roster-v2 CRUD and reconciler precondition failures emit `{ "error": { "code": "..." } }` and exit non-zero. Migration preview has its own result envelope: a non-ready preview emits `{ "status": "blocked", "blockers": [...] }` and exits non-zero rather than using the CRUD/reconciler error object. Use both exit status and command-specific state fields. A partial reconciliation result distinguishes `authoritativeRoster`, `projections`, `lifecycle`, `recovery`, and optional `cleanup`; it never claims automatic rollback. `verify` exits non-zero for drift, ownership failure, or unmanaged sessions. Sensitive legacy values, credentials, and rejected command text are never printed.
|
||||||
|
|
||||||
|
## Compatibility and scope
|
||||||
|
|
||||||
|
Roster-v1 initialization, provisioning, profiles/personas, and historical `fleet add/remove` remain compatibility surfaces, not roster-v2 CRUD aliases. New v2 automation should use the table above. `migrate-v1 preview` writes nothing and has no cutover, canary, or rollback option.
|
||||||
|
|
||||||
|
`mosaic agent` is a separate catalog/transport surface; it does not own `<MOSAIC_HOME>/fleet/roster.yaml` desired state. Remote/SSH reconciliation, connector mutation, arbitrary commands/channels, secret references, and gateway convergence are rejected or outside scope.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Fleet Generated Environment Boundary
|
# Fleet Generated Environment Boundary
|
||||||
|
|
||||||
**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** unreleased/card-local
|
**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** merged contract
|
||||||
|
|
||||||
The local fleet roster is the desired-state authority. A launch reads a deterministic,
|
The local fleet roster is the desired-state authority. A launch reads a deterministic,
|
||||||
roster-derived generated projection and an optional strictly data-only local file; neither file is
|
roster-derived generated projection and an optional strictly data-only local file; neither file is
|
||||||
@@ -78,12 +78,12 @@ This card does not add a USC site file, write a USC roster, or run a site canary
|
|||||||
consolidated downstream interface packet. Status is deliberately separated from checkout presence: no
|
consolidated downstream interface packet. Status is deliberately separated from checkout presence: no
|
||||||
product release version has been evidenced for this interface set.
|
product release version has been evidenced for this interface set.
|
||||||
|
|
||||||
| Interface | Canonical public path and version | Tracker/release status | Downstream limit |
|
| Interface | Canonical public path and version | Tracker/release status | Downstream limit |
|
||||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||||
| M1 structural compiler | `parseRosterV2` in `packages/mosaic/src/fleet/roster-v2.ts`; schema `docs/fleet/reference/roster-v2.schema.json`; roster `version: 2` | FCM-M1-001 is recorded done, merged as #764 (`aa5b43b`); no released product version is asserted here. | Parse YAML/JSON and canonicalize a supplied v2 site roster without writes. |
|
| M1 structural compiler | `parseRosterV2` in `packages/mosaic/src/fleet/roster-v2.ts`; schema `docs/fleet/reference/roster-v2.schema.json`; roster `version: 2` | FCM-M1-001 is recorded done, merged as #764 (`aa5b43b`); no released product version is asserted here. | Parse YAML/JSON and canonicalize a supplied v2 site roster without writes. |
|
||||||
| M1 semantic resolver | `validateRosterV2Semantics` in `packages/mosaic/src/fleet/roster-v2.ts`; baseline `framework/fleet/roles/` plus `roles.local/` | FCM-M1-002 remains `in-progress` in `docs/TASKS.md`; unreleased. | Reuse the shared resolver only; no parallel role resolver or lifecycle action. |
|
| M1 semantic resolver | `validateRosterV2Semantics` in `packages/mosaic/src/fleet/roster-v2.ts`; baseline `framework/fleet/roles/` plus `roles.local/` | FCM-M1-002 merged as #768 (`a5e8e55`); no released product version is asserted here. | Reuse the shared resolver only; no parallel role resolver or lifecycle action. |
|
||||||
| M1 disposition evidence | `packages/mosaic/src/fleet/example-profile-dispositions.ts`; `docs/fleet/migration/example-profile-disposition.md`; retained fixture `version: 1` | FCM-M1-003 remains `not-started` in `docs/TASKS.md`; unreleased even though these checkout artifacts are inspectable. | Inspect fixture/profile/service disposition evidence only; it is not migration authorization. |
|
| M1 disposition evidence | `packages/mosaic/src/fleet/example-profile-dispositions.ts`; `docs/fleet/migration/example-profile-disposition.md`; retained fixture `version: 1` | FCM-M1-003 merged as #770 (`e9c4aa3`); checkout evidence remains validation, not migration authorization. | Inspect fixture/profile/service disposition evidence only; it is not migration authorization. |
|
||||||
| M2 generated boundary | `packages/mosaic/src/fleet/generated-env-boundary.ts`; generated projection contract in this document | FCM-M2-001 card-local and uncommitted; unreleased. | Render/write a roster-derived projection; local input is never authority. |
|
| M2 generated boundary | `packages/mosaic/src/fleet/generated-env-boundary.ts`; generated projection contract in this document | FCM-M2-001 merged as #772 (`191efae`); no released product version is asserted here. | Render/write a roster-derived projection; local input is never authority. |
|
||||||
|
|
||||||
The canonical source remains `<MOSAIC_HOME>/fleet/roster.yaml` for the current local fleet path.
|
The canonical source remains `<MOSAIC_HOME>/fleet/roster.yaml` for the current local fleet path.
|
||||||
Generated environment data is a rebuildable projection, not an operator-editable source of membership,
|
Generated environment data is a rebuildable projection, not an operator-editable source of membership,
|
||||||
|
|||||||
21
docs/fleet/reference/lifecycle-transitions.md
Normal file
21
docs/fleet/reference/lifecycle-transitions.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Local Fleet Lifecycle Transitions
|
||||||
|
|
||||||
|
Roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` are the only persisted lifecycle authority. Systemd, tmux, generated environment, and heartbeat state are derived or observed.
|
||||||
|
|
||||||
|
| Event | Desired-state write | Runtime effect | Safety boundary |
|
||||||
|
| --------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `fleet create` | Adds enabled/stopped by default; `--persisted-start` records running | None | Generation-guarded; validates full roster/projections. |
|
||||||
|
| `fleet update` | Preserves the existing enabled/desired state; updates other mutable fields | None | Generation-guarded; stable name and lifecycle are immutable on this path. |
|
||||||
|
| `fleet delete` | Removes exact roster member | None | Removes only generated projection; retains local/quarantine evidence. |
|
||||||
|
| `fleet apply` / `reconcile` | Never | Rebuilds projections; starts only enabled/running; stops disabled or stopped roster members | Current generation, private lock/paths, semantic validity, holder ownership, no unmanaged named-socket sessions. |
|
||||||
|
| `fleet start <name>` | Never | One-shot exact service start | Exact enabled roster name and proven ownership. |
|
||||||
|
| `fleet stop <name>` | Never | One-shot exact service stop | Exact roster name and proven ownership. |
|
||||||
|
| `fleet restart <name>` | Never | One-shot exact service restart | Exact enabled roster name and proven ownership. |
|
||||||
|
| Reboot/service activation | Never | Current installation may activate enabled units without honoring roster lifecycle | **Held for FCM-M3-002:** boot preservation for stopped/disabled agents is not yet proven; inspect/disable units rather than assuming lifecycle-safe reboot. |
|
||||||
|
| v1 migration preview | Never | None | Observed active+present maps running; inactive+missing maps stopped; ambiguity blocks. |
|
||||||
|
| Cutover/canary | Held for FCM-M4-002 | Not implemented by preview | Must preserve every observed stopped state. |
|
||||||
|
| Rollback | Held for FCM-M4-002 | Not implemented | Must restore selected authority/projections without surprise starts or unmanaged targeting. |
|
||||||
|
|
||||||
|
Explicit apply/reconcile never starts a stopped roster agent. Direct lifecycle commands are explicit one-shot actions and do not persist intent. The current update operation preserves `existing.lifecycle`; there is no delivered generation-guarded CRUD operation for changing durable lifecycle after creation. Reboot preservation for stopped/disabled agents is not yet guaranteed because current enabled units and launcher projections do not carry the persisted lifecycle fence; that acceptance evidence remains FCM-M3-002.
|
||||||
|
|
||||||
|
Missing/stale generation, concurrent writer, unsafe path, ownership mismatch, unmanaged session, unsupported runtime, invalid projection, and lifecycle precondition failures return stable redacted JSON and non-zero status. No command targets fuzzy names, arbitrary sockets/commands/channels/secrets, or generated files as authority. Legacy sensitive values are never printed.
|
||||||
@@ -50,36 +50,38 @@ agents:
|
|||||||
|
|
||||||
## Root fields
|
## Root fields
|
||||||
|
|
||||||
| Field | Required | Constraint | Meaning |
|
| Field | Required | Default | Constraint | Meaning |
|
||||||
| ------------ | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------ | -------- | ------- | --------------------- | ------------------------------------------------------------------------------------------------- |
|
||||||
| `version` | yes | integer constant `2` | Identifies this contract. Version `1` is explicitly rejected by this compiler and remains on the existing v1 path until M4 migration. |
|
| `version` | yes | none | integer constant `2` | Identifies this contract. Version `1` stays on the compatibility path pending explicit migration. |
|
||||||
| `generation` | yes | positive safe integer | Desired-state generation. M2 uses it for mutation guards; M1 does not mutate it. |
|
| `generation` | yes | none | positive safe integer | Desired-state generation and mutation/reconcile concurrency fence. |
|
||||||
| `transport` | yes | constant `tmux` | M1–M5 support local tmux only. |
|
| `transport` | yes | none | constant `tmux` | M1–M5 support local tmux only. |
|
||||||
| `tmux` | yes | strict object | Explicit local socket and holder-session configuration. |
|
| `tmux` | yes | none | strict object | Explicit local socket and holder-session configuration. |
|
||||||
| `defaults` | yes | strict object | Default work directory and one supported local runtime. |
|
| `defaults` | yes | none | strict object | Default work directory and one supported local runtime. |
|
||||||
| `runtimes` | yes | non-empty object | Declared local runtime reset policy map. |
|
| `runtimes` | yes | none | non-empty object | Declared local runtime reset policy map. |
|
||||||
| `agents` | yes | non-empty array | Local fleet entries. Duplicate stable names are rejected. |
|
| `agents` | yes | none | non-empty array | Local fleet entries. Duplicate stable names are rejected. |
|
||||||
|
|
||||||
## Nested fields
|
## Nested fields
|
||||||
|
|
||||||
| Path | Required | Constraint |
|
All nested fields in the v2 schema are required and have no implicit default. CRUD `create` is the only higher-level convenience: it records `lifecycle.enabled: true` and `desired_state: stopped` unless `--persisted-start` explicitly records running. That convenience still performs no runtime action.
|
||||||
| ---------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
|
|
||||||
| `tmux.socket_name` | yes | non-empty `[A-Za-z0-9_.-]+`; an explicit named socket prevents default-versus-named socket ambiguity |
|
| Path | Required | Constraint |
|
||||||
| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` |
|
| ---------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
| `defaults.working_directory` | yes | non-empty string |
|
| `tmux.socket_name` | yes | `[A-Za-z0-9_.-]*`; empty string means the literal default tmux server, while a non-empty value names a socket |
|
||||||
| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
|
| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` |
|
||||||
| `runtimes.<runtime>.reset_command` | yes | non-empty string; runtime key must be a supported local runtime |
|
| `defaults.working_directory` | yes | non-empty string |
|
||||||
| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity |
|
| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
|
||||||
| `agents[].alias` | yes | non-empty display string |
|
| `runtimes.<runtime>.reset_command` | yes | non-empty string; runtime key must be a supported local runtime |
|
||||||
| `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 |
|
| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity |
|
||||||
| `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
|
| `agents[].alias` | yes | non-empty display string |
|
||||||
| `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card |
|
| `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 |
|
||||||
| `agents[].reasoning` | yes | `low`, `medium`, or `high` |
|
| `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
|
||||||
| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 |
|
| `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card |
|
||||||
| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans |
|
| `agents[].reasoning` | yes | `low`, `medium`, or `high` |
|
||||||
| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 |
|
| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 |
|
||||||
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
|
| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans |
|
||||||
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
|
| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 |
|
||||||
|
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
|
||||||
|
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
|
||||||
|
|
||||||
## Semantic handoff
|
## Semantic handoff
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
25
docs/fleet/reference/status-and-drift.md
Normal file
25
docs/fleet/reference/status-and-drift.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Local Fleet Status and Drift
|
||||||
|
|
||||||
|
`mosaic fleet status [name]`, `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, mutate desired state, operate lifecycle, or change tmux.
|
||||||
|
|
||||||
|
## State dimensions
|
||||||
|
|
||||||
|
- **Desired:** roster membership, generation, enabled flag, and persisted running/stopped target.
|
||||||
|
- **Managed/derived:** generated environment and expected exact service/session topology.
|
||||||
|
- **Observed by current roster-v2 commands:** systemd active state, tmux presence, exact holder ownership, and unmanaged sessions.
|
||||||
|
|
||||||
|
Implemented drift classifications include:
|
||||||
|
|
||||||
|
- `missing-session`: enabled/desired-running agent lacks its exact session;
|
||||||
|
- `unexpected-session`: desired-stopped agent has its exact session;
|
||||||
|
- `disabled-running`: disabled roster agent has its exact session;
|
||||||
|
- `unmanagedSessions`: named-socket sessions that are neither exact holder nor roster agent;
|
||||||
|
- `holder`: `owned`, `missing`, or `ownership-mismatch` after private identity and global environment checks.
|
||||||
|
|
||||||
|
Generated projection failures/staleness are surfaced by plan/apply preparation and bounded recovery fields rather than adopted as configuration. Heartbeat remains wider-fleet observational evidence, never desired state, but the current roster-v2 `status`, `doctor`, and `verify` commands do not read heartbeat files. A provable removed-agent projection may be treated as stale derived state during deletion, but general projection-orphan classification and installed source-versus-asset revision mismatch remain FCM-M4-002/M5-002 holds; current commands must not claim those future checks.
|
||||||
|
|
||||||
|
## Command behavior
|
||||||
|
|
||||||
|
`status` and `doctor` classify rather than adopt, destroy, or repair. `verify` is observational too, but exits non-zero if ownership cannot be proven, unmanaged sessions exist, or drift is present. Reconciliation fails closed under those conditions and never kills or adopts an unmanaged session.
|
||||||
|
|
||||||
|
Doctor/error output uses stable codes and bounded recovery context. Migration, quarantine, lifecycle, status, and troubleshooting output never prints a legacy sensitive value, credential, or privileged command text.
|
||||||
54
docs/reports/deferred/758-fleet-config-deferrals.md
Normal file
54
docs/reports/deferred/758-fleet-config-deferrals.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# FCM-M5-001 Fleet Documentation Deferrals and Holds
|
||||||
|
|
||||||
|
**Issue:** #758 · **Branch:** `docs/758-fleet-config-operator-docs`
|
||||||
|
|
||||||
|
These are accepted existing DAG boundaries, not omissions silently claimed as delivered.
|
||||||
|
|
||||||
|
## FCM-M3-002 hold
|
||||||
|
|
||||||
|
- Boot/reboot preservation for roster members persisted stopped or disabled.
|
||||||
|
- Current installation may enable all agent units, while the launcher projection does not yet carry
|
||||||
|
`lifecycle.enabled` or `desired_state`; documentation therefore does not claim lifecycle-safe reboot.
|
||||||
|
- Heartbeat/liveness integration into roster-v2 `status`, `doctor`, and `verify`; current observations
|
||||||
|
cover systemd active state, tmux sessions, holder ownership, and unmanaged sessions only.
|
||||||
|
|
||||||
|
## FCM-M4-002 hold
|
||||||
|
|
||||||
|
- Executable v1-to-v2 cutover, reversible canary, and rollback.
|
||||||
|
- Stale-projection/orphan migration classification and current-host managed/unmanaged fixture coverage.
|
||||||
|
- Any live migration, lifecycle, systemd/tmux/session, or rollback action.
|
||||||
|
|
||||||
|
M5 docs describe prerequisites and the preview boundary only. A ready preview is not migration or rollback evidence.
|
||||||
|
|
||||||
|
## Explicit validate-operation gap
|
||||||
|
|
||||||
|
- `FCM-REQ-03` requires a documented programmatic `mosaic fleet validate` operation.
|
||||||
|
- The current CLI does not expose that operation. Existing mutation/reconcile validation and the
|
||||||
|
documentation example test are not a replacement for the missing command.
|
||||||
|
- FCM-M5-001 documents this implementation gap without inventing syntax, JSON, exit behavior, or an
|
||||||
|
owning implementation card. Parent #758 must remain open until the requirement is implemented and
|
||||||
|
evidenced or the PRD/DAG is explicitly revised through the authoritative process.
|
||||||
|
|
||||||
|
## FCM-M5-002 hold
|
||||||
|
|
||||||
|
- Deterministic source-versus-installed asset revision detection and safe refresh implementation.
|
||||||
|
- Rolling local canary, independent validator certificate, final release evidence, merge-gate approval, and parent #758 closure.
|
||||||
|
|
||||||
|
`operations/upgrade-assets.md` is therefore a fail-closed hold, not an invented procedure.
|
||||||
|
|
||||||
|
## Compatibility interpretation
|
||||||
|
|
||||||
|
The M0 cross-cutting row requiring every retained/migrated artifact to validate through the executable contract is satisfied by each artifact's declared executable disposition, not by forcing versioned v1 fixtures through the v2 parser:
|
||||||
|
|
||||||
|
- retained examples are explicit `version: 1` fixtures validated by the production v1 parser;
|
||||||
|
- canonical profiles validate through the shared baseline plus `roles.local` resolver;
|
||||||
|
- the service preset validates through its production service-policy reader;
|
||||||
|
- migration candidates validate through the production v2 compiler and shared semantic resolver.
|
||||||
|
|
||||||
|
The executable disposition inventory rejects undeclared additions/removals and prevents silent legacy drift.
|
||||||
|
|
||||||
|
## Repository-wide documentation structure
|
||||||
|
|
||||||
|
The accepted #758 IA is the domain book under `docs/fleet/`. Creating global `USER-GUIDE`, `ADMIN-GUIDE`, or `DEVELOPER-GUIDE` books and cleaning unrelated pre-existing `docs/` root files are outside this bounded card. The repository sitemap links the fleet book. No HTTP/API/auth contract changed, so OpenAPI and endpoint-index updates are not applicable.
|
||||||
|
|
||||||
|
Canonical documentation remains in-repository; no external publishing or generated publishing output is in scope. Parent issue #758 stays open through M5.
|
||||||
44
docs/reports/documentation/758-fleet-config-ia-closure.md
Normal file
44
docs/reports/documentation/758-fleet-config-ia-closure.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# FCM-M5-001 Fleet Documentation IA Closure Evidence
|
||||||
|
|
||||||
|
**Issue:** #758 · **Task:** FCM-M5-001
|
||||||
|
|
||||||
|
## Artifact map
|
||||||
|
|
||||||
|
- Fleet entry point and desired/observed decision tree: `docs/fleet/README.md`.
|
||||||
|
- Concepts: `docs/fleet/concepts/` covers authority/projections, identity separation, role authority/leases, and the generated launch chain.
|
||||||
|
- Operator workflows: `docs/fleet/how-to/` covers CRUD, lifecycle, interaction and validator instances, and role overrides.
|
||||||
|
- Operations: `docs/fleet/operations/` covers reconciliation/recovery, quarantine, systemd/tmux troubleshooting, backup/restore boundaries, and upgrade-asset holds.
|
||||||
|
- References: executable schema, complete field/default/constraint reference, CLI/JSON/exit behavior, lifecycle/status/drift, role authority, and generated environment boundary under `docs/fleet/reference/`.
|
||||||
|
- Migration: preview field map, lifecycle preservation, backup/recovery prerequisites, aliases, and executable artifact dispositions under `docs/fleet/migration/`.
|
||||||
|
- Navigation: `docs/SITEMAP.md` and the fleet entry point.
|
||||||
|
|
||||||
|
## Acceptance mapping
|
||||||
|
|
||||||
|
| Checklist area | Evidence |
|
||||||
|
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Roster authority and fail-closed legacy handling | Root PRD FCM-REQ-01/05/08; desired/observed and quarantine pages. |
|
||||||
|
| Classes and authority | Root PRD FCM-REQ-07; role authority concept/reference; configurable interaction/validator how-tos. |
|
||||||
|
| Lifecycle | Root PRD FCM-REQ-04; lifecycle transition table and operator lifecycle how-to. |
|
||||||
|
| Local-only generated launch boundary | Root PRD FCM-REQ-05/09; generated launch concept/reference. |
|
||||||
|
| Complete DAG and artifact inventory | `docs/TASKS.md`; M0 inventory; executable disposition tests. |
|
||||||
|
| IA pages | Every path named by the M0 checklist exists and is linked from `docs/fleet/README.md`. |
|
||||||
|
| Examples | `docs/fleet/examples/roster-v2.yaml` validates through production v2 compiler/shared resolver; shipped artifact dispositions validate through declared production readers. |
|
||||||
|
| Links | Deterministic local Markdown link test covers the entire fleet book and sitemap, including local heading-fragment resolution. |
|
||||||
|
| Sensitive/example safety | Validator scans backtick- and tilde-fenced fleet-book examples plus the canonical roster for sensitive-looking keys, common credential formats (including Anthropic, OpenAI project, and Stripe restricted keys), path-qualified privileged commands, package-manager/root commands, arbitrary command override, and hardcoded Tess/Ultron identities; findings report only file/block and violation kind, never matched values. |
|
||||||
|
| Holds | `docs/reports/deferred/758-fleet-config-deferrals.md` records M3-002, M4-002, M5-002, compatibility, and repository-structure boundaries. |
|
||||||
|
|
||||||
|
## Documentation completion checklist
|
||||||
|
|
||||||
|
- [x] Root PRD exists and remains the #758 requirements authority.
|
||||||
|
- [ ] The accepted project-specific fleet book is indexed, but it is not complete against `FCM-REQ-03`: the required explicit programmatic `mosaic fleet validate` operation is not implemented. The CLI reference and deferral report record this gap without inventing behavior.
|
||||||
|
- [x] Sitemap links the fleet entry point and operator-critical pages.
|
||||||
|
- [x] No HTTP/API/auth contract changed; OpenAPI/endpoint rows are not applicable.
|
||||||
|
- [x] Working evidence remains under `docs/scratchpads/`; closure and deferral evidence remains under `docs/reports/`.
|
||||||
|
- [x] Canonical source remains in-repository; no external publishing action is in scope.
|
||||||
|
- [ ] Independent exact-head documentation review, PR CI, and FCM-M5-002 release certificate remain post-PR gates and are not claimed here.
|
||||||
|
|
||||||
|
## Live-action boundary
|
||||||
|
|
||||||
|
No migration, canary, rollback, deployment, systemd/tmux/session operation, generated projection, or product mutation was performed. `roster.yaml` remains the sole writable desired-state authority. `mos-comms` remains temporary. Parent issue #758 remains open.
|
||||||
|
|
||||||
|
Validation command results and exact commit/tree evidence are recorded in the task scratchpad and PR body after execution.
|
||||||
39
docs/scratchpads/758-fcm-m3-001-local-reconciler.md
Normal file
39
docs/scratchpads/758-fcm-m3-001-local-reconciler.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# FCM-M3-001 — Local roster-owned reconciliation and lifecycle
|
||||||
|
|
||||||
|
- **Task / issue:** FCM-M3-001 / #758
|
||||||
|
- **Branch / base:** `feat/758-local-reconciler` from `origin/main` `bc5e73629e92c56a80fa6a769ebad17c0177f504`
|
||||||
|
- **Base tree:** `1b9ebe4fa1a90734b6f81118e120bae5290cd350`
|
||||||
|
- **Scope:** source, isolated fake-adapter tests, and card documentation only. No live fleet/systemd/tmux action.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Provide local roster-v2 `apply`/`reconcile` and lifecycle/status contracts. The roster remains desired-state authority; projections and runtime observations are derived state.
|
||||||
|
|
||||||
|
## Red-first evidence
|
||||||
|
|
||||||
|
The initial focused reconciler test failed because `fleet-reconciler.ts` did not exist. The initial new-worktree test invocation also exposed absent dependencies; `pnpm install --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store` restored local workspace dependencies without changing source.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
- A new `fleet-reconciler.ts` accepts only typed roster-v2 input plus injected command and projection adapters.
|
||||||
|
- It targets only exact `mosaic-agent@<roster-name>.service` units and the exact configured tmux socket/session.
|
||||||
|
- It classifies unowned/unmanaged state and fails mutation closed rather than adopting or killing it.
|
||||||
|
- It validates the private install-derived holder identity and complete expected tmux global environment before mutating lifecycle state.
|
||||||
|
- REVIEW-1 remediation: RED review evidence found service-level apply could omit generation and had no mutation lock. Every non-observational command now requires an expected generation; a private exclusive roster-adjacent lock is acquired before effects and released on success or partial failure. Tests cover missing/stale values, concurrent denial, no effects, release, and lock-free observation.
|
||||||
|
- REVIEW-2 remediation: lock acquisition now validates private real `MOSAIC_HOME`/`fleet` ancestors, rejects symlink or unsafe leaves, distinguishes `EEXIST` concurrency from other I/O, and binds release to the created inode plus random ownership token. A replacement lock is retained and reported, not unlinked. A crash may leave a stale lock for inspection; no stale-lock break is claimed.
|
||||||
|
- REVIEW-3 remediation: a lock cleanup failure now adds bounded `cleanup` diagnostics to a known successful or partial effect result without replacing its projection/lifecycle/recovery truth. Cleanup is not claimed as complete, and the retained lock requires inspection before retry.
|
||||||
|
- REVIEW-4 remediation: command JSON with an additive cleanup diagnostic now exits non-zero even where known effects completed; clean effect and observational JSON remain zero-exit.
|
||||||
|
- REVIEW-5 remediation: mutating operations acquire the private lock before rereading canonical `roster.yaml`; the fenced reread, not a caller snapshot, supplies generation validation, plan, projection, and lifecycle authority.
|
||||||
|
- `apply` starts only enabled agents whose persisted desired state is `running`; stopped/default agents are never started by reconciliation.
|
||||||
|
- Observational commands produce JSON classification only. Partial projection or lifecycle effects report explicit recovery without values.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
Excluded: live host actions, remote/SSH/connector lifecycle mutation, migrations, canaries, deployment, gateway changes, arbitrary command/channel/secret inputs, `docs/TASKS.md`, and orchestration ledgers.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Focused reconciler/Commander/CRUD/fleet tests: 4 files / 229 tests passed.
|
||||||
|
- Full `@mosaicstack/mosaic` suite: 56 files / 820 tests passed after REVIEW-5 canonical roster fencing remediation.
|
||||||
|
- Package and root typecheck/lint, root format check, and `git diff --check`: passed.
|
||||||
|
- Isolated launcher and systemd template harnesses passed; they use fixtures only. No live fleet, systemd, tmux, session, remote, connector, or runtime action occurred.
|
||||||
@@ -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.
|
||||||
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.
|
||||||
85
docs/scratchpads/fcm-m5-001-fleet-config-operator-docs.md
Normal file
85
docs/scratchpads/fcm-m5-001-fleet-config-operator-docs.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# FCM-M5-001 — Fleet configuration operator documentation
|
||||||
|
|
||||||
|
- Task: `FCM-M5-001`
|
||||||
|
- Issue: `#758`
|
||||||
|
- Branch: `docs/758-fleet-config-operator-docs`
|
||||||
|
- Exact base: `9745bc3f29c26b021a478b7ad03cfb494f6c9de3` (tree `4da210da9a71b035130d4160a4a2e691bdfde2da`)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Deliver the accepted fleet documentation information architecture, operator workflows, operations and migration references, comprehensive contract documentation, and deterministic link/example validation without live fleet action or product mutation.
|
||||||
|
|
||||||
|
## Scope and constraints
|
||||||
|
|
||||||
|
- Documentation, examples, documentation validation, and tracking only.
|
||||||
|
- `roster.yaml` remains the sole writable desired-state authority; generated state is derived/observed.
|
||||||
|
- No M4-002 implementation or execution; no canary, migration, rollback, deployment, systemd/tmux/session, generated projection, or product mutation.
|
||||||
|
- `mos-comms` is temporary and is not permanent architecture.
|
||||||
|
- Parent issue `#758` remains open through M5.
|
||||||
|
- No credentials, sensitive values, or privileged command content.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Update tracking first with exact M4-001 evidence and mark M5-001 in progress.
|
||||||
|
2. Map the M0 checklist and current implementation behavior to documentation pages.
|
||||||
|
3. Author operator, operations, migration, schema/reference, recovery, troubleshooting, and security/authority docs.
|
||||||
|
4. Add or extend deterministic documentation/link/example validation if required, red-first.
|
||||||
|
5. Run repository documentation, link, example, and relevant package checks; review and remediate.
|
||||||
|
6. Commit, queue-guard, push one branch, and open one wrapper-created PR; stop for independent review.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
- Task estimate: `24K`.
|
||||||
|
- Working cap: stay within the card estimate by parallelizing read-only discovery and limiting edits to checklist-required artifacts.
|
||||||
|
|
||||||
|
## Progress checkpoints
|
||||||
|
|
||||||
|
- [x] Loaded repository/global delivery and documentation contracts.
|
||||||
|
- [x] Verified `origin/main` is exact required base and created isolated worktree.
|
||||||
|
- [x] Tracking updated first.
|
||||||
|
- [x] Checklist mapped and docs authored.
|
||||||
|
- [x] Validation green.
|
||||||
|
- [x] Review/remediation complete.
|
||||||
|
- [x] Commit, queue guard, push, PR #789.
|
||||||
|
- [x] Rejected exact-head RoR findings repaired on a new descendant commit candidate.
|
||||||
|
- [ ] New exact-head review and CI after repair push.
|
||||||
|
|
||||||
|
## Tests and verification
|
||||||
|
|
||||||
|
- Red-first documentation validator initially failed for the absent fleet entry point and canonical
|
||||||
|
example, then passed after the IA and example were added.
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/fleet/roster-v2.spec.ts src/fleet/example-profile-dispositions.spec.ts src/fleet/fleet-documentation.spec.ts src/fleet/v1-v2-migration.spec.ts src/fleet/generated-env-boundary.spec.ts src/fleet/fleet-agent-crud.spec.ts src/fleet/fleet-reconciler.spec.ts` — 7 files, 195 tests passed after building workspace dependencies.
|
||||||
|
- `pnpm format:check` — passed.
|
||||||
|
- `pnpm lint` — 23 tasks passed.
|
||||||
|
- `pnpm typecheck` — 42 tasks passed.
|
||||||
|
- `pnpm test` — 43 tasks passed; `@mosaicstack/mosaic` contributed 61 files and 1,045 tests.
|
||||||
|
- `bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh` — passed.
|
||||||
|
- `bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh` — passed.
|
||||||
|
- `git diff --check` — passed before final review.
|
||||||
|
- Independent staged-snapshot review identified four documentation/validation blockers: reboot safety,
|
||||||
|
heartbeat observation, migration failure envelope, and example-scan coverage. All were remediated;
|
||||||
|
focused rereview approved the staged remediations with no blockers. Exact committed-head review remains
|
||||||
|
a post-PR gate.
|
||||||
|
- Post-remediation `@mosaicstack/mosaic` lint/typecheck passed; package test passed 61 files / 1,045
|
||||||
|
tests; sanitization and resident-budget gates passed again.
|
||||||
|
|
||||||
|
- Post-PR exact-head RoR on rejected head `0aee2c09819fd06e28f927384ea56fa2ef374edf`
|
||||||
|
identified five blockers: update-lifecycle overclaim, missing explicit `fleet validate` gap,
|
||||||
|
fragment-blind link validation, unsupported checklist-evidence claim, and insufficient example safety
|
||||||
|
validation. Red-first regressions failed before implementation for missing-heading, privileged-command,
|
||||||
|
and credential-format fixtures. Repairs now preserve/document implementation truth, validate heading
|
||||||
|
fragments, narrow checklist claims, and scan fenced/canonical examples for common credential formats
|
||||||
|
and privileged commands without printing fixture values.
|
||||||
|
- Repair-focused fleet contracts: 7 files, 192 tests passed after review remediation; documentation
|
||||||
|
validator contributed 11 tests. Full gates passed: format; lint 23/23; typecheck 42/42; test 43/43
|
||||||
|
tasks with `@mosaicstack/mosaic` 61 files / 1,052 tests; sanitization; resident budget; and
|
||||||
|
`git diff --check`. New exact-head review/CI remain pending until the repair commit is pushed.
|
||||||
|
|
||||||
|
## Risks/blockers
|
||||||
|
|
||||||
|
- Checklist may include behavior intentionally deferred to M4-002/M5-002; such items must be recorded as approved-existing holds rather than claimed delivered.
|
||||||
|
- Commands/examples must remain non-live and avoid privileged/sensitive content.
|
||||||
|
|
||||||
|
## Final evidence
|
||||||
|
|
||||||
|
- Pending.
|
||||||
80
docs/scratchpads/issue-766-exact-fleet-comms.md
Normal file
80
docs/scratchpads/issue-766-exact-fleet-comms.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Issue 766 — exact cross-harness fleet comms targeting
|
||||||
|
|
||||||
|
- **Issue:** #766
|
||||||
|
- **Branch:** `fix/766-exact-fleet-comms`
|
||||||
|
- **Worktree:** `/home/jarvis/src/stack-issue-766`
|
||||||
|
- **Delivery boundary:** source/tests/docs only; no live tmux, session, or fleet actions; leave uncommitted for independent review.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Replace inference-prone fleet onboarding guidance with one roster-resolved contract that gives Claude Code, Codex, OpenCode, and Pi the same authoritative local identity and exact executable command for every known peer.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add issue-specific normative requirements to `docs/PRD.md` before source changes; do not modify orchestrator-owned `docs/TASKS.md`.
|
||||||
|
2. Extract the existing v1 roster parsing/normalization into one lightweight shared resolver used by both fleet commands and runtime comms composition.
|
||||||
|
3. Write failing contract tests for explicit SSH-only cross-host targeting, global/default socket authority, authoritative identity, unknown-peer failure, no operational metavariables, and four-harness parity.
|
||||||
|
4. Make source `TOOLS.md` non-operational and marker-versioned; prove fresh installation preserves that exact contract and composition detects a stale installed copy without rewriting it.
|
||||||
|
5. Render a deterministic comms generation and document comparison/relaunch handling; never rewrite an active session.
|
||||||
|
6. Run focused Vitest and shell exact-target tests, then package/repository typecheck, lint, format, test, and build gates as relevant.
|
||||||
|
7. Reconstruct the exact uncommitted tree, including untracked files, for independent review and remediate findings without committing.
|
||||||
|
|
||||||
|
## Contract decisions
|
||||||
|
|
||||||
|
- `tmux.socket_name` is the one supported socket authority for every local fleet session. A per-agent `socket`, when present for compatibility, must equal that global value; independent per-agent sockets fail closed because the runtime does not provision them. A named socket renders `-L`, while the empty literal default renders no `-L`.
|
||||||
|
- A peer is same-host only when its resolved host equals the current roster member's resolved host. Every host-omitted member resolves against the stable local fleet-host baseline, never against the viewer's explicit host. Same-host rows never render `-H`.
|
||||||
|
- A cross-host row requires that peer's explicit roster `ssh`; absence is a contract error. Never substitute `host` as an SSH target.
|
||||||
|
- The current member's explicit roster `host` wins; otherwise the local machine's short hostname is the baseline for host-omitted local members.
|
||||||
|
- Unknown members/peers return a deterministic error listing exact known names and an exact self-scoped discovery command. No fuzzy session lookup.
|
||||||
|
- Exact command fields are structurally constrained to safe targeting grammars and shell-rendered as individual arguments. Unsafe host/SSH/socket values fail roster normalization rather than entering executable guidance.
|
||||||
|
- Existing installed `TOOLS.md` remains user-owned during ordinary keep-mode updates. Currency requires the expected source and installed marker/version plus bounded SHA-256 byte identity. Explicit `mosaic update --repair-tools` is the supported current-version recovery path: it makes a digest-qualified no-clobber backup, restores the contract and regular executable helper, and does not rewrite active context.
|
||||||
|
- The v1 resolver preserves and validates `tmux`, `discord`, and `matrix` connector blocks in YAML and JSON; conflicting snake/camel aliases fail closed unless their values are identical.
|
||||||
|
- JSON roster fallback occurs only when `roster.yaml` is absent. Keep-mode reseed preserves both formats, and relaunch discovery uses the same canonical resolver.
|
||||||
|
- The helper is inspected without following symlinks and must be a regular executable file. Missing, directory, symlink, and non-executable installations fail closed with deterministic repair guidance.
|
||||||
|
- Active contexts carry a deterministic comms generation. Operators compare it to `mosaic agent comms-block <exact-agent>` output; mismatch means stale and requires an explicit exact-agent relaunch.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Import cycles if runtime composition imports the command-heavy `fleet.ts`; mitigate with a lightweight shared roster module and re-export compatibility.
|
||||||
|
- Existing schema prose allowed independent per-agent sockets even though runtime provisioning used one global socket; constrain compatibility declarations to the global value and preserve empty-global default behavior.
|
||||||
|
- Remote inventory may be incomplete. Fail composition closed for an unreachable cross-host row rather than generating a guessed command.
|
||||||
|
- `TOOLS.md` is user-seeded and intentionally preserved. Detect/report drift instead of overwriting custom content.
|
||||||
|
|
||||||
|
## Planned evidence
|
||||||
|
|
||||||
|
- `comms-onboarding.spec.ts`: resolver/renderer/failure/generation contracts.
|
||||||
|
- `compose-contract.spec.ts`: identical authoritative comms section for all four harnesses and stale installed-contract reporting without mutation.
|
||||||
|
- `file-adapter.test.ts`: source-to-fresh-install byte equality and preservation of customized installed `TOOLS.md`.
|
||||||
|
- Existing `agent-send.test.sh`, socket isolation, and tmux runtime transport tests.
|
||||||
|
- Repository quality gates and independent uncommitted-tree review.
|
||||||
|
|
||||||
|
## Evidence log
|
||||||
|
|
||||||
|
- Preflight collision scan: no issue-766 local/remote branch, worktree, or open PR collision before branch creation.
|
||||||
|
- Isolated branch created from fetched `origin/main` at `4990905`; original checkout not edited.
|
||||||
|
- One strict v1 resolver now serves fleet commands and communications composition; roster writes preserve `host`, `ssh`, and `socket`.
|
||||||
|
- Exact renderer covers authoritative self identity, global/default socket authority, rejected independent sockets, stable hostless-peer resolution, same-host omission of `-H`, explicit-SSH-only cross-host rows, shell-safe argv rendering, deterministic generations, and fail-closed unknown/missing targets.
|
||||||
|
- Real framework `defaults/TOOLS.md` is tested byte-equal through a fresh `FileConfigAdapter` install, the installed helper is executable, and the final Pi contract contains the same source contract plus exact generated command; separate parity coverage proves byte-equivalent comms sections for Claude Code, Codex, OpenCode, and Pi.
|
||||||
|
- Second review remediation adds strict connector/alias coverage, full-semantic generation coverage, ENOENT-only fallback, no-follow helper validation, unconditional current-version repair, digest-qualified no-clobber backups, and marker/version-gated currency.
|
||||||
|
- The helper, roster, installed TOOLS, and framework source files are read with canonical containment, every existing ancestor and target rejected if symlinked, `O_NOFOLLOW` descriptor reads, inode stability checks, and effective-identity execute access. Read-only TOOLS status treats source/installed symlinks as unavailable without following or rewriting them.
|
||||||
|
- Explicit repair validates both bundled inputs before destination creation, stages backup/TOOLS/helper plus exact-mode rollback files before any persistent file commit, revalidates destination identity at each commit boundary, installs the digest backup without clobber, and removes or exactly rolls back every committed output on injected failure. `changed: false` is returned only after full cleanup; cleanup/rollback failure is reported as `changed: true`.
|
||||||
|
- Connector schema and runtime normalization require kind-matching settings and reject inactive connector blocks. Keep-mode installers preserve only exact `roster.yaml`, `roster.json`, `agents/`, and `run/` paths while refreshing framework `roster.schema.json`; shell evidence covers byte preservation and schema refresh.
|
||||||
|
- Solo contracts render normalized role/class plus explicit no-peer/no-remote authority boundaries; composed-contract evidence keeps role Mandate/Boundaries before Fleet Comms.
|
||||||
|
- Operational documentation and CLI metavariable now use `mosaic agent comms-block <exact-member>`; historical issue-633 scratchpad text remains historical.
|
||||||
|
- The latest independent review rejected synthetic tree `556ae4ea04f2715a4e9d381f3cafaf4c8b991b2e` on three mandatory findings: installed `TOOLS.md` could be read through target/ancestor symlinks before unsafe status was reported; ambient class/tool-policy state could split identity authority from the canonical roster member; and the connector schema admitted empty or whitespace-only Discord/Matrix strings rejected by runtime parsing.
|
||||||
|
- Red-first reproduction proved all three findings with 20 failures and 79 passes. Remediation routes installed `TOOLS.md` through the bounded secure regular-file reader before composition, resolves one exact canonical fleet identity for persona/tool policy/normalized class/Fleet Comms, rejects canonicalized ambient class mismatches, canonicalizes compatibility classes during roster parsing, and aligns parser/schema non-whitespace requirements.
|
||||||
|
- Four-runtime coverage proves unsafe target and ancestor symlink content is omitted without mutation, while Claude Code, Codex, OpenCode, and Pi all project the same canonical member authority. Connector parser/schema coverage includes empty and whitespace-only Discord `channel_id` and Matrix `homeserver_url`, `user_id`, and `room_id` values.
|
||||||
|
- Remediated focused gates passed: 99/99 across the two finding-focused suites plus connector schema regression PASS; the six changed-suite matrix passed 341/341; secure-file/transaction coverage remains green, including 28/28 transactional repair tests; installer migration passed 21/21.
|
||||||
|
- Mosaic package suite passed 906/906. Shell/runtime regressions passed: `agent-send.test.sh` `PASS=11 FAIL=0`; named-socket isolation; matrix/tmux transport 12/12 (Matrix 5/5, tmux 7/7).
|
||||||
|
- Final repository gates passed: format check; typecheck 42/42 tasks; lint 23/23; tests 42/42 tasks (Mosaic 906/906, gateway 628 passed/12 skipped); build 23/23.
|
||||||
|
- A subsequent immutable review of tree `aa6414123643a504145fce6ac1d66f0b535feb5e` found one roster-authority blocker: a canonical member with omitted `tool_policy` inherited ambient `MOSAIC_AGENT_TOOL_POLICY`. Red-first four-runtime coverage failed 4/39 specifically on the leaked operator-interaction policy. Composition now branches on canonical membership: fleet launches use only `canonicalMember.toolPolicy` (including canonical absence), while genuinely non-fleet launches retain ambient fallback.
|
||||||
|
- Final remediated gates passed: four-runtime regression 39/39; six changed-suite matrix 345/345; connector schema regression PASS; Mosaic package 910/910; installer migration 21/21; `agent-send.test.sh` 11/11; named-socket isolation PASS; Matrix/tmux transport 12/12; repository format PASS; typecheck 42/42 tasks; lint 23/23 tasks; tests 42/42 tasks; build 23/23 tasks.
|
||||||
|
- No live tmux/session/fleet mutation, commit, push, PR mutation, issue mutation, context mutation, or reviewer launch performed.
|
||||||
|
- Exact synthetic-tree reconstruction and frozen evidence are included in the coordinator handoff.
|
||||||
|
- Sole-remediation preflight reverified the clean committed checkout at head `0dc47cac92c93a3ffd39ba9dd6685ac4165f6361`, tree `538de6ccce1f8c44ba288a7493286e63a3413e75`, branch `fix/766-exact-fleet-comms`; issue and PR state were read only through Mosaic wrappers.
|
||||||
|
- Deterministic red-first ancestor substitution swapped validated `root/tools` for an external symlink immediately after `lstat`; current head returned `external marker` (`1 failed, 4 passed`) before implementation.
|
||||||
|
- Secure reads now hold `/` and every root/descendant directory descriptor, traverse appended components through Linux `/proc/self/fd` with `O_DIRECTORY|O_NOFOLLOW`, and read plus effective-identity execute-check the same final descriptor. Non-Linux or unavailable proc-fd capability fails closed; stable errors redact managed paths while retaining Node `code` compatibility for missing/non-executable repair behavior.
|
||||||
|
- Added deterministic root-selection, descendant-ancestor, and final-target substitution coverage. All return trusted descriptor-bound bytes after rename/symlink replacement; the race suite passed 50/50 repeated runs.
|
||||||
|
- Isolated CLI verification drove `mosaic agent --mosaic-home <fixture> comms-block self` while repeatedly swapping `fleet/` with an external symlink: `trusted=2 fail_closed=10 external_marker=0`; a persistent symlink ancestor exited 1 with a redacted unsafe-ancestor error. No live fleet state was used or mutated.
|
||||||
|
- Remediation gates: focused secure-file/comms/launch/tmux/Matrix `110/110`; full `@mosaicstack/mosaic` `914/914`; package and repository typecheck pass (`42/42` repository tasks); package and repository lint pass (`23/23` repository tasks); repository format check and `git diff --check` pass.
|
||||||
|
- Independent review found one production hardening blocker (nonblocking final open), one redacted-error blocker, and a deterministic ancestor-test gap. Remediation added `O_NONBLOCK`, normalized execute errors while preserving errno, proved the ancestor hook fires, and added final-target substitution coverage; post-remediation review evidence is clean on the production invariant.
|
||||||
@@ -5,20 +5,20 @@ Tool suites live at `~/.config/mosaic/tools/<suite>/`. This is the index only.
|
|||||||
read it (or the relevant service guide) when your task actually touches that service.
|
read it (or the relevant service guide) when your task actually touches that service.
|
||||||
Project-specific tooling belongs in the project's `AGENTS.md`, not here.
|
Project-specific tooling belongs in the project's `AGENTS.md`, not here.
|
||||||
|
|
||||||
## ⚡ Most-used fleet tools (reach for these FIRST — don't hand-roll)
|
## Most-used fleet tools (reach for these first)
|
||||||
|
|
||||||
You are a Mosaic fleet agent. These cover the highest-frequency cross-agent and git-provider
|
<!-- fleet-comms-contract: 1 -->
|
||||||
tasks — use them before improvising with raw `tmux send-keys`, raw `tea`/`gh`/`glab`, or `curl`.
|
|
||||||
|
|
||||||
**1. Message another agent** → `tools/tmux/agent-send.sh` (NOT raw `tmux send-keys`):
|
You are a Mosaic fleet agent. Use the runtime-composed **Fleet Comms — authoritative exact targets**
|
||||||
|
section for inter-agent messaging. It renders your authoritative local host, exact agent/session, resolved
|
||||||
|
tmux socket, installed helper path, generation, and one executable command per known peer.
|
||||||
|
|
||||||
```bash
|
Select only a peer row rendered for your exact roster identity. Never invent, substitute, or fuzzy-match
|
||||||
tools/tmux/agent-send.sh -s <target-session> -m "message" # or -f <file> to send a file's contents
|
a host, session, socket, SSH destination, or helper path. If a peer is absent, stop and run the exact
|
||||||
```
|
self-scoped discovery command shown in that composed section; report the peer as unknown if it remains
|
||||||
|
absent. Do not use raw `tmux send-keys` for fleet messaging.
|
||||||
|
|
||||||
The coordinator session is `mos-claude` — send status, findings, and questions there.
|
**Issues / PRs / milestones** → `tools/git/*.sh` wrappers (before raw `tea`/`gh`/`glab`):
|
||||||
|
|
||||||
**2. Issues / PRs / milestones** → `tools/git/*.sh` wrappers (before raw `tea`/`gh`/`glab`):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ...
|
tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ...
|
||||||
|
|||||||
@@ -94,11 +94,11 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"ssh": {
|
"ssh": {
|
||||||
"description": "SSH target (user@host) for a cross-host peer, so onboarding renders the `agent-send.sh -H <user@host>` form. Optional; only needed for agents on a different host than the fleet.",
|
"description": "Explicit SSH target (normally user@host) for a cross-host inventory peer. Exact comms rendering requires this whenever the peer's resolved host differs from the current agent's host; the host value is never substituted as an SSH destination.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"socket": {
|
"socket": {
|
||||||
"description": "tmux socket the agent's session runs on. Onboarding renders `-L <socket>` when set; absent = the default socket (no `-L`). Must match the LIVE socket, not blindly inherit the roster's tmux.socket_name.",
|
"description": "Optional compatibility declaration of the fleet-wide tmux socket. When present it must exactly equal tmux.socket_name; independent per-agent sockets are rejected because the local fleet runtime provisions every session on the fleet-wide socket.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"working_directory": {
|
"working_directory": {
|
||||||
@@ -150,29 +150,67 @@
|
|||||||
"description": "Orchestrator chat connector (F4). Optional — absent means tmux (back-compat). Secrets (access/bot tokens) come from the environment, never this file.",
|
"description": "Orchestrator chat connector (F4). Optional — absent means tmux (back-compat). Secrets (access/bot tokens) come from the environment, never this file.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["kind"],
|
"oneOf": [
|
||||||
"properties": {
|
{
|
||||||
"kind": {
|
"properties": { "kind": { "const": "tmux" } },
|
||||||
"enum": ["tmux", "discord", "matrix"]
|
"required": ["kind"],
|
||||||
},
|
"not": {
|
||||||
"matrix": {
|
"anyOf": [{ "required": ["discord"] }, { "required": ["matrix"] }]
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["homeserver_url", "user_id", "room_id"],
|
|
||||||
"properties": {
|
|
||||||
"homeserver_url": { "type": "string" },
|
|
||||||
"user_id": { "type": "string" },
|
|
||||||
"room_id": { "type": "string" }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"discord": {
|
{
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["channel_id"],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"channel_id": { "type": "string" }
|
"kind": { "const": "discord" },
|
||||||
}
|
"discord": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["channel_id"],
|
||||||
|
"properties": {
|
||||||
|
"channel_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "\\S"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["kind", "discord"],
|
||||||
|
"not": { "required": ["matrix"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"kind": { "const": "matrix" },
|
||||||
|
"matrix": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["homeserver_url", "user_id", "room_id"],
|
||||||
|
"properties": {
|
||||||
|
"homeserver_url": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "\\S"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "\\S"
|
||||||
|
},
|
||||||
|
"room_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "\\S"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["kind", "matrix"],
|
||||||
|
"not": { "required": ["discord"] }
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"kind": { "enum": ["tmux", "discord", "matrix"] },
|
||||||
|
"matrix": { "type": "object" },
|
||||||
|
"discord": { "type": "object" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
|||||||
# fleet/* — the framework SEEDS fleet/examples, fleet/roles, fleet/profiles, and
|
# fleet/* — the framework SEEDS fleet/examples, fleet/roles, fleet/profiles, and
|
||||||
# fleet/roster.schema.json (synced normally — every fleet/roles/*.md role contract
|
# fleet/roster.schema.json (synced normally — every fleet/roles/*.md role contract
|
||||||
# and fleet/profiles/*.yaml system-type profile lands automatically via this sync,
|
# and fleet/profiles/*.yaml system-type profile lands automatically via this sync,
|
||||||
# so no per-file entry is needed; the preserved "fleet/*.yaml" glob is anchored to
|
# so no per-file entry is needed; exact preserved roster paths are anchored to
|
||||||
# the top level only and does NOT shadow fleet/profiles/*.yaml). The user's
|
# the top level only and do NOT shadow fleet/profiles/*.yaml). The user's
|
||||||
# own fleet files MUST
|
# own fleet files MUST
|
||||||
# survive `mosaic update` (which runs this sync automatically): the active
|
# survive `mosaic update` (which runs this sync automatically): the active
|
||||||
# roster (`fleet/roster.yaml` + any other `fleet/*.yaml`), per-agent env
|
# rosters (`fleet/roster.yaml` and `fleet/roster.json`), per-agent env
|
||||||
# (`fleet/agents/`), heartbeat run dir (`fleet/run/`), and the Mosaic-native
|
# (`fleet/agents/`), heartbeat run dir (`fleet/run/`), and the Mosaic-native
|
||||||
# backlog-of-record store (`fleet/backlog/` — embedded PGlite data dir; see
|
# backlog-of-record store (`fleet/backlog/` — embedded PGlite data dir; see
|
||||||
# packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update
|
# packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update
|
||||||
@@ -44,7 +44,7 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
|||||||
# and user-ADDED personas instead live in fleet/roles.local/ and MUST survive
|
# and user-ADDED personas instead live in fleet/roles.local/ and MUST survive
|
||||||
# `mosaic update` — they win over the baseline on merge (AC-NS-7; see
|
# `mosaic update` — they win over the baseline on merge (AC-NS-7; see
|
||||||
# packages/mosaic/src/commands/fleet-personas.ts).
|
# packages/mosaic/src/commands/fleet-personas.ts).
|
||||||
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/*.yaml" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")
|
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/roster.yaml" "fleet/roster.json" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")
|
||||||
|
|
||||||
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
|
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
|
||||||
# user must not edit them; a divergent copy is backed up once before overwrite).
|
# user must not edit them; a divergent copy is backed up once before overwrite).
|
||||||
@@ -200,8 +200,8 @@ sync_framework() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fallback: cp-based sync. Glob-aware so entries like "fleet/*.yaml" preserve
|
# Fallback: cp-based sync. Exact top-level preserved paths mirror the
|
||||||
# every matching user file (parity with the rsync --exclude path above).
|
# root-anchored rsync excludes above.
|
||||||
local preserve_tmp=""
|
local preserve_tmp=""
|
||||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||||
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"
|
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"
|
||||||
|
|||||||
@@ -61,25 +61,33 @@ MOSAIC_HOME="$T5" MOSAIC_INSTALL_MODE=bogus MOSAIC_SYNC_ONLY=1 bash "$INSTALL" >
|
|||||||
chk "F5 failure: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]"
|
chk "F5 failure: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]"
|
||||||
chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
|
chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
|
||||||
|
|
||||||
# F6 — keep-mode re-seed (the `mosaic update` path) MUST NOT wipe user fleet data.
|
# F6 — keep-mode re-seed (the `mosaic update` path) MUST preserve only the
|
||||||
# Regression for the roster-loss bug: fleet/ was not in PRESERVE_PATHS.
|
# exact user-owned roster paths while refreshing framework-owned schema/examples.
|
||||||
T6=$(mktemp -d); mkdir -p "$T6/fleet/examples" "$T6/fleet/run" "$T6/fleet/agents"
|
T6=$(mktemp -d); mkdir -p "$T6/fleet/examples" "$T6/fleet/run" "$T6/fleet/agents"
|
||||||
printf '# persona\n' > "$T6/SOUL.md" # makes it a recognized existing install (→ keep mode)
|
printf '# persona\n' > "$T6/SOUL.md" # makes it a recognized existing install (→ keep mode)
|
||||||
printf 'version: 1\nagents:\n - name: coder0\n' > "$T6/fleet/roster.yaml"
|
printf 'version: 1\nagents:\n - name: coder0\n' > "$T6/fleet/roster.yaml"
|
||||||
printf 'version: 1\nagents:\n - name: custom\n' > "$T6/fleet/my-fleet.yaml"
|
printf '{"version":1,"agents":[{"name":"json-user"}]}\n' > "$T6/fleet/roster.json"
|
||||||
|
printf 'version: 1\nagents:\n - name: not-active-roster\n' > "$T6/fleet/my-fleet.yaml"
|
||||||
printf 'ts=x\n' > "$T6/fleet/run/coder0.hb"
|
printf 'ts=x\n' > "$T6/fleet/run/coder0.hb"
|
||||||
printf 'MOSAIC_AGENT_NAME=coder0\n' > "$T6/fleet/agents/coder0.env"
|
printf 'MOSAIC_AGENT_NAME=coder0\n' > "$T6/fleet/agents/coder0.env"
|
||||||
printf '# stale preset\n' > "$T6/fleet/examples/general.yaml"
|
printf '# stale preset\n' > "$T6/fleet/examples/general.yaml"
|
||||||
|
printf '{"stale":true}\n' > "$T6/fleet/roster.schema.json"
|
||||||
|
E6=$(mktemp -d)
|
||||||
|
cp "$T6/fleet/roster.yaml" "$E6/roster-yaml.expected"
|
||||||
|
cp "$T6/fleet/roster.json" "$E6/roster-json.expected"
|
||||||
|
cp "$T6/fleet/run/coder0.hb" "$E6/run.expected"
|
||||||
|
cp "$T6/fleet/agents/coder0.env" "$E6/agent.expected"
|
||||||
echo 3 > "$T6/.framework-version"
|
echo 3 > "$T6/.framework-version"
|
||||||
run "$T6" keep
|
run "$T6" keep
|
||||||
chk "F6 reseed: user roster.yaml SURVIVES keep-mode sync" "grep -q coder0 '$T6/fleet/roster.yaml'"
|
chk "F6 reseed: exact roster.yaml bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.yaml' '$E6/roster-yaml.expected'"
|
||||||
chk "F6 reseed: other user fleet/*.yaml survives (glob)" "[ -f '$T6/fleet/my-fleet.yaml' ]"
|
chk "F6 reseed: exact roster.json bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.json' '$E6/roster-json.expected'"
|
||||||
chk "F6 reseed: per-agent env (fleet/agents) survives" "[ -f '$T6/fleet/agents/coder0.env' ]"
|
chk "F6 reseed: unrelated fleet YAML is not preserved" "[ ! -f '$T6/fleet/my-fleet.yaml' ]"
|
||||||
chk "F6 reseed: heartbeat run dir (fleet/run) survives" "[ -f '$T6/fleet/run/coder0.hb' ]"
|
chk "F6 reseed: per-agent env bytes survive" "cmp -s '$T6/fleet/agents/coder0.env' '$E6/agent.expected'"
|
||||||
chk "F6 reseed: framework examples ARE refreshed (not preserved stale)" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
|
chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'"
|
||||||
chk "F6 reseed: framework roster.schema.json seeded" "[ -f '$T6/fleet/roster.schema.json' ]"
|
chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
|
||||||
|
chk "F6 reseed: framework roster schema is refreshed" "cmp -s '$T6/fleet/roster.schema.json' '$FW/fleet/roster.schema.json'"
|
||||||
|
|
||||||
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6"
|
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6" "$E6"
|
||||||
echo
|
echo
|
||||||
echo "RESULT: $pass passed, $fail failed"
|
echo "RESULT: $pass passed, $fail failed"
|
||||||
[ "$fail" -eq 0 ]
|
[ "$fail" -eq 0 ]
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regression checks for connector-kind-conditional fleet roster schema."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jsonschema import Draft202012Validator
|
||||||
|
|
||||||
|
schema_path = Path(__file__).resolve().parents[3] / "fleet" / "roster.schema.json"
|
||||||
|
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||||
|
validator = Draft202012Validator(schema)
|
||||||
|
base = {
|
||||||
|
"version": 1,
|
||||||
|
"transport": "tmux",
|
||||||
|
"agents": [{"name": "orchestrator", "runtime": "pi"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
valid = [
|
||||||
|
{"kind": "tmux"},
|
||||||
|
{"kind": "discord", "discord": {"channel_id": "123"}},
|
||||||
|
{
|
||||||
|
"kind": "matrix",
|
||||||
|
"matrix": {
|
||||||
|
"homeserver_url": "https://matrix.example",
|
||||||
|
"user_id": "@mosaic:example",
|
||||||
|
"room_id": "!room:example",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
invalid = [
|
||||||
|
{"kind": "tmux", "discord": {"channel_id": "123"}},
|
||||||
|
{"kind": "tmux", "matrix": {}},
|
||||||
|
{"kind": "discord"},
|
||||||
|
{"kind": "discord", "matrix": {}},
|
||||||
|
{
|
||||||
|
"kind": "discord",
|
||||||
|
"discord": {"channel_id": "123"},
|
||||||
|
"matrix": {
|
||||||
|
"homeserver_url": "https://matrix.example",
|
||||||
|
"user_id": "@mosaic:example",
|
||||||
|
"room_id": "!room:example",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"kind": "matrix"},
|
||||||
|
{"kind": "matrix", "discord": {"channel_id": "123"}},
|
||||||
|
{"kind": "discord", "discord": {"channel_id": ""}},
|
||||||
|
{"kind": "discord", "discord": {"channel_id": " "}},
|
||||||
|
{
|
||||||
|
"kind": "matrix",
|
||||||
|
"matrix": {
|
||||||
|
"homeserver_url": "",
|
||||||
|
"user_id": "@mosaic:example",
|
||||||
|
"room_id": "!room:example",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "matrix",
|
||||||
|
"matrix": {
|
||||||
|
"homeserver_url": "\t",
|
||||||
|
"user_id": "@mosaic:example",
|
||||||
|
"room_id": "!room:example",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "matrix",
|
||||||
|
"matrix": {
|
||||||
|
"homeserver_url": "https://matrix.example",
|
||||||
|
"user_id": "",
|
||||||
|
"room_id": "!room:example",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "matrix",
|
||||||
|
"matrix": {
|
||||||
|
"homeserver_url": "https://matrix.example",
|
||||||
|
"user_id": " ",
|
||||||
|
"room_id": "!room:example",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "matrix",
|
||||||
|
"matrix": {
|
||||||
|
"homeserver_url": "https://matrix.example",
|
||||||
|
"user_id": "@mosaic:example",
|
||||||
|
"room_id": "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "matrix",
|
||||||
|
"matrix": {
|
||||||
|
"homeserver_url": "https://matrix.example",
|
||||||
|
"user_id": "@mosaic:example",
|
||||||
|
"room_id": "\n",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for connector in valid:
|
||||||
|
errors = list(validator.iter_errors({**base, "connector": connector}))
|
||||||
|
if errors:
|
||||||
|
print(f"expected valid connector {connector}: {errors}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
for connector in invalid:
|
||||||
|
if not list(validator.iter_errors({**base, "connector": connector})):
|
||||||
|
print(f"expected invalid connector: {connector}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
print("connector schema regression: PASS")
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
checkForAllUpdates,
|
checkForAllUpdates,
|
||||||
formatAllPackagesTable,
|
formatAllPackagesTable,
|
||||||
getInstallAllCommand,
|
getInstallAllCommand,
|
||||||
|
repairFleetCommsTools,
|
||||||
runFrameworkReseed,
|
runFrameworkReseed,
|
||||||
refreshActiveFleetUnits,
|
refreshActiveFleetUnits,
|
||||||
readRosterAgentNames,
|
readRosterAgentNames,
|
||||||
@@ -420,115 +421,142 @@ program
|
|||||||
'Skip re-seeding framework files into ~/.config/mosaic after the CLI update',
|
'Skip re-seeding framework files into ~/.config/mosaic after the CLI update',
|
||||||
)
|
)
|
||||||
.option('--relaunch', 'Restart durable fleet agents so the new launcher/runtime takes effect')
|
.option('--relaunch', 'Restart durable fleet agents so the new launcher/runtime takes effect')
|
||||||
.action(async (opts: { check?: boolean; reseed?: boolean; relaunch?: boolean }) => {
|
.option(
|
||||||
// checkForAllUpdates imported statically above
|
'--repair-tools',
|
||||||
const { execSync } = await import('node:child_process');
|
'Restore the supported current-version TOOLS contract and executable fleet helper',
|
||||||
|
)
|
||||||
// Re-seed the framework from the freshly-installed package, propagate shipped
|
.action(
|
||||||
// systemd unit fixes to the active units, and (opt-in) relaunch durable
|
async (opts: {
|
||||||
// agents. Shared by the "packages updated" and the "framework drift" paths.
|
check?: boolean;
|
||||||
const reseedFramework = (reason: string): void => {
|
reseed?: boolean;
|
||||||
console.log(reason);
|
relaunch?: boolean;
|
||||||
const reseed = runFrameworkReseed();
|
repairTools?: boolean;
|
||||||
if (!reseed.ok) {
|
}) => {
|
||||||
console.error(
|
if (opts.repairTools) {
|
||||||
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
|
const repair = repairFleetCommsTools();
|
||||||
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
|
if (!repair.ok) {
|
||||||
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
|
console.error(`Fleet communications tools repair failed: ${repair.reason ?? 'unknown'}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
repair.changed
|
||||||
|
? 'Fleet communications tools repaired from the supported current framework.'
|
||||||
|
: 'Fleet communications tools already match the supported current framework.',
|
||||||
);
|
);
|
||||||
|
if (repair.backupPath) console.log(`Preserved previous TOOLS.md at ${repair.backupPath}.`);
|
||||||
|
console.log('No active context or session was rewritten.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('✔ Framework re-seeded.');
|
// checkForAllUpdates imported statically above
|
||||||
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
|
const { execSync } = await import('node:child_process');
|
||||||
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
|
|
||||||
const units = refreshActiveFleetUnits();
|
// Re-seed the framework from the freshly-installed package, propagate shipped
|
||||||
if (units.refreshed.length > 0) {
|
// systemd unit fixes to the active units, and (opt-in) relaunch durable
|
||||||
console.log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`);
|
// agents. Shared by the "packages updated" and the "framework drift" paths.
|
||||||
}
|
const reseedFramework = (reason: string): void => {
|
||||||
const agents = readRosterAgentNames();
|
console.log(reason);
|
||||||
if (agents.length === 0) return;
|
const reseed = runFrameworkReseed();
|
||||||
if (opts.relaunch) {
|
if (!reseed.ok) {
|
||||||
console.log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`);
|
console.error(
|
||||||
for (const restart of buildRelaunchCommands(agents)) {
|
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
|
||||||
try {
|
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
|
||||||
execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
|
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
|
||||||
} catch {
|
);
|
||||||
console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
console.log('✔ Agents relaunched.');
|
console.log('✔ Framework re-seeded.');
|
||||||
} else {
|
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
|
||||||
console.log(
|
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
|
||||||
`\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` +
|
const units = refreshActiveFleetUnits();
|
||||||
'Restart them to activate the update:\n mosaic update --relaunch ' +
|
if (units.refreshed.length > 0) {
|
||||||
'(or: mosaic fleet restart <agent>)',
|
console.log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`);
|
||||||
);
|
}
|
||||||
|
const agents = readRosterAgentNames();
|
||||||
|
if (agents.length === 0) return;
|
||||||
|
if (opts.relaunch) {
|
||||||
|
console.log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`);
|
||||||
|
for (const restart of buildRelaunchCommands(agents)) {
|
||||||
|
try {
|
||||||
|
execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
|
||||||
|
} catch {
|
||||||
|
console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('✔ Agents relaunched.');
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` +
|
||||||
|
'Restart them to activate the update:\n mosaic update --relaunch ' +
|
||||||
|
'(or: mosaic fleet restart <agent>)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Checking for updates…');
|
||||||
|
const results = checkForAllUpdates({ skipCache: true });
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(formatAllPackagesTable(results));
|
||||||
|
|
||||||
|
const outdated = results.filter((r: { updateAvailable: boolean }) => r.updateAvailable);
|
||||||
|
if (outdated.length === 0) {
|
||||||
|
const anyInstalled = results.some((r: { current: string }) => r.current);
|
||||||
|
if (!anyInstalled) {
|
||||||
|
console.error('No @mosaicstack/* packages are installed.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('\n✔ All packages up to date.');
|
||||||
|
// #642: the CLI may have been upgraded outside `mosaic update` (e.g. a
|
||||||
|
// direct `npm i -g`), leaving the framework files stale even though no
|
||||||
|
// package is reported outdated. Detect that via the framework version and
|
||||||
|
// re-seed so shipped launcher/runtime fixes still activate.
|
||||||
|
const drift = checkFrameworkDrift();
|
||||||
|
if (drift.drifted && opts.reseed !== false) {
|
||||||
|
reseedFramework(
|
||||||
|
`\nFramework drift detected (on-disk v${drift.installed} < bundled v${drift.bundled}) — ` +
|
||||||
|
'the CLI was updated outside `mosaic update`. Re-seeding framework files into ' +
|
||||||
|
'~/.config/mosaic (data-safe; keeps your edits)…',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
console.log('Checking for updates…');
|
if (opts.check) {
|
||||||
const results = checkForAllUpdates({ skipCache: true });
|
process.exit(2); // Signal to callers that an update exists
|
||||||
|
}
|
||||||
|
|
||||||
console.log('');
|
console.log(`\nInstalling ${outdated.length} update(s)…`);
|
||||||
console.log(formatAllPackagesTable(results));
|
try {
|
||||||
|
// Relies on @mosaicstack:registry in ~/.npmrc
|
||||||
const outdated = results.filter((r: { updateAvailable: boolean }) => r.updateAvailable);
|
const cmd = getInstallAllCommand(outdated);
|
||||||
if (outdated.length === 0) {
|
execSync(cmd, {
|
||||||
const anyInstalled = results.some((r: { current: string }) => r.current);
|
stdio: 'inherit',
|
||||||
if (!anyInstalled) {
|
timeout: 60_000,
|
||||||
console.error('No @mosaicstack/* packages are installed.');
|
});
|
||||||
|
console.log('\n✔ Updated successfully.');
|
||||||
|
} catch {
|
||||||
|
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log('\n✔ All packages up to date.');
|
|
||||||
// #642: the CLI may have been upgraded outside `mosaic update` (e.g. a
|
// F3-m3 / R13: the CLI is updated, but the framework files in
|
||||||
// direct `npm i -g`), leaving the framework files stale even though no
|
// ~/.config/mosaic/ are still the previous version. Re-seed them from the
|
||||||
// package is reported outdated. Detect that via the framework version and
|
// freshly-installed package so shipped launcher/runtime changes ACTIVATE.
|
||||||
// re-seed so shipped launcher/runtime fixes still activate.
|
// Re-seed when the framework-bearing package itself updated OR the on-disk
|
||||||
|
// framework is older than the freshly-installed one (#642 — e.g. only
|
||||||
|
// sibling packages were outdated but the CLI was already ahead).
|
||||||
|
const mosaicUpdated = outdated.some(
|
||||||
|
(r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE,
|
||||||
|
);
|
||||||
const drift = checkFrameworkDrift();
|
const drift = checkFrameworkDrift();
|
||||||
if (drift.drifted && opts.reseed !== false) {
|
if ((mosaicUpdated || drift.drifted) && opts.reseed !== false) {
|
||||||
reseedFramework(
|
reseedFramework(
|
||||||
`\nFramework drift detected (on-disk v${drift.installed} < bundled v${drift.bundled}) — ` +
|
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…',
|
||||||
'the CLI was updated outside `mosaic update`. Re-seeding framework files into ' +
|
|
||||||
'~/.config/mosaic (data-safe; keeps your edits)…',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
},
|
||||||
}
|
);
|
||||||
|
|
||||||
if (opts.check) {
|
|
||||||
process.exit(2); // Signal to callers that an update exists
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nInstalling ${outdated.length} update(s)…`);
|
|
||||||
try {
|
|
||||||
// Relies on @mosaicstack:registry in ~/.npmrc
|
|
||||||
const cmd = getInstallAllCommand(outdated);
|
|
||||||
execSync(cmd, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
timeout: 60_000,
|
|
||||||
});
|
|
||||||
console.log('\n✔ Updated successfully.');
|
|
||||||
} catch {
|
|
||||||
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// F3-m3 / R13: the CLI is updated, but the framework files in
|
|
||||||
// ~/.config/mosaic/ are still the previous version. Re-seed them from the
|
|
||||||
// freshly-installed package so shipped launcher/runtime changes ACTIVATE.
|
|
||||||
// Re-seed when the framework-bearing package itself updated OR the on-disk
|
|
||||||
// framework is older than the freshly-installed one (#642 — e.g. only
|
|
||||||
// sibling packages were outdated but the CLI was already ahead).
|
|
||||||
const mosaicUpdated = outdated.some(
|
|
||||||
(r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE,
|
|
||||||
);
|
|
||||||
const drift = checkFrameworkDrift();
|
|
||||||
if ((mosaicUpdated || drift.drifted) && opts.reseed !== false) {
|
|
||||||
reseedFramework(
|
|
||||||
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── wizard ─────────────────────────────────────────────────────────────
|
// ─── wizard ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
import {
|
||||||
|
accessSync,
|
||||||
|
chmodSync,
|
||||||
|
constants,
|
||||||
|
mkdtempSync,
|
||||||
|
mkdirSync,
|
||||||
|
writeFileSync,
|
||||||
|
rmSync,
|
||||||
|
readFileSync,
|
||||||
|
renameSync,
|
||||||
|
symlinkSync,
|
||||||
|
} from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { FileConfigAdapter } from '../config/file-adapter.js';
|
||||||
import { composeContract } from './launch.js';
|
import { composeContract } from './launch.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +35,9 @@ import { composeContract } from './launch.js';
|
|||||||
const CONSTITUTION = '# CONSTITUTION\n\nGATE-1: the non-negotiable law.\n';
|
const CONSTITUTION = '# CONSTITUTION\n\nGATE-1: the non-negotiable law.\n';
|
||||||
const AGENTS = '# Mosaic Agent Dispatcher\n\nLoad order + guide router.\n';
|
const AGENTS = '# Mosaic Agent Dispatcher\n\nLoad order + guide router.\n';
|
||||||
const USER = '# operator\n\nName: Test Operator\n';
|
const USER = '# operator\n\nName: Test Operator\n';
|
||||||
const TOOLS = '# tools index\n';
|
const TOOLS = '# tools index\n\n<!-- fleet-comms-contract: 1 -->\n';
|
||||||
|
const FRAMEWORK_SOURCE = fileURLToPath(new URL('../../framework', import.meta.url));
|
||||||
|
const SOURCE_TOOLS_PATH = join(FRAMEWORK_SOURCE, 'defaults', 'TOOLS.md');
|
||||||
|
|
||||||
function makeHome(): { home: string; root: string } {
|
function makeHome(): { home: string; root: string } {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-compose-'));
|
const root = mkdtempSync(join(tmpdir(), 'mosaic-compose-'));
|
||||||
@@ -31,6 +46,12 @@ function makeHome(): { home: string; root: string } {
|
|||||||
mkdirSync(join(home, 'runtime', h), { recursive: true });
|
mkdirSync(join(home, 'runtime', h), { recursive: true });
|
||||||
writeFileSync(join(home, 'runtime', h, 'RUNTIME.md'), `# ${h} runtime contract\n`);
|
writeFileSync(join(home, 'runtime', h, 'RUNTIME.md'), `# ${h} runtime contract\n`);
|
||||||
}
|
}
|
||||||
|
mkdirSync(join(home, 'defaults'), { recursive: true });
|
||||||
|
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||||
|
writeFileSync(join(home, 'defaults', 'TOOLS.md'), TOOLS);
|
||||||
|
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||||
|
writeFileSync(helper, '#!/bin/sh\n');
|
||||||
|
chmodSync(helper, 0o755);
|
||||||
writeFileSync(join(home, 'CONSTITUTION.md'), CONSTITUTION);
|
writeFileSync(join(home, 'CONSTITUTION.md'), CONSTITUTION);
|
||||||
writeFileSync(join(home, 'AGENTS.md'), AGENTS);
|
writeFileSync(join(home, 'AGENTS.md'), AGENTS);
|
||||||
writeFileSync(join(home, 'USER.md'), USER);
|
writeFileSync(join(home, 'USER.md'), USER);
|
||||||
@@ -42,16 +63,31 @@ describe('composeContract — overlay composer', () => {
|
|||||||
let fixture: ReturnType<typeof makeHome>;
|
let fixture: ReturnType<typeof makeHome>;
|
||||||
let prevCwd: string;
|
let prevCwd: string;
|
||||||
let cwdDir: string;
|
let cwdDir: string;
|
||||||
|
let prevAgentName: string | undefined;
|
||||||
|
let prevAgentClass: string | undefined;
|
||||||
|
let prevAgentToolPolicy: string | undefined;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = makeHome();
|
fixture = makeHome();
|
||||||
prevCwd = process.cwd();
|
prevCwd = process.cwd();
|
||||||
|
prevAgentName = process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
prevAgentClass = process.env['MOSAIC_AGENT_CLASS'];
|
||||||
|
prevAgentToolPolicy = process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||||
|
delete process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
delete process.env['MOSAIC_AGENT_CLASS'];
|
||||||
|
delete process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||||
cwdDir = mkdtempSync(join(tmpdir(), 'mosaic-cwd-'));
|
cwdDir = mkdtempSync(join(tmpdir(), 'mosaic-cwd-'));
|
||||||
process.chdir(cwdDir); // neutralize cwd-relative mission/PRD blocks
|
process.chdir(cwdDir); // neutralize cwd-relative mission/PRD blocks
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.chdir(prevCwd);
|
process.chdir(prevCwd);
|
||||||
|
if (prevAgentName === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
else process.env['MOSAIC_AGENT_NAME'] = prevAgentName;
|
||||||
|
if (prevAgentClass === undefined) delete process.env['MOSAIC_AGENT_CLASS'];
|
||||||
|
else process.env['MOSAIC_AGENT_CLASS'] = prevAgentClass;
|
||||||
|
if (prevAgentToolPolicy === undefined) delete process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||||
|
else process.env['MOSAIC_AGENT_TOOL_POLICY'] = prevAgentToolPolicy;
|
||||||
rmSync(fixture.root, { recursive: true, force: true });
|
rmSync(fixture.root, { recursive: true, force: true });
|
||||||
rmSync(cwdDir, { recursive: true, force: true });
|
rmSync(cwdDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
@@ -64,13 +100,17 @@ describe('composeContract — overlay composer', () => {
|
|||||||
[
|
[
|
||||||
'version: 1',
|
'version: 1',
|
||||||
'transport: tmux',
|
'transport: tmux',
|
||||||
|
'tmux:',
|
||||||
|
' socket_name: mosaic-fleet',
|
||||||
'agents:',
|
'agents:',
|
||||||
' - name: orchestrator',
|
' - name: orchestrator',
|
||||||
' runtime: claude',
|
' runtime: claude',
|
||||||
' class: orchestrator',
|
' class: orchestrator',
|
||||||
|
' host: w-jarvis',
|
||||||
' - name: enhancer',
|
' - name: enhancer',
|
||||||
' runtime: claude',
|
' runtime: claude',
|
||||||
' class: enhancer',
|
' class: enhancer',
|
||||||
|
' host: w-jarvis',
|
||||||
' - name: coder0-0',
|
' - name: coder0-0',
|
||||||
' runtime: claude',
|
' runtime: claude',
|
||||||
' class: implementer',
|
' class: implementer',
|
||||||
@@ -82,19 +122,266 @@ describe('composeContract — overlay composer', () => {
|
|||||||
const prev = process.env['MOSAIC_AGENT_NAME'];
|
const prev = process.env['MOSAIC_AGENT_NAME'];
|
||||||
try {
|
try {
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'enhancer';
|
process.env['MOSAIC_AGENT_NAME'] = 'enhancer';
|
||||||
const out = composeContract('claude', fixture.home);
|
const outputs = (['claude', 'codex', 'opencode', 'pi'] as const).map((runtime) =>
|
||||||
expect(out).toContain('# Fleet Comms');
|
composeContract(runtime, fixture.home),
|
||||||
expect(out).toMatch(/`\[[^\]]+:enhancer\]`/); // own [host:session] identity (host machine-dependent)
|
);
|
||||||
// local peer → no -H; cross-host peer → -H ssh
|
for (const out of outputs) {
|
||||||
expect(out).toContain('-s orchestrator -m "…"');
|
expect(out).toContain('# Fleet Comms');
|
||||||
expect(out).toContain('-H jwoltje@10.1.10.37 -s coder0-0 -m "…"');
|
expect(out).toContain('Host: `w-jarvis`');
|
||||||
expect(out).not.toContain('-H jwoltje@10.1.10.37 -s orchestrator'); // local stays local
|
expect(out).toContain('Agent/session: `enhancer`');
|
||||||
|
expect(out).toContain('tmux socket: `mosaic-fleet`');
|
||||||
|
expect(out).toContain(
|
||||||
|
`Helper: \`${join(fixture.home, 'tools', 'tmux', 'agent-send.sh')}\``,
|
||||||
|
);
|
||||||
|
expect(out).toContain('-L mosaic-fleet -s orchestrator -m "…"');
|
||||||
|
expect(out).toContain('-L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"');
|
||||||
|
expect(out).not.toContain('-H jwoltje@10.1.10.37 -s orchestrator');
|
||||||
|
}
|
||||||
|
const commsSection = (out: string): string => out.slice(out.indexOf('# Fleet Comms'));
|
||||||
|
const authoritative = commsSection(outputs[0]!);
|
||||||
|
expect(outputs.map(commsSection)).toEqual([
|
||||||
|
authoritative,
|
||||||
|
authoritative,
|
||||||
|
authoritative,
|
||||||
|
authoritative,
|
||||||
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
if (prev === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
if (prev === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||||
else process.env['MOSAIC_AGENT_NAME'] = prev;
|
else process.env['MOSAIC_AGENT_NAME'] = prev;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||||
|
'derives canonical class, persona, tool policy, and comms from one roster member for %s',
|
||||||
|
(runtime) => {
|
||||||
|
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(fixture.home, 'fleet', 'roles', 'code.md'),
|
||||||
|
'# Code\n\n(`class: code`)\n\nCANONICAL-CODE-MANDATE.\n',
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||||
|
[
|
||||||
|
'version: 1',
|
||||||
|
'transport: tmux',
|
||||||
|
'agents:',
|
||||||
|
' - name: exact-coder',
|
||||||
|
` runtime: ${runtime}`,
|
||||||
|
' class: implementer',
|
||||||
|
' tool_policy: operator-interaction',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
|
||||||
|
process.env['MOSAIC_AGENT_CLASS'] = 'implementer';
|
||||||
|
process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'ambient-policy-must-not-win';
|
||||||
|
|
||||||
|
const out = composeContract(runtime, fixture.home);
|
||||||
|
|
||||||
|
expect(out).toContain('# Persona Contract (code)');
|
||||||
|
expect(out).toContain('CANONICAL-CODE-MANDATE');
|
||||||
|
expect(out).toContain('Role/class: `code`');
|
||||||
|
expect(out).toContain('# Fleet Tool Policy (operator-interaction)');
|
||||||
|
expect(out).not.toContain('ambient-policy-must-not-win');
|
||||||
|
expect(out.indexOf('# Persona Contract')).toBeLessThan(out.indexOf('# Fleet Comms'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||||
|
'does not inherit ambient tool policy when canonical member omits tool_policy for %s',
|
||||||
|
(runtime) => {
|
||||||
|
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(fixture.home, 'fleet', 'roles', 'code.md'),
|
||||||
|
'# Code\n\n(`class: code`)\n\nCANONICAL-CODE-MANDATE.\n',
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||||
|
[
|
||||||
|
'version: 1',
|
||||||
|
'transport: tmux',
|
||||||
|
'agents:',
|
||||||
|
' - name: exact-coder',
|
||||||
|
` runtime: ${runtime}`,
|
||||||
|
' class: implementer',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
|
||||||
|
process.env['MOSAIC_AGENT_CLASS'] = 'implementer';
|
||||||
|
process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'operator-interaction';
|
||||||
|
|
||||||
|
const out = composeContract(runtime, fixture.home);
|
||||||
|
|
||||||
|
expect(out).toContain('# Persona Contract (code)');
|
||||||
|
expect(out).toContain('Role/class: `code`');
|
||||||
|
expect(out).toContain('# Fleet Comms');
|
||||||
|
expect(out).not.toContain('# Fleet Tool Policy (operator-interaction)');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||||
|
'rejects an ambient class that mismatches the canonical roster member for %s',
|
||||||
|
(runtime) => {
|
||||||
|
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||||
|
[
|
||||||
|
'version: 1',
|
||||||
|
'transport: tmux',
|
||||||
|
'agents:',
|
||||||
|
' - name: exact-coder',
|
||||||
|
` runtime: ${runtime}`,
|
||||||
|
' class: implementer',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
|
||||||
|
process.env['MOSAIC_AGENT_CLASS'] = 'reviewer';
|
||||||
|
|
||||||
|
expect(() => composeContract(runtime, fixture.home)).toThrow(
|
||||||
|
/ambient MOSAIC_AGENT_CLASS.*review.*canonical roster.*code/i,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('composes solo role mandate and boundaries before explicit no-peer authority', () => {
|
||||||
|
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(fixture.home, 'fleet', 'roles', 'orchestrator.md'),
|
||||||
|
'# Orchestrator\n\n## Mandate\n\nCoordinate exact work.\n\n## Boundaries\n\nDo not infer authority.\n',
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||||
|
[
|
||||||
|
'version: 1',
|
||||||
|
'transport: tmux',
|
||||||
|
'agents:',
|
||||||
|
' - name: solo',
|
||||||
|
' runtime: claude',
|
||||||
|
' class: orchestrator',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'solo';
|
||||||
|
process.env['MOSAIC_AGENT_CLASS'] = 'orchestrator';
|
||||||
|
|
||||||
|
const out = composeContract('claude', fixture.home);
|
||||||
|
|
||||||
|
expect(out).toContain('## Mandate');
|
||||||
|
expect(out).toContain('## Boundaries');
|
||||||
|
expect(out).toContain('Role/class: `orchestrator`');
|
||||||
|
expect(out).toContain('## Solo authority boundaries');
|
||||||
|
expect(out.indexOf('## Mandate')).toBeLessThan(out.indexOf('# Fleet Comms'));
|
||||||
|
expect(out.indexOf('## Boundaries')).toBeLessThan(out.indexOf('# Fleet Comms'));
|
||||||
|
expect(out).toContain('no peer, orchestrator, or remote communication authority');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proves real source TOOLS.md through fresh install, executable helper, and final composition', async () => {
|
||||||
|
const installRoot = mkdtempSync(join(tmpdir(), 'mosaic-real-contract-'));
|
||||||
|
const installedHome = join(installRoot, 'mosaic-home');
|
||||||
|
mkdirSync(installedHome, { recursive: true });
|
||||||
|
const previous = process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adapter = new FileConfigAdapter(installedHome, FRAMEWORK_SOURCE);
|
||||||
|
await adapter.syncFramework('fresh');
|
||||||
|
|
||||||
|
const sourceTools = readFileSync(SOURCE_TOOLS_PATH, 'utf8');
|
||||||
|
const installedToolsPath = join(installedHome, 'TOOLS.md');
|
||||||
|
expect(readFileSync(installedToolsPath, 'utf8')).toBe(sourceTools);
|
||||||
|
expect(sourceTools).toContain('fleet-comms-contract: 1');
|
||||||
|
expect(sourceTools).not.toMatch(
|
||||||
|
/<(?:user@host|src_host|src_session|dst_host|dst_session|target-session)>/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const helper = join(installedHome, 'tools', 'tmux', 'agent-send.sh');
|
||||||
|
expect(() => accessSync(helper, constants.X_OK)).not.toThrow();
|
||||||
|
|
||||||
|
mkdirSync(join(installedHome, 'fleet'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(installedHome, 'fleet', 'roster.yaml'),
|
||||||
|
[
|
||||||
|
'version: 1',
|
||||||
|
'transport: tmux',
|
||||||
|
'tmux:',
|
||||||
|
' socket_name: exact-socket',
|
||||||
|
'agents:',
|
||||||
|
' - name: exact-self',
|
||||||
|
' runtime: pi',
|
||||||
|
' class: orchestrator',
|
||||||
|
' host: local-host',
|
||||||
|
' - name: exact-peer',
|
||||||
|
' runtime: claude',
|
||||||
|
' class: implementer',
|
||||||
|
' host: local-host',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'exact-self';
|
||||||
|
|
||||||
|
const composed = composeContract('pi', installedHome);
|
||||||
|
expect(composed).toContain(sourceTools);
|
||||||
|
expect(composed).toContain(`Helper: \`${helper}\``);
|
||||||
|
expect(composed).toContain(`${helper} -L exact-socket -s exact-peer -m "…"`);
|
||||||
|
expect(composed).not.toContain('# Fleet Comms Installation Status');
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
else process.env['MOSAIC_AGENT_NAME'] = previous;
|
||||||
|
rmSync(installRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||||
|
'never injects installed TOOLS.md through a target symlink for %s',
|
||||||
|
(runtime) => {
|
||||||
|
const toolsPath = join(fixture.home, 'TOOLS.md');
|
||||||
|
const external = join(fixture.root, 'attacker-tools.md');
|
||||||
|
writeFileSync(external, 'UNSAFE-TARGET-SYMLINK-CONTENT\n');
|
||||||
|
rmSync(toolsPath);
|
||||||
|
symlinkSync(external, toolsPath);
|
||||||
|
|
||||||
|
const out = composeContract(runtime, fixture.home);
|
||||||
|
|
||||||
|
expect(out).not.toContain('UNSAFE-TARGET-SYMLINK-CONTENT');
|
||||||
|
expect(out).toContain('# Fleet Comms Installation Status');
|
||||||
|
expect(out).toContain('unavailable');
|
||||||
|
expect(readFileSync(external, 'utf8')).toBe('UNSAFE-TARGET-SYMLINK-CONTENT\n');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||||
|
'never injects installed TOOLS.md through an ancestor symlink for %s',
|
||||||
|
(runtime) => {
|
||||||
|
const realHome = join(fixture.root, 'real-mosaic-home');
|
||||||
|
renameSync(fixture.home, realHome);
|
||||||
|
symlinkSync(realHome, fixture.home, 'dir');
|
||||||
|
writeFileSync(join(realHome, 'TOOLS.md'), 'UNSAFE-ANCESTOR-SYMLINK-CONTENT\n');
|
||||||
|
|
||||||
|
const out = composeContract(runtime, fixture.home);
|
||||||
|
|
||||||
|
expect(out).not.toContain('UNSAFE-ANCESTOR-SYMLINK-CONTENT');
|
||||||
|
expect(out).toContain('# Fleet Comms Installation Status');
|
||||||
|
expect(out).toContain('unavailable');
|
||||||
|
expect(readFileSync(join(realHome, 'TOOLS.md'), 'utf8')).toBe(
|
||||||
|
'UNSAFE-ANCESTOR-SYMLINK-CONTENT\n',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('reports stale preserved TOOLS.md without rewriting it', () => {
|
||||||
|
const toolsPath = join(fixture.home, 'TOOLS.md');
|
||||||
|
const stale = '# user-customized tools without fleet contract marker\n';
|
||||||
|
writeFileSync(toolsPath, stale);
|
||||||
|
|
||||||
|
const out = composeContract('pi', fixture.home);
|
||||||
|
|
||||||
|
expect(out).toContain('# Fleet Comms Installation Status');
|
||||||
|
expect(out).toContain('does not byte-match');
|
||||||
|
expect(out).toContain('active context was not rewritten');
|
||||||
|
expect(readFileSync(toolsPath, 'utf8')).toBe(stale);
|
||||||
|
});
|
||||||
|
|
||||||
it('does NOT inject fleet comms when MOSAIC_AGENT_NAME is unset (non-fleet launch)', () => {
|
it('does NOT inject fleet comms when MOSAIC_AGENT_NAME is unset (non-fleet launch)', () => {
|
||||||
const prev = process.env['MOSAIC_AGENT_NAME'];
|
const prev = process.env['MOSAIC_AGENT_NAME'];
|
||||||
try {
|
try {
|
||||||
@@ -105,6 +392,24 @@ describe('composeContract — overlay composer', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails closed when an explicitly requested fleet identity is unknown', () => {
|
||||||
|
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||||
|
['version: 1', 'transport: tmux', 'agents:', ' - name: exact-agent', ' runtime: pi'].join(
|
||||||
|
'\n',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const previous = process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
try {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'invented-agent';
|
||||||
|
expect(() => composeContract('pi', fixture.home)).toThrow(/known exact names: exact-agent/i);
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
else process.env['MOSAIC_AGENT_NAME'] = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('includes the per-tier anchors and the selected harness runtime', () => {
|
it('includes the per-tier anchors and the selected harness runtime', () => {
|
||||||
const out = composeContract('claude', fixture.home);
|
const out = composeContract('claude', fixture.home);
|
||||||
expect(out).toContain('GATE-1: the non-negotiable law.'); // L0
|
expect(out).toContain('GATE-1: the non-negotiable law.'); // L0
|
||||||
@@ -240,10 +545,14 @@ describe('composeContract — overlay composer', () => {
|
|||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||||
[
|
[
|
||||||
|
'version: 1',
|
||||||
|
'transport: tmux',
|
||||||
'agents:',
|
'agents:',
|
||||||
' - name: orchestrator',
|
' - name: orchestrator',
|
||||||
|
' runtime: claude',
|
||||||
' class: orchestrator',
|
' class: orchestrator',
|
||||||
' - name: enhancer',
|
' - name: enhancer',
|
||||||
|
' runtime: claude',
|
||||||
' class: enhancer',
|
' class: enhancer',
|
||||||
'',
|
'',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
|
|||||||
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
257
packages/mosaic/src/commands/fleet-reconciler-command.spec.ts
Normal file
257
packages/mosaic/src/commands/fleet-reconciler-command.spec.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { type FleetReconcileDeps } from '../fleet/fleet-reconciler.js';
|
||||||
|
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
|
||||||
|
|
||||||
|
const roster = `
|
||||||
|
version: 2
|
||||||
|
generation: 7
|
||||||
|
transport: tmux
|
||||||
|
tmux:
|
||||||
|
socket_name: mosaic-fleet
|
||||||
|
holder_session: _holder
|
||||||
|
defaults:
|
||||||
|
working_directory: /srv/mosaic
|
||||||
|
runtime: pi
|
||||||
|
runtimes:
|
||||||
|
pi:
|
||||||
|
reset_command: /new
|
||||||
|
agents:
|
||||||
|
- name: coder0
|
||||||
|
alias: Coder 0
|
||||||
|
class: code
|
||||||
|
runtime: pi
|
||||||
|
provider: openai
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
reasoning: high
|
||||||
|
tool_policy: code
|
||||||
|
working_directory: /srv/mosaic
|
||||||
|
persistent_persona: false
|
||||||
|
reset_between_tasks: true
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
desired_state: stopped
|
||||||
|
launch:
|
||||||
|
yolo: true
|
||||||
|
`;
|
||||||
|
|
||||||
|
let cleanup: string | undefined;
|
||||||
|
|
||||||
|
afterEach(async (): Promise<void> => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.exitCode = undefined;
|
||||||
|
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||||
|
cleanup = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fleetHome(): Promise<string> {
|
||||||
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-reconciler-command-'));
|
||||||
|
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
||||||
|
await mkdir(join(cleanup, directory), { recursive: true, mode: 0o700 });
|
||||||
|
await chmod(join(cleanup, directory), 0o700);
|
||||||
|
}
|
||||||
|
await chmod(cleanup, 0o700);
|
||||||
|
await writeFile(join(cleanup, 'fleet', 'roster.yaml'), roster, { mode: 0o600 });
|
||||||
|
await writeFile(join(cleanup, 'fleet', 'roles', 'code.md'), '# code\n\n(`class: code`)\n', {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
return cleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function program(
|
||||||
|
mosaicHome: string,
|
||||||
|
runner: FleetCommandDeps['runner'],
|
||||||
|
reconcileOverrides: Partial<FleetReconcileDeps> = {},
|
||||||
|
): Command {
|
||||||
|
const result = new Command();
|
||||||
|
result.exitOverride();
|
||||||
|
registerFleetCommand(result, {
|
||||||
|
mosaicHome,
|
||||||
|
runner,
|
||||||
|
reconcileDeps: {
|
||||||
|
homeDirectory: '/home/mosaic',
|
||||||
|
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
||||||
|
validateRoster: async () => undefined,
|
||||||
|
prepareProjections: async () => [{ agentName: 'coder0' }],
|
||||||
|
applyProjection: async () => undefined,
|
||||||
|
...reconcileOverrides,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capture(): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||||
|
lines.push(value);
|
||||||
|
});
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownedRunner(
|
||||||
|
calls: string[][],
|
||||||
|
): (command: string, args: string[]) => Promise<CommandResult> {
|
||||||
|
return async (command: string, args: string[]): Promise<CommandResult> => {
|
||||||
|
calls.push([command, ...args]);
|
||||||
|
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||||
|
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||||
|
}
|
||||||
|
if (command === 'tmux' && args.includes('show-environment')) {
|
||||||
|
return {
|
||||||
|
stdout:
|
||||||
|
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mosaic fleet reconciler commands', (): void => {
|
||||||
|
it('plans apply as stable JSON without applying projections or lifecycle effects', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await program(home, ownedRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'apply',
|
||||||
|
'--expected-generation',
|
||||||
|
'7',
|
||||||
|
'--dry-run',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
|
||||||
|
applied: false,
|
||||||
|
authoritativeRoster: 'unchanged',
|
||||||
|
projections: 'not-applied',
|
||||||
|
lifecycle: 'not-applied',
|
||||||
|
});
|
||||||
|
expect(calls).not.toContainEqual([
|
||||||
|
'systemctl',
|
||||||
|
'--user',
|
||||||
|
'start',
|
||||||
|
'mosaic-agent@coder0.service',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses direct fleet status and doctor as observational JSON commands', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const lines = capture();
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const cli = program(home, ownedRunner(calls));
|
||||||
|
|
||||||
|
await cli.parseAsync(['node', 'mosaic', 'fleet', 'status', 'coder0']);
|
||||||
|
await cli.parseAsync(['node', 'mosaic', 'fleet', 'doctor']);
|
||||||
|
|
||||||
|
expect(lines.map((line: string): unknown => JSON.parse(line))).toMatchObject([
|
||||||
|
{ applied: false, lifecycle: 'not-applied' },
|
||||||
|
{ applied: false, lifecycle: 'not-applied' },
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['start', 'stop', 'restart'] as const)(
|
||||||
|
'uses exact roster-owned systemd targeting for %s',
|
||||||
|
async (operation: 'start' | 'stop' | 'restart'): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await program(home, ownedRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
operation,
|
||||||
|
'coder0',
|
||||||
|
'--expected-generation',
|
||||||
|
'7',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({ applied: true, lifecycle: 'complete' });
|
||||||
|
expect(calls).toContainEqual([
|
||||||
|
'systemctl',
|
||||||
|
'--user',
|
||||||
|
operation,
|
||||||
|
'mosaic-agent@coder0.service',
|
||||||
|
]);
|
||||||
|
expect(calls.some((call: string[]): boolean => call.join(' ').includes('coder0-extra'))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('makes cleanup-incomplete effect JSON non-zero without losing effect truth', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const lines = capture();
|
||||||
|
await program(home, ownedRunner([]), {
|
||||||
|
acquireMutationLock: async () => async () => {
|
||||||
|
throw new Error('cleanup failure');
|
||||||
|
},
|
||||||
|
}).parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
|
||||||
|
|
||||||
|
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
|
||||||
|
applied: true,
|
||||||
|
projections: 'complete',
|
||||||
|
lifecycle: 'complete',
|
||||||
|
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||||
|
});
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps primary partial recovery JSON and exits non-zero when cleanup is also incomplete', async (): Promise<void> => {
|
||||||
|
const cleanupFailure = async () => async () => {
|
||||||
|
throw new Error('cleanup failure');
|
||||||
|
};
|
||||||
|
const projectionHome = await fleetHome();
|
||||||
|
const projectionLines = capture();
|
||||||
|
await program(projectionHome, ownedRunner([]), {
|
||||||
|
applyProjection: async () => {
|
||||||
|
throw new Error('projection failure');
|
||||||
|
},
|
||||||
|
acquireMutationLock: cleanupFailure,
|
||||||
|
}).parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
|
||||||
|
expect(JSON.parse(projectionLines.pop() ?? '')).toMatchObject({
|
||||||
|
projections: 'incomplete',
|
||||||
|
lifecycle: 'not-applied',
|
||||||
|
recovery: { code: 'projection-apply-failed' },
|
||||||
|
cleanup: { code: 'lock-cleanup-failed' },
|
||||||
|
});
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps clean effect and observational commands at zero exit', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const cli = program(home, ownedRunner([]));
|
||||||
|
await cli.parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
|
||||||
|
expect(process.exitCode).toBe(0);
|
||||||
|
process.exitCode = undefined;
|
||||||
|
await cli.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||||
|
expect(process.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects stale apply generations as non-zero redacted JSON', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await program(home, ownedRunner([])).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'apply',
|
||||||
|
'--expected-generation',
|
||||||
|
'6',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(JSON.parse(lines.pop() ?? '')).toEqual({ error: { code: 'stale-generation' } });
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
146
packages/mosaic/src/commands/fleet-reconciler-command.ts
Normal file
146
packages/mosaic/src/commands/fleet-reconciler-command.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import type { Command } from 'commander';
|
||||||
|
import type { CommandRunner } from './fleet.js';
|
||||||
|
import {
|
||||||
|
executeFleetReconcile,
|
||||||
|
FleetReconcileError,
|
||||||
|
type FleetReconcileCommand,
|
||||||
|
type FleetReconcileDeps,
|
||||||
|
} from '../fleet/fleet-reconciler.js';
|
||||||
|
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
||||||
|
|
||||||
|
export interface FleetReconcilerCommandDeps {
|
||||||
|
readonly runner: CommandRunner;
|
||||||
|
readonly mosaicHome?: string;
|
||||||
|
readonly reconcileDeps?: Omit<FleetReconcileDeps, 'runner' | 'mosaicHome'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReconcileOptions {
|
||||||
|
readonly expectedGeneration?: string;
|
||||||
|
readonly dryRun?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registers roster-v2 reconciliation commands on the canonical fleet control plane. */
|
||||||
|
export function registerFleetReconcilerCommands(
|
||||||
|
fleetCommand: Command,
|
||||||
|
deps: FleetReconcilerCommandDeps,
|
||||||
|
): void {
|
||||||
|
for (const operation of ['apply', 'reconcile'] as const) {
|
||||||
|
fleetCommand
|
||||||
|
.command(operation)
|
||||||
|
.description(`${operation} local roster-owned projections and desired lifecycle`)
|
||||||
|
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
|
||||||
|
.option('--dry-run', 'Plan and preflight without writing projections or lifecycle state')
|
||||||
|
.action(async (opts: ReconcileOptions): Promise<void> => {
|
||||||
|
await writeOutcome(async (): Promise<void> => {
|
||||||
|
await executeReconcilerCommand(fleetCommand, deps, operation, opts);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fleetCommand
|
||||||
|
.command('doctor')
|
||||||
|
.description('Classify local roster-owned drift without mutation')
|
||||||
|
.action(async (): Promise<void> => {
|
||||||
|
await writeOutcome(async (): Promise<void> => {
|
||||||
|
await executeReconcilerCommand(fleetCommand, deps, 'doctor', {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeReconcilerCommandJson(
|
||||||
|
fleetCommand: Command,
|
||||||
|
deps: FleetReconcilerCommandDeps,
|
||||||
|
operation: FleetReconcileCommand,
|
||||||
|
opts: ReconcileOptions,
|
||||||
|
agentName?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await writeOutcome(async (): Promise<void> => {
|
||||||
|
await executeReconcilerCommand(fleetCommand, deps, operation, opts, agentName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeReconcilerCommand(
|
||||||
|
fleetCommand: Command,
|
||||||
|
deps: FleetReconcilerCommandDeps,
|
||||||
|
operation: FleetReconcileCommand,
|
||||||
|
opts: ReconcileOptions,
|
||||||
|
agentName?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const mosaicHome = resolveMosaicHome(fleetCommand, deps);
|
||||||
|
const rosterPath = resolveRosterPath(fleetCommand, mosaicHome);
|
||||||
|
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
||||||
|
const mutating = operation === 'apply' || operation === 'reconcile' || isLifecycle(operation);
|
||||||
|
const expectedGeneration = mutating
|
||||||
|
? parseExpectedGeneration(opts.expectedGeneration)
|
||||||
|
: undefined;
|
||||||
|
const result = await executeFleetReconcile({
|
||||||
|
roster,
|
||||||
|
command: opts.dryRun === true ? 'plan' : operation,
|
||||||
|
...(agentName === undefined ? {} : { agentName }),
|
||||||
|
...(expectedGeneration === undefined ? {} : { expectedGeneration }),
|
||||||
|
deps: {
|
||||||
|
runner: async (command: string, args: readonly string[]) => deps.runner(command, [...args]),
|
||||||
|
mosaicHome,
|
||||||
|
rolesDir: join(mosaicHome, 'fleet', 'roles'),
|
||||||
|
overrideDir: join(mosaicHome, 'fleet', 'roles.local'),
|
||||||
|
readRoster: async (): Promise<typeof roster> =>
|
||||||
|
parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml'),
|
||||||
|
...(deps.reconcileDeps ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
printJson(result);
|
||||||
|
process.exitCode = result.recovery === undefined && result.cleanup === undefined ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLifecycle(operation: FleetReconcileCommand): boolean {
|
||||||
|
return operation === 'start' || operation === 'stop' || operation === 'restart';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExpectedGeneration(value: string | undefined): number {
|
||||||
|
const generation = Number(value);
|
||||||
|
if (!Number.isSafeInteger(generation) || generation < 1) {
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'lifecycle-precondition-failed',
|
||||||
|
'--expected-generation must be a positive safe integer.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMosaicHome(fleetCommand: Command, deps: FleetReconcilerCommandDeps): string {
|
||||||
|
const options = fleetCommand.optsWithGlobals<{ mosaicHome?: string }>();
|
||||||
|
return (
|
||||||
|
options.mosaicHome ?? deps.mosaicHome ?? join(process.env['HOME'] ?? '', '.config', 'mosaic')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRosterPath(fleetCommand: Command, mosaicHome: string): string {
|
||||||
|
const options = fleetCommand.optsWithGlobals<{ roster?: string }>();
|
||||||
|
const canonical = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||||
|
if (options.roster !== undefined && resolve(options.roster) !== resolve(canonical)) {
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'lifecycle-precondition-failed',
|
||||||
|
'Roster-v2 reconciliation requires the canonical roster path.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeOutcome(action: () => Promise<void>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
printJson({
|
||||||
|
error: {
|
||||||
|
code: error instanceof FleetReconcileError ? error.code : 'reconcile-failed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printJson(value: object): void {
|
||||||
|
console.log(JSON.stringify(value));
|
||||||
|
}
|
||||||
@@ -81,18 +81,22 @@ describe('registerFleetCommand', () => {
|
|||||||
expect(fleet).toBeDefined();
|
expect(fleet).toBeDefined();
|
||||||
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
||||||
'add',
|
'add',
|
||||||
|
'apply',
|
||||||
'backlog',
|
'backlog',
|
||||||
'create',
|
'create',
|
||||||
'delete',
|
'delete',
|
||||||
|
'doctor',
|
||||||
'get',
|
'get',
|
||||||
'init',
|
'init',
|
||||||
'install',
|
'install',
|
||||||
'install-systemd',
|
'install-systemd',
|
||||||
|
'migrate-v1',
|
||||||
'persona',
|
'persona',
|
||||||
'plan',
|
'plan',
|
||||||
'profile',
|
'profile',
|
||||||
'provision',
|
'provision',
|
||||||
'ps',
|
'ps',
|
||||||
|
'reconcile',
|
||||||
'remove',
|
'remove',
|
||||||
'restart',
|
'restart',
|
||||||
'start',
|
'start',
|
||||||
@@ -194,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');
|
||||||
@@ -267,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');
|
||||||
@@ -285,8 +335,8 @@ describe('fleet roster parsing', () => {
|
|||||||
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
|
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
|
||||||
[
|
[
|
||||||
'MOSAIC_AGENT_NAME=coder0',
|
'MOSAIC_AGENT_NAME=coder0',
|
||||||
// Reflects the roster's non-default `class: implementer` (A3a).
|
// Reflects the roster's canonicalized compatibility class (A3a).
|
||||||
'MOSAIC_AGENT_CLASS=implementer',
|
'MOSAIC_AGENT_CLASS=code',
|
||||||
'MOSAIC_AGENT_RUNTIME=codex',
|
'MOSAIC_AGENT_RUNTIME=codex',
|
||||||
'MOSAIC_AGENT_MODEL=',
|
'MOSAIC_AGENT_MODEL=',
|
||||||
'MOSAIC_AGENT_REASONING=',
|
'MOSAIC_AGENT_REASONING=',
|
||||||
@@ -490,7 +540,13 @@ describe('fleet command construction', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs fleet status through injected runner without touching tmux in tests', async () => {
|
it('runs legacy fleet status through injected runner without touching tmux in tests', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(home, 'fleet', 'roster.yaml'),
|
||||||
|
'version: 1\ntransport: tmux\nagents: []\n',
|
||||||
|
);
|
||||||
const calls: string[][] = [];
|
const calls: string[][] = [];
|
||||||
const runner: CommandRunner = async (command, args) => {
|
const runner: CommandRunner = async (command, args) => {
|
||||||
calls.push([command, ...args]);
|
calls.push([command, ...args]);
|
||||||
@@ -498,11 +554,14 @@ describe('fleet command construction', () => {
|
|||||||
};
|
};
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
program.exitOverride();
|
program.exitOverride();
|
||||||
registerFleetCommand(program, { runner });
|
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||||
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
try {
|
||||||
|
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||||
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
|
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('verifies liveness with tmux has-session and does not trust systemd active exited', async () => {
|
it('verifies liveness with tmux has-session and does not trust systemd active exited', async () => {
|
||||||
@@ -618,13 +677,19 @@ describe('fleet command construction', () => {
|
|||||||
).rejects.toThrow('Unsupported fleet profile');
|
).rejects.toThrow('Unsupported fleet profile');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets process exitCode when status runner fails', async () => {
|
it('sets process exitCode when legacy status runner fails', async () => {
|
||||||
|
const home = await tempDir();
|
||||||
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(home, 'fleet', 'roster.yaml'),
|
||||||
|
'version: 1\ntransport: tmux\nagents: []\n',
|
||||||
|
);
|
||||||
const originalExitCode = process.exitCode;
|
const originalExitCode = process.exitCode;
|
||||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||||
const runner: CommandRunner = async () => ({ stdout: '', stderr: 'missing\n', exitCode: 3 });
|
const runner: CommandRunner = async () => ({ stdout: '', stderr: 'missing\n', exitCode: 3 });
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
program.exitOverride();
|
program.exitOverride();
|
||||||
registerFleetCommand(program, { runner });
|
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||||
@@ -632,6 +697,7 @@ describe('fleet command construction', () => {
|
|||||||
} finally {
|
} finally {
|
||||||
process.exitCode = originalExitCode;
|
process.exitCode = originalExitCode;
|
||||||
stderrSpy.mockRestore();
|
stderrSpy.mockRestore();
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3486,7 +3552,66 @@ describe('fleet add/remove — pure helpers', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serializeRosterToYaml round-trips optional fields (modelHint, workingDirectory)', async () => {
|
it.each([
|
||||||
|
['tmux', { kind: 'tmux' }],
|
||||||
|
['discord', { kind: 'discord', discord: { channelId: '1234567890' } }],
|
||||||
|
[
|
||||||
|
'matrix',
|
||||||
|
{
|
||||||
|
kind: 'matrix',
|
||||||
|
matrix: {
|
||||||
|
homeserverUrl: 'https://matrix.example.test',
|
||||||
|
userId: '@mosaic:example.test',
|
||||||
|
roomId: '!fleet:example.test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
] as const)('round-trips the supported %s connector through YAML', async (_kind, connector) => {
|
||||||
|
const yaml = serializeRosterToYaml({ ...baseRoster, connector });
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-connector-'));
|
||||||
|
const rosterPath = join(dir, 'roster.yaml');
|
||||||
|
try {
|
||||||
|
await writeFile(rosterPath, yaml);
|
||||||
|
expect((await loadFleetRoster(rosterPath)).connector).toEqual(connector);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['tmux', { kind: 'tmux' }],
|
||||||
|
['discord', { kind: 'discord', discord: { channel_id: '1234567890' } }],
|
||||||
|
[
|
||||||
|
'matrix',
|
||||||
|
{
|
||||||
|
kind: 'matrix',
|
||||||
|
matrix: {
|
||||||
|
homeserver_url: 'https://matrix.example.test',
|
||||||
|
user_id: '@mosaic:example.test',
|
||||||
|
room_id: '!fleet:example.test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
] as const)('parses the supported %s connector from JSON', async (_kind, connector) => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-connector-'));
|
||||||
|
const rosterPath = join(dir, 'roster.json');
|
||||||
|
try {
|
||||||
|
await writeFile(
|
||||||
|
rosterPath,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
transport: 'tmux',
|
||||||
|
agents: [{ name: 'orchestrator', runtime: 'claude', class: 'orchestrator' }],
|
||||||
|
connector,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect((await loadFleetRoster(rosterPath)).connector?.kind).toBe(_kind);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializeRosterToYaml round-trips optional fields and exact comms targets', async () => {
|
||||||
const rosterWithOptionals: FleetRoster = {
|
const rosterWithOptionals: FleetRoster = {
|
||||||
...baseRoster,
|
...baseRoster,
|
||||||
agents: [
|
agents: [
|
||||||
@@ -3498,6 +3623,9 @@ describe('fleet add/remove — pure helpers', () => {
|
|||||||
workingDirectory: '/tmp/work',
|
workingDirectory: '/tmp/work',
|
||||||
persistentPersona: true,
|
persistentPersona: true,
|
||||||
resetBetweenTasks: false,
|
resetBetweenTasks: false,
|
||||||
|
host: '10.1.10.37',
|
||||||
|
ssh: 'jwoltje@10.1.10.37',
|
||||||
|
socket: 'mosaic-fleet',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -3505,6 +3633,9 @@ describe('fleet add/remove — pure helpers', () => {
|
|||||||
expect(yaml).toContain('model_hint: claude-3-5-sonnet');
|
expect(yaml).toContain('model_hint: claude-3-5-sonnet');
|
||||||
expect(yaml).toContain('working_directory: /tmp/work');
|
expect(yaml).toContain('working_directory: /tmp/work');
|
||||||
expect(yaml).toContain('persistent_persona: true');
|
expect(yaml).toContain('persistent_persona: true');
|
||||||
|
expect(yaml).toContain('host: 10.1.10.37');
|
||||||
|
expect(yaml).toContain('ssh: jwoltje@10.1.10.37');
|
||||||
|
expect(yaml).toContain('socket: mosaic-fleet');
|
||||||
|
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||||
const rosterPath = join(dir, 'roster.yaml');
|
const rosterPath = join(dir, 'roster.yaml');
|
||||||
@@ -3514,6 +3645,9 @@ describe('fleet add/remove — pure helpers', () => {
|
|||||||
expect(loaded.agents[0]!.modelHint).toBe('claude-3-5-sonnet');
|
expect(loaded.agents[0]!.modelHint).toBe('claude-3-5-sonnet');
|
||||||
expect(loaded.agents[0]!.workingDirectory).toBe('/tmp/work');
|
expect(loaded.agents[0]!.workingDirectory).toBe('/tmp/work');
|
||||||
expect(loaded.agents[0]!.persistentPersona).toBe(true);
|
expect(loaded.agents[0]!.persistentPersona).toBe(true);
|
||||||
|
expect(loaded.agents[0]!.host).toBe('10.1.10.37');
|
||||||
|
expect(loaded.agents[0]!.ssh).toBe('jwoltje@10.1.10.37');
|
||||||
|
expect(loaded.agents[0]!.socket).toBe('mosaic-fleet');
|
||||||
} finally {
|
} finally {
|
||||||
await rm(dir, { recursive: true, force: true });
|
await rm(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,32 @@ import { spawn } from 'node:child_process';
|
|||||||
import * as readline from 'node:readline';
|
import * as readline from 'node:readline';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
|
import {
|
||||||
|
getRosterAgent,
|
||||||
|
loadFleetRoster,
|
||||||
|
resolveInstalledFleetRosterPath,
|
||||||
|
type FleetAgent,
|
||||||
|
type FleetRoster,
|
||||||
|
} from '../fleet/fleet-roster-v1.js';
|
||||||
|
export {
|
||||||
|
getRosterAgent,
|
||||||
|
loadFleetRoster,
|
||||||
|
resolveInstalledFleetRosterPath,
|
||||||
|
} from '../fleet/fleet-roster-v1.js';
|
||||||
|
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
|
||||||
import {
|
import {
|
||||||
registerFleetAgentCrudCommands,
|
registerFleetAgentCrudCommands,
|
||||||
type FleetAgentCrudCommandDeps,
|
type FleetAgentCrudCommandDeps,
|
||||||
} from './fleet-agent-crud-command.js';
|
} from './fleet-agent-crud-command.js';
|
||||||
|
import {
|
||||||
|
registerFleetMigrationCommand,
|
||||||
|
type FleetMigrationCommandDeps,
|
||||||
|
} from './fleet-migration-command.js';
|
||||||
|
import {
|
||||||
|
executeReconcilerCommandJson,
|
||||||
|
registerFleetReconcilerCommands,
|
||||||
|
type FleetReconcilerCommandDeps,
|
||||||
|
} from './fleet-reconciler-command.js';
|
||||||
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
|
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
|
||||||
import {
|
import {
|
||||||
applyPreparedAgentEnvironmentProjection,
|
applyPreparedAgentEnvironmentProjection,
|
||||||
@@ -78,72 +100,8 @@ export interface FleetCommandDeps {
|
|||||||
*/
|
*/
|
||||||
isStdinTTY?: boolean;
|
isStdinTTY?: boolean;
|
||||||
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
|
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
|
||||||
}
|
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
|
||||||
|
migrationDeps?: Omit<FleetMigrationCommandDeps, 'mosaicHome'>;
|
||||||
interface RawFleetRoster {
|
|
||||||
version?: unknown;
|
|
||||||
transport?: unknown;
|
|
||||||
tmux?: {
|
|
||||||
socket_name?: unknown;
|
|
||||||
socketName?: unknown;
|
|
||||||
holder_session?: unknown;
|
|
||||||
holderSession?: unknown;
|
|
||||||
};
|
|
||||||
defaults?: {
|
|
||||||
working_directory?: unknown;
|
|
||||||
workingDirectory?: unknown;
|
|
||||||
};
|
|
||||||
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
|
||||||
agents?: Array<{
|
|
||||||
name?: unknown;
|
|
||||||
alias?: unknown;
|
|
||||||
provider?: unknown;
|
|
||||||
runtime?: unknown;
|
|
||||||
class?: unknown;
|
|
||||||
working_directory?: unknown;
|
|
||||||
workingDirectory?: unknown;
|
|
||||||
model_hint?: unknown;
|
|
||||||
modelHint?: unknown;
|
|
||||||
reasoning_level?: unknown;
|
|
||||||
reasoningLevel?: unknown;
|
|
||||||
tool_policy?: unknown;
|
|
||||||
toolPolicy?: unknown;
|
|
||||||
persistent_persona?: unknown;
|
|
||||||
persistentPersona?: unknown;
|
|
||||||
reset_between_tasks?: unknown;
|
|
||||||
resetBetweenTasks?: unknown;
|
|
||||||
kickstart_template?: unknown;
|
|
||||||
kickstartTemplate?: unknown;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FleetAgent {
|
|
||||||
name: string;
|
|
||||||
alias?: string;
|
|
||||||
provider?: string;
|
|
||||||
runtime: string;
|
|
||||||
className: string;
|
|
||||||
workingDirectory?: string;
|
|
||||||
modelHint?: string;
|
|
||||||
reasoningLevel?: string;
|
|
||||||
toolPolicy?: string;
|
|
||||||
persistentPersona?: boolean | string;
|
|
||||||
resetBetweenTasks?: boolean;
|
|
||||||
kickstartTemplate?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FleetRoster {
|
|
||||||
version: 1;
|
|
||||||
transport: 'tmux';
|
|
||||||
tmux: {
|
|
||||||
socketName: string;
|
|
||||||
holderSession: string;
|
|
||||||
};
|
|
||||||
defaults: {
|
|
||||||
workingDirectory: string;
|
|
||||||
};
|
|
||||||
runtimes: Record<string, { resetCommand: string }>;
|
|
||||||
agents: FleetAgent[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FleetPaths {
|
export interface FleetPaths {
|
||||||
@@ -164,8 +122,6 @@ type FleetServiceAction = 'start' | 'stop' | 'restart' | 'status';
|
|||||||
* fallback for a socket-less roster (that now resolves to the default socket).
|
* fallback for a socket-less roster (that now resolves to the default socket).
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_SOCKET_NAME = 'mosaic-fleet';
|
export const DEFAULT_SOCKET_NAME = 'mosaic-fleet';
|
||||||
const DEFAULT_HOLDER_SESSION = '_holder';
|
|
||||||
const DEFAULT_WORKING_DIRECTORY = '~/src';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* tmux `-L` args for a socket name. An empty/absent socket ⇒ the LITERAL default
|
* tmux `-L` args for a socket name. An empty/absent socket ⇒ the LITERAL default
|
||||||
@@ -189,13 +145,6 @@ export const VERIFY_POLL_INTERVAL_MS = 400;
|
|||||||
* Configurable via `--verify-timeout <ms>` on `agent send`.
|
* Configurable via `--verify-timeout <ms>` on `agent send`.
|
||||||
*/
|
*/
|
||||||
export const VERIFY_DEFAULT_TIMEOUT_MS = 6_000;
|
export const VERIFY_DEFAULT_TIMEOUT_MS = 6_000;
|
||||||
const DEFAULT_RUNTIME_RESETS: Record<string, { resetCommand: string }> = {
|
|
||||||
claude: { resetCommand: '/clear' },
|
|
||||||
codex: { resetCommand: '/clear' },
|
|
||||||
opencode: { resetCommand: '/clear' },
|
|
||||||
pi: { resetCommand: '/new' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths {
|
export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths {
|
||||||
return {
|
return {
|
||||||
mosaicHome,
|
mosaicHome,
|
||||||
@@ -223,20 +172,6 @@ function assertDefaultMosaicHomeForSystemd(mosaicHome: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
|
||||||
const rawText = await readFile(path, 'utf8');
|
|
||||||
const parsed = parseRosterText(rawText, path);
|
|
||||||
return normalizeRoster(parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
|
||||||
const agent = roster.agents.find((candidate) => candidate.name === name);
|
|
||||||
if (!agent) {
|
|
||||||
throw new Error(`Agent "${name}" is not in the fleet roster.`);
|
|
||||||
}
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// NORTH_STAR — machine-readable fleet planning source + Markdown projection
|
// NORTH_STAR — machine-readable fleet planning source + Markdown projection
|
||||||
//
|
//
|
||||||
@@ -550,8 +485,8 @@ function generateAgentEnvValues(
|
|||||||
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
||||||
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
|
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
|
||||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
|
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
|
||||||
MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory),
|
MOSAIC_AGENT_WORKDIR: workingDirectory,
|
||||||
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
MOSAIC_TMUX_SOCKET: agent.socket ?? roster.tmux.socketName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1606,53 +1541,70 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
cmd
|
cmd
|
||||||
.command(`${action} [agent]`)
|
.command(`${action} [agent]`)
|
||||||
.description(`${action} the fleet holder or one agent`)
|
.description(`${action} the fleet holder or one agent`)
|
||||||
.action(async (agent?: string) => {
|
.option('--expected-generation <number>', 'Authoritative roster generation for roster-v2')
|
||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
.option('--dry-run', 'Plan roster-v2 lifecycle effects without mutation')
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
.action(
|
||||||
const roster = await loadRosterForCommand(cmd);
|
async (
|
||||||
if (agent) {
|
agent: string | undefined,
|
||||||
getRosterAgent(roster, agent);
|
opts: { expectedGeneration?: string; dryRun?: boolean },
|
||||||
// Single-agent restart is guarded too: it can race a full restart that
|
) => {
|
||||||
// is tearing the shared holder down.
|
if (await usesRosterV2ControlPlane(cmd)) {
|
||||||
|
await executeReconcilerCommandJson(
|
||||||
|
cmd,
|
||||||
|
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
|
||||||
|
action,
|
||||||
|
opts,
|
||||||
|
agent,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
|
const roster = await loadRosterForCommand(cmd);
|
||||||
|
if (agent) {
|
||||||
|
getRosterAgent(roster, agent);
|
||||||
|
// Single-agent restart is guarded too: it can race a full restart that
|
||||||
|
// is tearing the shared holder down.
|
||||||
|
if (action === 'restart') {
|
||||||
|
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
||||||
|
try {
|
||||||
|
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||||
|
} finally {
|
||||||
|
await guard.release();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'stop') {
|
||||||
|
await stopFleetBestEffort(
|
||||||
|
runner,
|
||||||
|
roster.agents.map((rosterAgent) => rosterAgent.name),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action === 'restart') {
|
if (action === 'restart') {
|
||||||
|
// Serialize the holder+agents teardown/relaunch behind the restart lock
|
||||||
|
// so a re-entrant restart waits for clean shutdown before relaunching,
|
||||||
|
// instead of racing a half-torn-down holder into a tight loop.
|
||||||
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
||||||
try {
|
try {
|
||||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
await runChecked(runner, buildFleetServiceCommand(action));
|
||||||
|
for (const rosterAgent of roster.agents) {
|
||||||
|
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await guard.release();
|
await guard.release();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
await runChecked(runner, buildFleetServiceCommand(action));
|
||||||
return;
|
for (const rosterAgent of roster.agents) {
|
||||||
}
|
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||||
if (action === 'stop') {
|
|
||||||
await stopFleetBestEffort(
|
|
||||||
runner,
|
|
||||||
roster.agents.map((rosterAgent) => rosterAgent.name),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action === 'restart') {
|
|
||||||
// Serialize the holder+agents teardown/relaunch behind the restart lock
|
|
||||||
// so a re-entrant restart waits for clean shutdown before relaunching,
|
|
||||||
// instead of racing a half-torn-down holder into a tight loop.
|
|
||||||
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
|
||||||
try {
|
|
||||||
await runChecked(runner, buildFleetServiceCommand(action));
|
|
||||||
for (const rosterAgent of roster.agents) {
|
|
||||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await guard.release();
|
|
||||||
}
|
}
|
||||||
return;
|
},
|
||||||
}
|
);
|
||||||
await runChecked(runner, buildFleetServiceCommand(action));
|
|
||||||
for (const rosterAgent of roster.agents) {
|
|
||||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
@@ -1660,6 +1612,16 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.description('Show fleet holder or agent systemd status')
|
.description('Show fleet holder or agent systemd status')
|
||||||
.option('--json', 'Print JSON status')
|
.option('--json', 'Print JSON status')
|
||||||
.action(async (agent: string | undefined, opts: { json?: boolean }) => {
|
.action(async (agent: string | undefined, opts: { json?: boolean }) => {
|
||||||
|
if (await usesRosterV2ControlPlane(cmd)) {
|
||||||
|
await executeReconcilerCommandJson(
|
||||||
|
cmd,
|
||||||
|
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
|
||||||
|
'status',
|
||||||
|
{},
|
||||||
|
agent,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (agent) {
|
if (agent) {
|
||||||
const roster = await loadRosterForCommand(cmd);
|
const roster = await loadRosterForCommand(cmd);
|
||||||
getRosterAgent(roster, agent);
|
getRosterAgent(roster, agent);
|
||||||
@@ -1683,6 +1645,15 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.command('verify')
|
.command('verify')
|
||||||
.description('Verify the local canary holder and roster sessions on the isolated socket')
|
.description('Verify the local canary holder and roster sessions on the isolated socket')
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
|
if (await usesRosterV2ControlPlane(cmd)) {
|
||||||
|
await executeReconcilerCommandJson(
|
||||||
|
cmd,
|
||||||
|
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
|
||||||
|
'verify',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const roster = await loadRosterForCommand(cmd);
|
const roster = await loadRosterForCommand(cmd);
|
||||||
const socketName = roster.tmux.socketName;
|
const socketName = roster.tmux.socketName;
|
||||||
await runChecked(runner, [
|
await runChecked(runner, [
|
||||||
@@ -2082,6 +2053,15 @@ 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, {
|
||||||
|
runner,
|
||||||
|
mosaicHome: deps.mosaicHome,
|
||||||
|
reconcileDeps: deps.reconcileDeps,
|
||||||
|
});
|
||||||
|
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
@@ -2110,14 +2090,11 @@ export function registerFleetAgentCommands(
|
|||||||
});
|
});
|
||||||
|
|
||||||
agentCommand
|
agentCommand
|
||||||
.command('comms-block <role>')
|
.command('comms-block <exact-member>')
|
||||||
.description(
|
.description('Print the Fleet Comms contract for one exact roster member')
|
||||||
"Print the Fleet Comms cheat-sheet for a roster role (preview a peer's peer-reach view)",
|
.action((exactMember: string) => {
|
||||||
)
|
|
||||||
.option('--host <host>', 'Override the fleet host (preview a cross-host peer view)')
|
|
||||||
.action((role: string, opts: { host?: string }) => {
|
|
||||||
const mosaicHome = resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome);
|
const mosaicHome = resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome);
|
||||||
const res = resolveCommsBlock(mosaicHome, role, opts.host);
|
const res = resolveCommsBlock(mosaicHome, exactMember);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(`[mosaic] comms-block: ${res.error}`);
|
console.error(`[mosaic] comms-block: ${res.error}`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@@ -2415,6 +2392,20 @@ async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
|||||||
return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
|
return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */
|
||||||
|
async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
|
||||||
|
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
|
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
|
||||||
|
const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
|
||||||
|
return (
|
||||||
|
typeof parsed === 'object' &&
|
||||||
|
parsed !== null &&
|
||||||
|
!Array.isArray(parsed) &&
|
||||||
|
'version' in parsed &&
|
||||||
|
parsed.version === 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRosterFromAgentCommand(
|
async function loadRosterFromAgentCommand(
|
||||||
command: Command,
|
command: Command,
|
||||||
mosaicHomeOverride?: string,
|
mosaicHomeOverride?: string,
|
||||||
@@ -2429,253 +2420,6 @@ function resolveMosaicHomeFromCommand(command: Command, override?: string): stri
|
|||||||
return opts.mosaicHome ?? override ?? defaultMosaicHome();
|
return opts.mosaicHome ?? override ?? defaultMosaicHome();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRosterText(text: string, path: string): RawFleetRoster {
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if (path.endsWith('.json')) {
|
|
||||||
return JSON.parse(trimmed) as RawFleetRoster;
|
|
||||||
}
|
|
||||||
return YAML.parse(trimmed) as RawFleetRoster;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRoster(raw: RawFleetRoster): FleetRoster {
|
|
||||||
assertObject(raw, 'Fleet roster');
|
|
||||||
assertKnownKeys(raw, 'Fleet roster', [
|
|
||||||
'version',
|
|
||||||
'transport',
|
|
||||||
'tmux',
|
|
||||||
'defaults',
|
|
||||||
'runtimes',
|
|
||||||
'agents',
|
|
||||||
]);
|
|
||||||
if (raw.tmux !== undefined) {
|
|
||||||
assertObject(raw.tmux, 'Fleet roster tmux');
|
|
||||||
assertKnownKeys(raw.tmux, 'Fleet roster tmux', [
|
|
||||||
'socket_name',
|
|
||||||
'socketName',
|
|
||||||
'holder_session',
|
|
||||||
'holderSession',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
if (raw.defaults !== undefined) {
|
|
||||||
assertObject(raw.defaults, 'Fleet roster defaults');
|
|
||||||
assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
|
|
||||||
'working_directory',
|
|
||||||
'workingDirectory',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
if (raw.runtimes !== undefined) {
|
|
||||||
assertObject(raw.runtimes, 'Fleet roster runtimes');
|
|
||||||
for (const [runtime, config] of Object.entries(raw.runtimes)) {
|
|
||||||
assertObject(config, `Fleet roster runtime "${runtime}"`);
|
|
||||||
assertKnownKeys(config, `Fleet roster runtime "${runtime}"`, [
|
|
||||||
'reset_command',
|
|
||||||
'resetCommand',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (raw.version !== 1) {
|
|
||||||
throw new Error('Fleet roster version must be 1.');
|
|
||||||
}
|
|
||||||
if (raw.transport !== 'tmux') {
|
|
||||||
throw new Error('Fleet roster transport must be "tmux".');
|
|
||||||
}
|
|
||||||
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
|
|
||||||
throw new Error('Fleet roster must define at least one agent.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const agents = raw.agents.map(normalizeAgent);
|
|
||||||
assertUniqueAgentNames(agents);
|
|
||||||
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
transport: 'tmux',
|
|
||||||
tmux: {
|
|
||||||
// Absent socket_name ⇒ '' (the literal default tmux socket, no -L) — NOT
|
|
||||||
// mosaic-fleet. Shipped presets set socket_name explicitly, so they are
|
|
||||||
// unaffected; only socket-less rosters get default-socket behavior.
|
|
||||||
socketName: stringValue(
|
|
||||||
raw.tmux?.socket_name ?? raw.tmux?.socketName,
|
|
||||||
'',
|
|
||||||
'Fleet roster tmux socket_name',
|
|
||||||
),
|
|
||||||
holderSession: stringValue(
|
|
||||||
raw.tmux?.holder_session ?? raw.tmux?.holderSession,
|
|
||||||
DEFAULT_HOLDER_SESSION,
|
|
||||||
'Fleet roster tmux holder_session',
|
|
||||||
),
|
|
||||||
},
|
|
||||||
defaults: {
|
|
||||||
workingDirectory: stringValue(
|
|
||||||
raw.defaults?.working_directory ?? raw.defaults?.workingDirectory,
|
|
||||||
DEFAULT_WORKING_DIRECTORY,
|
|
||||||
'Fleet roster defaults working_directory',
|
|
||||||
),
|
|
||||||
},
|
|
||||||
runtimes: normalizeRuntimes(raw.runtimes as RawFleetRoster['runtimes']),
|
|
||||||
agents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): FleetAgent {
|
|
||||||
assertObject(raw, 'Fleet roster agent');
|
|
||||||
assertKnownKeys(raw, 'Fleet roster agent', [
|
|
||||||
'name',
|
|
||||||
'alias',
|
|
||||||
'provider',
|
|
||||||
'runtime',
|
|
||||||
'class',
|
|
||||||
'working_directory',
|
|
||||||
'workingDirectory',
|
|
||||||
'model_hint',
|
|
||||||
'modelHint',
|
|
||||||
'reasoning_level',
|
|
||||||
'reasoningLevel',
|
|
||||||
'tool_policy',
|
|
||||||
'toolPolicy',
|
|
||||||
'persistent_persona',
|
|
||||||
'persistentPersona',
|
|
||||||
'reset_between_tasks',
|
|
||||||
'resetBetweenTasks',
|
|
||||||
'kickstart_template',
|
|
||||||
'kickstartTemplate',
|
|
||||||
]);
|
|
||||||
const name = stringValue(raw.name, '', 'Fleet roster agent name');
|
|
||||||
const runtime = stringValue(
|
|
||||||
raw.runtime,
|
|
||||||
'',
|
|
||||||
`Fleet roster agent "${name || '<unknown>'}" runtime`,
|
|
||||||
);
|
|
||||||
if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
|
|
||||||
throw new Error(`Invalid fleet agent name: ${name || '<empty>'}`);
|
|
||||||
}
|
|
||||||
if (!runtime) {
|
|
||||||
throw new Error(`Fleet agent "${name}" must define a runtime.`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`),
|
|
||||||
provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`),
|
|
||||||
runtime,
|
|
||||||
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
|
||||||
workingDirectory: optionalString(
|
|
||||||
raw.working_directory ?? raw.workingDirectory,
|
|
||||||
`Fleet roster agent "${name}" working_directory`,
|
|
||||||
),
|
|
||||||
modelHint: optionalString(
|
|
||||||
raw.model_hint ?? raw.modelHint,
|
|
||||||
`Fleet roster agent "${name}" model_hint`,
|
|
||||||
),
|
|
||||||
reasoningLevel: optionalString(
|
|
||||||
raw.reasoning_level ?? raw.reasoningLevel,
|
|
||||||
`Fleet roster agent "${name}" reasoning_level`,
|
|
||||||
),
|
|
||||||
toolPolicy: optionalString(
|
|
||||||
raw.tool_policy ?? raw.toolPolicy,
|
|
||||||
`Fleet roster agent "${name}" tool_policy`,
|
|
||||||
),
|
|
||||||
persistentPersona: optionalBooleanOrString(
|
|
||||||
raw.persistent_persona ?? raw.persistentPersona,
|
|
||||||
`Fleet roster agent "${name}" persistent_persona`,
|
|
||||||
),
|
|
||||||
resetBetweenTasks: optionalBoolean(
|
|
||||||
raw.reset_between_tasks ?? raw.resetBetweenTasks,
|
|
||||||
`Fleet roster agent "${name}" reset_between_tasks`,
|
|
||||||
),
|
|
||||||
kickstartTemplate: optionalString(
|
|
||||||
raw.kickstart_template ?? raw.kickstartTemplate,
|
|
||||||
`Fleet roster agent "${name}" kickstart_template`,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRuntimes(
|
|
||||||
raw: RawFleetRoster['runtimes'] | undefined,
|
|
||||||
): Record<string, { resetCommand: string }> {
|
|
||||||
const result: Record<string, { resetCommand: string }> = { ...DEFAULT_RUNTIME_RESETS };
|
|
||||||
for (const [runtime, config] of Object.entries(raw ?? {})) {
|
|
||||||
result[runtime] = {
|
|
||||||
resetCommand: stringValue(
|
|
||||||
config.reset_command ?? config.resetCommand,
|
|
||||||
'/clear',
|
|
||||||
`Fleet roster runtime "${runtime}" reset_command`,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertObject(value: unknown, label: string): asserts value is Record<string, unknown> {
|
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
||||||
throw new Error(`${label} must be an object.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertKnownKeys(
|
|
||||||
value: Record<string, unknown>,
|
|
||||||
label: string,
|
|
||||||
allowedKeys: readonly string[],
|
|
||||||
): void {
|
|
||||||
const allowed = new Set(allowedKeys);
|
|
||||||
const unknownKeys = Object.keys(value).filter((key) => !allowed.has(key));
|
|
||||||
if (unknownKeys.length > 0) {
|
|
||||||
throw new Error(`${label} has unknown field(s): ${unknownKeys.join(', ')}.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertUniqueAgentNames(agents: FleetAgent[]): void {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const agent of agents) {
|
|
||||||
if (seen.has(agent.name)) {
|
|
||||||
throw new Error(`Fleet roster has duplicate agent name: ${agent.name}.`);
|
|
||||||
}
|
|
||||||
seen.add(agent.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringValue(value: unknown, fallback = '', label = 'Value'): string {
|
|
||||||
if (value === undefined) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
throw new Error(`${label} must be a string.`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalString(value: unknown, label = 'Value'): string | undefined {
|
|
||||||
if (value === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
throw new Error(`${label} must be a string.`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalBoolean(value: unknown, label = 'Value'): boolean | undefined {
|
|
||||||
if (value === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (typeof value !== 'boolean') {
|
|
||||||
throw new Error(`${label} must be a boolean.`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalBooleanOrString(value: unknown, label = 'Value'): boolean | string | undefined {
|
|
||||||
if (value === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (typeof value !== 'boolean' && typeof value !== 'string') {
|
|
||||||
throw new Error(`${label} must be a boolean or string.`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandHome(path: string): string {
|
|
||||||
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
|
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
for (const agentName of agentNames) {
|
for (const agentName of agentNames) {
|
||||||
@@ -2828,6 +2572,23 @@ export function removeAgentFromRoster(roster: FleetRoster, name: string): FleetR
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeConnector(
|
||||||
|
connector: NonNullable<FleetRoster['connector']>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (connector.kind === 'tmux') return { kind: 'tmux' };
|
||||||
|
if (connector.kind === 'discord') {
|
||||||
|
return { kind: 'discord', discord: { channel_id: connector.discord.channelId } };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'matrix',
|
||||||
|
matrix: {
|
||||||
|
homeserver_url: connector.matrix.homeserverUrl,
|
||||||
|
user_id: connector.matrix.userId,
|
||||||
|
room_id: connector.matrix.roomId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize a FleetRoster to YAML text (snake_case keys).
|
* Serialize a FleetRoster to YAML text (snake_case keys).
|
||||||
* The output is parseable by loadFleetRoster.
|
* The output is parseable by loadFleetRoster.
|
||||||
@@ -2845,6 +2606,15 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
|
|||||||
if (agent.provider !== undefined) {
|
if (agent.provider !== undefined) {
|
||||||
raw['provider'] = agent.provider;
|
raw['provider'] = agent.provider;
|
||||||
}
|
}
|
||||||
|
if (agent.host !== undefined) {
|
||||||
|
raw['host'] = agent.host;
|
||||||
|
}
|
||||||
|
if (agent.ssh !== undefined) {
|
||||||
|
raw['ssh'] = agent.ssh;
|
||||||
|
}
|
||||||
|
if (agent.socket !== undefined) {
|
||||||
|
raw['socket'] = agent.socket;
|
||||||
|
}
|
||||||
if (agent.workingDirectory !== undefined) {
|
if (agent.workingDirectory !== undefined) {
|
||||||
raw['working_directory'] = agent.workingDirectory;
|
raw['working_directory'] = agent.workingDirectory;
|
||||||
}
|
}
|
||||||
@@ -2886,6 +2656,7 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
|
|||||||
},
|
},
|
||||||
runtimes,
|
runtimes,
|
||||||
agents,
|
agents,
|
||||||
|
...(roster.connector ? { connector: serializeConnector(roster.connector) } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return YAML.stringify(raw);
|
return YAML.stringify(raw);
|
||||||
@@ -3032,6 +2803,5 @@ export async function resolveRosterPath(
|
|||||||
if (await canRead(yamlPath)) {
|
if (await canRead(yamlPath)) {
|
||||||
return yamlPath;
|
return yamlPath;
|
||||||
}
|
}
|
||||||
const jsonPath = join(mosaicHome, 'fleet', 'roster.json');
|
return resolveInstalledFleetRosterPath(mosaicHome);
|
||||||
return jsonPath;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,17 @@ import { createRequire } from 'node:module';
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import { readFleetCommsBlock } from '../fleet/comms-onboarding.js';
|
import {
|
||||||
|
buildResolvedFleetCommsBlock,
|
||||||
|
renderToolsContractStatus,
|
||||||
|
resolveFleetIdentity,
|
||||||
|
} from '../fleet/comms-onboarding.js';
|
||||||
|
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
||||||
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
||||||
|
import { canonicalizeRoleClass } from './fleet-personas.js';
|
||||||
|
|
||||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||||
|
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
||||||
|
|
||||||
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
||||||
|
|
||||||
@@ -185,6 +192,17 @@ function readOptional(path: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readInstalledToolsSecure(mosaicHome: string): string {
|
||||||
|
try {
|
||||||
|
return readRegularFileSecure(join(mosaicHome, 'TOOLS.md'), {
|
||||||
|
root: mosaicHome,
|
||||||
|
maxBytes: MAX_INSTALLED_TOOLS_BYTES,
|
||||||
|
}).content.toString('utf8');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function readJson(path: string): Record<string, unknown> | null {
|
function readJson(path: string): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
|
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
|
||||||
@@ -361,9 +379,25 @@ For required push/merge/issue-close/release actions, execute without routine con
|
|||||||
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
|
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fleetIdentity = resolveFleetIdentity(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
|
||||||
|
if (!fleetIdentity.ok) {
|
||||||
|
throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`);
|
||||||
|
}
|
||||||
|
const canonicalMember = fleetIdentity.identity?.member;
|
||||||
|
if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) {
|
||||||
|
const ambientClass = canonicalizeRoleClass(process.env['MOSAIC_AGENT_CLASS']).canonicalClass;
|
||||||
|
if (ambientClass !== canonicalMember.className) {
|
||||||
|
throw new Error(
|
||||||
|
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TOOLS.md
|
// TOOLS.md
|
||||||
const tools = readOptional(join(mosaicHome, 'TOOLS.md'));
|
const tools = readInstalledToolsSecure(mosaicHome);
|
||||||
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
|
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
|
||||||
|
const toolsContractStatus = renderToolsContractStatus(mosaicHome);
|
||||||
|
if (toolsContractStatus) parts.push('\n\n' + toolsContractStatus);
|
||||||
|
|
||||||
// Operator overlays whose base layers are load-on-demand (SOUL, STANDARDS):
|
// Operator overlays whose base layers are load-on-demand (SOUL, STANDARDS):
|
||||||
// inject only the small `.local` delta by value so the customization reaches
|
// inject only the small `.local` delta by value so the customization reaches
|
||||||
@@ -385,24 +419,23 @@ For required push/merge/issue-close/release actions, execute without routine con
|
|||||||
// Runtime-specific contract
|
// Runtime-specific contract
|
||||||
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
|
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
|
||||||
|
|
||||||
// Persona contract (A3b): when this agent was spawned with a class
|
// Fleet launches derive every identity projection from the one canonical roster
|
||||||
// (MOSAIC_AGENT_CLASS, exported into the pane env by A3a), inject its resolved
|
// member resolved above. Non-fleet launches retain the legacy ambient persona
|
||||||
// role contract so its identity (mandate + boundaries) is resident from the
|
// and tool-policy behavior.
|
||||||
// first turn. Override-aware via the persona resolver: a user-customized
|
const personaClass = canonicalMember?.className ?? process.env['MOSAIC_AGENT_CLASS'];
|
||||||
// persona in fleet/roles.local/ wins over the baseline (AC-NS-7 launch proof).
|
const persona = readPersonaContractBlock(mosaicHome, personaClass);
|
||||||
// Placed BEFORE fleet comms: identity first, then how-to-reach-peers. No-ops
|
|
||||||
// silently when the class is unset/unknown (mirrors the comms block).
|
|
||||||
const persona = readPersonaContractBlock(mosaicHome, process.env['MOSAIC_AGENT_CLASS']);
|
|
||||||
if (persona) parts.push('\n\n' + persona);
|
if (persona) parts.push('\n\n' + persona);
|
||||||
|
|
||||||
const toolPolicy = readFleetToolPolicyBlock(process.env['MOSAIC_AGENT_TOOL_POLICY']);
|
const toolPolicyName = canonicalMember
|
||||||
|
? canonicalMember.toolPolicy
|
||||||
|
: process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||||
|
const toolPolicy = readFleetToolPolicyBlock(toolPolicyName);
|
||||||
if (toolPolicy) parts.push('\n\n' + toolPolicy);
|
if (toolPolicy) parts.push('\n\n' + toolPolicy);
|
||||||
|
|
||||||
// Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set
|
if (fleetIdentity.identity) {
|
||||||
// and present in the roster), inject a comms cheat-sheet + peer roster so it
|
const fleetComms = buildResolvedFleetCommsBlock(fleetIdentity.identity);
|
||||||
// knows how to reach the orchestrator and its peers from its first turn.
|
if (fleetComms) parts.push('\n\n' + fleetComms);
|
||||||
const fleetComms = readFleetCommsBlock(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
|
}
|
||||||
if (fleetComms) parts.push('\n\n' + fleetComms);
|
|
||||||
|
|
||||||
return parts.join('\n');
|
return parts.join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ function makeFixture(): { sourceDir: string; mosaicHome: string; defaultsDir: st
|
|||||||
writeFileSync(join(defaultsDir, 'CONSTITUTION.md'), '# CONSTITUTION default\n');
|
writeFileSync(join(defaultsDir, 'CONSTITUTION.md'), '# CONSTITUTION default\n');
|
||||||
writeFileSync(join(defaultsDir, 'AGENTS.md'), '# AGENTS default\n');
|
writeFileSync(join(defaultsDir, 'AGENTS.md'), '# AGENTS default\n');
|
||||||
writeFileSync(join(defaultsDir, 'STANDARDS.md'), '# STANDARDS default\n');
|
writeFileSync(join(defaultsDir, 'STANDARDS.md'), '# STANDARDS default\n');
|
||||||
writeFileSync(join(defaultsDir, 'TOOLS.md'), '# TOOLS default\n');
|
writeFileSync(
|
||||||
|
join(defaultsDir, 'TOOLS.md'),
|
||||||
|
'# TOOLS default\n\n<!-- fleet-comms-contract: 1 -->\n',
|
||||||
|
);
|
||||||
|
|
||||||
// Non-contract files we must NOT seed on first install.
|
// Non-contract files we must NOT seed on first install.
|
||||||
writeFileSync(join(defaultsDir, 'SOUL.md'), '# SOUL default (should not be seeded)\n');
|
writeFileSync(join(defaultsDir, 'SOUL.md'), '# SOUL default (should not be seeded)\n');
|
||||||
@@ -71,9 +74,8 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
|||||||
for (const name of DEFAULT_SEED_FILES) {
|
for (const name of DEFAULT_SEED_FILES) {
|
||||||
expect(existsSync(join(fixture.mosaicHome, name))).toBe(true);
|
expect(existsSync(join(fixture.mosaicHome, name))).toBe(true);
|
||||||
}
|
}
|
||||||
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toContain(
|
const sourceTools = readFileSync(join(fixture.defaultsDir, 'TOOLS.md'), 'utf-8');
|
||||||
'# TOOLS default',
|
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toBe(sourceTools);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does NOT seed SOUL.md or USER.md from defaults/ (wizard stages own those)', async () => {
|
it('does NOT seed SOUL.md or USER.md from defaults/ (wizard stages own those)', async () => {
|
||||||
@@ -153,17 +155,20 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
|||||||
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n');
|
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves user fleet data (roster.yaml, agents/, run/) through a keep-mode sync', async () => {
|
it('preserves user fleet data (YAML/JSON rosters, agents/, run/) through a keep-mode sync', async () => {
|
||||||
// Regression for the roster-loss bug (#631): user-authored fleet files must
|
// Regression for roster loss (#631/#766): user-authored fleet files must
|
||||||
// survive the framework re-seed that `mosaic update` runs.
|
// survive the framework re-seed that `mosaic update` runs.
|
||||||
mkdirSync(join(fixture.mosaicHome, 'fleet', 'run'), { recursive: true });
|
mkdirSync(join(fixture.mosaicHome, 'fleet', 'run'), { recursive: true });
|
||||||
mkdirSync(join(fixture.mosaicHome, 'fleet', 'agents'), { recursive: true });
|
mkdirSync(join(fixture.mosaicHome, 'fleet', 'agents'), { recursive: true });
|
||||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'roster.yaml'), 'version: 1\nMINE\n');
|
writeFileSync(join(fixture.mosaicHome, 'fleet', 'roster.yaml'), 'version: 1\nMINE\n');
|
||||||
|
writeFileSync(join(fixture.mosaicHome, 'fleet', 'roster.json'), '{"mine":true}\n');
|
||||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'run', 'a.hb'), 'ts=x\n');
|
writeFileSync(join(fixture.mosaicHome, 'fleet', 'run', 'a.hb'), 'ts=x\n');
|
||||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'agents', 'a.env'), 'X=1\n');
|
writeFileSync(join(fixture.mosaicHome, 'fleet', 'agents', 'a.env'), 'X=1\n');
|
||||||
// The framework ships fleet/examples — it should still seed/refresh.
|
writeFileSync(join(fixture.mosaicHome, 'fleet', 'roster.schema.json'), '{"stale":true}\n');
|
||||||
|
// The framework ships fleet/examples and roster.schema.json — both refresh.
|
||||||
mkdirSync(join(fixture.sourceDir, 'fleet', 'examples'), { recursive: true });
|
mkdirSync(join(fixture.sourceDir, 'fleet', 'examples'), { recursive: true });
|
||||||
writeFileSync(join(fixture.sourceDir, 'fleet', 'examples', 'general.yaml'), '# preset\n');
|
writeFileSync(join(fixture.sourceDir, 'fleet', 'examples', 'general.yaml'), '# preset\n');
|
||||||
|
writeFileSync(join(fixture.sourceDir, 'fleet', 'roster.schema.json'), '{"fresh":true}\n');
|
||||||
|
|
||||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||||
await adapter.syncFramework('keep');
|
await adapter.syncFramework('keep');
|
||||||
@@ -171,10 +176,16 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
|||||||
expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.yaml'), 'utf-8')).toBe(
|
expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.yaml'), 'utf-8')).toBe(
|
||||||
'version: 1\nMINE\n',
|
'version: 1\nMINE\n',
|
||||||
);
|
);
|
||||||
|
expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.json'), 'utf-8')).toBe(
|
||||||
|
'{"mine":true}\n',
|
||||||
|
);
|
||||||
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'run', 'a.hb'))).toBe(true);
|
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'run', 'a.hb'))).toBe(true);
|
||||||
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'agents', 'a.env'))).toBe(true);
|
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'agents', 'a.env'))).toBe(true);
|
||||||
// framework-owned fleet/examples is seeded
|
// Framework-owned fleet assets are refreshed; unrelated user YAML is not preserved.
|
||||||
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'examples', 'general.yaml'))).toBe(true);
|
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'examples', 'general.yaml'))).toBe(true);
|
||||||
|
expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.schema.json'), 'utf-8')).toBe(
|
||||||
|
'{"fresh":true}\n',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
|
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
|
||||||
|
|||||||
@@ -177,7 +177,8 @@ export class FileConfigAdapter implements ConfigService {
|
|||||||
// The framework seeds only fleet/examples + fleet/roles +
|
// The framework seeds only fleet/examples + fleet/roles +
|
||||||
// fleet/roster.schema.json; the operator's roster, per-agent env, and
|
// fleet/roster.schema.json; the operator's roster, per-agent env, and
|
||||||
// heartbeat run dir stay user-owned. (Mirror of install.sh PRESERVE_PATHS.)
|
// heartbeat run dir stay user-owned. (Mirror of install.sh PRESERVE_PATHS.)
|
||||||
'fleet/*.yaml',
|
'fleet/roster.yaml',
|
||||||
|
'fleet/roster.json',
|
||||||
'fleet/agents',
|
'fleet/agents',
|
||||||
'fleet/run',
|
'fleet/run',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,30 +1,43 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
import {
|
||||||
|
chmodSync,
|
||||||
|
mkdtempSync,
|
||||||
|
mkdirSync,
|
||||||
|
writeFileSync,
|
||||||
|
rmSync,
|
||||||
|
readFileSync,
|
||||||
|
symlinkSync,
|
||||||
|
} from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { parseFleetRosterV1, type FleetRoster, type FleetAgent } from './fleet-roster-v1.js';
|
||||||
import {
|
import {
|
||||||
parseRosterAgents,
|
|
||||||
buildFleetCommsBlock,
|
buildFleetCommsBlock,
|
||||||
renderPeerReach,
|
renderPeerReach,
|
||||||
readFleetCommsBlock,
|
readFleetCommsBlock,
|
||||||
resolveCommsBlock,
|
resolveCommsBlock,
|
||||||
type CommsPeer,
|
resolvePeerCommand,
|
||||||
|
renderToolsContractStatus,
|
||||||
} from './comms-onboarding.js';
|
} from './comms-onboarding.js';
|
||||||
|
|
||||||
const ROSTER = [
|
const ROSTER = [
|
||||||
'version: 1',
|
'version: 1',
|
||||||
'transport: tmux',
|
'transport: tmux',
|
||||||
|
'tmux:',
|
||||||
|
' socket_name: mosaic-fleet',
|
||||||
'agents:',
|
'agents:',
|
||||||
' - name: orchestrator',
|
' - name: orchestrator',
|
||||||
' runtime: claude',
|
' runtime: claude',
|
||||||
' class: orchestrator',
|
' class: orchestrator',
|
||||||
|
' host: w-jarvis',
|
||||||
' - name: enhancer',
|
' - name: enhancer',
|
||||||
' runtime: claude',
|
' runtime: claude',
|
||||||
' class: enhancer',
|
' class: enhancer',
|
||||||
|
' host: w-jarvis',
|
||||||
' - name: coder0',
|
' - name: coder0',
|
||||||
' runtime: pi',
|
' runtime: pi',
|
||||||
' class: implementer',
|
' class: implementer',
|
||||||
' # a manually-listed cross-host peer (pre-federation stopgap)',
|
' host: w-jarvis',
|
||||||
' - name: coder0-0',
|
' - name: coder0-0',
|
||||||
' runtime: claude',
|
' runtime: claude',
|
||||||
' class: implementer',
|
' class: implementer',
|
||||||
@@ -33,206 +46,687 @@ const ROSTER = [
|
|||||||
'',
|
'',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
describe('parseRosterAgents', () => {
|
function roster(source = ROSTER): FleetRoster {
|
||||||
it('parses name + class + optional host/ssh', () => {
|
return parseFleetRosterV1(source, 'yaml');
|
||||||
const peers = parseRosterAgents(ROSTER);
|
}
|
||||||
expect(peers.map((p) => p.name)).toEqual(['orchestrator', 'enhancer', 'coder0', 'coder0-0']);
|
|
||||||
expect(peers.find((p) => p.name === 'coder0')).toMatchObject({ className: 'implementer' });
|
describe('shared fleet roster v1 resolver', () => {
|
||||||
expect(peers.find((p) => p.name === 'coder0-0')).toMatchObject({
|
it('resolves comms fields and the global socket through the canonical roster contract', () => {
|
||||||
className: 'implementer',
|
const resolved = roster();
|
||||||
|
expect(resolved.tmux.socketName).toBe('mosaic-fleet');
|
||||||
|
expect(resolved.agents.find((agent) => agent.name === 'coder0-0')).toMatchObject({
|
||||||
|
className: 'code',
|
||||||
host: '10.1.10.37',
|
host: '10.1.10.37',
|
||||||
ssh: 'jwoltje@10.1.10.37',
|
ssh: 'jwoltje@10.1.10.37',
|
||||||
});
|
});
|
||||||
// local agents have no host/ssh
|
|
||||||
expect(peers.find((p) => p.name === 'orchestrator')!.host).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses an optional per-agent socket', () => {
|
it('rejects unknown fields instead of leniently constructing a second roster view', () => {
|
||||||
const peers = parseRosterAgents(
|
expect(() => parseFleetRosterV1(`${ROSTER}\nunknown: value\n`, 'yaml')).toThrow(
|
||||||
['agents:', ' - name: a', ' class: worker', ' socket: mosaic-fleet'].join('\n'),
|
/unknown field/i,
|
||||||
);
|
);
|
||||||
expect(peers[0]).toMatchObject({ name: 'a', socket: 'mosaic-fleet' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stops at the next top-level key', () => {
|
it('rejects an unsupported independent per-agent socket instead of targeting a nonexistent session', () => {
|
||||||
const peers = parseRosterAgents(
|
expect(() =>
|
||||||
['agents:', ' - name: a', ' class: worker', 'defaults:', ' working_directory: ~'].join(
|
roster(
|
||||||
'\n',
|
ROSTER.replace(
|
||||||
|
' host: w-jarvis\n - name: coder0',
|
||||||
|
' host: w-jarvis\n socket: other-socket\n - name: coder0',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
).toThrow(/independent per-agent sockets are not supported/i);
|
||||||
expect(peers.map((p) => p.name)).toEqual(['a']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects unsafe operational targeting values', () => {
|
||||||
|
expect(() =>
|
||||||
|
roster(ROSTER.replace(' ssh: jwoltje@10.1.10.37', ' ssh: host;touch-owned')),
|
||||||
|
).toThrow(/unsupported targeting characters/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes matching connector settings for YAML and JSON rosters', () => {
|
||||||
|
const yamlSource = `${ROSTER}connector:\n kind: discord\n discord:\n channel_id: "123"\n`;
|
||||||
|
const jsonSource = JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
transport: 'tmux',
|
||||||
|
agents: [{ name: 'a', runtime: 'claude', class: 'worker' }],
|
||||||
|
connector: {
|
||||||
|
kind: 'matrix',
|
||||||
|
matrix: {
|
||||||
|
homeserver_url: 'https://matrix.example',
|
||||||
|
user_id: '@a:example',
|
||||||
|
room_id: '!room:example',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parseFleetRosterV1(yamlSource, 'yaml').connector).toEqual({
|
||||||
|
kind: 'discord',
|
||||||
|
discord: { channelId: '123' },
|
||||||
|
});
|
||||||
|
expect(parseFleetRosterV1(jsonSource, 'json').connector).toEqual({
|
||||||
|
kind: 'matrix',
|
||||||
|
matrix: {
|
||||||
|
homeserverUrl: 'https://matrix.example',
|
||||||
|
userId: '@a:example',
|
||||||
|
roomId: '!room:example',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['discord channel_id', { kind: 'discord', discord: { channel_id: '' } }],
|
||||||
|
['discord channel_id whitespace', { kind: 'discord', discord: { channel_id: ' ' } }],
|
||||||
|
[
|
||||||
|
'matrix homeserver_url',
|
||||||
|
{
|
||||||
|
kind: 'matrix',
|
||||||
|
matrix: { homeserver_url: '', user_id: '@a:example', room_id: '!room:example' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'matrix homeserver_url whitespace',
|
||||||
|
{
|
||||||
|
kind: 'matrix',
|
||||||
|
matrix: { homeserver_url: '\t', user_id: '@a:example', room_id: '!room:example' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'matrix user_id',
|
||||||
|
{
|
||||||
|
kind: 'matrix',
|
||||||
|
matrix: {
|
||||||
|
homeserver_url: 'https://matrix.example',
|
||||||
|
user_id: '',
|
||||||
|
room_id: '!room:example',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'matrix user_id whitespace',
|
||||||
|
{
|
||||||
|
kind: 'matrix',
|
||||||
|
matrix: {
|
||||||
|
homeserver_url: 'https://matrix.example',
|
||||||
|
user_id: ' ',
|
||||||
|
room_id: '!room:example',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'matrix room_id',
|
||||||
|
{
|
||||||
|
kind: 'matrix',
|
||||||
|
matrix: {
|
||||||
|
homeserver_url: 'https://matrix.example',
|
||||||
|
user_id: '@a:example',
|
||||||
|
room_id: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'matrix room_id whitespace',
|
||||||
|
{
|
||||||
|
kind: 'matrix',
|
||||||
|
matrix: {
|
||||||
|
homeserver_url: 'https://matrix.example',
|
||||||
|
user_id: '@a:example',
|
||||||
|
room_id: '\n',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
'rejects empty or whitespace-only parser-required connector string: %s',
|
||||||
|
(_label, connector) => {
|
||||||
|
expect(() =>
|
||||||
|
parseFleetRosterV1(
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
transport: 'tmux',
|
||||||
|
agents: [{ name: 'a', runtime: 'claude', class: 'worker' }],
|
||||||
|
connector,
|
||||||
|
}),
|
||||||
|
'json',
|
||||||
|
),
|
||||||
|
).toThrow(/required/i);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['tmux with discord settings', { kind: 'tmux', discord: { channel_id: '123' } }],
|
||||||
|
['discord without discord settings', { kind: 'discord' }],
|
||||||
|
[
|
||||||
|
'discord with matrix settings',
|
||||||
|
{ kind: 'discord', discord: { channel_id: '123' }, matrix: {} },
|
||||||
|
],
|
||||||
|
['matrix without matrix settings', { kind: 'matrix' }],
|
||||||
|
[
|
||||||
|
'matrix with discord settings',
|
||||||
|
{
|
||||||
|
kind: 'matrix',
|
||||||
|
matrix: {
|
||||||
|
homeserver_url: 'https://matrix.example',
|
||||||
|
user_id: '@a:example',
|
||||||
|
room_id: '!room:example',
|
||||||
|
},
|
||||||
|
discord: { channel_id: '123' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])('rejects connector kind/settings mismatch: %s', (_label, connector) => {
|
||||||
|
expect(() =>
|
||||||
|
parseFleetRosterV1(
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
transport: 'tmux',
|
||||||
|
agents: [{ name: 'a', runtime: 'claude', class: 'worker' }],
|
||||||
|
connector,
|
||||||
|
}),
|
||||||
|
'json',
|
||||||
|
),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['tmux socket', ['tmux', 'socket_name'], ['tmux', 'socketName'], 'same', 'different'],
|
||||||
|
['tmux holder', ['tmux', 'holder_session'], ['tmux', 'holderSession'], 'same', 'different'],
|
||||||
|
[
|
||||||
|
'defaults working directory',
|
||||||
|
['defaults', 'working_directory'],
|
||||||
|
['defaults', 'workingDirectory'],
|
||||||
|
'same',
|
||||||
|
'different',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'runtime reset command',
|
||||||
|
['runtimes', 'claude', 'reset_command'],
|
||||||
|
['runtimes', 'claude', 'resetCommand'],
|
||||||
|
'same',
|
||||||
|
'different',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'agent working directory',
|
||||||
|
['agents', 0, 'working_directory'],
|
||||||
|
['agents', 0, 'workingDirectory'],
|
||||||
|
'same',
|
||||||
|
'different',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'agent model hint',
|
||||||
|
['agents', 0, 'model_hint'],
|
||||||
|
['agents', 0, 'modelHint'],
|
||||||
|
'same',
|
||||||
|
'different',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'agent reasoning level',
|
||||||
|
['agents', 0, 'reasoning_level'],
|
||||||
|
['agents', 0, 'reasoningLevel'],
|
||||||
|
'same',
|
||||||
|
'different',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'agent tool policy',
|
||||||
|
['agents', 0, 'tool_policy'],
|
||||||
|
['agents', 0, 'toolPolicy'],
|
||||||
|
'same',
|
||||||
|
'different',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'agent persistent persona',
|
||||||
|
['agents', 0, 'persistent_persona'],
|
||||||
|
['agents', 0, 'persistentPersona'],
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'agent reset between tasks',
|
||||||
|
['agents', 0, 'reset_between_tasks'],
|
||||||
|
['agents', 0, 'resetBetweenTasks'],
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'agent kickstart template',
|
||||||
|
['agents', 0, 'kickstart_template'],
|
||||||
|
['agents', 0, 'kickstartTemplate'],
|
||||||
|
'same',
|
||||||
|
'different',
|
||||||
|
],
|
||||||
|
] as const)(
|
||||||
|
'rejects conflicting %s aliases and accepts identical aliases',
|
||||||
|
(_label, snake, camel, same, different) => {
|
||||||
|
const base: Record<string, unknown> = {
|
||||||
|
version: 1,
|
||||||
|
transport: 'tmux',
|
||||||
|
tmux: {},
|
||||||
|
defaults: {},
|
||||||
|
runtimes: { claude: {} },
|
||||||
|
agents: [{ name: 'a', runtime: 'claude', class: 'worker' }],
|
||||||
|
};
|
||||||
|
const assign = (
|
||||||
|
root: Record<string, unknown>,
|
||||||
|
path: readonly (string | number)[],
|
||||||
|
value: unknown,
|
||||||
|
) => {
|
||||||
|
let cursor: unknown = root;
|
||||||
|
for (const segment of path.slice(0, -1)) {
|
||||||
|
cursor = (cursor as Record<string | number, unknown>)[segment];
|
||||||
|
}
|
||||||
|
(cursor as Record<string | number, unknown>)[path.at(-1)!] = value;
|
||||||
|
};
|
||||||
|
assign(base, snake, same);
|
||||||
|
assign(base, camel, different);
|
||||||
|
expect(() => parseFleetRosterV1(JSON.stringify(base), 'json')).toThrow(
|
||||||
|
/aliases .* conflict/i,
|
||||||
|
);
|
||||||
|
assign(base, camel, same);
|
||||||
|
expect(() => parseFleetRosterV1(JSON.stringify(base), 'json')).not.toThrow();
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('renderPeerReach — same-host vs cross-host', () => {
|
describe('renderPeerReach — exact same-host/cross-host/socket targeting', () => {
|
||||||
const send = '/home/u/.config/mosaic/tools/tmux/agent-send.sh';
|
const send = '/home/u/.config/mosaic/tools/tmux/agent-send.sh';
|
||||||
|
const base: FleetAgent = {
|
||||||
|
name: 'peer',
|
||||||
|
runtime: 'claude',
|
||||||
|
className: 'worker',
|
||||||
|
};
|
||||||
|
|
||||||
it('renders the short form for a same-host peer', () => {
|
it('renders the global named socket and omits -H for a same-host peer', () => {
|
||||||
const peer: CommsPeer = { name: 'enhancer', className: 'enhancer' };
|
expect(renderPeerReach(base, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s enhancer -m "…"`);
|
`${send} -L mosaic-fleet -s peer -m "…"`,
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the -H form for a cross-host peer using ssh', () => {
|
|
||||||
const peer: CommsPeer = {
|
|
||||||
name: 'coder0-0',
|
|
||||||
className: 'implementer',
|
|
||||||
host: '10.1.10.37',
|
|
||||||
ssh: 'jwoltje@10.1.10.37',
|
|
||||||
};
|
|
||||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
|
||||||
`${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to host when a cross-host peer has no ssh', () => {
|
it('uses only the explicit roster ssh target for a cross-host peer', () => {
|
||||||
const peer: CommsPeer = { name: 'x', className: 'worker', host: '10.0.0.9' };
|
const peer: FleetAgent = {
|
||||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -H 10.0.0.9 -s x -m "…"`);
|
...base,
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a peer whose host equals the fleet host as same-host', () => {
|
|
||||||
const peer: CommsPeer = { name: 'y', className: 'worker', host: 'w-jarvis' };
|
|
||||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s y -m "…"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits NO -L for an unset/default socket', () => {
|
|
||||||
const peer: CommsPeer = { name: 'lead', className: 'orchestrator' };
|
|
||||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s lead -m "…"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits -L <socket> for a named socket', () => {
|
|
||||||
const peer: CommsPeer = { name: 'coder0', className: 'implementer', socket: 'mosaic-fleet' };
|
|
||||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
|
||||||
`${send} -L mosaic-fleet -s coder0 -m "…"`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('combines -L (named socket) and -H (cross-host) in order', () => {
|
|
||||||
const peer: CommsPeer = {
|
|
||||||
name: 'coder0-0',
|
name: 'coder0-0',
|
||||||
className: 'implementer',
|
|
||||||
host: '10.1.10.37',
|
host: '10.1.10.37',
|
||||||
ssh: 'jwoltje@10.1.10.37',
|
ssh: 'jwoltje@10.1.10.37',
|
||||||
socket: 'mosaic-fleet',
|
|
||||||
};
|
};
|
||||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
expect(renderPeerReach(peer, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||||
`${send} -L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`,
|
`${send} -L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails closed when a cross-host peer has no explicit roster ssh target', () => {
|
||||||
|
const peer: FleetAgent = { ...base, name: 'x', host: '10.0.0.9' };
|
||||||
|
expect(() => renderPeerReach(peer, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toThrow(
|
||||||
|
/explicit roster ssh target/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders only the fleet-wide supported socket', () => {
|
||||||
|
const peer: FleetAgent = { ...base, socket: 'mosaic-fleet' };
|
||||||
|
expect(renderPeerReach(peer, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||||
|
`${send} -L mosaic-fleet -s peer -m "…"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves hostless peers against the stable fleet-host baseline, not the viewer host', () => {
|
||||||
|
const peer: FleetAgent = { ...base, ssh: 'fleet-user@w-jarvis' };
|
||||||
|
expect(renderPeerReach(peer, 'remote-host', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||||
|
`${send} -L mosaic-fleet -H fleet-user@w-jarvis -s peer -m "…"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shell-quotes an exact helper path that contains spaces', () => {
|
||||||
|
expect(
|
||||||
|
renderPeerReach(base, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', '/home/test user/send.sh'),
|
||||||
|
).toBe(`'/home/test user/send.sh' -L mosaic-fleet -s peer -m "…"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits -L only for the literal default socket', () => {
|
||||||
|
expect(renderPeerReach(base, 'w-jarvis', 'w-jarvis', '', send)).toBe(`${send} -s peer -m "…"`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildFleetCommsBlock', () => {
|
describe('buildFleetCommsBlock', () => {
|
||||||
const send = '/h/.config/mosaic/tools/tmux/agent-send.sh';
|
const send = '/h/.config/mosaic/tools/tmux/agent-send.sh';
|
||||||
const agents = parseRosterAgents(ROSTER);
|
|
||||||
|
|
||||||
it('excludes self, lists peers, flags the orchestrator, and emits both address forms', () => {
|
it('renders authoritative identity, exact rows, generation, and no operational metavariables', () => {
|
||||||
const block = buildFleetCommsBlock({
|
const block = buildFleetCommsBlock({
|
||||||
selfName: 'enhancer',
|
selfName: 'enhancer',
|
||||||
agents,
|
roster: roster(),
|
||||||
fleetHost: 'w-jarvis',
|
localHost: 'ignored-process-host',
|
||||||
agentSendPath: send,
|
agentSendPath: send,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(block).toContain('# Fleet Comms');
|
expect(block).toContain('# Fleet Comms');
|
||||||
expect(block).toContain('You are **enhancer**');
|
expect(block).toContain('Host: `w-jarvis`');
|
||||||
// criterion 1: agent's own [host:session] identity
|
expect(block).toContain('Agent/session: `enhancer`');
|
||||||
expect(block).toContain('`[w-jarvis:enhancer]`');
|
expect(block).toContain('tmux socket: `mosaic-fleet`');
|
||||||
// self excluded
|
expect(block).toContain(`Helper: \`${send}\``);
|
||||||
|
expect(block).toMatch(/Comms generation: `[a-f0-9]{64}`/);
|
||||||
expect(block).not.toMatch(/\|\s*enhancer\s*\|/);
|
expect(block).not.toMatch(/\|\s*enhancer\s*\|/);
|
||||||
// peers present
|
expect(block).toContain(`${send} -L mosaic-fleet -s orchestrator -m "…"`);
|
||||||
expect(block).toContain('| orchestrator |');
|
expect(block).toContain(`${send} -L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`);
|
||||||
expect(block).toContain('point of contact');
|
expect(block).toContain(`mosaic agent comms-block enhancer`);
|
||||||
// same-host peer short form
|
expect(block).toMatch(/Never invent, substitute, or fuzzy-match/i);
|
||||||
expect(block).toContain(`${send} -s coder0 -m "…"`);
|
expect(block).not.toMatch(
|
||||||
// cross-host peer -H form + host annotation
|
/<(?:user@host|src_host|src_session|dst_host|dst_session|target-session)>/,
|
||||||
expect(block).toContain(`${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`);
|
);
|
||||||
expect(block).toContain('host `10.1.10.37`');
|
expect(block).not.toContain('FLIP the preamble');
|
||||||
// conventions
|
|
||||||
expect(block).toContain('FLIP the preamble');
|
|
||||||
expect(block).toContain('ACCEPTED');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty when the agent has no peers', () => {
|
it('changes the generation when a rendered peer role changes', () => {
|
||||||
expect(
|
const generation = (block: string) => block.match(/Comms generation: `([a-f0-9]{64})`/)?.[1];
|
||||||
|
const before = buildFleetCommsBlock({
|
||||||
|
selfName: 'enhancer',
|
||||||
|
roster: roster(),
|
||||||
|
localHost: 'w-jarvis',
|
||||||
|
agentSendPath: send,
|
||||||
|
});
|
||||||
|
const changedRoster = roster(ROSTER.replace('class: implementer', 'class: reviewer'));
|
||||||
|
const after = buildFleetCommsBlock({
|
||||||
|
selfName: 'enhancer',
|
||||||
|
roster: changedRoster,
|
||||||
|
localHost: 'w-jarvis',
|
||||||
|
agentSendPath: send,
|
||||||
|
});
|
||||||
|
expect(generation(before)).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(generation(after)).not.toBe(generation(before));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when any rendered cross-host row lacks ssh', () => {
|
||||||
|
const bad = roster(ROSTER.replace(' ssh: jwoltje@10.1.10.37\n', ''));
|
||||||
|
expect(() =>
|
||||||
buildFleetCommsBlock({
|
buildFleetCommsBlock({
|
||||||
selfName: 'solo',
|
selfName: 'enhancer',
|
||||||
agents: [{ name: 'solo', className: 'orchestrator' }],
|
roster: bad,
|
||||||
fleetHost: 'h',
|
localHost: 'w-jarvis',
|
||||||
agentSendPath: send,
|
agentSendPath: send,
|
||||||
}),
|
}),
|
||||||
).toBe('');
|
).toThrow(/explicit roster ssh target/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still renders authoritative local identity when the agent has no peers', () => {
|
||||||
|
const solo = roster(
|
||||||
|
[
|
||||||
|
'version: 1',
|
||||||
|
'transport: tmux',
|
||||||
|
'agents:',
|
||||||
|
' - name: solo',
|
||||||
|
' runtime: claude',
|
||||||
|
' class: orchestrator',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
const block = buildFleetCommsBlock({
|
||||||
|
selfName: 'solo',
|
||||||
|
roster: solo,
|
||||||
|
localHost: 'h',
|
||||||
|
agentSendPath: send,
|
||||||
|
});
|
||||||
|
expect(block).toContain('Host: `h`');
|
||||||
|
expect(block).toContain('Agent/session: `solo`');
|
||||||
|
expect(block).toContain('Role/class: `orchestrator`');
|
||||||
|
expect(block).toMatch(/Comms generation: `[a-f0-9]{64}`/);
|
||||||
|
expect(block).toContain('This roster has no peers');
|
||||||
|
expect(block).toContain('## Solo authority boundaries');
|
||||||
|
expect(block).toContain('no peer, orchestrator, or remote communication authority');
|
||||||
|
expect(block).toContain('Do not send, infer a target, or claim fleet coordination');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('readFleetCommsBlock — situational (the context a spawned agent gets)', () => {
|
describe('resolvePeerCommand', () => {
|
||||||
|
const send = '/h/.config/mosaic/tools/tmux/agent-send.sh';
|
||||||
|
|
||||||
|
it('returns one exact known-peer row', () => {
|
||||||
|
const result = resolvePeerCommand(roster(), 'enhancer', 'coder0-0', 'w-jarvis', send);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.command).toContain('-H jwoltje@10.1.10.37 -s coder0-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed for an unknown peer with exact-name discovery guidance', () => {
|
||||||
|
const result = resolvePeerCommand(roster(), 'enhancer', 'invented-host', 'w-jarvis', send);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.command).toBe('');
|
||||||
|
expect(result.error).toContain('invented-host');
|
||||||
|
expect(result.error).toContain('orchestrator, coder0, coder0-0');
|
||||||
|
expect(result.error).toContain('mosaic agent comms-block enhancer');
|
||||||
|
expect(result.error).not.toContain('tmux ls');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readFleetCommsBlock — spawned-agent context', () => {
|
||||||
let home: string;
|
let home: string;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
home = mkdtempSync(join(tmpdir(), 'mosaic-comms-'));
|
home = mkdtempSync(join(tmpdir(), 'mosaic-comms-'));
|
||||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||||
|
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||||
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER);
|
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER);
|
||||||
|
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||||
|
writeFileSync(helper, '#!/bin/sh\n');
|
||||||
|
chmodSync(helper, 0o755);
|
||||||
});
|
});
|
||||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||||
|
|
||||||
it('builds the cheat-sheet with correct peer addresses for a fleet member', () => {
|
it('uses the authoritative self host and global socket from the shared roster resolver', () => {
|
||||||
const block = readFleetCommsBlock(home, 'orchestrator', 'w-jarvis');
|
const result = readFleetCommsBlock(home, 'enhancer', 'process-host-must-not-win');
|
||||||
expect(block).toContain('# Fleet Comms');
|
expect(result.ok).toBe(true);
|
||||||
expect(block).toContain('| enhancer |');
|
expect(result.output).toContain('Host: `w-jarvis`');
|
||||||
expect(block).toContain(`${join(home, 'tools', 'tmux', 'agent-send.sh')} -s coder0 -m "…"`);
|
expect(result.output).toContain('tmux socket: `mosaic-fleet`');
|
||||||
expect(block).toContain('-H jwoltje@10.1.10.37 -s coder0-0');
|
expect(result.output).toContain('-L mosaic-fleet -s orchestrator');
|
||||||
expect(block).not.toMatch(/\|\s*orchestrator\s*\|/); // self excluded
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty when MOSAIC_AGENT_NAME is unset, no roster, or agent not a member', () => {
|
it('fails closed for a requested fleet identity that is absent', () => {
|
||||||
expect(readFleetCommsBlock(home, undefined, 'w-jarvis')).toBe('');
|
const result = readFleetCommsBlock(home, 'stranger', 'w-jarvis');
|
||||||
expect(readFleetCommsBlock(home, 'stranger', 'w-jarvis')).toBe('');
|
expect(result.ok).toBe(false);
|
||||||
expect(readFleetCommsBlock(mkdtempSync(join(tmpdir(), 'noroster-')), 'orchestrator')).toBe('');
|
expect(result.output).toBe('');
|
||||||
|
expect(result.error).toContain('Known exact names');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a supported JSON-only installed roster', () => {
|
||||||
|
rmSync(join(home, 'fleet', 'roster.yaml'));
|
||||||
|
writeFileSync(
|
||||||
|
join(home, 'fleet', 'roster.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
transport: 'tmux',
|
||||||
|
tmux: { socket_name: 'mosaic-fleet' },
|
||||||
|
agents: [
|
||||||
|
{ name: 'enhancer', runtime: 'claude', class: 'enhancer', host: 'w-jarvis' },
|
||||||
|
{ name: 'orchestrator', runtime: 'claude', class: 'orchestrator', host: 'w-jarvis' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const result = readFleetCommsBlock(home, 'enhancer', 'process-host-must-not-win');
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.output).toContain('-L mosaic-fleet -s orchestrator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed on a YAML I/O error instead of falling back to JSON', () => {
|
||||||
|
rmSync(join(home, 'fleet', 'roster.yaml'));
|
||||||
|
mkdirSync(join(home, 'fleet', 'roster.yaml'));
|
||||||
|
writeFileSync(
|
||||||
|
join(home, 'fleet', 'roster.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
transport: 'tmux',
|
||||||
|
agents: [{ name: 'enhancer', runtime: 'claude', class: 'enhancer' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toContain('invalid fleet roster at');
|
||||||
|
expect(result.error).toContain('roster.yaml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['missing', () => rmSync(join(home, 'tools', 'tmux', 'agent-send.sh'))],
|
||||||
|
[
|
||||||
|
'directory',
|
||||||
|
() => {
|
||||||
|
rmSync(join(home, 'tools', 'tmux', 'agent-send.sh'));
|
||||||
|
mkdirSync(join(home, 'tools', 'tmux', 'agent-send.sh'));
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'symlink',
|
||||||
|
() => {
|
||||||
|
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||||
|
rmSync(helper);
|
||||||
|
writeFileSync(join(home, 'real-send.sh'), '#!/bin/sh\n');
|
||||||
|
symlinkSync(join(home, 'real-send.sh'), helper);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
['non-executable', () => chmodSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 0o644)],
|
||||||
|
])('fails closed for a %s helper with deterministic repair guidance', (_case, mutate) => {
|
||||||
|
mutate();
|
||||||
|
const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.output).toBe('');
|
||||||
|
expect(result.error).toContain('mosaic update --repair-tools');
|
||||||
|
expect(result.error).toContain('no active context or session was rewritten');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not rewrite the roster while resolving context', () => {
|
||||||
|
const path = join(home, 'fleet', 'roster.yaml');
|
||||||
|
const before = readFileSync(path, 'utf8');
|
||||||
|
readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
|
||||||
|
expect(readFileSync(path, 'utf8')).toBe(before);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('resolveCommsBlock — `mosaic fleet comms-block <role>` emitter semantics', () => {
|
describe('renderToolsContractStatus — non-mutating install drift', () => {
|
||||||
// The emitter wraps readFleetCommsBlock but must NEVER print an empty string silently:
|
let home: string;
|
||||||
// an unknown role / missing roster has to fail loud (caller maps !ok → stderr + exit 1)
|
|
||||||
// so `mosaic fleet comms-block bogus` is a visible error, not a confusing no-op. The
|
beforeEach(() => {
|
||||||
// success path returns the block verbatim for `mosaic fleet comms-block <peer>` previews.
|
home = mkdtempSync(join(tmpdir(), 'mosaic-tools-status-'));
|
||||||
|
mkdirSync(join(home, 'defaults'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(home, 'defaults', 'TOOLS.md'),
|
||||||
|
'# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it('uses the supported repair command when installed TOOLS.md is missing', () => {
|
||||||
|
const status = renderToolsContractStatus(home);
|
||||||
|
expect(status).toContain('mosaic update --repair-tools');
|
||||||
|
expect(status).not.toContain('--reseed');
|
||||||
|
expect(status).toContain('authorized operator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports stale preserved content without rewriting it', () => {
|
||||||
|
const path = join(home, 'TOOLS.md');
|
||||||
|
const stale = '# customized tools\n';
|
||||||
|
writeFileSync(path, stale);
|
||||||
|
const status = renderToolsContractStatus(home);
|
||||||
|
expect(status).toContain('fleet-comms-contract: 1');
|
||||||
|
expect(status).toContain('digest-qualified backup');
|
||||||
|
expect(status).toContain('mosaic update --repair-tools');
|
||||||
|
expect(status).toContain('active context was not rewritten');
|
||||||
|
expect(readFileSync(path, 'utf8')).toBe(stale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not accept marker-only customized content as current', () => {
|
||||||
|
const path = join(home, 'TOOLS.md');
|
||||||
|
writeFileSync(path, '<!-- fleet-comms-contract: 1 -->\ncorrupt\n');
|
||||||
|
expect(renderToolsContractStatus(home)).toContain('does not byte-match');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects markerless byte-equal source and installed content', () => {
|
||||||
|
const content = '# markerless but equal\n';
|
||||||
|
writeFileSync(join(home, 'defaults', 'TOOLS.md'), content);
|
||||||
|
writeFileSync(join(home, 'TOOLS.md'), content);
|
||||||
|
const status = renderToolsContractStatus(home);
|
||||||
|
expect(status).toContain('source contract');
|
||||||
|
expect(status).toContain('does not declare the expected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['source', join('defaults', 'TOOLS.md')],
|
||||||
|
['installed', 'TOOLS.md'],
|
||||||
|
])('rejects a wrong contract version in %s content', (_case, relativePath) => {
|
||||||
|
const current = '# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n';
|
||||||
|
writeFileSync(join(home, 'TOOLS.md'), current);
|
||||||
|
writeFileSync(join(home, 'defaults', 'TOOLS.md'), current);
|
||||||
|
writeFileSync(join(home, relativePath), current.replace('contract: 1', 'contract: 2'));
|
||||||
|
expect(renderToolsContractStatus(home)).not.toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats installed TOOLS.md symlinks as stale without following or rewriting them', () => {
|
||||||
|
const external = join(home, 'external-tools.md');
|
||||||
|
const externalContent = '# external\n<!-- fleet-comms-contract: 1 -->\n';
|
||||||
|
writeFileSync(external, externalContent);
|
||||||
|
symlinkSync(external, join(home, 'TOOLS.md'));
|
||||||
|
|
||||||
|
const status = renderToolsContractStatus(home);
|
||||||
|
|
||||||
|
expect(status).toContain('unavailable');
|
||||||
|
expect(status).toContain('mosaic update --repair-tools');
|
||||||
|
expect(readFileSync(external, 'utf8')).toBe(externalContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats source TOOLS.md symlinks as unavailable without following them', () => {
|
||||||
|
const external = join(home, 'external-source.md');
|
||||||
|
const content = '# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n';
|
||||||
|
writeFileSync(external, content);
|
||||||
|
rmSync(join(home, 'defaults', 'TOOLS.md'));
|
||||||
|
symlinkSync(external, join(home, 'defaults', 'TOOLS.md'));
|
||||||
|
writeFileSync(join(home, 'TOOLS.md'), content);
|
||||||
|
|
||||||
|
const status = renderToolsContractStatus(home);
|
||||||
|
|
||||||
|
expect(status).toContain('source contract');
|
||||||
|
expect(status).toContain('unavailable');
|
||||||
|
expect(readFileSync(external, 'utf8')).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts byte-equal bounded source and installed contracts', () => {
|
||||||
|
const source = readFileSync(join(home, 'defaults', 'TOOLS.md'), 'utf8');
|
||||||
|
writeFileSync(join(home, 'TOOLS.md'), source);
|
||||||
|
expect(renderToolsContractStatus(home)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveCommsBlock — mosaic agent comms-block', () => {
|
||||||
let home: string;
|
let home: string;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
home = mkdtempSync(join(tmpdir(), 'mosaic-commsblk-'));
|
home = mkdtempSync(join(tmpdir(), 'mosaic-commsblk-'));
|
||||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||||
|
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||||
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER);
|
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER);
|
||||||
|
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||||
|
writeFileSync(helper, '#!/bin/sh\n');
|
||||||
|
chmodSync(helper, 0o755);
|
||||||
});
|
});
|
||||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||||
|
|
||||||
it('returns ok + the cheat-sheet for a roster member', () => {
|
it('returns the exact contract for a roster member', () => {
|
||||||
const res = resolveCommsBlock(home, 'orchestrator', 'w-jarvis');
|
const result = resolveCommsBlock(home, 'enhancer');
|
||||||
expect(res.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(res.output).toContain('# Fleet Comms');
|
expect(result.output).toContain('Host: `w-jarvis`');
|
||||||
expect(res.output).toContain('| enhancer |');
|
expect(result.error).toBeUndefined();
|
||||||
expect(res.error).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails loud (not ok + error naming the role) for a non-member — never silently empty', () => {
|
it('fails loud and lists known exact names for a non-member', () => {
|
||||||
const res = resolveCommsBlock(home, 'stranger', 'w-jarvis');
|
const result = resolveCommsBlock(home, 'stranger');
|
||||||
expect(res.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(res.output).toBe('');
|
expect(result.output).toBe('');
|
||||||
expect(res.error).toContain('stranger');
|
expect(result.error).toContain('stranger');
|
||||||
|
expect(result.error).toContain('orchestrator');
|
||||||
|
expect(result.error).toContain('enhancer');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails loud when no roster exists at the mosaic home', () => {
|
it('fails loud when no roster exists', () => {
|
||||||
const noRoster = mkdtempSync(join(tmpdir(), 'mosaic-noroster-'));
|
const noRoster = mkdtempSync(join(tmpdir(), 'mosaic-noroster-'));
|
||||||
const res = resolveCommsBlock(noRoster, 'orchestrator', 'w-jarvis');
|
mkdirSync(join(noRoster, 'tools', 'tmux'), { recursive: true });
|
||||||
expect(res.ok).toBe(false);
|
const helper = join(noRoster, 'tools', 'tmux', 'agent-send.sh');
|
||||||
expect(res.error).toBeTruthy();
|
writeFileSync(helper, '#!/bin/sh\n');
|
||||||
|
chmodSync(helper, 0o755);
|
||||||
|
const result = resolveCommsBlock(noRoster, 'orchestrator');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toContain('no fleet roster');
|
||||||
rmSync(noRoster, { recursive: true, force: true });
|
rmSync(noRoster, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails loud for a missing role argument', () => {
|
it('fails loud for a missing role argument', () => {
|
||||||
const res = resolveCommsBlock(home, undefined, 'w-jarvis');
|
const result = resolveCommsBlock(home, undefined);
|
||||||
expect(res.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(res.error).toBeTruthy();
|
expect(result.error).toContain('requires');
|
||||||
});
|
|
||||||
|
|
||||||
it('honors a host override so a peer can preview its own cross-host view', () => {
|
|
||||||
// coder0-0 viewing with its own host → its self-identity line uses that host.
|
|
||||||
const res = resolveCommsBlock(home, 'coder0-0', '10.1.10.37');
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
expect(res.output).toContain('`[10.1.10.37:coder0-0]`');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,226 +1,423 @@
|
|||||||
/**
|
/**
|
||||||
* Fleet onboarding-injection (#620).
|
* Exact roster-resolved fleet communications contract (#766).
|
||||||
*
|
*
|
||||||
* Fleet agents are born not knowing how to reach their peers — the root cause of
|
* The runtime composer and `mosaic fleet` command surface share the canonical
|
||||||
* a spawned agent's failed first send. When an agent boots via `mosaic yolo
|
* v1 roster resolver. This module never probes tmux, guesses an SSH target, or
|
||||||
* <runtime>` (→ composeContract → system prompt), we append a comms cheat-sheet
|
* mutates an active session.
|
||||||
* + peer roster so it can talk to the orchestrator and other agents immediately.
|
|
||||||
*
|
|
||||||
* Cross-host aware: a peer may carry `host`/`ssh` (a deliberate pre-federation
|
|
||||||
* stopgap — manual cross-host listing; federation/W1 auto-discovers later), so a
|
|
||||||
* w-jarvis agent is born knowing the exact `-H` command to reach a dragon-lin
|
|
||||||
* peer. Same-host peers render the short form.
|
|
||||||
*
|
|
||||||
* Standalone (no fleet.ts import) to keep launch.ts's prompt path free of the
|
|
||||||
* heavy fleet command module. The roster is parsed leniently — the cheat-sheet
|
|
||||||
* is best-effort onboarding, never a hard dependency.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
import { homedir, hostname } from 'node:os';
|
import { homedir, hostname } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { readRegularFileSecure } from './secure-file.js';
|
||||||
export interface CommsPeer {
|
import {
|
||||||
name: string;
|
parseFleetRosterV1,
|
||||||
/** Roster `class` (orchestrator | enhancer | implementer | worker | …). */
|
resolveInstalledFleetRosterPath,
|
||||||
className: string;
|
getRosterAgent,
|
||||||
/** Host the peer runs on; absent ⇒ the fleet host (same host). */
|
type FleetAgent,
|
||||||
host?: string;
|
type FleetRoster,
|
||||||
/** SSH target (user@host) for a cross-host peer; renders the `-H` form. */
|
} from './fleet-roster-v1.js';
|
||||||
ssh?: string;
|
|
||||||
/** tmux socket the peer's session lives on; absent ⇒ default socket (no `-L`). */
|
|
||||||
socket?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lenient parse of a fleet `roster.yaml` for agent name/class/host/ssh. Avoids a
|
|
||||||
* dependency on the full fleet roster parser; the format is `- name:` list items
|
|
||||||
* with `class:`/`host:`/`ssh:` siblings under `agents:`.
|
|
||||||
*/
|
|
||||||
export function parseRosterAgents(yamlText: string): CommsPeer[] {
|
|
||||||
const peers: CommsPeer[] = [];
|
|
||||||
let current: CommsPeer | null = null;
|
|
||||||
let inAgents = false;
|
|
||||||
const scalar = (line: string, key: string): string | null => {
|
|
||||||
const m = line.match(new RegExp(`^\\s*${key}:\\s*["']?([^"'#]+?)["']?\\s*$`));
|
|
||||||
return m ? (m[1] as string).trim() : null;
|
|
||||||
};
|
|
||||||
for (const rawLine of yamlText.split('\n')) {
|
|
||||||
const line = rawLine.replace(/\s+$/, '');
|
|
||||||
if (/^agents:\s*$/.test(line)) {
|
|
||||||
inAgents = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!inAgents) continue;
|
|
||||||
// A new top-level key (no leading space) ends the agents block.
|
|
||||||
if (/^\S/.test(line)) break;
|
|
||||||
|
|
||||||
const nameMatch = line.match(/^\s*-\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
|
|
||||||
if (nameMatch) {
|
|
||||||
if (current) peers.push(current);
|
|
||||||
current = { name: nameMatch[1] as string, className: 'worker' };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!current) continue;
|
|
||||||
const cls = scalar(line, 'class');
|
|
||||||
if (cls) current.className = cls;
|
|
||||||
const host = scalar(line, 'host');
|
|
||||||
if (host) current.host = host;
|
|
||||||
const ssh = scalar(line, 'ssh');
|
|
||||||
if (ssh) current.ssh = ssh;
|
|
||||||
const socket = scalar(line, 'socket');
|
|
||||||
if (socket) current.socket = socket;
|
|
||||||
}
|
|
||||||
if (current) peers.push(current);
|
|
||||||
return peers;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FleetCommsOptions {
|
export interface FleetCommsOptions {
|
||||||
/** This agent's name (it is excluded from its own peer list). */
|
/** Exact current roster member. */
|
||||||
selfName: string;
|
selfName: string;
|
||||||
/** All roster agents (including self; filtered out internally). */
|
/** Canonically resolved roster. */
|
||||||
agents: CommsPeer[];
|
roster: FleetRoster;
|
||||||
/** Host the fleet runs on (short hostname) — the same-host baseline. */
|
/** Stable fleet-host baseline for members whose roster host is absent. */
|
||||||
fleetHost: string;
|
localHost: string;
|
||||||
/** Absolute path to agent-send.sh in this install. */
|
/** Absolute helper path in this installation. */
|
||||||
agentSendPath: string;
|
agentSendPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Is this peer on a different host than the fleet baseline? */
|
export interface CommsBlockResult {
|
||||||
function isRemote(peer: CommsPeer, fleetHost: string): boolean {
|
ok: boolean;
|
||||||
return peer.host !== undefined && peer.host !== fleetHost;
|
output: string;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface ResolvedFleetIdentity {
|
||||||
* Render the exact agent-send command to reach a peer (session = agent name).
|
readonly roster: FleetRoster;
|
||||||
* Data-driven per peer: a named `socket` → `-L <socket>`; an unset socket → the
|
readonly member: FleetAgent;
|
||||||
* default tmux socket (no `-L`). A cross-host peer adds `-H <ssh|host>`.
|
readonly requestedName: string;
|
||||||
*/
|
readonly agentSendPath: string;
|
||||||
export function renderPeerReach(peer: CommsPeer, fleetHost: string, agentSendPath: string): string {
|
readonly localHost: string;
|
||||||
const parts = [agentSendPath];
|
}
|
||||||
if (peer.socket) parts.push('-L', peer.socket); // unset ⇒ default socket, no -L
|
|
||||||
if (isRemote(peer, fleetHost)) parts.push('-H', peer.ssh ?? (peer.host as string));
|
export interface FleetIdentityResult {
|
||||||
parts.push('-s', peer.name, '-m', '"…"');
|
ok: boolean;
|
||||||
|
identity?: ResolvedFleetIdentity;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeerCommandResult {
|
||||||
|
ok: boolean;
|
||||||
|
command: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FLEET_COMMS_TOOLS_CONTRACT = 'fleet-comms-contract: 1';
|
||||||
|
const MAX_TOOLS_CONTRACT_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
function shortHostname(): string {
|
||||||
|
return hostname().split('.')[0] || 'localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedHost(agent: FleetAgent, fleetHost: string): string {
|
||||||
|
return agent.host ?? fleetHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displaySocket(socket: string): string {
|
||||||
|
return socket || '(default)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function knownNames(roster: FleetRoster, except?: string): string {
|
||||||
|
return roster.agents
|
||||||
|
.filter((agent) => agent.name !== except)
|
||||||
|
.map((agent) => agent.name)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function missingMemberError(roster: FleetRoster, selfName: string): string {
|
||||||
|
return `Agent "${selfName}" is not in the fleet roster. Known exact names: ${knownNames(roster)}. Select an exact roster name; do not infer or fuzzy-match a tmux session.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render one shell argument without changing already-safe exact values. */
|
||||||
|
function shellArg(value: string): string {
|
||||||
|
if (/^[A-Za-z0-9_./:@=+-]+$/.test(value)) return value;
|
||||||
|
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the exact command for one peer. Throws rather than guessing. */
|
||||||
|
export function renderPeerReach(
|
||||||
|
peer: FleetAgent,
|
||||||
|
selfHost: string,
|
||||||
|
fleetHost: string,
|
||||||
|
rosterSocket: string,
|
||||||
|
agentSendPath: string,
|
||||||
|
): string {
|
||||||
|
const parts = [shellArg(agentSendPath)];
|
||||||
|
if (rosterSocket) parts.push('-L', shellArg(rosterSocket));
|
||||||
|
|
||||||
|
const peerHost = resolvedHost(peer, fleetHost);
|
||||||
|
if (peerHost !== selfHost) {
|
||||||
|
if (!peer.ssh) {
|
||||||
|
throw new Error(
|
||||||
|
`Cross-host peer "${peer.name}" (${peerHost}) requires an explicit roster ssh target; refusing to substitute its host value.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parts.push('-H', shellArg(peer.ssh));
|
||||||
|
}
|
||||||
|
parts.push('-s', shellArg(peer.name), '-m', '"…"');
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Resolve one requested peer without fuzzy lookup. */
|
||||||
* Build the `# Fleet Comms` onboarding block (pure markdown). Returns '' when
|
export function resolvePeerCommand(
|
||||||
* the agent has no peers (a single-agent roster has no one to talk to).
|
roster: FleetRoster,
|
||||||
*/
|
selfName: string,
|
||||||
|
peerName: string,
|
||||||
|
localHost: string,
|
||||||
|
agentSendPath: string,
|
||||||
|
): PeerCommandResult {
|
||||||
|
const self = roster.agents.find((agent) => agent.name === selfName);
|
||||||
|
if (!self) return { ok: false, command: '', error: missingMemberError(roster, selfName) };
|
||||||
|
const peer = roster.agents.find((agent) => agent.name === peerName && agent.name !== selfName);
|
||||||
|
if (!peer) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
command: '',
|
||||||
|
error:
|
||||||
|
`Peer "${peerName}" is absent from the fleet roster. Known exact peer names: ${knownNames(roster, selfName)}. ` +
|
||||||
|
`Run \`mosaic agent comms-block ${selfName}\` to rediscover exact rendered rows; do not infer or fuzzy-match a tmux session.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
command: renderPeerReach(
|
||||||
|
peer,
|
||||||
|
resolvedHost(self, localHost),
|
||||||
|
localHost,
|
||||||
|
roster.tmux.socketName,
|
||||||
|
agentSendPath,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
command: '',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedRow {
|
||||||
|
readonly peer: FleetAgent;
|
||||||
|
readonly host: string;
|
||||||
|
readonly socket: string;
|
||||||
|
readonly command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRows(opts: FleetCommsOptions, self: FleetAgent): readonly ResolvedRow[] {
|
||||||
|
const selfHost = resolvedHost(self, opts.localHost);
|
||||||
|
return opts.roster.agents
|
||||||
|
.filter((agent) => agent.name !== opts.selfName)
|
||||||
|
.map(
|
||||||
|
(peer): ResolvedRow => ({
|
||||||
|
peer,
|
||||||
|
host: resolvedHost(peer, opts.localHost),
|
||||||
|
socket: opts.roster.tmux.socketName,
|
||||||
|
command: renderPeerReach(
|
||||||
|
peer,
|
||||||
|
selfHost,
|
||||||
|
opts.localHost,
|
||||||
|
opts.roster.tmux.socketName,
|
||||||
|
opts.agentSendPath,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commsGeneration(
|
||||||
|
self: FleetAgent,
|
||||||
|
selfHost: string,
|
||||||
|
selfSocket: string,
|
||||||
|
helper: string,
|
||||||
|
rows: readonly ResolvedRow[],
|
||||||
|
): string {
|
||||||
|
const canonical = JSON.stringify({
|
||||||
|
self: { ...self, resolvedHost: selfHost, resolvedSocket: selfSocket, helper },
|
||||||
|
peers: rows.map((row) => ({
|
||||||
|
...row.peer,
|
||||||
|
resolvedHost: row.host,
|
||||||
|
resolvedSocket: row.socket,
|
||||||
|
exactCommand: row.command,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return createHash('sha256').update(canonical).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the authoritative Markdown contract for one exact roster member. */
|
||||||
export function buildFleetCommsBlock(opts: FleetCommsOptions): string {
|
export function buildFleetCommsBlock(opts: FleetCommsOptions): string {
|
||||||
const peers = opts.agents.filter((a) => a.name !== opts.selfName);
|
const self = opts.roster.agents.find((agent) => agent.name === opts.selfName);
|
||||||
if (peers.length === 0) return '';
|
if (!self) throw new Error(missingMemberError(opts.roster, opts.selfName));
|
||||||
|
const rows = resolveRows(opts, self);
|
||||||
|
const selfHost = resolvedHost(self, opts.localHost);
|
||||||
|
const selfSocket = opts.roster.tmux.socketName;
|
||||||
|
const generation = commsGeneration(self, selfHost, selfSocket, opts.agentSendPath, rows);
|
||||||
|
const orchestrator = rows.find((row) => row.peer.className === 'orchestrator');
|
||||||
|
const peerSection =
|
||||||
|
rows.length === 0
|
||||||
|
? 'This roster has no peers. Do not invent a target.'
|
||||||
|
: `| Agent | Role | Host | Socket | Exact command |
|
||||||
|
| ----- | ---- | ---- | ------ | ------------- |
|
||||||
|
${rows
|
||||||
|
.map((row) => {
|
||||||
|
const pointOfContact = row.peer.className === 'orchestrator' ? ' ← point of contact' : '';
|
||||||
|
return `| ${row.peer.name} | ${row.peer.className}${pointOfContact} | ${row.host} | ${displaySocket(row.socket)} | \`${row.command}\` |`;
|
||||||
|
})
|
||||||
|
.join('\n')}`;
|
||||||
|
const contact = orchestrator
|
||||||
|
? `Your point of contact is **${orchestrator.peer.name}**. Select that exact peer row for status, questions, and decisions.`
|
||||||
|
: rows.length === 0
|
||||||
|
? 'No peer coordination target exists in this roster.'
|
||||||
|
: 'This roster has no orchestrator. Select an exact peer row for coordination.';
|
||||||
|
const soloAuthority =
|
||||||
|
rows.length === 0
|
||||||
|
? `\n## Solo authority boundaries\n\nThis member is normalized as role/class **${self.className}**. The roster grants no peer, orchestrator, or remote communication authority. Do not send, infer a target, or claim fleet coordination until an exact peer is added to the canonical roster and this block is recomposed.\n`
|
||||||
|
: '';
|
||||||
|
|
||||||
const orchestrator = peers.find((p) => p.className === 'orchestrator');
|
return `# Fleet Comms — authoritative exact targets
|
||||||
const rows = peers
|
|
||||||
.map((p) => {
|
|
||||||
const where = isRemote(p, opts.fleetHost)
|
|
||||||
? `${p.className} · host \`${p.host}\``
|
|
||||||
: p.className;
|
|
||||||
const role = p.className === 'orchestrator' ? `${where} ← point of contact` : where;
|
|
||||||
return `| ${p.name} | ${role} | \`${renderPeerReach(p, opts.fleetHost, opts.agentSendPath)}\` |`;
|
|
||||||
})
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const orchLine = orchestrator
|
## Local identity
|
||||||
? `Your point of contact is **${orchestrator.name}** (the orchestrator) — route questions, ` +
|
|
||||||
`status, and decisions there.`
|
|
||||||
: `This fleet has no orchestrator in its roster; coordinate with your peers directly.`;
|
|
||||||
|
|
||||||
return `# Fleet Comms — reach your peers
|
- Host: \`${selfHost}\`
|
||||||
|
- Agent/session: \`${self.name}\`
|
||||||
|
- Role/class: \`${self.className}\`
|
||||||
|
- tmux socket: \`${displaySocket(selfSocket)}\`
|
||||||
|
- Helper: \`${opts.agentSendPath}\`
|
||||||
|
- Comms generation: \`${generation}\`
|
||||||
|
|
||||||
You are **${opts.selfName}** in this fleet. Your comms identity is \`[${opts.fleetHost}:${opts.selfName}]\` —
|
The roster-resolved rows below are the only valid operational targets. Select the row whose Agent value
|
||||||
that is the \`<src>\` other agents see and reply to. Reach other agents (durable tmux sessions) with the
|
exactly matches the requested peer. Never invent, substitute, or fuzzy-match host, session, socket, SSH,
|
||||||
Mosaic comms tool at \`${opts.agentSendPath}\`. The **Reach** column below is the exact command per peer:
|
or helper-path values. If the peer is absent, stop and run \`mosaic agent comms-block ${self.name}\` to
|
||||||
same-host peers use the short form (no \`-H\`); cross-host peers include \`-H <user@host>\`.
|
rediscover this exact member's rows; if it is still absent, report the unknown peer.
|
||||||
|
|
||||||
## Peers
|
## Peers
|
||||||
|
|
||||||
| Agent | Role | Reach (session = agent name) |
|
${peerSection}
|
||||||
| ----- | ---- | ---------------------------- |
|
|
||||||
${rows}
|
|
||||||
|
|
||||||
${orchLine}
|
${contact}
|
||||||
|
${soloAuthority}
|
||||||
|
## Context freshness
|
||||||
|
|
||||||
## Conventions
|
This block is a snapshot; Mosaic does not rewrite an active agent's context. Compare its Comms generation
|
||||||
|
with fresh output from \`mosaic agent comms-block ${self.name}\`. If they differ, report stale composed
|
||||||
|
context and have an authorized operator relaunch only this exact roster member with
|
||||||
|
\`mosaic fleet restart ${self.name}\`. Do not restart or mutate a session automatically.`;
|
||||||
|
}
|
||||||
|
|
||||||
- Every message carries a self-identifying preamble \`[<src_host>:<src_session> -> <dst_host>:<dst_session>]\` — \`agent-send.sh\` adds it automatically.
|
function validateAgentSendHelper(path: string, mosaicHome: string): string | undefined {
|
||||||
- **To reply, FLIP the preamble:** address your reply to the sender's \`src\` (their host:session becomes your \`-s\`/\`-H\`).
|
try {
|
||||||
- \`agent-send.sh\` (a.k.a. \`agent send --verify\`) confirms the message was **ACCEPTED** at the destination prompt — not merely injected. Prefer it for anything that matters.`;
|
readRegularFileSecure(path, { root: mosaicHome, executable: true });
|
||||||
|
return undefined;
|
||||||
|
} catch (error) {
|
||||||
|
const reason = error instanceof Error ? error.message : String(error);
|
||||||
|
return `helper is unavailable or unsafe: ${path} (${reason})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function helperFailureGuidance(reason: string): string {
|
||||||
|
return `${reason}. Run \`mosaic update --repair-tools\` to restore the supported current-version helper and TOOLS contract, then retry exact-member composition; no active context or session was rewritten.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFleetIdentity(
|
||||||
|
mosaicHome: string,
|
||||||
|
requestedName: string | undefined,
|
||||||
|
localHost: string = shortHostname(),
|
||||||
|
): FleetIdentityResult {
|
||||||
|
if (!requestedName) return { ok: true };
|
||||||
|
const agentSendPath = join(mosaicHome, 'tools', 'tmux', 'agent-send.sh');
|
||||||
|
const helperError = validateAgentSendHelper(agentSendPath, mosaicHome);
|
||||||
|
if (helperError) return { ok: false, error: helperFailureGuidance(helperError) };
|
||||||
|
|
||||||
|
let rosterPath: string;
|
||||||
|
try {
|
||||||
|
rosterPath = resolveInstalledFleetRosterPath(mosaicHome);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `cannot inspect fleet roster.yaml: ${error instanceof Error ? error.message : String(error)}; refusing JSON fallback because fallback is allowed only when YAML is absent`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!existsSync(rosterPath)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `no fleet roster at ${join(mosaicHome, 'fleet', 'roster.yaml')} or ${join(mosaicHome, 'fleet', 'roster.json')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let roster: FleetRoster;
|
||||||
|
try {
|
||||||
|
roster = parseFleetRosterV1(
|
||||||
|
readRegularFileSecure(rosterPath, { root: mosaicHome }).content.toString('utf8'),
|
||||||
|
rosterPath.endsWith('.json') ? 'json' : 'yaml',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `invalid fleet roster at ${rosterPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
identity: {
|
||||||
|
roster,
|
||||||
|
member: getRosterAgent(roster, requestedName),
|
||||||
|
requestedName,
|
||||||
|
agentSendPath,
|
||||||
|
localHost,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: missingMemberError(roster, requestedName) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render Fleet Comms from one already-resolved canonical member identity. */
|
||||||
|
export function buildResolvedFleetCommsBlock(identity: ResolvedFleetIdentity): string {
|
||||||
|
return buildFleetCommsBlock({
|
||||||
|
selfName: identity.member.name,
|
||||||
|
roster: identity.roster,
|
||||||
|
localHost: identity.localHost,
|
||||||
|
agentSendPath: identity.agentSendPath,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the fleet roster from `mosaicHome` and build the comms block for
|
* Read and resolve the installed roster for runtime composition. A requested
|
||||||
* `selfName`. Returns '' when there is no roster, the agent is not in it, or
|
* fleet identity fails closed; only a genuinely non-fleet launch (no selfName)
|
||||||
* there are no peers — onboarding is best-effort and never throws.
|
* is a quiet no-op.
|
||||||
*/
|
*/
|
||||||
export function readFleetCommsBlock(
|
export function readFleetCommsBlock(
|
||||||
mosaicHome: string,
|
mosaicHome: string,
|
||||||
selfName: string | undefined,
|
selfName: string | undefined,
|
||||||
fleetHost: string = hostname().split('.')[0] || 'localhost',
|
localHost: string = shortHostname(),
|
||||||
): string {
|
|
||||||
if (!selfName) return '';
|
|
||||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
|
||||||
if (!existsSync(rosterPath)) return '';
|
|
||||||
let text: string;
|
|
||||||
try {
|
|
||||||
text = readFileSync(rosterPath, 'utf-8');
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
const agents = parseRosterAgents(text);
|
|
||||||
if (!agents.some((a) => a.name === selfName)) return ''; // not a member of this fleet
|
|
||||||
return buildFleetCommsBlock({
|
|
||||||
selfName,
|
|
||||||
agents,
|
|
||||||
fleetHost,
|
|
||||||
agentSendPath: join(mosaicHome, 'tools', 'tmux', 'agent-send.sh'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result of resolving a comms-block emit request — see `mosaic fleet comms-block`. */
|
|
||||||
export interface CommsBlockResult {
|
|
||||||
/** True when a cheat-sheet was produced; false maps to stderr + non-zero exit. */
|
|
||||||
ok: boolean;
|
|
||||||
/** The Fleet-Comms cheat-sheet (empty unless ok). */
|
|
||||||
output: string;
|
|
||||||
/** Operator-facing reason when !ok. */
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the Fleet-Comms cheat-sheet for an explicit <role>, backing the
|
|
||||||
* `mosaic fleet comms-block <role>` command. Unlike readFleetCommsBlock — which
|
|
||||||
* returns '' on any miss so composeContract can no-op silently during a launch —
|
|
||||||
* this NEVER silently emits empty: an unknown role or missing roster yields
|
|
||||||
* ok:false + an operator-facing reason, so the CLI surfaces it (stderr + exit 1)
|
|
||||||
* rather than printing nothing. That makes it safe to preview any peer's view,
|
|
||||||
* e.g. `mosaic fleet comms-block coder0-0`.
|
|
||||||
*/
|
|
||||||
export function resolveCommsBlock(
|
|
||||||
mosaicHome: string,
|
|
||||||
role: string | undefined,
|
|
||||||
fleetHost?: string,
|
|
||||||
): CommsBlockResult {
|
): CommsBlockResult {
|
||||||
if (!role) {
|
const resolved = resolveFleetIdentity(mosaicHome, selfName, localHost);
|
||||||
return { ok: false, output: '', error: 'comms-block requires a <role> argument' };
|
if (!resolved.ok) return { ok: false, output: '', error: resolved.error };
|
||||||
}
|
if (!resolved.identity) return { ok: true, output: '' };
|
||||||
const block = fleetHost
|
try {
|
||||||
? readFleetCommsBlock(mosaicHome, role, fleetHost)
|
return { ok: true, output: buildResolvedFleetCommsBlock(resolved.identity) };
|
||||||
: readFleetCommsBlock(mosaicHome, role);
|
} catch (error) {
|
||||||
if (!block) {
|
|
||||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
output: '',
|
output: '',
|
||||||
error: existsSync(rosterPath)
|
error: error instanceof Error ? error.message : String(error),
|
||||||
? `role "${role}" is not a member of the fleet roster at ${rosterPath}`
|
|
||||||
: `no fleet roster at ${rosterPath}`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ok: true, output: block };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */
|
/** Backing resolver for `mosaic agent comms-block <exact-member>`. */
|
||||||
|
export function resolveCommsBlock(
|
||||||
|
mosaicHome: string,
|
||||||
|
exactMember: string | undefined,
|
||||||
|
): CommsBlockResult {
|
||||||
|
if (!exactMember) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
output: '',
|
||||||
|
error: 'comms-block requires an exact <exact-member> argument',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return readFleetCommsBlock(mosaicHome, exactMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedContractVersion(content: Buffer | string): boolean {
|
||||||
|
return content.toString().includes(`<!-- ${FLEET_COMMS_TOOLS_CONTRACT} -->`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedContractDigest(
|
||||||
|
path: string,
|
||||||
|
mosaicHome: string,
|
||||||
|
): { digest?: string; versionOk: boolean } {
|
||||||
|
try {
|
||||||
|
const content = readRegularFileSecure(path, {
|
||||||
|
root: mosaicHome,
|
||||||
|
maxBytes: MAX_TOOLS_CONTRACT_BYTES,
|
||||||
|
}).content;
|
||||||
|
return {
|
||||||
|
digest: createHash('sha256').update(content).digest('hex'),
|
||||||
|
versionOk: expectedContractVersion(content),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { versionOk: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replacementGuidance(): string {
|
||||||
|
return `Run \`mosaic update --repair-tools\` to make a digest-qualified backup and restore the supported current-version TOOLS contract, then have an authorized operator explicitly relaunch the exact roster member. The active context was not rewritten.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect preserved installed TOOLS.md drift without changing it. */
|
||||||
|
export function renderToolsContractStatus(mosaicHome: string): string {
|
||||||
|
const installedPath = join(mosaicHome, 'TOOLS.md');
|
||||||
|
const sourcePath = join(mosaicHome, 'defaults', 'TOOLS.md');
|
||||||
|
if (!existsSync(installedPath)) {
|
||||||
|
return `# Fleet Comms Installation Status\n\nInstalled TOOLS.md is missing at \`${installedPath}\`. ${replacementGuidance()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installed = boundedContractDigest(installedPath, mosaicHome);
|
||||||
|
const source = boundedContractDigest(sourcePath, mosaicHome);
|
||||||
|
if (!source.digest || !source.versionOk) {
|
||||||
|
return `# Fleet Comms Installation Status\n\nThe bounded framework source contract at \`${sourcePath}\` is unavailable or does not declare the expected \`${FLEET_COMMS_TOOLS_CONTRACT}\` version. Run \`mosaic update\` to restore framework source data, verify again, then have an authorized operator explicitly relaunch the exact roster member. The installed file and active context were not rewritten.`;
|
||||||
|
}
|
||||||
|
if (installed.versionOk && installed.digest === source.digest) return '';
|
||||||
|
|
||||||
|
return `# Fleet Comms Installation Status\n\nInstalled TOOLS.md is unavailable, has the wrong contract version, or does not byte-match the bounded framework source contract \`${FLEET_COMMS_TOOLS_CONTRACT}\`. ${replacementGuidance()}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');
|
export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
335
packages/mosaic/src/fleet/fleet-documentation.spec.ts
Normal file
335
packages/mosaic/src/fleet/fleet-documentation.spec.ts
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import { readFile, readdir } from 'node:fs/promises';
|
||||||
|
import { dirname, extname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseRosterV2, validateRosterV2Semantics } from './roster-v2.js';
|
||||||
|
|
||||||
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||||
|
const repositoryRoot = resolve(packageRoot, '..', '..');
|
||||||
|
const fleetDocs = join(repositoryRoot, 'docs', 'fleet');
|
||||||
|
const frameworkFleet = join(packageRoot, 'framework', 'fleet');
|
||||||
|
|
||||||
|
const REQUIRED_FLEET_PAGES = [
|
||||||
|
'README.md',
|
||||||
|
'concepts/desired-vs-observed-state.md',
|
||||||
|
'concepts/identity-class-runtime.md',
|
||||||
|
'concepts/role-authority-and-leases.md',
|
||||||
|
'concepts/generated-env-launch-chain.md',
|
||||||
|
'reference/roster-v2.schema.json',
|
||||||
|
'reference/roster-v2-fields.md',
|
||||||
|
'reference/cli.md',
|
||||||
|
'reference/role-classes.md',
|
||||||
|
'reference/lifecycle-transitions.md',
|
||||||
|
'reference/status-and-drift.md',
|
||||||
|
'how-to/create-update-delete-agent.md',
|
||||||
|
'how-to/start-stop-restart.md',
|
||||||
|
'how-to/configure-tess-interaction.md',
|
||||||
|
'how-to/configure-ultron-validator.md',
|
||||||
|
'how-to/customize-roles.md',
|
||||||
|
'operations/reconcile-and-recover.md',
|
||||||
|
'operations/env-quarantine.md',
|
||||||
|
'operations/systemd-tmux-troubleshooting.md',
|
||||||
|
'operations/backup-restore.md',
|
||||||
|
'operations/upgrade-assets.md',
|
||||||
|
'migration/v1-to-v2.md',
|
||||||
|
'migration/example-profile-disposition.md',
|
||||||
|
'migration/legacy-class-aliases.md',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
async function markdownFiles(root: string): Promise<string[]> {
|
||||||
|
const entries = await readdir(root, { withFileTypes: true });
|
||||||
|
const paths = await Promise.all(
|
||||||
|
entries.map(async (entry): Promise<string[]> => {
|
||||||
|
const path = join(root, entry.name);
|
||||||
|
if (entry.isDirectory()) return markdownFiles(path);
|
||||||
|
return extname(entry.name) === '.md' ? [path] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return paths.flat().sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function localMarkdownTargets(source: string): string[] {
|
||||||
|
const link =
|
||||||
|
/\[[^\]]*\]\(\s*(?:<([^>]+)>|((?:\\.|[^()\s]|\([^()]*\))+))(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g;
|
||||||
|
return [...source.matchAll(link)]
|
||||||
|
.map((match): string => match[1] ?? match[2] ?? '')
|
||||||
|
.filter(
|
||||||
|
(target): boolean =>
|
||||||
|
target !== '' &&
|
||||||
|
!target.startsWith('http://') &&
|
||||||
|
!target.startsWith('https://') &&
|
||||||
|
!target.startsWith('mailto:'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownHeadingAnchors(source: string): Set<string> {
|
||||||
|
const anchors = new Set<string>();
|
||||||
|
let fence: { readonly marker: string; readonly length: number } | undefined;
|
||||||
|
|
||||||
|
for (const line of source.split('\n')) {
|
||||||
|
const fenceMatch = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
|
||||||
|
if (fence === undefined && fenceMatch !== null) {
|
||||||
|
const run = fenceMatch[1] ?? '';
|
||||||
|
fence = { marker: run[0] ?? '', length: run.length };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fence !== undefined) {
|
||||||
|
const closingRun = line.match(/^\s{0,3}(`{3,}|~{3,})\s*$/)?.[1];
|
||||||
|
if (
|
||||||
|
closingRun !== undefined &&
|
||||||
|
closingRun[0] === fence.marker &&
|
||||||
|
closingRun.length >= fence.length
|
||||||
|
) {
|
||||||
|
fence = undefined;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/)?.[1];
|
||||||
|
if (heading === undefined) continue;
|
||||||
|
const base = heading
|
||||||
|
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||||
|
.replace(/<[^>]*>/g, '')
|
||||||
|
.replace(/[`*_~]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
||||||
|
.replace(/\s+/g, '-');
|
||||||
|
let anchor = base;
|
||||||
|
let duplicate = 0;
|
||||||
|
while (anchors.has(anchor)) {
|
||||||
|
duplicate += 1;
|
||||||
|
anchor = `${base}-${duplicate}`;
|
||||||
|
}
|
||||||
|
anchors.add(anchor);
|
||||||
|
}
|
||||||
|
return anchors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownLinkViolations(
|
||||||
|
sourcePath: string,
|
||||||
|
source: string,
|
||||||
|
documents: Readonly<Record<string, string>>,
|
||||||
|
): string[] {
|
||||||
|
const violations: string[] = [];
|
||||||
|
for (const target of localMarkdownTargets(source)) {
|
||||||
|
const [encodedPath = '', encodedFragment] = target.split('#', 2);
|
||||||
|
const targetPath = decodeURIComponent(encodedPath);
|
||||||
|
const normalizedTarget = resolve('/', dirname(sourcePath), targetPath).slice(1);
|
||||||
|
const targetSource = documents[normalizedTarget];
|
||||||
|
if (targetSource === undefined) {
|
||||||
|
violations.push(`${sourcePath} -> ${target}: missing file`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (encodedFragment !== undefined) {
|
||||||
|
const fragment = decodeURIComponent(encodedFragment);
|
||||||
|
if (fragment === '' || !markdownHeadingAnchors(targetSource).has(fragment)) {
|
||||||
|
violations.push(`${sourcePath} -> ${target}: missing heading`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exampleSafetyViolationKinds(source: string): string[] {
|
||||||
|
const kinds = new Set<string>();
|
||||||
|
const sensitiveKey = /(?:secret|token|password|credential|MOSAIC_AGENT_COMMAND)/i;
|
||||||
|
const credentialFormat =
|
||||||
|
/(?:\bAKIA[0-9A-Z]{16}\b|\bAIza[0-9A-Za-z_-]{35}\b|\bgh[pousr]_[A-Za-z0-9]{20,}\b|\bgithub_pat_[A-Za-z0-9_]{20,}\b|\bglpat-[A-Za-z0-9_-]{20,}\b|\bnpm_[A-Za-z0-9]{20,}\b|\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{20,}\b|\bsk-proj-[A-Za-z0-9_-]{20,}\b|\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b|\bxox[baprs]-[A-Za-z0-9-]{10,}\b|\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b|-----BEGIN [A-Z ]*PRIVATE KEY-----|\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s/:]+:[^\s/@]+@)/;
|
||||||
|
const privilegedCommand =
|
||||||
|
/(?:^|[\n;&|])\s*(?:[$#>]\s*)?(?:(?:\/(?:[A-Za-z0-9._+-]+\/)*?)?(?:env|command)\s+(?:(?:-{1,2}[^\s]+|[A-Za-z_][A-Za-z0-9_]*=[^\s]+)\s+)*?)*(?:\/(?:[A-Za-z0-9._+-]+\/)*?)?(?:(?:sudo|doas|pkexec)\s+|su\s+(?:root\b|-|--command\b|-c\b)|(?:systemctl|service|mount|umount|reboot|shutdown|poweroff|halt|chown|chmod|chroot|useradd|usermod|groupadd|visudo|apt|apt-get|apt-cache|dpkg|yum|dnf|rpm|apk|pacman|zypper|emerge|snap|flatpak|brew|nix-env)\s+)/m;
|
||||||
|
|
||||||
|
if (sensitiveKey.test(source)) kinds.add('sensitive-key');
|
||||||
|
if (credentialFormat.test(source)) kinds.add('credential-format');
|
||||||
|
if (privilegedCommand.test(source)) kinds.add('privileged-command');
|
||||||
|
if (/\b(?:Tess|Ultron)\b/.test(source)) kinds.add('identity');
|
||||||
|
return [...kinds].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fencedCodeBlocks(source: string): string[] {
|
||||||
|
const blocks: string[] = [];
|
||||||
|
let fence: { readonly marker: string; readonly length: number } | undefined;
|
||||||
|
let lines: string[] = [];
|
||||||
|
|
||||||
|
for (const line of source.split('\n')) {
|
||||||
|
const openingRun = line.match(/^\s{0,3}(`{3,}|~{3,})[^\n]*$/)?.[1];
|
||||||
|
if (fence === undefined && openingRun !== undefined) {
|
||||||
|
fence = { marker: openingRun[0] ?? '', length: openingRun.length };
|
||||||
|
lines = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fence === undefined) continue;
|
||||||
|
|
||||||
|
const closingRun = line.match(/^\s{0,3}(`{3,}|~{3,})\s*$/)?.[1];
|
||||||
|
if (
|
||||||
|
closingRun !== undefined &&
|
||||||
|
closingRun[0] === fence.marker &&
|
||||||
|
closingRun.length >= fence.length
|
||||||
|
) {
|
||||||
|
blocks.push(lines.join('\n'));
|
||||||
|
fence = undefined;
|
||||||
|
lines = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fence !== undefined) blocks.push(lines.join('\n'));
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNSAFE_EXAMPLE_FIXTURES = [
|
||||||
|
{
|
||||||
|
expected: 'privileged-command',
|
||||||
|
source: ['cd /srv && su', 'do systemctl restart example'].join(''),
|
||||||
|
},
|
||||||
|
{ expected: 'privileged-command', source: '/usr/bin/sudo systemctl restart example' },
|
||||||
|
{ expected: 'privileged-command', source: '/usr/bin/apt-get install example' },
|
||||||
|
{
|
||||||
|
expected: 'privileged-command',
|
||||||
|
source: '/usr/bin/env -i /usr/bin/command -- /usr/bin/apt-get install example',
|
||||||
|
},
|
||||||
|
{ expected: 'privileged-command', source: 'su root' },
|
||||||
|
{ expected: 'privileged-command', source: '/usr/sbin/chroot /srv/example' },
|
||||||
|
{ expected: 'credential-format', source: ['ghp_', 'a'.repeat(36)].join('') },
|
||||||
|
{
|
||||||
|
expected: 'credential-format',
|
||||||
|
source: ['sk-ant-api03-', 'a'.repeat(80)].join(''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expected: 'credential-format',
|
||||||
|
source: ['sk-proj-', 'a'.repeat(80)].join(''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expected: 'credential-format',
|
||||||
|
source: ['rk_live_', 'a'.repeat(24)].join(''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expected: 'credential-format',
|
||||||
|
source: ['eyJ', 'a'.repeat(12), '.', 'b'.repeat(12), '.', 'c'.repeat(12)].join(''),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe('documentation validation regressions', (): void => {
|
||||||
|
it('rejects a local Markdown link whose heading fragment does not exist', (): void => {
|
||||||
|
expect(
|
||||||
|
markdownLinkViolations('docs/fleet/source.md', '[broken](target.md#missing)', {
|
||||||
|
'docs/fleet/target.md': '# Present',
|
||||||
|
}),
|
||||||
|
).toEqual(['docs/fleet/source.md -> target.md#missing: missing heading']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts balanced-parenthesis link destinations and visible-text heading anchors', (): void => {
|
||||||
|
expect(localMarkdownTargets('[guide](guide-(legacy).md#setup "Guide")')).toEqual([
|
||||||
|
'guide-(legacy).md#setup',
|
||||||
|
]);
|
||||||
|
expect(markdownHeadingAnchors('# [Fleet API](cli.md) behavior')).toContain(
|
||||||
|
'fleet-api-behavior',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps longer fenced blocks closed only by an equal-or-longer fence', (): void => {
|
||||||
|
expect(markdownHeadingAnchors('````markdown\n```\n# Not a heading\n````\n# Present')).toEqual(
|
||||||
|
new Set(['present']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns a free suffix when a prior heading already occupies the next duplicate anchor', (): void => {
|
||||||
|
expect(markdownHeadingAnchors('# Foo\n# Foo-1\n# Foo')).toEqual(
|
||||||
|
new Set(['foo', 'foo-1', 'foo-2']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(UNSAFE_EXAMPLE_FIXTURES)(
|
||||||
|
'classifies unsafe example fixture as $expected',
|
||||||
|
({ expected, source }): void => {
|
||||||
|
expect(exampleSafetyViolationKinds(source)).toContain(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('extracts tilde-fenced and unclosed examples without exposing their contents', (): void => {
|
||||||
|
const sensitiveFixture = ['sk-ant-api03-', 'a'.repeat(80)].join('');
|
||||||
|
const blocks = fencedCodeBlocks(
|
||||||
|
`~~~sh\n${sensitiveFixture}\n~~~\n\n\`\`\`sh\n${sensitiveFixture}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(blocks).toHaveLength(2);
|
||||||
|
for (const block of blocks) {
|
||||||
|
const kinds = exampleSafetyViolationKinds(block);
|
||||||
|
expect(kinds).toContain('credential-format');
|
||||||
|
expect(JSON.stringify(kinds)).not.toContain(sensitiveFixture);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fleet operator documentation', (): void => {
|
||||||
|
it('ships every accepted information-architecture page', async (): Promise<void> => {
|
||||||
|
await expect(
|
||||||
|
Promise.all(REQUIRED_FLEET_PAGES.map((path) => readFile(join(fleetDocs, path), 'utf8'))),
|
||||||
|
).resolves.toHaveLength(REQUIRED_FLEET_PAGES.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves every local Markdown link and heading fragment in the fleet book and sitemap', async (): Promise<void> => {
|
||||||
|
const files = [...(await markdownFiles(fleetDocs)), join(repositoryRoot, 'docs', 'SITEMAP.md')];
|
||||||
|
const documents: Record<string, string> = {};
|
||||||
|
for (const file of files) {
|
||||||
|
const relative = file.slice(repositoryRoot.length + 1);
|
||||||
|
documents[relative] = await readFile(file, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const violations: string[] = [];
|
||||||
|
for (const [sourcePath, source] of Object.entries(documents)) {
|
||||||
|
for (const target of localMarkdownTargets(source)) {
|
||||||
|
const encodedPath = target.split('#', 1)[0] ?? '';
|
||||||
|
const targetPath = resolve(
|
||||||
|
dirname(join(repositoryRoot, sourcePath)),
|
||||||
|
decodeURIComponent(encodedPath),
|
||||||
|
);
|
||||||
|
const relativeTarget = targetPath.slice(repositoryRoot.length + 1);
|
||||||
|
if (documents[relativeTarget] === undefined) {
|
||||||
|
try {
|
||||||
|
documents[relativeTarget] = await readFile(targetPath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
// The deterministic validator below records the missing target without exposing content.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
violations.push(...markdownLinkViolations(sourcePath, source, documents));
|
||||||
|
}
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates the canonical documentation example through the production compiler and resolver', async (): Promise<void> => {
|
||||||
|
const source = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
|
||||||
|
const roster = parseRosterV2(source, 'yaml');
|
||||||
|
const validated = await validateRosterV2Semantics(roster, {
|
||||||
|
rolesDir: join(frameworkFleet, 'roles'),
|
||||||
|
overrideDir: join(fleetDocs, 'examples', 'roles.local'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validated.generation).toBe(1);
|
||||||
|
expect(validated.agents.map((agent) => agent.canonicalClass)).toEqual([
|
||||||
|
'code',
|
||||||
|
'interaction',
|
||||||
|
'validator',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every fenced fleet example free of sensitive values, privileged commands, arbitrary command overrides, and product-hardcoded identities', async (): Promise<void> => {
|
||||||
|
const violations: string[] = [];
|
||||||
|
for (const file of await markdownFiles(fleetDocs)) {
|
||||||
|
const source = await readFile(file, 'utf8');
|
||||||
|
for (const [index, block] of fencedCodeBlocks(source).entries()) {
|
||||||
|
for (const kind of exampleSafetyViolationKinds(block)) {
|
||||||
|
violations.push(`${file.slice(repositoryRoot.length + 1)}#block-${index + 1}: ${kind}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8');
|
||||||
|
for (const kind of exampleSafetyViolationKinds(rosterSource)) {
|
||||||
|
violations.push(`docs/fleet/examples/roster-v2.yaml: ${kind}`);
|
||||||
|
}
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
484
packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts
Normal file
484
packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
529
packages/mosaic/src/fleet/fleet-reconciler.spec.ts
Normal file
529
packages/mosaic/src/fleet/fleet-reconciler.spec.ts
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
acquirePrivateReconcileLock,
|
||||||
|
FleetReconcileError,
|
||||||
|
executeFleetReconcile,
|
||||||
|
type FleetReconcileCommand,
|
||||||
|
type FleetReconcileDeps,
|
||||||
|
} from './fleet-reconciler.js';
|
||||||
|
import type { FleetRosterV2 } from './roster-v2.js';
|
||||||
|
|
||||||
|
let cleanup: string | undefined;
|
||||||
|
|
||||||
|
afterEach(async (): Promise<void> => {
|
||||||
|
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||||
|
cleanup = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function lockHome(): Promise<string> {
|
||||||
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-reconcile-lock-'));
|
||||||
|
const fleet = join(cleanup, 'fleet');
|
||||||
|
await mkdir(fleet, { mode: 0o700 });
|
||||||
|
await chmod(cleanup, 0o700);
|
||||||
|
await chmod(fleet, 0o700);
|
||||||
|
return cleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roster: FleetRosterV2 = {
|
||||||
|
version: 2,
|
||||||
|
generation: 7,
|
||||||
|
transport: 'tmux',
|
||||||
|
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
|
||||||
|
defaults: { workingDirectory: '/srv/mosaic', runtime: 'pi' },
|
||||||
|
runtimes: { pi: { resetCommand: '/new' } },
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
name: 'coder0',
|
||||||
|
alias: 'Coder 0',
|
||||||
|
className: 'code',
|
||||||
|
runtime: 'pi',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-5.6-sol',
|
||||||
|
reasoning: 'high',
|
||||||
|
toolPolicy: 'code',
|
||||||
|
workingDirectory: '/srv/mosaic',
|
||||||
|
persistentPersona: false,
|
||||||
|
resetBetweenTasks: true,
|
||||||
|
lifecycle: { enabled: true, desiredState: 'stopped' },
|
||||||
|
launch: { yolo: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function deps(overrides: Partial<FleetReconcileDeps> = {}): FleetReconcileDeps {
|
||||||
|
return {
|
||||||
|
runner: async (command, args) => {
|
||||||
|
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||||
|
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||||
|
}
|
||||||
|
if (command === 'tmux' && args.includes('show-environment')) {
|
||||||
|
return {
|
||||||
|
stdout:
|
||||||
|
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
},
|
||||||
|
homeDirectory: '/home/mosaic',
|
||||||
|
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
||||||
|
validateRoster: async () => undefined,
|
||||||
|
prepareProjections: async () => [{ agentName: 'coder0' }],
|
||||||
|
applyProjection: async () => undefined,
|
||||||
|
readRoster: async () => roster,
|
||||||
|
acquireMutationLock: async () => async () => undefined,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(command: FleetReconcileCommand, overrides: Partial<FleetReconcileDeps> = {}) {
|
||||||
|
return executeFleetReconcile({
|
||||||
|
roster,
|
||||||
|
command,
|
||||||
|
...(command === 'status' || command === 'verify' || command === 'doctor'
|
||||||
|
? {}
|
||||||
|
: { expectedGeneration: 7 }),
|
||||||
|
deps: deps({ readRoster: async () => roster, ...overrides }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('fleet roster-owned reconciler', (): void => {
|
||||||
|
it('fails closed on a symlinked fleet ancestor without touching its target', async (): Promise<void> => {
|
||||||
|
const home = await lockHome();
|
||||||
|
const fleet = join(home, 'fleet');
|
||||||
|
const attacker = await mkdtemp(join(tmpdir(), 'mosaic-reconcile-attacker-'));
|
||||||
|
await writeFile(join(attacker, 'sentinel'), 'unchanged\n', { mode: 0o600 });
|
||||||
|
try {
|
||||||
|
await rm(fleet, { recursive: true });
|
||||||
|
await symlink(attacker, fleet, 'dir');
|
||||||
|
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||||
|
code: 'unsafe-managed-path',
|
||||||
|
});
|
||||||
|
expect(await readFile(join(attacker, 'sentinel'), 'utf8')).toBe('unchanged\n');
|
||||||
|
} finally {
|
||||||
|
await rm(attacker, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsafe ancestors, symlink leaves, EEXIST, and non-EEXIST lock creation failures', async (): Promise<void> => {
|
||||||
|
const home = await lockHome();
|
||||||
|
const fleet = join(home, 'fleet');
|
||||||
|
const lockPath = join(fleet, 'roster.yaml.reconcile.lock');
|
||||||
|
|
||||||
|
await chmod(fleet, 0o770);
|
||||||
|
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||||
|
code: 'unsafe-managed-path',
|
||||||
|
});
|
||||||
|
await chmod(fleet, 0o700);
|
||||||
|
|
||||||
|
const target = join(home, 'target');
|
||||||
|
await writeFile(target, 'target\n', { mode: 0o600 });
|
||||||
|
await symlink(target, lockPath);
|
||||||
|
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||||
|
code: 'unsafe-lock',
|
||||||
|
});
|
||||||
|
await rm(lockPath);
|
||||||
|
|
||||||
|
await writeFile(lockPath, 'other\n', { mode: 0o600 });
|
||||||
|
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||||
|
code: 'concurrent-mutation',
|
||||||
|
});
|
||||||
|
await rm(lockPath);
|
||||||
|
|
||||||
|
const ioFailure = Object.assign(new Error('injected I/O failure'), { code: 'EIO' });
|
||||||
|
await expect(
|
||||||
|
acquirePrivateReconcileLock(home, async () => Promise.reject(ioFailure))(),
|
||||||
|
).rejects.toMatchObject({ code: 'lock-io-failed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not unlink a replacement lock and normally releases its own lock', async (): Promise<void> => {
|
||||||
|
const home = await lockHome();
|
||||||
|
const lockPath = join(home, 'fleet', 'roster.yaml.reconcile.lock');
|
||||||
|
const release = await acquirePrivateReconcileLock(home)();
|
||||||
|
await rm(lockPath);
|
||||||
|
await writeFile(lockPath, 'replacement\n', { mode: 0o600 });
|
||||||
|
await expect(release()).rejects.toMatchObject({ code: 'lock-cleanup-failed' });
|
||||||
|
expect(await readFile(lockPath, 'utf8')).toBe('replacement\n');
|
||||||
|
|
||||||
|
await rm(lockPath);
|
||||||
|
const normalRelease = await acquirePrivateReconcileLock(home)();
|
||||||
|
await normalRelease();
|
||||||
|
await expect(readFile(lockPath, 'utf8')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
it('requires a generation before any mutating preflight or effect', async (): Promise<void> => {
|
||||||
|
let effects = 0;
|
||||||
|
await expect(
|
||||||
|
executeFleetReconcile({
|
||||||
|
roster,
|
||||||
|
command: 'apply',
|
||||||
|
deps: deps({
|
||||||
|
validateRoster: async () => {
|
||||||
|
effects += 1;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'missing-generation' });
|
||||||
|
expect(effects).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fences mutation against the canonical roster reread under lock', async (): Promise<void> => {
|
||||||
|
let effects = 0;
|
||||||
|
await expect(
|
||||||
|
executeFleetReconcile({
|
||||||
|
roster,
|
||||||
|
command: 'apply',
|
||||||
|
expectedGeneration: 7,
|
||||||
|
deps: deps({
|
||||||
|
readRoster: async () => ({ ...roster, generation: 8 }),
|
||||||
|
runner: async () => {
|
||||||
|
effects += 1;
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
},
|
||||||
|
applyProjection: async () => {
|
||||||
|
effects += 1;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'stale-generation' });
|
||||||
|
expect(effects).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects stale generation before projection or lifecycle effects', async (): Promise<void> => {
|
||||||
|
let effects = 0;
|
||||||
|
await expect(
|
||||||
|
executeFleetReconcile({
|
||||||
|
roster,
|
||||||
|
command: 'apply',
|
||||||
|
expectedGeneration: 6,
|
||||||
|
deps: deps({
|
||||||
|
validateRoster: async () => {
|
||||||
|
effects += 1;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'stale-generation' });
|
||||||
|
expect(effects).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies a concurrent mutation lock without effects and leaves observations lock-free', async (): Promise<void> => {
|
||||||
|
let effects = 0;
|
||||||
|
const busy = async (): Promise<() => Promise<void>> => {
|
||||||
|
throw new FleetReconcileError('concurrent-mutation', 'busy');
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
run('apply', {
|
||||||
|
acquireMutationLock: busy,
|
||||||
|
applyProjection: async () => {
|
||||||
|
effects += 1;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'concurrent-mutation' });
|
||||||
|
expect(effects).toBe(0);
|
||||||
|
|
||||||
|
await expect(run('doctor', { acquireMutationLock: busy })).resolves.toMatchObject({
|
||||||
|
applied: false,
|
||||||
|
lifecycle: 'not-applied',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always releases the mutation lock after success and partial failure', async (): Promise<void> => {
|
||||||
|
let releases = 0;
|
||||||
|
const lock = async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||||
|
releases += 1;
|
||||||
|
};
|
||||||
|
await run('apply', { acquireMutationLock: lock });
|
||||||
|
await run('apply', {
|
||||||
|
acquireMutationLock: lock,
|
||||||
|
applyProjection: async () => {
|
||||||
|
throw new Error('injected failure');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(releases).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves stopped desired state during apply', async (): Promise<void> => {
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const result = await run('apply', {
|
||||||
|
runner: async (command, args) => {
|
||||||
|
calls.push([command, ...args]);
|
||||||
|
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||||
|
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||||
|
}
|
||||||
|
if (command === 'tmux' && args.includes('show-environment')) {
|
||||||
|
return {
|
||||||
|
stdout:
|
||||||
|
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.lifecycle).toBe('complete');
|
||||||
|
expect(calls).not.toContainEqual([
|
||||||
|
'systemctl',
|
||||||
|
'--user',
|
||||||
|
'start',
|
||||||
|
'mosaic-agent@coder0.service',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['plan', 'status', 'doctor', 'verify'] as const)(
|
||||||
|
'keeps default-server %s observational and free of lifecycle effects',
|
||||||
|
async (command) => {
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const defaultServerRoster: FleetRosterV2 = {
|
||||||
|
...roster,
|
||||||
|
tmux: { ...roster.tmux, socketName: '' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
executeFleetReconcile({
|
||||||
|
roster: defaultServerRoster,
|
||||||
|
command,
|
||||||
|
deps: deps({
|
||||||
|
runner: async (executable, args) => {
|
||||||
|
calls.push([executable, ...args]);
|
||||||
|
if (executable === 'tmux' && args.includes('list-sessions')) {
|
||||||
|
return { stdout: '_holder\n', stderr: '', exitCode: 0 };
|
||||||
|
}
|
||||||
|
if (executable === 'tmux' && args.includes('show-environment')) {
|
||||||
|
return {
|
||||||
|
stdout:
|
||||||
|
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { stdout: 'ActiveState=inactive\n', stderr: '', exitCode: 0 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ applied: false, lifecycle: 'not-applied' });
|
||||||
|
expect(
|
||||||
|
calls.some(
|
||||||
|
([executable, , action]): boolean =>
|
||||||
|
executable === 'systemctl' &&
|
||||||
|
(action === 'start' || action === 'stop' || action === 'restart'),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['start', 'stop', 'restart', 'apply', 'reconcile'] as const)(
|
||||||
|
'fails closed before %s can route fixed named-socket services for a default-server roster',
|
||||||
|
async (command) => {
|
||||||
|
const calls: string[][] = [];
|
||||||
|
let projectionPrepares = 0;
|
||||||
|
let projectionApplies = 0;
|
||||||
|
const defaultServerRoster: FleetRosterV2 = {
|
||||||
|
...roster,
|
||||||
|
tmux: { ...roster.tmux, socketName: '' },
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
...roster.agents[0]!,
|
||||||
|
lifecycle: {
|
||||||
|
enabled: true,
|
||||||
|
desiredState: command === 'start' || command === 'restart' ? 'running' : 'stopped',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
executeFleetReconcile({
|
||||||
|
roster: defaultServerRoster,
|
||||||
|
command,
|
||||||
|
expectedGeneration: 7,
|
||||||
|
deps: deps({
|
||||||
|
readRoster: async () => defaultServerRoster,
|
||||||
|
prepareProjections: async () => {
|
||||||
|
projectionPrepares += 1;
|
||||||
|
return [{ agentName: 'coder0' }];
|
||||||
|
},
|
||||||
|
applyProjection: async () => {
|
||||||
|
projectionApplies += 1;
|
||||||
|
},
|
||||||
|
runner: async (executable, args) => {
|
||||||
|
calls.push([executable, ...args]);
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'lifecycle-precondition-failed' });
|
||||||
|
expect(projectionPrepares).toBe(0);
|
||||||
|
expect(projectionApplies).toBe(0);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('starts only an explicitly running roster agent with exact systemd targets', async (): Promise<void> => {
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const runningRoster: FleetRosterV2 = {
|
||||||
|
...roster,
|
||||||
|
agents: [{ ...roster.agents[0]!, lifecycle: { enabled: true, desiredState: 'running' } }],
|
||||||
|
};
|
||||||
|
const result = await executeFleetReconcile({
|
||||||
|
roster: runningRoster,
|
||||||
|
command: 'apply',
|
||||||
|
expectedGeneration: 7,
|
||||||
|
deps: deps({
|
||||||
|
readRoster: async () => runningRoster,
|
||||||
|
runner: async (command, args) => {
|
||||||
|
calls.push([command, ...args]);
|
||||||
|
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||||
|
return { stdout: '', stderr: '', exitCode: 1 };
|
||||||
|
}
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.lifecycle).toBe('complete');
|
||||||
|
expect(calls).toContainEqual(['systemctl', '--user', 'start', 'mosaic-tmux-holder.service']);
|
||||||
|
expect(calls).toContainEqual(['systemctl', '--user', 'start', 'mosaic-agent@coder0.service']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a fake holder with contaminated global state before projection application', async (): Promise<void> => {
|
||||||
|
let projectionApplied = false;
|
||||||
|
await expect(
|
||||||
|
run('apply', {
|
||||||
|
runner: async (command, args) => {
|
||||||
|
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||||
|
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||||
|
}
|
||||||
|
if (command === 'tmux' && args.includes('show-environment')) {
|
||||||
|
return { stdout: 'MOSAIC_FLEET_OWNER=forged\n', stderr: '', exitCode: 0 };
|
||||||
|
}
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
},
|
||||||
|
applyProjection: async () => {
|
||||||
|
projectionApplied = true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'ownership-mismatch' });
|
||||||
|
expect(projectionApplied).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports but never adopts unmanaged sessions', async (): Promise<void> => {
|
||||||
|
await expect(
|
||||||
|
run('apply', {
|
||||||
|
runner: async (command, args) => {
|
||||||
|
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||||
|
return { stdout: '_holder\ncoder0\nother\n', stderr: '', exitCode: 0 };
|
||||||
|
}
|
||||||
|
if (command === 'tmux' && args.includes('show-environment')) {
|
||||||
|
return {
|
||||||
|
stdout:
|
||||||
|
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'unmanaged-session' } satisfies Partial<FleetReconcileError>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects remote inventory before a local lifecycle command is constructed', async (): Promise<void> => {
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const remoteRoster = {
|
||||||
|
...roster,
|
||||||
|
agents: [{ ...roster.agents[0]!, remote: { host: 'inventory-only' } }],
|
||||||
|
} as unknown as FleetRosterV2;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
executeFleetReconcile({
|
||||||
|
roster: remoteRoster,
|
||||||
|
command: 'apply',
|
||||||
|
expectedGeneration: 7,
|
||||||
|
deps: deps({
|
||||||
|
readRoster: async () => remoteRoster,
|
||||||
|
runner: async (command, args) => {
|
||||||
|
calls.push([command, ...args]);
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'lifecycle-precondition-failed' });
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plans without applying projections or lifecycle effects', async (): Promise<void> => {
|
||||||
|
let applied = false;
|
||||||
|
const result = await run('plan', {
|
||||||
|
applyProjection: async () => {
|
||||||
|
applied = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.applied).toBe(false);
|
||||||
|
expect(result.lifecycle).toBe('not-applied');
|
||||||
|
expect(applied).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a truthful partial result if projection application fails', async (): Promise<void> => {
|
||||||
|
const result = await run('apply', {
|
||||||
|
applyProjection: async () => {
|
||||||
|
throw new Error('injected failure');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
applied: false,
|
||||||
|
projections: 'incomplete',
|
||||||
|
lifecycle: 'not-applied',
|
||||||
|
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds cleanup diagnostics without masking projection or lifecycle partial truth', async (): Promise<void> => {
|
||||||
|
const failingRelease = async (): Promise<never> => {
|
||||||
|
throw new FleetReconcileError('lock-cleanup-failed', 'injected');
|
||||||
|
};
|
||||||
|
const projectionPartial = await run('apply', {
|
||||||
|
applyProjection: async () => {
|
||||||
|
throw new Error('injected projection failure');
|
||||||
|
},
|
||||||
|
acquireMutationLock: async () => failingRelease,
|
||||||
|
});
|
||||||
|
expect(projectionPartial).toMatchObject({
|
||||||
|
projections: 'incomplete',
|
||||||
|
lifecycle: 'not-applied',
|
||||||
|
recovery: { code: 'projection-apply-failed' },
|
||||||
|
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const lifecyclePartial = await run('apply', {
|
||||||
|
runner: async () => ({ stdout: '', stderr: '', exitCode: 1 }),
|
||||||
|
acquireMutationLock: async () => failingRelease,
|
||||||
|
});
|
||||||
|
expect(lifecyclePartial).toMatchObject({
|
||||||
|
projections: 'complete',
|
||||||
|
lifecycle: 'incomplete',
|
||||||
|
recovery: { code: 'lifecycle-apply-failed' },
|
||||||
|
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds cleanup diagnostics after successful effects without changing effect completion', async (): Promise<void> => {
|
||||||
|
const result = await run('apply', {
|
||||||
|
acquireMutationLock: async () => async () => {
|
||||||
|
throw new FleetReconcileError('lock-cleanup-failed', 'injected');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
applied: true,
|
||||||
|
projections: 'complete',
|
||||||
|
lifecycle: 'complete',
|
||||||
|
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
799
packages/mosaic/src/fleet/fleet-reconciler.ts
Normal file
799
packages/mosaic/src/fleet/fleet-reconciler.ts
Normal file
@@ -0,0 +1,799 @@
|
|||||||
|
import { constants } from 'node:fs';
|
||||||
|
import { lstat, open, readFile, unlink, type FileHandle } from 'node:fs/promises';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
applyPreparedAgentEnvironmentProjection,
|
||||||
|
prepareAgentEnvironmentProjection,
|
||||||
|
type PreparedAgentEnvironmentProjection,
|
||||||
|
} from './generated-env-boundary.js';
|
||||||
|
import {
|
||||||
|
validateRosterV2Semantics,
|
||||||
|
type FleetRosterV2,
|
||||||
|
type FleetRosterV2Agent,
|
||||||
|
} from './roster-v2.js';
|
||||||
|
|
||||||
|
export type FleetReconcileCommand =
|
||||||
|
| 'plan'
|
||||||
|
| 'apply'
|
||||||
|
| 'reconcile'
|
||||||
|
| 'start'
|
||||||
|
| 'stop'
|
||||||
|
| 'restart'
|
||||||
|
| 'status'
|
||||||
|
| 'verify'
|
||||||
|
| 'doctor';
|
||||||
|
|
||||||
|
export interface FleetReconcileCommandResult {
|
||||||
|
readonly stdout: string;
|
||||||
|
readonly stderr: string;
|
||||||
|
readonly exitCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FleetReconcileRunner = (
|
||||||
|
command: string,
|
||||||
|
args: readonly string[],
|
||||||
|
) => Promise<FleetReconcileCommandResult>;
|
||||||
|
|
||||||
|
export interface FleetReconcileDeps {
|
||||||
|
readonly runner: FleetReconcileRunner;
|
||||||
|
readonly mosaicHome?: string;
|
||||||
|
readonly rolesDir?: string;
|
||||||
|
readonly overrideDir?: string;
|
||||||
|
readonly homeDirectory?: string;
|
||||||
|
readonly readHolderIdentity?: () => Promise<string>;
|
||||||
|
readonly validateRoster?: (roster: FleetRosterV2) => Promise<void>;
|
||||||
|
readonly prepareProjections?: (roster: FleetRosterV2) => Promise<readonly unknown[]>;
|
||||||
|
readonly applyProjection?: (prepared: unknown) => Promise<unknown>;
|
||||||
|
/** Canonical roster reader; mutation authority is reread under the private lock. */
|
||||||
|
readonly readRoster?: () => Promise<FleetRosterV2>;
|
||||||
|
/** Test seam; production uses a private exclusive roster-adjacent lock. */
|
||||||
|
readonly acquireMutationLock?: () => Promise<() => Promise<void>>;
|
||||||
|
/** Internal recursion guard for the under-lock canonical roster read. */
|
||||||
|
readonly lockAlreadyHeld?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetReconcileRequest {
|
||||||
|
readonly roster: FleetRosterV2;
|
||||||
|
readonly command: FleetReconcileCommand;
|
||||||
|
readonly agentName?: string;
|
||||||
|
readonly expectedGeneration?: number;
|
||||||
|
readonly deps: FleetReconcileDeps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetReconcileObservedAgent {
|
||||||
|
readonly name: string;
|
||||||
|
readonly desiredState: 'running' | 'stopped';
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly systemd: 'active' | 'inactive' | 'unknown';
|
||||||
|
readonly tmux: 'present' | 'missing';
|
||||||
|
readonly drift: readonly ('missing-session' | 'unexpected-session' | 'disabled-running')[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetReconcilePlan {
|
||||||
|
readonly generation: number;
|
||||||
|
readonly holder: 'owned' | 'missing' | 'ownership-mismatch';
|
||||||
|
readonly agents: readonly FleetReconcileObservedAgent[];
|
||||||
|
readonly unmanagedSessions: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FleetReconcileProjectionState = 'not-applied' | 'complete' | 'incomplete';
|
||||||
|
export type FleetReconcileLifecycleState = 'not-applied' | 'complete' | 'incomplete';
|
||||||
|
|
||||||
|
export interface FleetReconcileResult {
|
||||||
|
readonly applied: boolean;
|
||||||
|
readonly authoritativeRoster: 'unchanged';
|
||||||
|
readonly projections: FleetReconcileProjectionState;
|
||||||
|
readonly lifecycle: FleetReconcileLifecycleState;
|
||||||
|
readonly plan: FleetReconcilePlan;
|
||||||
|
readonly recovery?: {
|
||||||
|
readonly code: 'projection-apply-failed' | 'lifecycle-apply-failed';
|
||||||
|
readonly action:
|
||||||
|
| 'regenerate-projections-from-roster'
|
||||||
|
| 'rerun-after-inspecting-owned-resources';
|
||||||
|
};
|
||||||
|
/** Additive: effects remain truthful when private lock cleanup cannot be proven. */
|
||||||
|
readonly cleanup?: {
|
||||||
|
readonly code: 'lock-cleanup-failed';
|
||||||
|
readonly action: 'inspect-lock-before-retry';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FleetReconcileError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code:
|
||||||
|
| 'missing-generation'
|
||||||
|
| 'stale-generation'
|
||||||
|
| 'concurrent-mutation'
|
||||||
|
| 'unsafe-managed-path'
|
||||||
|
| 'unsafe-lock'
|
||||||
|
| 'lock-io-failed'
|
||||||
|
| 'lock-cleanup-failed'
|
||||||
|
| 'agent-not-found'
|
||||||
|
| 'disabled-agent'
|
||||||
|
| 'ownership-mismatch'
|
||||||
|
| 'unmanaged-session'
|
||||||
|
| 'lifecycle-precondition-failed',
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = FleetReconcileError.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconciles only local roster-owned projections and exact service targets.
|
||||||
|
* The roster is read-only desired state: no observed runtime result ever writes it.
|
||||||
|
*/
|
||||||
|
export async function executeFleetReconcile(
|
||||||
|
request: FleetReconcileRequest,
|
||||||
|
): Promise<FleetReconcileResult> {
|
||||||
|
const mutating =
|
||||||
|
request.command === 'apply' ||
|
||||||
|
request.command === 'reconcile' ||
|
||||||
|
isLifecycleCommand(request.command);
|
||||||
|
if (mutating && !request.deps.lockAlreadyHeld) {
|
||||||
|
if (request.expectedGeneration === undefined) assertExpectedGeneration(request);
|
||||||
|
const acquire =
|
||||||
|
request.deps.acquireMutationLock ?? acquirePrivateReconcileLock(mosaicHomeFor(request.deps));
|
||||||
|
const release = await acquire();
|
||||||
|
let result: FleetReconcileResult | undefined;
|
||||||
|
let primaryError: unknown;
|
||||||
|
try {
|
||||||
|
if (!request.deps.readRoster) {
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'lifecycle-precondition-failed',
|
||||||
|
'Canonical roster state cannot be read for mutation.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const canonicalRoster = await request.deps.readRoster();
|
||||||
|
result = await executeFleetReconcile({
|
||||||
|
...request,
|
||||||
|
roster: canonicalRoster,
|
||||||
|
deps: { ...request.deps, lockAlreadyHeld: true },
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
primaryError = error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await release();
|
||||||
|
} catch (cleanupError: unknown) {
|
||||||
|
if (result) {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (primaryError) throw primaryError;
|
||||||
|
throw cleanupError;
|
||||||
|
}
|
||||||
|
if (primaryError) throw primaryError;
|
||||||
|
return result as FleetReconcileResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertExpectedGeneration(request);
|
||||||
|
assertLocalRosterOnly(request.roster);
|
||||||
|
const validateRoster = request.deps.validateRoster ?? defaultValidateRoster(request);
|
||||||
|
await validateRoster(request.roster);
|
||||||
|
assertLifecycleSocketAuthority(request);
|
||||||
|
const plan = scopePlan(await observeFleet(request.roster, request.deps), request.agentName);
|
||||||
|
|
||||||
|
if (request.command === 'status' || request.command === 'doctor') {
|
||||||
|
return {
|
||||||
|
applied: false,
|
||||||
|
authoritativeRoster: 'unchanged',
|
||||||
|
projections: 'not-applied',
|
||||||
|
lifecycle: 'not-applied',
|
||||||
|
plan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (request.command === 'verify') {
|
||||||
|
assertVerificationSafe(plan);
|
||||||
|
return {
|
||||||
|
applied: false,
|
||||||
|
authoritativeRoster: 'unchanged',
|
||||||
|
projections: 'not-applied',
|
||||||
|
lifecycle: 'not-applied',
|
||||||
|
plan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
assertMutationSafe(plan, request.command);
|
||||||
|
const prepareProjections = request.deps.prepareProjections ?? defaultPrepareProjections(request);
|
||||||
|
const prepared = await prepareProjections(request.roster);
|
||||||
|
if (request.command === 'plan') {
|
||||||
|
return {
|
||||||
|
applied: false,
|
||||||
|
authoritativeRoster: 'unchanged',
|
||||||
|
projections: 'not-applied',
|
||||||
|
lifecycle: 'not-applied',
|
||||||
|
plan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const targetAgents = targetAgentsFor(request.roster, request.agentName);
|
||||||
|
const release = async (): Promise<void> => undefined;
|
||||||
|
let result: FleetReconcileResult | undefined;
|
||||||
|
let primaryError: unknown;
|
||||||
|
try {
|
||||||
|
if (request.command !== 'apply' && request.command !== 'reconcile') {
|
||||||
|
result = await executeExplicitLifecycle(request, plan, targetAgents);
|
||||||
|
} else {
|
||||||
|
const applyProjection = request.deps.applyProjection ?? defaultApplyProjection;
|
||||||
|
try {
|
||||||
|
for (const projection of prepared) await applyProjection(projection);
|
||||||
|
} catch {
|
||||||
|
result = {
|
||||||
|
applied: false,
|
||||||
|
authoritativeRoster: 'unchanged',
|
||||||
|
projections: 'incomplete',
|
||||||
|
lifecycle: 'not-applied',
|
||||||
|
plan,
|
||||||
|
recovery: {
|
||||||
|
code: 'projection-apply-failed',
|
||||||
|
action: 'regenerate-projections-from-roster',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!result) {
|
||||||
|
try {
|
||||||
|
await applyDesiredLifecycle(request.roster, plan, request.deps);
|
||||||
|
result = {
|
||||||
|
applied: true,
|
||||||
|
authoritativeRoster: 'unchanged',
|
||||||
|
projections: 'complete',
|
||||||
|
lifecycle: 'complete',
|
||||||
|
plan,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
result = {
|
||||||
|
applied: false,
|
||||||
|
authoritativeRoster: 'unchanged',
|
||||||
|
projections: 'complete',
|
||||||
|
lifecycle: 'incomplete',
|
||||||
|
plan,
|
||||||
|
recovery: {
|
||||||
|
code: 'lifecycle-apply-failed',
|
||||||
|
action: 'rerun-after-inspecting-owned-resources',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
primaryError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await release();
|
||||||
|
} catch (cleanupError: unknown) {
|
||||||
|
if (result) {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (primaryError) throw primaryError;
|
||||||
|
throw cleanupError;
|
||||||
|
}
|
||||||
|
if (primaryError) throw primaryError;
|
||||||
|
return result as FleetReconcileResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLocalRosterOnly(roster: FleetRosterV2): void {
|
||||||
|
for (const agent of roster.agents) {
|
||||||
|
const untypedAgent = agent as unknown as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
Object.hasOwn(untypedAgent, 'remote') ||
|
||||||
|
Object.hasOwn(untypedAgent, 'ssh') ||
|
||||||
|
Object.hasOwn(untypedAgent, 'connector') ||
|
||||||
|
Object.hasOwn(untypedAgent, 'host')
|
||||||
|
) {
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'lifecycle-precondition-failed',
|
||||||
|
'Remote or connector inventory cannot receive local lifecycle actions.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertExpectedGeneration(request: FleetReconcileRequest): void {
|
||||||
|
if (isObservational(request.command)) return;
|
||||||
|
if (request.expectedGeneration === undefined) {
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'missing-generation',
|
||||||
|
'A roster generation is required for mutation.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (request.expectedGeneration !== request.roster.generation) {
|
||||||
|
throw new FleetReconcileError('stale-generation', 'The roster generation is stale.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObservational(command: FleetReconcileCommand): boolean {
|
||||||
|
return command === 'plan' || command === 'status' || command === 'verify' || command === 'doctor';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function observeFleet(
|
||||||
|
roster: FleetRosterV2,
|
||||||
|
deps: FleetReconcileDeps,
|
||||||
|
): Promise<FleetReconcilePlan> {
|
||||||
|
const sessionsResult = await run(deps, 'tmux', [
|
||||||
|
...tmuxSocketArgs(roster.tmux.socketName),
|
||||||
|
'list-sessions',
|
||||||
|
'-F',
|
||||||
|
'#{session_name}',
|
||||||
|
]);
|
||||||
|
if (sessionsResult.exitCode !== 0) {
|
||||||
|
return {
|
||||||
|
generation: roster.generation,
|
||||||
|
holder: 'missing',
|
||||||
|
agents: await observeAgents(roster, deps, new Set<string>()),
|
||||||
|
unmanagedSessions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Set(
|
||||||
|
sessionsResult.stdout
|
||||||
|
.split('\n')
|
||||||
|
.map((value: string): string => value.trim())
|
||||||
|
.filter((value: string): boolean => value.length > 0),
|
||||||
|
);
|
||||||
|
const knownSessions = new Set([
|
||||||
|
roster.tmux.holderSession,
|
||||||
|
...roster.agents.map((agent) => agent.name),
|
||||||
|
]);
|
||||||
|
const unmanagedSessions = [...sessions].filter(
|
||||||
|
(session: string): boolean => !knownSessions.has(session),
|
||||||
|
);
|
||||||
|
const holder = await observeHolder(roster, deps, sessions);
|
||||||
|
return {
|
||||||
|
generation: roster.generation,
|
||||||
|
holder,
|
||||||
|
agents: await observeAgents(roster, deps, sessions),
|
||||||
|
unmanagedSessions: Object.freeze(unmanagedSessions.sort()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function observeHolder(
|
||||||
|
roster: FleetRosterV2,
|
||||||
|
deps: FleetReconcileDeps,
|
||||||
|
sessions: ReadonlySet<string>,
|
||||||
|
): Promise<FleetReconcilePlan['holder']> {
|
||||||
|
if (!sessions.has(roster.tmux.holderSession)) return 'ownership-mismatch';
|
||||||
|
let owner: string;
|
||||||
|
try {
|
||||||
|
owner = await (deps.readHolderIdentity ?? defaultReadHolderIdentity(mosaicHomeFor(deps)))();
|
||||||
|
} catch {
|
||||||
|
return 'ownership-mismatch';
|
||||||
|
}
|
||||||
|
const environment = await run(deps, 'tmux', [
|
||||||
|
...tmuxSocketArgs(roster.tmux.socketName),
|
||||||
|
'show-environment',
|
||||||
|
'-g',
|
||||||
|
]);
|
||||||
|
if (environment.exitCode !== 0) return 'ownership-mismatch';
|
||||||
|
const homeDirectory = deps.homeDirectory ?? homedir();
|
||||||
|
const expected = [
|
||||||
|
`HOME=${homeDirectory}`,
|
||||||
|
`MOSAIC_FLEET_OWNER=${owner}`,
|
||||||
|
`MOSAIC_TMUX_HOLDER=${roster.tmux.holderSession}`,
|
||||||
|
`MOSAIC_TMUX_SOCKET=${roster.tmux.socketName}`,
|
||||||
|
'PATH=/usr/bin:/bin',
|
||||||
|
`PWD=${homeDirectory}`,
|
||||||
|
].sort();
|
||||||
|
const actual = environment.stdout
|
||||||
|
.split('\n')
|
||||||
|
.filter((line: string): boolean => line.length > 0)
|
||||||
|
.sort();
|
||||||
|
return sameStringArray(actual, expected) ? 'owned' : 'ownership-mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function observeAgents(
|
||||||
|
roster: FleetRosterV2,
|
||||||
|
deps: FleetReconcileDeps,
|
||||||
|
sessions: ReadonlySet<string>,
|
||||||
|
): Promise<readonly FleetReconcileObservedAgent[]> {
|
||||||
|
return Promise.all(
|
||||||
|
roster.agents.map(async (agent: FleetRosterV2Agent): Promise<FleetReconcileObservedAgent> => {
|
||||||
|
const service = await run(deps, 'systemctl', [
|
||||||
|
'--user',
|
||||||
|
'show',
|
||||||
|
`mosaic-agent@${agent.name}.service`,
|
||||||
|
'-p',
|
||||||
|
'ActiveState',
|
||||||
|
]);
|
||||||
|
const active = /^ActiveState=active$/m.test(service.stdout)
|
||||||
|
? 'active'
|
||||||
|
: service.exitCode === 0
|
||||||
|
? 'inactive'
|
||||||
|
: 'unknown';
|
||||||
|
const tmux = sessions.has(agent.name) ? 'present' : 'missing';
|
||||||
|
const drift: Array<'missing-session' | 'unexpected-session' | 'disabled-running'> = [];
|
||||||
|
if (
|
||||||
|
agent.lifecycle.enabled &&
|
||||||
|
agent.lifecycle.desiredState === 'running' &&
|
||||||
|
tmux === 'missing'
|
||||||
|
) {
|
||||||
|
drift.push('missing-session');
|
||||||
|
}
|
||||||
|
if (agent.lifecycle.desiredState === 'stopped' && tmux === 'present')
|
||||||
|
drift.push('unexpected-session');
|
||||||
|
if (!agent.lifecycle.enabled && tmux === 'present') drift.push('disabled-running');
|
||||||
|
return {
|
||||||
|
name: agent.name,
|
||||||
|
desiredState: agent.lifecycle.desiredState,
|
||||||
|
enabled: agent.lifecycle.enabled,
|
||||||
|
systemd: active,
|
||||||
|
tmux,
|
||||||
|
drift: Object.freeze(drift),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertMutationSafe(plan: FleetReconcilePlan, command: FleetReconcileCommand): void {
|
||||||
|
if (plan.holder === 'ownership-mismatch') {
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'ownership-mismatch',
|
||||||
|
'The named tmux server ownership cannot be proven.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (plan.unmanagedSessions.length > 0 && affectsHolder(command)) {
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'unmanaged-session',
|
||||||
|
'Unmanaged sessions are present on the named socket.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function affectsHolder(command: FleetReconcileCommand): boolean {
|
||||||
|
return (
|
||||||
|
command === 'apply' || command === 'reconcile' || command === 'start' || command === 'restart'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopePlan(plan: FleetReconcilePlan, agentName?: string): FleetReconcilePlan {
|
||||||
|
if (agentName === undefined) return plan;
|
||||||
|
const agent = plan.agents.find(
|
||||||
|
(candidate: FleetReconcileObservedAgent): boolean => candidate.name === agentName,
|
||||||
|
);
|
||||||
|
if (!agent)
|
||||||
|
throw new FleetReconcileError('agent-not-found', 'The lifecycle target is not roster-owned.');
|
||||||
|
return { ...plan, agents: [agent] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertVerificationSafe(plan: FleetReconcilePlan): void {
|
||||||
|
if (plan.holder !== 'owned' || plan.unmanagedSessions.length > 0) {
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'lifecycle-precondition-failed',
|
||||||
|
'Fleet ownership cannot be verified.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (plan.agents.some((agent: FleetReconcileObservedAgent): boolean => agent.drift.length > 0)) {
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'lifecycle-precondition-failed',
|
||||||
|
'Fleet drift prevents verification.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLifecycleSocketAuthority(request: FleetReconcileRequest): void {
|
||||||
|
const usesFixedLifecycleUnits =
|
||||||
|
request.command === 'apply' ||
|
||||||
|
request.command === 'reconcile' ||
|
||||||
|
isLifecycleCommand(request.command);
|
||||||
|
if (usesFixedLifecycleUnits && request.roster.tmux.socketName === '') {
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'lifecycle-precondition-failed',
|
||||||
|
'Default-server lifecycle mutation is unsupported by the fixed named-socket systemd units.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetAgentsFor(roster: FleetRosterV2, agentName?: string): readonly FleetRosterV2Agent[] {
|
||||||
|
if (agentName === undefined) return roster.agents;
|
||||||
|
const agent = roster.agents.find(
|
||||||
|
(candidate: FleetRosterV2Agent): boolean => candidate.name === agentName,
|
||||||
|
);
|
||||||
|
if (!agent)
|
||||||
|
throw new FleetReconcileError('agent-not-found', 'The lifecycle target is not roster-owned.');
|
||||||
|
return [agent];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeExplicitLifecycle(
|
||||||
|
request: FleetReconcileRequest,
|
||||||
|
plan: FleetReconcilePlan,
|
||||||
|
agents: readonly FleetRosterV2Agent[],
|
||||||
|
): Promise<FleetReconcileResult> {
|
||||||
|
if (request.command === 'start') {
|
||||||
|
for (const agent of agents) {
|
||||||
|
if (!agent.lifecycle.enabled) {
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'disabled-agent',
|
||||||
|
'A disabled roster agent cannot be started.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (request.command === 'start' && plan.holder === 'missing') {
|
||||||
|
await runChecked(request.deps, 'systemctl', [
|
||||||
|
'--user',
|
||||||
|
'start',
|
||||||
|
'mosaic-tmux-holder.service',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for (const agent of agents) {
|
||||||
|
await runChecked(request.deps, 'systemctl', [
|
||||||
|
'--user',
|
||||||
|
request.command,
|
||||||
|
`mosaic-agent@${agent.name}.service`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
applied: false,
|
||||||
|
authoritativeRoster: 'unchanged',
|
||||||
|
projections: 'not-applied',
|
||||||
|
lifecycle: 'incomplete',
|
||||||
|
plan,
|
||||||
|
recovery: {
|
||||||
|
code: 'lifecycle-apply-failed',
|
||||||
|
action: 'rerun-after-inspecting-owned-resources',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
applied: true,
|
||||||
|
authoritativeRoster: 'unchanged',
|
||||||
|
projections: 'not-applied',
|
||||||
|
lifecycle: 'complete',
|
||||||
|
plan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyDesiredLifecycle(
|
||||||
|
roster: FleetRosterV2,
|
||||||
|
plan: FleetReconcilePlan,
|
||||||
|
deps: FleetReconcileDeps,
|
||||||
|
): Promise<void> {
|
||||||
|
const needsRunningAgent = roster.agents.some(
|
||||||
|
(agent: FleetRosterV2Agent): boolean =>
|
||||||
|
agent.lifecycle.enabled && agent.lifecycle.desiredState === 'running',
|
||||||
|
);
|
||||||
|
if (needsRunningAgent && plan.holder === 'missing') {
|
||||||
|
await runChecked(deps, 'systemctl', ['--user', 'start', 'mosaic-tmux-holder.service']);
|
||||||
|
}
|
||||||
|
for (const agent of roster.agents) {
|
||||||
|
const action =
|
||||||
|
agent.lifecycle.enabled && agent.lifecycle.desiredState === 'running' ? 'start' : 'stop';
|
||||||
|
await runChecked(deps, 'systemctl', ['--user', action, `mosaic-agent@${agent.name}.service`]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultValidateRoster(
|
||||||
|
request: FleetReconcileRequest,
|
||||||
|
): (roster: FleetRosterV2) => Promise<void> {
|
||||||
|
return async (roster: FleetRosterV2): Promise<void> => {
|
||||||
|
const mosaicHome = mosaicHomeFor(request.deps);
|
||||||
|
await validateRosterV2Semantics(roster, {
|
||||||
|
rolesDir: request.deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
|
||||||
|
overrideDir: request.deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPrepareProjections(
|
||||||
|
request: FleetReconcileRequest,
|
||||||
|
): (roster: FleetRosterV2) => Promise<readonly PreparedAgentEnvironmentProjection[]> {
|
||||||
|
return async (roster: FleetRosterV2): Promise<readonly PreparedAgentEnvironmentProjection[]> => {
|
||||||
|
const mosaicHome = mosaicHomeFor(request.deps);
|
||||||
|
return Promise.all(
|
||||||
|
roster.agents.map(
|
||||||
|
(agent: FleetRosterV2Agent): Promise<PreparedAgentEnvironmentProjection> =>
|
||||||
|
prepareAgentEnvironmentProjection({
|
||||||
|
mosaicHome,
|
||||||
|
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||||
|
agentName: agent.name,
|
||||||
|
generated: {
|
||||||
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
|
MOSAIC_AGENT_CLASS: agent.className,
|
||||||
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
|
MOSAIC_AGENT_MODEL: agent.model,
|
||||||
|
MOSAIC_AGENT_REASONING: agent.reasoning,
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
|
||||||
|
MOSAIC_AGENT_WORKDIR: agent.workingDirectory,
|
||||||
|
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultApplyProjection(prepared: unknown): Promise<unknown> {
|
||||||
|
return applyPreparedAgentEnvironmentProjection(prepared as PreparedAgentEnvironmentProjection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mosaicHomeFor(deps: FleetReconcileDeps): string {
|
||||||
|
return deps.mosaicHome ?? join(homedir(), '.config', 'mosaic');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Acquires a private lock only after proving the canonical managed path. */
|
||||||
|
export function acquirePrivateReconcileLock(
|
||||||
|
mosaicHome: string,
|
||||||
|
openLock: typeof open = open,
|
||||||
|
): () => Promise<() => Promise<void>> {
|
||||||
|
const fleetDir = join(mosaicHome, 'fleet');
|
||||||
|
const lockPath = join(fleetDir, 'roster.yaml.reconcile.lock');
|
||||||
|
return async (): Promise<() => Promise<void>> => {
|
||||||
|
await assertPrivateManagedDirectory(mosaicHome);
|
||||||
|
await assertPrivateManagedDirectory(fleetDir);
|
||||||
|
await assertSafeLockLeafIfPresent(lockPath);
|
||||||
|
|
||||||
|
let handle: FileHandle;
|
||||||
|
try {
|
||||||
|
handle = await openLock(lockPath, 'wx', 0o600);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (isCode(error, 'EEXIST')) {
|
||||||
|
await assertSafeLockLeafIfPresent(lockPath);
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'concurrent-mutation',
|
||||||
|
'Another roster reconciliation is in progress.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new FleetReconcileError('lock-io-failed', 'The reconciliation lock cannot be created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = randomUUID();
|
||||||
|
try {
|
||||||
|
await handle.writeFile(`${token}\n`, 'utf8');
|
||||||
|
const opened = await handle.stat();
|
||||||
|
await handle.close();
|
||||||
|
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'unsafe-lock');
|
||||||
|
return async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
|
||||||
|
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
|
||||||
|
await unlink(lockPath);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof FleetReconcileError) throw error;
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'lock-cleanup-failed',
|
||||||
|
'The reconciliation lock cleanup failed.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
await handle.close().catch((): void => {});
|
||||||
|
if (error instanceof FleetReconcileError) throw error;
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'lock-io-failed',
|
||||||
|
'The reconciliation lock cannot be initialized.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertPrivateManagedDirectory(path: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const metadata = await lstat(path);
|
||||||
|
if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||||
|
throw new FleetReconcileError('unsafe-managed-path', 'The managed lock ancestor is unsafe.');
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof FleetReconcileError) throw error;
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'unsafe-managed-path',
|
||||||
|
'The managed lock ancestor is unavailable.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertSafeLockLeafIfPresent(lockPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const metadata = await lstat(lockPath);
|
||||||
|
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||||
|
throw new FleetReconcileError('unsafe-lock', 'The reconciliation lock path is unsafe.');
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (isCode(error, 'ENOENT')) return;
|
||||||
|
if (error instanceof FleetReconcileError) throw error;
|
||||||
|
throw new FleetReconcileError('unsafe-lock', 'The reconciliation lock path is unavailable.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertLockOwnership(
|
||||||
|
lockPath: string,
|
||||||
|
device: number,
|
||||||
|
inode: number,
|
||||||
|
token: string,
|
||||||
|
failureCode: 'unsafe-lock' | 'lock-cleanup-failed',
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const metadata = await lstat(lockPath);
|
||||||
|
if (
|
||||||
|
!metadata.isFile() ||
|
||||||
|
metadata.isSymbolicLink() ||
|
||||||
|
(metadata.mode & 0o077) !== 0 ||
|
||||||
|
metadata.dev !== device ||
|
||||||
|
metadata.ino !== inode
|
||||||
|
) {
|
||||||
|
throw new FleetReconcileError(failureCode, 'The reconciliation lock ownership changed.');
|
||||||
|
}
|
||||||
|
const handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||||
|
try {
|
||||||
|
const opened = await handle.stat();
|
||||||
|
const contents = await handle.readFile({ encoding: 'utf8' });
|
||||||
|
if (opened.dev !== device || opened.ino !== inode || contents !== `${token}\n`) {
|
||||||
|
throw new FleetReconcileError(failureCode, 'The reconciliation lock ownership changed.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof FleetReconcileError) throw error;
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
failureCode,
|
||||||
|
'The reconciliation lock ownership cannot be proven.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLifecycleCommand(command: FleetReconcileCommand): boolean {
|
||||||
|
return command === 'start' || command === 'stop' || command === 'restart';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCode(error: unknown, code: string): boolean {
|
||||||
|
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultReadHolderIdentity(mosaicHome: string): () => Promise<string> {
|
||||||
|
return async (): Promise<string> => {
|
||||||
|
const fleetDir = join(mosaicHome, 'fleet');
|
||||||
|
const runDir = join(fleetDir, 'run');
|
||||||
|
for (const directory of [mosaicHome, fleetDir, runDir]) {
|
||||||
|
const metadata = await lstat(directory);
|
||||||
|
if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||||
|
throw new Error('Unsafe holder identity ancestor.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const identityPath = join(runDir, 'holder-owner');
|
||||||
|
const metadata = await lstat(identityPath);
|
||||||
|
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||||
|
throw new Error('Unsafe holder identity.');
|
||||||
|
}
|
||||||
|
const value = (await readFile(identityPath, 'utf8')).trim();
|
||||||
|
if (!/^[a-f0-9-]{36}$/.test(value)) throw new Error('Malformed holder identity.');
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(
|
||||||
|
deps: FleetReconcileDeps,
|
||||||
|
command: string,
|
||||||
|
args: readonly string[],
|
||||||
|
): Promise<FleetReconcileCommandResult> {
|
||||||
|
return deps.runner(command, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runChecked(
|
||||||
|
deps: FleetReconcileDeps,
|
||||||
|
command: string,
|
||||||
|
args: readonly string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await run(deps, command, args);
|
||||||
|
if (result.exitCode !== 0) throw new Error('Lifecycle action failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tmuxSocketArgs(socketName: string): readonly string[] {
|
||||||
|
return socketName === '' ? [] : ['-L', socketName];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameStringArray(left: readonly string[], right: readonly string[]): boolean {
|
||||||
|
return (
|
||||||
|
left.length === right.length &&
|
||||||
|
left.every((value: string, index: number): boolean => value === right[index])
|
||||||
|
);
|
||||||
|
}
|
||||||
522
packages/mosaic/src/fleet/fleet-roster-v1.ts
Normal file
522
packages/mosaic/src/fleet/fleet-roster-v1.ts
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
import { lstatSync } from 'node:fs';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import YAML from 'yaml';
|
||||||
|
import { canonicalizeRoleClass } from '../commands/fleet-personas.js';
|
||||||
|
|
||||||
|
interface RawFleetRoster {
|
||||||
|
version?: unknown;
|
||||||
|
transport?: unknown;
|
||||||
|
tmux?: {
|
||||||
|
socket_name?: unknown;
|
||||||
|
socketName?: unknown;
|
||||||
|
holder_session?: unknown;
|
||||||
|
holderSession?: unknown;
|
||||||
|
};
|
||||||
|
defaults?: {
|
||||||
|
working_directory?: unknown;
|
||||||
|
workingDirectory?: unknown;
|
||||||
|
};
|
||||||
|
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
||||||
|
agents?: Array<{
|
||||||
|
name?: unknown;
|
||||||
|
alias?: unknown;
|
||||||
|
provider?: unknown;
|
||||||
|
runtime?: unknown;
|
||||||
|
class?: unknown;
|
||||||
|
host?: unknown;
|
||||||
|
ssh?: unknown;
|
||||||
|
socket?: unknown;
|
||||||
|
working_directory?: unknown;
|
||||||
|
workingDirectory?: unknown;
|
||||||
|
model_hint?: unknown;
|
||||||
|
modelHint?: unknown;
|
||||||
|
reasoning_level?: unknown;
|
||||||
|
reasoningLevel?: unknown;
|
||||||
|
tool_policy?: unknown;
|
||||||
|
toolPolicy?: unknown;
|
||||||
|
persistent_persona?: unknown;
|
||||||
|
persistentPersona?: unknown;
|
||||||
|
reset_between_tasks?: unknown;
|
||||||
|
resetBetweenTasks?: unknown;
|
||||||
|
kickstart_template?: unknown;
|
||||||
|
kickstartTemplate?: unknown;
|
||||||
|
}>;
|
||||||
|
connector?: {
|
||||||
|
kind?: unknown;
|
||||||
|
matrix?: {
|
||||||
|
homeserver_url?: unknown;
|
||||||
|
user_id?: unknown;
|
||||||
|
room_id?: unknown;
|
||||||
|
};
|
||||||
|
discord?: {
|
||||||
|
channel_id?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetAgent {
|
||||||
|
name: string;
|
||||||
|
alias?: string;
|
||||||
|
provider?: string;
|
||||||
|
runtime: string;
|
||||||
|
className: string;
|
||||||
|
/** Resolved host identity. Absent means the caller's authoritative local host. */
|
||||||
|
host?: string;
|
||||||
|
/** Explicit SSH destination for a cross-host inventory peer. */
|
||||||
|
ssh?: string;
|
||||||
|
/** Compatibility declaration; when set it must equal fleet-wide tmux.socketName. */
|
||||||
|
socket?: string;
|
||||||
|
workingDirectory?: string;
|
||||||
|
modelHint?: string;
|
||||||
|
reasoningLevel?: string;
|
||||||
|
toolPolicy?: string;
|
||||||
|
persistentPersona?: boolean | string;
|
||||||
|
resetBetweenTasks?: boolean;
|
||||||
|
kickstartTemplate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FleetConnector =
|
||||||
|
| { kind: 'tmux' }
|
||||||
|
| {
|
||||||
|
kind: 'discord';
|
||||||
|
discord: { channelId: string };
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'matrix';
|
||||||
|
matrix: { homeserverUrl: string; userId: string; roomId: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FleetRoster {
|
||||||
|
version: 1;
|
||||||
|
transport: 'tmux';
|
||||||
|
tmux: {
|
||||||
|
socketName: string;
|
||||||
|
holderSession: string;
|
||||||
|
};
|
||||||
|
defaults: {
|
||||||
|
workingDirectory: string;
|
||||||
|
};
|
||||||
|
runtimes: Record<string, { resetCommand: string }>;
|
||||||
|
agents: FleetAgent[];
|
||||||
|
connector?: FleetConnector;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FleetRosterInputFormat = 'yaml' | 'json';
|
||||||
|
|
||||||
|
export function resolveInstalledFleetRosterPath(mosaicHome: string): string {
|
||||||
|
const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||||
|
try {
|
||||||
|
lstatSync(yamlPath);
|
||||||
|
return yamlPath;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
||||||
|
return join(mosaicHome, 'fleet', 'roster.json');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_HOLDER_SESSION = '_holder';
|
||||||
|
const DEFAULT_WORKING_DIRECTORY = '~/src';
|
||||||
|
const DEFAULT_RUNTIME_RESETS: Record<string, { resetCommand: string }> = {
|
||||||
|
claude: { resetCommand: '/clear' },
|
||||||
|
codex: { resetCommand: '/clear' },
|
||||||
|
opencode: { resetCommand: '/clear' },
|
||||||
|
pi: { resetCommand: '/new' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One structural v1 resolver used by fleet commands and runtime comms composition. */
|
||||||
|
export function parseFleetRosterV1(
|
||||||
|
source: string,
|
||||||
|
format: FleetRosterInputFormat = 'yaml',
|
||||||
|
): FleetRoster {
|
||||||
|
const trimmed = source.trim();
|
||||||
|
const parsed =
|
||||||
|
format === 'json'
|
||||||
|
? (JSON.parse(trimmed) as RawFleetRoster)
|
||||||
|
: (YAML.parse(trimmed) as RawFleetRoster);
|
||||||
|
return normalizeFleetRosterV1(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
||||||
|
const source = await readFile(path, 'utf8');
|
||||||
|
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
||||||
|
const agent = roster.agents.find((candidate) => candidate.name === name);
|
||||||
|
if (!agent) throw new Error(`Agent "${name}" is not in the fleet roster.`);
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFleetRosterV1(raw: RawFleetRoster): FleetRoster {
|
||||||
|
assertObject(raw, 'Fleet roster');
|
||||||
|
assertKnownKeys(raw, 'Fleet roster', [
|
||||||
|
'version',
|
||||||
|
'transport',
|
||||||
|
'tmux',
|
||||||
|
'defaults',
|
||||||
|
'runtimes',
|
||||||
|
'agents',
|
||||||
|
'connector',
|
||||||
|
]);
|
||||||
|
if (raw.tmux !== undefined) {
|
||||||
|
assertObject(raw.tmux, 'Fleet roster tmux');
|
||||||
|
assertKnownKeys(raw.tmux, 'Fleet roster tmux', [
|
||||||
|
'socket_name',
|
||||||
|
'socketName',
|
||||||
|
'holder_session',
|
||||||
|
'holderSession',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (raw.defaults !== undefined) {
|
||||||
|
assertObject(raw.defaults, 'Fleet roster defaults');
|
||||||
|
assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
|
||||||
|
'working_directory',
|
||||||
|
'workingDirectory',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (raw.runtimes !== undefined) {
|
||||||
|
assertObject(raw.runtimes, 'Fleet roster runtimes');
|
||||||
|
for (const [runtime, config] of Object.entries(raw.runtimes)) {
|
||||||
|
assertObject(config, `Fleet roster runtime "${runtime}"`);
|
||||||
|
assertKnownKeys(config, `Fleet roster runtime "${runtime}"`, [
|
||||||
|
'reset_command',
|
||||||
|
'resetCommand',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (raw.version !== 1) throw new Error('Fleet roster version must be 1.');
|
||||||
|
if (raw.transport !== 'tmux') throw new Error('Fleet roster transport must be "tmux".');
|
||||||
|
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
|
||||||
|
throw new Error('Fleet roster must define at least one agent.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketName = targetingString(
|
||||||
|
aliasValue(raw.tmux, 'socket_name', 'socketName', 'Fleet roster tmux socket'),
|
||||||
|
'',
|
||||||
|
'Fleet roster tmux socket_name',
|
||||||
|
/^[A-Za-z0-9_.-]+$/,
|
||||||
|
);
|
||||||
|
const agents = raw.agents.map(normalizeAgent);
|
||||||
|
assertUniqueAgentNames(agents);
|
||||||
|
for (const agent of agents) {
|
||||||
|
if (agent.socket !== undefined && agent.socket !== socketName) {
|
||||||
|
throw new Error(
|
||||||
|
`Fleet agent "${agent.name}" socket must equal the fleet-wide tmux socket_name; independent per-agent sockets are not supported.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
transport: 'tmux',
|
||||||
|
tmux: {
|
||||||
|
socketName,
|
||||||
|
holderSession: stringValue(
|
||||||
|
aliasValue(raw.tmux, 'holder_session', 'holderSession', 'Fleet roster tmux holder'),
|
||||||
|
DEFAULT_HOLDER_SESSION,
|
||||||
|
'Fleet roster tmux holder_session',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
workingDirectory: stringValue(
|
||||||
|
aliasValue(
|
||||||
|
raw.defaults,
|
||||||
|
'working_directory',
|
||||||
|
'workingDirectory',
|
||||||
|
'Fleet roster defaults working directory',
|
||||||
|
),
|
||||||
|
DEFAULT_WORKING_DIRECTORY,
|
||||||
|
'Fleet roster defaults working_directory',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
runtimes: normalizeRuntimes(raw.runtimes as RawFleetRoster['runtimes']),
|
||||||
|
agents,
|
||||||
|
connector: normalizeConnector(raw.connector as RawFleetRoster['connector']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): FleetAgent {
|
||||||
|
assertObject(raw, 'Fleet roster agent');
|
||||||
|
assertKnownKeys(raw, 'Fleet roster agent', [
|
||||||
|
'name',
|
||||||
|
'alias',
|
||||||
|
'provider',
|
||||||
|
'runtime',
|
||||||
|
'class',
|
||||||
|
'host',
|
||||||
|
'ssh',
|
||||||
|
'socket',
|
||||||
|
'working_directory',
|
||||||
|
'workingDirectory',
|
||||||
|
'model_hint',
|
||||||
|
'modelHint',
|
||||||
|
'reasoning_level',
|
||||||
|
'reasoningLevel',
|
||||||
|
'tool_policy',
|
||||||
|
'toolPolicy',
|
||||||
|
'persistent_persona',
|
||||||
|
'persistentPersona',
|
||||||
|
'reset_between_tasks',
|
||||||
|
'resetBetweenTasks',
|
||||||
|
'kickstart_template',
|
||||||
|
'kickstartTemplate',
|
||||||
|
]);
|
||||||
|
const name = stringValue(raw.name, '', 'Fleet roster agent name');
|
||||||
|
const runtime = stringValue(
|
||||||
|
raw.runtime,
|
||||||
|
'',
|
||||||
|
`Fleet roster agent "${name || '<unknown>'}" runtime`,
|
||||||
|
);
|
||||||
|
if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
|
||||||
|
throw new Error(`Invalid fleet agent name: ${name || '<empty>'}`);
|
||||||
|
}
|
||||||
|
if (!runtime) throw new Error(`Fleet agent "${name}" must define a runtime.`);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`),
|
||||||
|
provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`),
|
||||||
|
runtime,
|
||||||
|
className: canonicalizeRoleClass(
|
||||||
|
stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
||||||
|
).canonicalClass,
|
||||||
|
host: optionalTargetingString(
|
||||||
|
raw.host,
|
||||||
|
`Fleet roster agent "${name}" host`,
|
||||||
|
/^[A-Za-z0-9_.:[\]-]+$/,
|
||||||
|
),
|
||||||
|
ssh: optionalTargetingString(
|
||||||
|
raw.ssh,
|
||||||
|
`Fleet roster agent "${name}" ssh`,
|
||||||
|
/^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9_.:[\]-]+$/,
|
||||||
|
),
|
||||||
|
socket: optionalTargetingString(
|
||||||
|
raw.socket,
|
||||||
|
`Fleet roster agent "${name}" socket`,
|
||||||
|
/^[A-Za-z0-9_.-]+$/,
|
||||||
|
),
|
||||||
|
workingDirectory: optionalString(
|
||||||
|
aliasValue(
|
||||||
|
raw,
|
||||||
|
'working_directory',
|
||||||
|
'workingDirectory',
|
||||||
|
`Fleet roster agent "${name}" working directory`,
|
||||||
|
),
|
||||||
|
`Fleet roster agent "${name}" working_directory`,
|
||||||
|
),
|
||||||
|
modelHint: optionalString(
|
||||||
|
aliasValue(raw, 'model_hint', 'modelHint', `Fleet roster agent "${name}" model hint`),
|
||||||
|
`Fleet roster agent "${name}" model_hint`,
|
||||||
|
),
|
||||||
|
reasoningLevel: optionalString(
|
||||||
|
aliasValue(
|
||||||
|
raw,
|
||||||
|
'reasoning_level',
|
||||||
|
'reasoningLevel',
|
||||||
|
`Fleet roster agent "${name}" reasoning level`,
|
||||||
|
),
|
||||||
|
`Fleet roster agent "${name}" reasoning_level`,
|
||||||
|
),
|
||||||
|
toolPolicy: optionalString(
|
||||||
|
aliasValue(raw, 'tool_policy', 'toolPolicy', `Fleet roster agent "${name}" tool policy`),
|
||||||
|
`Fleet roster agent "${name}" tool_policy`,
|
||||||
|
),
|
||||||
|
persistentPersona: optionalBooleanOrString(
|
||||||
|
aliasValue(
|
||||||
|
raw,
|
||||||
|
'persistent_persona',
|
||||||
|
'persistentPersona',
|
||||||
|
`Fleet roster agent "${name}" persistent persona`,
|
||||||
|
),
|
||||||
|
`Fleet roster agent "${name}" persistent_persona`,
|
||||||
|
),
|
||||||
|
resetBetweenTasks: optionalBoolean(
|
||||||
|
aliasValue(
|
||||||
|
raw,
|
||||||
|
'reset_between_tasks',
|
||||||
|
'resetBetweenTasks',
|
||||||
|
`Fleet roster agent "${name}" reset between tasks`,
|
||||||
|
),
|
||||||
|
`Fleet roster agent "${name}" reset_between_tasks`,
|
||||||
|
),
|
||||||
|
kickstartTemplate: optionalString(
|
||||||
|
aliasValue(
|
||||||
|
raw,
|
||||||
|
'kickstart_template',
|
||||||
|
'kickstartTemplate',
|
||||||
|
`Fleet roster agent "${name}" kickstart template`,
|
||||||
|
),
|
||||||
|
`Fleet roster agent "${name}" kickstart_template`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRuntimes(
|
||||||
|
raw: RawFleetRoster['runtimes'] | undefined,
|
||||||
|
): Record<string, { resetCommand: string }> {
|
||||||
|
const result: Record<string, { resetCommand: string }> = { ...DEFAULT_RUNTIME_RESETS };
|
||||||
|
for (const [runtime, config] of Object.entries(raw ?? {})) {
|
||||||
|
result[runtime] = {
|
||||||
|
resetCommand: stringValue(
|
||||||
|
aliasValue(
|
||||||
|
config,
|
||||||
|
'reset_command',
|
||||||
|
'resetCommand',
|
||||||
|
`Fleet roster runtime "${runtime}" reset command`,
|
||||||
|
),
|
||||||
|
'/clear',
|
||||||
|
`Fleet roster runtime "${runtime}" reset_command`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConnector(raw: RawFleetRoster['connector']): FleetConnector | undefined {
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
assertObject(raw, 'Fleet roster connector');
|
||||||
|
assertKnownKeys(raw, 'Fleet roster connector', ['kind', 'matrix', 'discord']);
|
||||||
|
const kind = stringValue(raw.kind, '', 'Fleet roster connector kind');
|
||||||
|
if (kind === 'tmux') {
|
||||||
|
if (raw.matrix !== undefined || raw.discord !== undefined) {
|
||||||
|
throw new Error('Fleet roster tmux connector must not define matrix or discord settings.');
|
||||||
|
}
|
||||||
|
return { kind };
|
||||||
|
}
|
||||||
|
if (kind === 'discord') {
|
||||||
|
if (raw.matrix !== undefined) {
|
||||||
|
throw new Error('Fleet roster discord connector must not define matrix settings.');
|
||||||
|
}
|
||||||
|
assertObject(raw.discord, 'Fleet roster connector discord');
|
||||||
|
assertKnownKeys(raw.discord, 'Fleet roster connector discord', ['channel_id']);
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
discord: {
|
||||||
|
channelId: requiredString(
|
||||||
|
raw.discord.channel_id,
|
||||||
|
'Fleet roster connector discord channel_id',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (kind === 'matrix') {
|
||||||
|
if (raw.discord !== undefined) {
|
||||||
|
throw new Error('Fleet roster matrix connector must not define discord settings.');
|
||||||
|
}
|
||||||
|
assertObject(raw.matrix, 'Fleet roster connector matrix');
|
||||||
|
assertKnownKeys(raw.matrix, 'Fleet roster connector matrix', [
|
||||||
|
'homeserver_url',
|
||||||
|
'user_id',
|
||||||
|
'room_id',
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
matrix: {
|
||||||
|
homeserverUrl: requiredString(
|
||||||
|
raw.matrix.homeserver_url,
|
||||||
|
'Fleet roster connector matrix homeserver_url',
|
||||||
|
),
|
||||||
|
userId: requiredString(raw.matrix.user_id, 'Fleet roster connector matrix user_id'),
|
||||||
|
roomId: requiredString(raw.matrix.room_id, 'Fleet roster connector matrix room_id'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error('Fleet roster connector kind must be one of: tmux, discord, matrix.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function aliasValue<T extends Record<string, unknown>>(
|
||||||
|
source: T | undefined,
|
||||||
|
snake: keyof T,
|
||||||
|
camel: keyof T,
|
||||||
|
label: string,
|
||||||
|
): unknown {
|
||||||
|
const snakeValue = source?.[snake];
|
||||||
|
const camelValue = source?.[camel];
|
||||||
|
if (snakeValue !== undefined && camelValue !== undefined && snakeValue !== camelValue) {
|
||||||
|
throw new Error(`${label} aliases ${String(snake)} and ${String(camel)} conflict.`);
|
||||||
|
}
|
||||||
|
return snakeValue ?? camelValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNodeErrorCode(error: unknown, code: string): boolean {
|
||||||
|
return error instanceof Error && 'code' in error && error.code === code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredString(value: unknown, label: string): string {
|
||||||
|
const resolved = stringValue(value, '', label).trim();
|
||||||
|
if (!resolved) throw new Error(`${label} is required.`);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertObject(value: unknown, label: string): asserts value is Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new Error(`${label} must be an object.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertKnownKeys(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
label: string,
|
||||||
|
allowedKeys: readonly string[],
|
||||||
|
): void {
|
||||||
|
const allowed = new Set(allowedKeys);
|
||||||
|
const unknownKeys = Object.keys(value).filter((key) => !allowed.has(key));
|
||||||
|
if (unknownKeys.length > 0) {
|
||||||
|
throw new Error(`${label} has unknown field(s): ${unknownKeys.join(', ')}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertUniqueAgentNames(agents: FleetAgent[]): void {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const agent of agents) {
|
||||||
|
if (seen.has(agent.name)) {
|
||||||
|
throw new Error(`Fleet roster has duplicate agent name: ${agent.name}.`);
|
||||||
|
}
|
||||||
|
seen.add(agent.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown, fallback = '', label = 'Value'): string {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
if (typeof value !== 'string') throw new Error(`${label} must be a string.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetingString(value: unknown, fallback: string, label: string, pattern: RegExp): string {
|
||||||
|
const resolved = stringValue(value, fallback, label);
|
||||||
|
if (resolved && !pattern.test(resolved)) {
|
||||||
|
throw new Error(`${label} contains unsupported targeting characters.`);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalTargetingString(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
pattern: RegExp,
|
||||||
|
): string | undefined {
|
||||||
|
const resolved = optionalString(value, label);
|
||||||
|
if (resolved !== undefined && (!resolved || !pattern.test(resolved))) {
|
||||||
|
throw new Error(`${label} contains unsupported targeting characters.`);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value: unknown, label = 'Value'): string | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (typeof value !== 'string') throw new Error(`${label} must be a string.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalBoolean(value: unknown, label = 'Value'): boolean | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (typeof value !== 'boolean') throw new Error(`${label} must be a boolean.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalBooleanOrString(value: unknown, label = 'Value'): boolean | string | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (typeof value !== 'boolean' && typeof value !== 'string') {
|
||||||
|
throw new Error(`${label} must be a boolean or string.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -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> => ({
|
||||||
|
|||||||
179
packages/mosaic/src/fleet/secure-file.spec.ts
Normal file
179
packages/mosaic/src/fleet/secure-file.spec.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
symlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
type PathLike,
|
||||||
|
} from 'node:fs';
|
||||||
|
import type * as NodeFs from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
interface FilesystemRaceState {
|
||||||
|
afterLstat?: (path: string) => void;
|
||||||
|
afterOpen?: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesystemRaceState = vi.hoisted<FilesystemRaceState>(() => ({}));
|
||||||
|
|
||||||
|
vi.mock('node:fs', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof NodeFs>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
lstatSync: (path: PathLike) => {
|
||||||
|
const result = actual.lstatSync(path);
|
||||||
|
filesystemRaceState.afterLstat?.(String(path));
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
openSync: (path: PathLike, flags: string | number, mode?: number) => {
|
||||||
|
const fd = actual.openSync(path, flags, mode);
|
||||||
|
filesystemRaceState.afterOpen?.(String(path));
|
||||||
|
return fd;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { assertCanonicalContainment, readRegularFileSecure } from './secure-file.js';
|
||||||
|
|
||||||
|
describe('secure file reads', () => {
|
||||||
|
let root: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
root = mkdtempSync(join(tmpdir(), 'mosaic-secure-file-'));
|
||||||
|
filesystemRaceState.afterLstat = undefined;
|
||||||
|
filesystemRaceState.afterOpen = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
filesystemRaceState.afterLstat = undefined;
|
||||||
|
filesystemRaceState.afterOpen = undefined;
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects canonical path escape', () => {
|
||||||
|
expect(() => assertCanonicalContainment(root, join(root, '..', 'outside'))).toThrow(
|
||||||
|
'path escapes managed root',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a symlink in a file ancestor', () => {
|
||||||
|
const external = join(root, 'external');
|
||||||
|
mkdirSync(external);
|
||||||
|
writeFileSync(join(external, 'file'), 'external\n');
|
||||||
|
symlinkSync(external, join(root, 'linked'));
|
||||||
|
|
||||||
|
expect(() => readRegularFileSecure(join(root, 'linked', 'file'), { root })).toThrow(
|
||||||
|
'path ancestor is a symbolic link',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a symlink target', () => {
|
||||||
|
const external = join(root, 'external');
|
||||||
|
writeFileSync(external, 'external\n');
|
||||||
|
symlinkSync(external, join(root, 'linked-file'));
|
||||||
|
|
||||||
|
expect(() => readRegularFileSecure(join(root, 'linked-file'), { root })).toThrow(
|
||||||
|
'file is a symbolic link',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps ancestor traversal bound when an opened directory is substituted', () => {
|
||||||
|
const tools = join(root, 'tools');
|
||||||
|
const displacedTools = join(root, 'tools.displaced');
|
||||||
|
const external = join(root, 'external');
|
||||||
|
const helper = join(tools, 'helper.sh');
|
||||||
|
mkdirSync(tools);
|
||||||
|
mkdirSync(external);
|
||||||
|
writeFileSync(helper, 'trusted\n', { mode: 0o755 });
|
||||||
|
writeFileSync(join(external, 'helper.sh'), 'external marker\n', { mode: 0o755 });
|
||||||
|
|
||||||
|
let substituted = false;
|
||||||
|
filesystemRaceState.afterOpen = (openedPath: string): void => {
|
||||||
|
if (substituted || !openedPath.startsWith('/proc/self/fd/')) return;
|
||||||
|
if (openedPath.split('/').at(-1) !== 'tools') return;
|
||||||
|
substituted = true;
|
||||||
|
renameSync(tools, displacedTools);
|
||||||
|
symlinkSync(external, tools);
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = readRegularFileSecure(helper, { root, executable: true });
|
||||||
|
expect(substituted).toBe(true);
|
||||||
|
expect(result.content.toString('utf8')).toBe('trusted\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps root selection bound when the opened root is substituted', () => {
|
||||||
|
const displacedRoot = `${root}.displaced`;
|
||||||
|
const externalRoot = `${root}.external`;
|
||||||
|
const helper = join(root, 'helper.sh');
|
||||||
|
mkdirSync(externalRoot);
|
||||||
|
writeFileSync(helper, 'trusted root\n', { mode: 0o755 });
|
||||||
|
writeFileSync(join(externalRoot, 'helper.sh'), 'external root marker\n', { mode: 0o755 });
|
||||||
|
|
||||||
|
let substituted = false;
|
||||||
|
filesystemRaceState.afterOpen = (openedPath: string): void => {
|
||||||
|
if (substituted || !openedPath.startsWith('/proc/self/fd/')) return;
|
||||||
|
const match = openedPath.match(/\/([^/]+)$/);
|
||||||
|
if (match?.[1] !== root.split('/').filter(Boolean).at(-1)) return;
|
||||||
|
substituted = true;
|
||||||
|
renameSync(root, displacedRoot);
|
||||||
|
symlinkSync(externalRoot, root);
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = readRegularFileSecure(helper, { root, executable: true });
|
||||||
|
expect(substituted).toBe(true);
|
||||||
|
expect(result.content.toString('utf8')).toBe('trusted root\n');
|
||||||
|
filesystemRaceState.afterOpen = undefined;
|
||||||
|
rmSync(root);
|
||||||
|
renameSync(displacedRoot, root);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps target read and execute validation bound to the opened file', () => {
|
||||||
|
const file = join(root, 'helper.sh');
|
||||||
|
const displaced = join(root, 'helper.displaced.sh');
|
||||||
|
const external = join(root, 'external-helper.sh');
|
||||||
|
writeFileSync(file, 'trusted target\n', { mode: 0o755 });
|
||||||
|
writeFileSync(external, 'external target marker\n', { mode: 0o755 });
|
||||||
|
|
||||||
|
let substituted = false;
|
||||||
|
filesystemRaceState.afterOpen = (openedPath: string): void => {
|
||||||
|
if (substituted || !openedPath.startsWith('/proc/self/fd/')) return;
|
||||||
|
if (openedPath.split('/').at(-1) !== 'helper.sh') return;
|
||||||
|
substituted = true;
|
||||||
|
renameSync(file, displaced);
|
||||||
|
symlinkSync(external, file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = readRegularFileSecure(file, { root, executable: true });
|
||||||
|
expect(substituted).toBe(true);
|
||||||
|
expect(result.content.toString('utf8')).toBe('trusted target\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a stable redacted executable error while retaining the error code', () => {
|
||||||
|
const file = join(root, 'helper.sh');
|
||||||
|
writeFileSync(file, '#!/bin/sh\n', { mode: 0o644 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
readRegularFileSecure(file, { root, executable: true });
|
||||||
|
throw new Error('expected executable validation to fail');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toMatchObject({ message: 'managed file is not executable', code: 'EACCES' });
|
||||||
|
expect(String(error)).not.toContain('/proc/self/fd/');
|
||||||
|
expect(String(error)).not.toContain(root);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses effective-identity execute access after regular-file validation', () => {
|
||||||
|
const file = join(root, 'helper.sh');
|
||||||
|
writeFileSync(file, '#!/bin/sh\n', { mode: 0o644 });
|
||||||
|
expect(() => readRegularFileSecure(file, { root, executable: true })).toThrow();
|
||||||
|
|
||||||
|
chmodSync(file, 0o755);
|
||||||
|
expect(readRegularFileSecure(file, { root, executable: true }).content.toString()).toBe(
|
||||||
|
'#!/bin/sh\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
242
packages/mosaic/src/fleet/secure-file.ts
Normal file
242
packages/mosaic/src/fleet/secure-file.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import {
|
||||||
|
accessSync,
|
||||||
|
closeSync,
|
||||||
|
constants,
|
||||||
|
fstatSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
openSync,
|
||||||
|
readFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { platform } from 'node:os';
|
||||||
|
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||||
|
|
||||||
|
export interface SecureFileReadOptions {
|
||||||
|
root: string;
|
||||||
|
maxBytes?: number;
|
||||||
|
executable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecureFileSnapshot {
|
||||||
|
content: Buffer;
|
||||||
|
mode: number;
|
||||||
|
dev: number | bigint;
|
||||||
|
ino: number | bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameIdentity(
|
||||||
|
left: { dev: number | bigint; ino: number | bigint },
|
||||||
|
right: { dev: number | bigint; ino: number | bigint },
|
||||||
|
): boolean {
|
||||||
|
return left.dev === right.dev && left.ino === right.ino;
|
||||||
|
}
|
||||||
|
|
||||||
|
function secureFilesystemError(message: string, cause: unknown): Error {
|
||||||
|
const error = new Error(message);
|
||||||
|
if (cause instanceof Error && 'code' in cause && typeof cause.code === 'string') {
|
||||||
|
Object.defineProperty(error, 'code', { value: cause.code, enumerable: true });
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDescriptors(descriptors: number[]): void {
|
||||||
|
for (const fd of descriptors.reverse()) {
|
||||||
|
try {
|
||||||
|
closeSync(fd);
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup must not replace the security decision already made.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function procDescriptorPath(fd: number, component?: string): string {
|
||||||
|
const descriptor = `/proc/self/fd/${fd}`;
|
||||||
|
return component === undefined ? descriptor : `${descriptor}/${component}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hold each directory while opening its child through Linux proc-fd. The only
|
||||||
|
* symlink followed is the kernel-owned descriptor link; O_NOFOLLOW protects
|
||||||
|
* every appended filesystem component from substitution.
|
||||||
|
*/
|
||||||
|
function openDirectoryChain(absoluteDirectory: string): { fd: number; descriptors: number[] } {
|
||||||
|
if (platform() !== 'linux') {
|
||||||
|
throw new Error('secure descriptor traversal is unsupported on this platform');
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptors: number[] = [];
|
||||||
|
try {
|
||||||
|
let fd = openSync(sep, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||||
|
descriptors.push(fd);
|
||||||
|
for (const component of absoluteDirectory.split(sep).filter(Boolean)) {
|
||||||
|
fd = openSync(
|
||||||
|
procDescriptorPath(fd, component),
|
||||||
|
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||||
|
);
|
||||||
|
descriptors.push(fd);
|
||||||
|
if (!fstatSync(fd).isDirectory()) {
|
||||||
|
throw new Error('secure descriptor traversal encountered a non-directory component');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { fd, descriptors };
|
||||||
|
} catch (error) {
|
||||||
|
closeDescriptors(descriptors);
|
||||||
|
throw secureFilesystemError(
|
||||||
|
'secure descriptor traversal failed: symbolic link, unavailable, or not a directory',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFileBeneathRoot(root: string, target: string): { fd: number; descriptors: number[] } {
|
||||||
|
const canonicalRoot = resolve(root);
|
||||||
|
const canonicalTarget = resolve(target);
|
||||||
|
assertCanonicalContainment(canonicalRoot, canonicalTarget);
|
||||||
|
const components = relative(canonicalRoot, canonicalTarget).split(sep).filter(Boolean);
|
||||||
|
const fileName = components.pop();
|
||||||
|
if (fileName === undefined) throw new Error('managed file path names the managed root');
|
||||||
|
|
||||||
|
const rootChain = openDirectoryChain(canonicalRoot);
|
||||||
|
try {
|
||||||
|
let parentFd = rootChain.fd;
|
||||||
|
for (const component of components) {
|
||||||
|
try {
|
||||||
|
parentFd = openSync(
|
||||||
|
procDescriptorPath(parentFd, component),
|
||||||
|
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw secureFilesystemError(
|
||||||
|
'path ancestor is a symbolic link, unavailable, or not a directory',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rootChain.descriptors.push(parentFd);
|
||||||
|
if (!fstatSync(parentFd).isDirectory()) {
|
||||||
|
throw new Error('path ancestor is a symbolic link or not a directory');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let fd: number;
|
||||||
|
try {
|
||||||
|
fd = openSync(
|
||||||
|
procDescriptorPath(parentFd, fileName),
|
||||||
|
constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw secureFilesystemError('file is a symbolic link or unavailable', error);
|
||||||
|
}
|
||||||
|
rootChain.descriptors.push(fd);
|
||||||
|
return { fd, descriptors: rootChain.descriptors };
|
||||||
|
} catch (error) {
|
||||||
|
closeDescriptors(rootChain.descriptors);
|
||||||
|
if (error instanceof Error) throw error;
|
||||||
|
throw new Error('secure managed file open failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCanonicalContainment(root: string, target: string): void {
|
||||||
|
const canonicalRoot = resolve(root);
|
||||||
|
const canonicalTarget = resolve(target);
|
||||||
|
const rel = relative(canonicalRoot, canonicalTarget);
|
||||||
|
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
||||||
|
throw new Error(`path escapes managed root ${canonicalRoot}: ${canonicalTarget}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject every symlink from the filesystem root through the target's parent. */
|
||||||
|
export function assertNoSymlinkAncestors(target: string): void {
|
||||||
|
const absolute = resolve(target);
|
||||||
|
const parent = dirname(absolute);
|
||||||
|
const pieces = parent.split(sep).filter(Boolean);
|
||||||
|
let cursor: string = sep;
|
||||||
|
for (const piece of pieces) {
|
||||||
|
cursor = resolve(cursor, piece);
|
||||||
|
const stat = lstatSync(cursor);
|
||||||
|
if (stat.isSymbolicLink()) throw new Error(`path ancestor is a symbolic link: ${cursor}`);
|
||||||
|
if (!stat.isDirectory()) throw new Error(`path ancestor is not a directory: ${cursor}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureManagedDirectory(root: string, directory: string): void {
|
||||||
|
assertCanonicalContainment(root, directory);
|
||||||
|
const canonicalRoot = resolve(root);
|
||||||
|
const canonicalDirectory = resolve(directory);
|
||||||
|
assertNoSymlinkAncestors(canonicalRoot);
|
||||||
|
try {
|
||||||
|
const rootStat = lstatSync(canonicalRoot);
|
||||||
|
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
||||||
|
throw new Error(`managed root is not a real directory: ${canonicalRoot}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
|
||||||
|
mkdirSync(canonicalRoot, { mode: 0o700 });
|
||||||
|
const rootStat = lstatSync(canonicalRoot);
|
||||||
|
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
||||||
|
throw new Error(`managed root creation was redirected: ${canonicalRoot}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rel = relative(canonicalRoot, canonicalDirectory);
|
||||||
|
let cursor = canonicalRoot;
|
||||||
|
for (const piece of rel.split(sep).filter(Boolean)) {
|
||||||
|
cursor = resolve(cursor, piece);
|
||||||
|
try {
|
||||||
|
const stat = lstatSync(cursor);
|
||||||
|
if (stat.isSymbolicLink()) throw new Error(`path ancestor is a symbolic link: ${cursor}`);
|
||||||
|
if (!stat.isDirectory()) throw new Error(`path ancestor is not a directory: ${cursor}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
|
||||||
|
mkdirSync(cursor, { mode: 0o700 });
|
||||||
|
const created = lstatSync(cursor);
|
||||||
|
if (!created.isDirectory() || created.isSymbolicLink()) {
|
||||||
|
throw new Error(`managed directory creation was redirected: ${cursor}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a regular file through an O_NOFOLLOW descriptor. The inode is checked
|
||||||
|
* before and after access/read, and executable access is tested against the
|
||||||
|
* already-open descriptor so path replacement cannot redirect the check.
|
||||||
|
*/
|
||||||
|
export function readRegularFileSecure(
|
||||||
|
path: string,
|
||||||
|
options: SecureFileReadOptions,
|
||||||
|
): SecureFileSnapshot {
|
||||||
|
const openedFile = openFileBeneathRoot(options.root, path);
|
||||||
|
try {
|
||||||
|
const opened = fstatSync(openedFile.fd);
|
||||||
|
if (!opened.isFile()) throw new Error('managed file is not a regular file');
|
||||||
|
if (options.maxBytes !== undefined && opened.size > options.maxBytes) {
|
||||||
|
throw new Error(`managed file exceeds ${options.maxBytes} bytes`);
|
||||||
|
}
|
||||||
|
if (options.executable) {
|
||||||
|
try {
|
||||||
|
accessSync(procDescriptorPath(openedFile.fd), constants.X_OK);
|
||||||
|
} catch (error) {
|
||||||
|
throw secureFilesystemError('managed file is not executable', error);
|
||||||
|
}
|
||||||
|
const afterAccess = fstatSync(openedFile.fd);
|
||||||
|
if (!afterAccess.isFile() || !sameIdentity(opened, afterAccess)) {
|
||||||
|
throw new Error('managed file changed during executable access check');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = readFileSync(openedFile.fd);
|
||||||
|
const after = fstatSync(openedFile.fd);
|
||||||
|
if (!after.isFile() || !sameIdentity(opened, after)) {
|
||||||
|
throw new Error('managed file changed during secure read');
|
||||||
|
}
|
||||||
|
if (options.maxBytes !== undefined && content.byteLength > options.maxBytes) {
|
||||||
|
throw new Error(`managed file exceeds ${options.maxBytes} bytes`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
mode: Number(opened.mode),
|
||||||
|
dev: opened.dev,
|
||||||
|
ino: opened.ino,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
closeDescriptors(openedFile.descriptors);
|
||||||
|
}
|
||||||
|
}
|
||||||
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
@@ -1,5 +1,18 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
import {
|
||||||
|
chmodSync,
|
||||||
|
existsSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdtempSync,
|
||||||
|
mkdirSync,
|
||||||
|
readdirSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
symlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import {
|
import {
|
||||||
@@ -11,8 +24,8 @@ import {
|
|||||||
readInstalledFrameworkVersion,
|
readInstalledFrameworkVersion,
|
||||||
readBundledFrameworkVersion,
|
readBundledFrameworkVersion,
|
||||||
checkFrameworkDrift,
|
checkFrameworkDrift,
|
||||||
|
repairFleetCommsTools,
|
||||||
} from './update-checker.js';
|
} from './update-checker.js';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
|
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
|
||||||
@@ -66,6 +79,7 @@ describe('readRosterAgentNames', () => {
|
|||||||
join(home, 'fleet', 'roster.yaml'),
|
join(home, 'fleet', 'roster.yaml'),
|
||||||
[
|
[
|
||||||
'version: 1',
|
'version: 1',
|
||||||
|
'transport: tmux',
|
||||||
'agents:',
|
'agents:',
|
||||||
' - name: orchestrator',
|
' - name: orchestrator',
|
||||||
' runtime: pi',
|
' runtime: pi',
|
||||||
@@ -77,6 +91,212 @@ describe('readRosterAgentNames', () => {
|
|||||||
);
|
);
|
||||||
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0', 'reviewer-1']);
|
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0', 'reviewer-1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('extracts agent names from a JSON-only roster', () => {
|
||||||
|
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(home, 'fleet', 'roster.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
transport: 'tmux',
|
||||||
|
agents: [
|
||||||
|
{ name: 'orchestrator', runtime: 'pi', class: 'orchestrator' },
|
||||||
|
{ name: 'coder0', runtime: 'claude', class: 'worker' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('repairFleetCommsTools', () => {
|
||||||
|
let root: string;
|
||||||
|
let framework: string;
|
||||||
|
let home: string;
|
||||||
|
const toolsContent = '# tools\n<!-- fleet-comms-contract: 1 -->\n';
|
||||||
|
const helperContent = '#!/bin/sh\nexit 0\n';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
root = mkdtempSync(join(tmpdir(), 'mosaic-tools-repair-'));
|
||||||
|
framework = join(root, 'framework');
|
||||||
|
home = join(root, 'home');
|
||||||
|
mkdirSync(join(framework, 'defaults'), { recursive: true });
|
||||||
|
mkdirSync(join(framework, 'tools', 'tmux'), { recursive: true });
|
||||||
|
writeFileSync(join(framework, 'defaults', 'TOOLS.md'), toolsContent);
|
||||||
|
const helper = join(framework, 'tools', 'tmux', 'agent-send.sh');
|
||||||
|
writeFileSync(helper, helperContent);
|
||||||
|
chmodSync(helper, 0o755);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it('restores a partially deleted current-version installation without package updates', () => {
|
||||||
|
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||||
|
writeFileSync(join(home, 'TOOLS.md'), toolsContent);
|
||||||
|
|
||||||
|
const result = repairFleetCommsTools(framework, home);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: true, changed: true });
|
||||||
|
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(toolsContent);
|
||||||
|
expect(readFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 'utf8')).toBe(helperContent);
|
||||||
|
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).mode & 0o111).not.toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a digest-qualified no-clobber backup and is idempotent', () => {
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
const stale = '# user tools\n';
|
||||||
|
writeFileSync(join(home, 'TOOLS.md'), stale);
|
||||||
|
|
||||||
|
const first = repairFleetCommsTools(framework, home);
|
||||||
|
expect(first).toMatchObject({ ok: true, changed: true });
|
||||||
|
expect(first.backupPath).toMatch(/\.pre-fleet-comms-[a-f0-9]{16}\.bak$/);
|
||||||
|
expect(readFileSync(first.backupPath!, 'utf8')).toBe(stale);
|
||||||
|
|
||||||
|
const second = repairFleetCommsTools(framework, home);
|
||||||
|
expect(second).toEqual({ ok: true, changed: false, backupPath: undefined });
|
||||||
|
expect(readFileSync(first.backupPath!, 'utf8')).toBe(stale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an installed helper symlink without modifying its target', () => {
|
||||||
|
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||||
|
writeFileSync(join(home, 'TOOLS.md'), toolsContent);
|
||||||
|
const target = join(root, 'external-helper');
|
||||||
|
writeFileSync(target, 'do not touch\n');
|
||||||
|
symlinkSync(target, join(home, 'tools', 'tmux', 'agent-send.sh'));
|
||||||
|
|
||||||
|
const result = repairFleetCommsTools(framework, home);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, changed: false });
|
||||||
|
expect(result.reason).toContain('symbolic link');
|
||||||
|
expect(readFileSync(target, 'utf8')).toBe('do not touch\n');
|
||||||
|
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).isSymbolicLink()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a helper directory before replacing stale TOOLS content', () => {
|
||||||
|
mkdirSync(join(home, 'tools', 'tmux', 'agent-send.sh'), { recursive: true });
|
||||||
|
const stale = '# user tools\n';
|
||||||
|
writeFileSync(join(home, 'TOOLS.md'), stale);
|
||||||
|
|
||||||
|
const result = repairFleetCommsTools(framework, home);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, changed: false });
|
||||||
|
expect(result.reason).toContain('not a regular file');
|
||||||
|
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a pre-existing digest backup whose bytes do not match', () => {
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
const stale = '# user tools\n';
|
||||||
|
writeFileSync(join(home, 'TOOLS.md'), stale);
|
||||||
|
const digest = createHash('sha256').update(stale).digest('hex').slice(0, 16);
|
||||||
|
writeFileSync(join(home, `TOOLS.md.pre-fleet-comms-${digest}.bak`), 'collision\n');
|
||||||
|
|
||||||
|
const result = repairFleetCommsTools(framework, home);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, changed: false });
|
||||||
|
expect(result.reason).toContain('backup collision');
|
||||||
|
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a symlink in each installed destination ancestor without external writes', () => {
|
||||||
|
const cases = [
|
||||||
|
{ name: 'home', prefix: join(root, 'linked-home'), suffix: '' },
|
||||||
|
{ name: 'tools', prefix: join(root, 'real-home'), suffix: 'tools' },
|
||||||
|
{ name: 'tmux', prefix: join(root, 'real-home'), suffix: join('tools', 'tmux') },
|
||||||
|
];
|
||||||
|
for (const testCase of cases) {
|
||||||
|
const external = join(root, `external-${testCase.name}`);
|
||||||
|
mkdirSync(external, { recursive: true });
|
||||||
|
const targetHome =
|
||||||
|
testCase.name === 'home' ? testCase.prefix : join(root, `installed-${testCase.name}`);
|
||||||
|
if (testCase.name === 'home') {
|
||||||
|
symlinkSync(external, targetHome);
|
||||||
|
} else {
|
||||||
|
mkdirSync(targetHome, { recursive: true });
|
||||||
|
const linkPath = join(targetHome, testCase.suffix);
|
||||||
|
mkdirSync(join(linkPath, '..'), { recursive: true });
|
||||||
|
symlinkSync(external, linkPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = repairFleetCommsTools(framework, targetHome);
|
||||||
|
|
||||||
|
expect(result, testCase.name).toMatchObject({ ok: false, changed: false });
|
||||||
|
expect(result.reason, testCase.name).toContain('symbolic link');
|
||||||
|
expect(readdirSync(external), testCase.name).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back the backup and exact TOOLS bytes/mode when helper commit fails', () => {
|
||||||
|
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||||
|
const staleTools = '# user tools\n';
|
||||||
|
const staleHelper = '#!/bin/sh\nexit 17\n';
|
||||||
|
writeFileSync(join(home, 'TOOLS.md'), staleTools, { mode: 0o640 });
|
||||||
|
writeFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), staleHelper, { mode: 0o710 });
|
||||||
|
|
||||||
|
const result = repairFleetCommsTools(framework, home, {
|
||||||
|
beforeCommit(which) {
|
||||||
|
if (which === 'helper') throw new Error('injected helper commit failure');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, changed: false });
|
||||||
|
expect(result.backupPath).toBeUndefined();
|
||||||
|
expect(result.reason).toContain('injected helper commit failure');
|
||||||
|
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(staleTools);
|
||||||
|
expect(statSync(join(home, 'TOOLS.md')).mode & 0o777).toBe(0o640);
|
||||||
|
expect(readFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 'utf8')).toBe(staleHelper);
|
||||||
|
expect(statSync(join(home, 'tools', 'tmux', 'agent-send.sh')).mode & 0o777).toBe(0o710);
|
||||||
|
expect(readdirSync(home).filter((name) => name.includes('pre-fleet-comms'))).toEqual([]);
|
||||||
|
expect(
|
||||||
|
readdirSync(home).some((name) => name.includes('.repair-')) ||
|
||||||
|
readdirSync(join(home, 'tools', 'tmux')).some((name) => name.includes('.repair-')),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back initially absent destinations and created directories on commit failure', () => {
|
||||||
|
const result = repairFleetCommsTools(framework, home, {
|
||||||
|
beforeCommit(which) {
|
||||||
|
if (which === 'helper') throw new Error('injected absent helper failure');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, changed: false });
|
||||||
|
expect(result.reason).toContain('injected absent helper failure');
|
||||||
|
expect(existsSync(home)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not persist a backup or replacement when backup commit fails', () => {
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
const stale = '# user tools\n';
|
||||||
|
writeFileSync(join(home, 'TOOLS.md'), stale, { mode: 0o640 });
|
||||||
|
|
||||||
|
const result = repairFleetCommsTools(framework, home, {
|
||||||
|
beforeCommit(which) {
|
||||||
|
if (which === 'backup') throw new Error('injected backup commit failure');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, changed: false });
|
||||||
|
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
|
||||||
|
expect(statSync(join(home, 'TOOLS.md')).mode & 0o777).toBe(0o640);
|
||||||
|
expect(readdirSync(home).filter((name) => name.includes('pre-fleet-comms'))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails before writes when bundled source paths traverse a symlink ancestor', () => {
|
||||||
|
const external = join(root, 'external-source');
|
||||||
|
mkdirSync(join(external, 'defaults'), { recursive: true });
|
||||||
|
mkdirSync(join(external, 'tools', 'tmux'), { recursive: true });
|
||||||
|
writeFileSync(join(external, 'defaults', 'TOOLS.md'), toolsContent);
|
||||||
|
writeFileSync(join(external, 'tools', 'tmux', 'agent-send.sh'), helperContent, { mode: 0o755 });
|
||||||
|
const linkedFramework = join(root, 'linked-framework');
|
||||||
|
symlinkSync(external, linkedFramework);
|
||||||
|
|
||||||
|
const result = repairFleetCommsTools(linkedFramework, home);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, changed: false });
|
||||||
|
expect(result.reason).toContain('symbolic link');
|
||||||
|
expect(existsSync(home)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('runFrameworkReseed', () => {
|
describe('runFrameworkReseed', () => {
|
||||||
|
|||||||
@@ -15,16 +15,34 @@
|
|||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import {
|
import {
|
||||||
existsSync,
|
closeSync,
|
||||||
mkdirSync,
|
constants,
|
||||||
readFileSync,
|
|
||||||
writeFileSync,
|
|
||||||
readdirSync,
|
|
||||||
copyFileSync,
|
copyFileSync,
|
||||||
|
existsSync,
|
||||||
|
fchmodSync,
|
||||||
|
fsyncSync,
|
||||||
|
linkSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
openSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
renameSync,
|
||||||
|
rmdirSync,
|
||||||
|
unlinkSync,
|
||||||
|
writeFileSync,
|
||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { basename, dirname, join, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { parseFleetRosterV1, resolveInstalledFleetRosterPath } from '../fleet/fleet-roster-v1.js';
|
||||||
|
import {
|
||||||
|
assertCanonicalContainment,
|
||||||
|
assertNoSymlinkAncestors,
|
||||||
|
ensureManagedDirectory,
|
||||||
|
readRegularFileSecure,
|
||||||
|
} from '../fleet/secure-file.js';
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -54,6 +72,10 @@ const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
|
|||||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
const NETWORK_TIMEOUT_MS = 5_000;
|
const NETWORK_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
function isNodeErrorCode(error: unknown, code: string): boolean {
|
||||||
|
return error instanceof Error && 'code' in error && error.code === code;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function npmExec(args: string, timeoutMs = NETWORK_TIMEOUT_MS): string {
|
function npmExec(args: string, timeoutMs = NETWORK_TIMEOUT_MS): string {
|
||||||
@@ -500,6 +522,346 @@ export function buildReseedCommand(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToolsRepairResult {
|
||||||
|
ok: boolean;
|
||||||
|
changed: boolean;
|
||||||
|
backupPath?: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolsRepairHooks {
|
||||||
|
beforeCommit?: (which: 'backup' | 'tools' | 'helper') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalSecureFile(
|
||||||
|
path: string,
|
||||||
|
root: string,
|
||||||
|
): ReturnType<typeof readRegularFileSecure> | undefined {
|
||||||
|
try {
|
||||||
|
return readRegularFileSecure(path, { root });
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeErrorCode(error, 'ENOENT')) return undefined;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageManagedFile(
|
||||||
|
root: string,
|
||||||
|
directory: string,
|
||||||
|
target: string,
|
||||||
|
content: Buffer,
|
||||||
|
mode: number,
|
||||||
|
): string {
|
||||||
|
assertCanonicalContainment(root, target);
|
||||||
|
ensureManagedDirectory(root, directory);
|
||||||
|
assertNoSymlinkAncestors(target);
|
||||||
|
const staged = join(directory, `.${basename(target)}.repair-${process.pid}-${cryptoRandom()}`);
|
||||||
|
assertCanonicalContainment(root, staged);
|
||||||
|
const fd = openSync(
|
||||||
|
staged,
|
||||||
|
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||||
|
0o600,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
writeFileSync(fd, content);
|
||||||
|
fchmodSync(fd, mode);
|
||||||
|
fsyncSync(fd);
|
||||||
|
} finally {
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
return staged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cryptoRandom(): string {
|
||||||
|
return randomBytes(8).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManagedOriginal {
|
||||||
|
path: string;
|
||||||
|
snapshot?: ReturnType<typeof readRegularFileSecure>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertManagedOriginalUnchanged(original: ManagedOriginal, root: string): void {
|
||||||
|
if (!original.snapshot) {
|
||||||
|
try {
|
||||||
|
lstatSync(original.path);
|
||||||
|
throw new Error(`repair destination appeared during staging: ${original.path}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeErrorCode(error, 'ENOENT')) return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const current = readRegularFileSecure(original.path, { root });
|
||||||
|
if (
|
||||||
|
current.dev !== original.snapshot.dev ||
|
||||||
|
current.ino !== original.snapshot.ino ||
|
||||||
|
current.mode !== original.snapshot.mode ||
|
||||||
|
!current.content.equals(original.snapshot.content)
|
||||||
|
) {
|
||||||
|
throw new Error(`repair destination changed during staging: ${original.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installBackupNoClobber(staged: string, target: string, root: string): void {
|
||||||
|
assertCanonicalContainment(root, target);
|
||||||
|
assertNoSymlinkAncestors(target);
|
||||||
|
try {
|
||||||
|
lstatSync(target);
|
||||||
|
throw new Error(`digest-qualified backup collision at ${target}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
||||||
|
}
|
||||||
|
linkSync(staged, target);
|
||||||
|
unlinkSync(staged);
|
||||||
|
}
|
||||||
|
|
||||||
|
function atomicInstall(staged: string, target: string, root: string): void {
|
||||||
|
assertCanonicalContainment(root, target);
|
||||||
|
assertNoSymlinkAncestors(target);
|
||||||
|
try {
|
||||||
|
const current = lstatSync(target);
|
||||||
|
if (current.isSymbolicLink() || !current.isFile()) {
|
||||||
|
throw new Error(`repair destination is not a regular file: ${target}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
||||||
|
}
|
||||||
|
renameSync(staged, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly repair the user-owned TOOLS contract and required helper from the
|
||||||
|
* bundled current framework. Existing divergent TOOLS content is preserved in
|
||||||
|
* a digest-qualified no-clobber backup; repeated repairs are idempotent.
|
||||||
|
*/
|
||||||
|
export function repairFleetCommsTools(
|
||||||
|
frameworkRoot = resolveBundledFrameworkRoot(),
|
||||||
|
mosaicHome = join(homedir(), '.config', 'mosaic'),
|
||||||
|
hooks: ToolsRepairHooks = {},
|
||||||
|
): ToolsRepairResult {
|
||||||
|
const sourceTools = join(frameworkRoot, 'defaults', 'TOOLS.md');
|
||||||
|
const sourceHelper = join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh');
|
||||||
|
const installedTools = join(mosaicHome, 'TOOLS.md');
|
||||||
|
const helperDirectory = join(mosaicHome, 'tools', 'tmux');
|
||||||
|
const installedHelper = join(helperDirectory, 'agent-send.sh');
|
||||||
|
let stagedBackup: string | undefined;
|
||||||
|
let stagedTools: string | undefined;
|
||||||
|
let stagedHelper: string | undefined;
|
||||||
|
let rollbackTools: string | undefined;
|
||||||
|
let rollbackHelper: string | undefined;
|
||||||
|
let committedBackup = false;
|
||||||
|
let committedTools = false;
|
||||||
|
let committedHelper = false;
|
||||||
|
let createdHome = false;
|
||||||
|
let createdToolsDirectory = false;
|
||||||
|
let createdHelperDirectory = false;
|
||||||
|
let backupPath: string | undefined;
|
||||||
|
let toolsOriginal: ManagedOriginal | undefined;
|
||||||
|
let helperOriginal: ManagedOriginal | undefined;
|
||||||
|
try {
|
||||||
|
const sourceToolsSnapshot = readRegularFileSecure(sourceTools, { root: frameworkRoot });
|
||||||
|
const sourceHelperSnapshot = readRegularFileSecure(sourceHelper, {
|
||||||
|
root: frameworkRoot,
|
||||||
|
executable: true,
|
||||||
|
});
|
||||||
|
if (!sourceToolsSnapshot.content.includes('<!-- fleet-comms-contract: 1 -->')) {
|
||||||
|
return { ok: false, changed: false, reason: 'bundled TOOLS contract has wrong version' };
|
||||||
|
}
|
||||||
|
|
||||||
|
assertCanonicalContainment(mosaicHome, installedTools);
|
||||||
|
assertCanonicalContainment(mosaicHome, installedHelper);
|
||||||
|
assertNoSymlinkAncestors(mosaicHome);
|
||||||
|
const homeExisted = existsSync(mosaicHome);
|
||||||
|
const toolsDirectory = dirname(helperDirectory);
|
||||||
|
const toolsDirectoryExisted = existsSync(toolsDirectory);
|
||||||
|
const helperDirectoryExisted = existsSync(helperDirectory);
|
||||||
|
if (homeExisted) {
|
||||||
|
const homeStat = lstatSync(mosaicHome);
|
||||||
|
if (homeStat.isSymbolicLink()) {
|
||||||
|
throw new Error(`managed root is a symbolic link: ${mosaicHome}`);
|
||||||
|
}
|
||||||
|
if (!homeStat.isDirectory()) {
|
||||||
|
throw new Error(`managed root is not a real directory: ${mosaicHome}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const installedToolsSnapshot = homeExisted
|
||||||
|
? optionalSecureFile(installedTools, mosaicHome)
|
||||||
|
: undefined;
|
||||||
|
let installedHelperSnapshot: ReturnType<typeof readRegularFileSecure> | undefined;
|
||||||
|
let installedHelperExecutable = false;
|
||||||
|
if (homeExisted) {
|
||||||
|
try {
|
||||||
|
installedHelperSnapshot = readRegularFileSecure(installedHelper, {
|
||||||
|
root: mosaicHome,
|
||||||
|
executable: true,
|
||||||
|
});
|
||||||
|
installedHelperExecutable = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isNodeErrorCode(error, 'ENOENT') && !isNodeErrorCode(error, 'EACCES')) throw error;
|
||||||
|
if (isNodeErrorCode(error, 'EACCES')) {
|
||||||
|
installedHelperSnapshot = optionalSecureFile(installedHelper, mosaicHome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolsChanged = !installedToolsSnapshot?.content.equals(sourceToolsSnapshot.content);
|
||||||
|
const helperChanged =
|
||||||
|
!installedHelperExecutable ||
|
||||||
|
!installedHelperSnapshot?.content.equals(sourceHelperSnapshot.content);
|
||||||
|
if (!toolsChanged && !helperChanged) return { ok: true, changed: false };
|
||||||
|
|
||||||
|
toolsOriginal = { path: installedTools, snapshot: installedToolsSnapshot };
|
||||||
|
helperOriginal = { path: installedHelper, snapshot: installedHelperSnapshot };
|
||||||
|
|
||||||
|
ensureManagedDirectory(dirname(mosaicHome), mosaicHome);
|
||||||
|
createdHome = !homeExisted;
|
||||||
|
ensureManagedDirectory(mosaicHome, helperDirectory);
|
||||||
|
createdToolsDirectory = !toolsDirectoryExisted;
|
||||||
|
createdHelperDirectory = !helperDirectoryExisted;
|
||||||
|
|
||||||
|
if (toolsChanged) {
|
||||||
|
stagedTools = stageManagedFile(
|
||||||
|
mosaicHome,
|
||||||
|
mosaicHome,
|
||||||
|
installedTools,
|
||||||
|
sourceToolsSnapshot.content,
|
||||||
|
sourceToolsSnapshot.mode & 0o777,
|
||||||
|
);
|
||||||
|
if (installedToolsSnapshot) {
|
||||||
|
const digest = createHash('sha256')
|
||||||
|
.update(installedToolsSnapshot.content)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 16);
|
||||||
|
backupPath = `${installedTools}.pre-fleet-comms-${digest}.bak`;
|
||||||
|
const existingBackup = optionalSecureFile(backupPath, mosaicHome);
|
||||||
|
if (existingBackup && !existingBackup.content.equals(installedToolsSnapshot.content)) {
|
||||||
|
throw new Error(`digest-qualified backup collision at ${backupPath}`);
|
||||||
|
}
|
||||||
|
if (!existingBackup) {
|
||||||
|
stagedBackup = stageManagedFile(
|
||||||
|
mosaicHome,
|
||||||
|
mosaicHome,
|
||||||
|
backupPath,
|
||||||
|
installedToolsSnapshot.content,
|
||||||
|
installedToolsSnapshot.mode & 0o777,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rollbackTools = stageManagedFile(
|
||||||
|
mosaicHome,
|
||||||
|
mosaicHome,
|
||||||
|
installedTools,
|
||||||
|
installedToolsSnapshot.content,
|
||||||
|
installedToolsSnapshot.mode & 0o777,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (helperChanged) {
|
||||||
|
stagedHelper = stageManagedFile(
|
||||||
|
mosaicHome,
|
||||||
|
helperDirectory,
|
||||||
|
installedHelper,
|
||||||
|
sourceHelperSnapshot.content,
|
||||||
|
sourceHelperSnapshot.mode & 0o777,
|
||||||
|
);
|
||||||
|
if (installedHelperSnapshot) {
|
||||||
|
rollbackHelper = stageManagedFile(
|
||||||
|
mosaicHome,
|
||||||
|
helperDirectory,
|
||||||
|
installedHelper,
|
||||||
|
installedHelperSnapshot.content,
|
||||||
|
installedHelperSnapshot.mode & 0o777,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||||
|
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||||
|
if (stagedBackup && backupPath) {
|
||||||
|
hooks.beforeCommit?.('backup');
|
||||||
|
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||||
|
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||||
|
installBackupNoClobber(stagedBackup, backupPath, mosaicHome);
|
||||||
|
stagedBackup = undefined;
|
||||||
|
committedBackup = true;
|
||||||
|
}
|
||||||
|
if (stagedTools) {
|
||||||
|
hooks.beforeCommit?.('tools');
|
||||||
|
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||||
|
atomicInstall(stagedTools, installedTools, mosaicHome);
|
||||||
|
stagedTools = undefined;
|
||||||
|
committedTools = true;
|
||||||
|
}
|
||||||
|
if (stagedHelper) {
|
||||||
|
hooks.beforeCommit?.('helper');
|
||||||
|
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||||
|
atomicInstall(stagedHelper, installedHelper, mosaicHome);
|
||||||
|
stagedHelper = undefined;
|
||||||
|
committedHelper = true;
|
||||||
|
}
|
||||||
|
if (rollbackTools) unlinkSync(rollbackTools);
|
||||||
|
if (rollbackHelper) unlinkSync(rollbackHelper);
|
||||||
|
return { ok: true, changed: true, backupPath };
|
||||||
|
} catch (error) {
|
||||||
|
const failures: string[] = [];
|
||||||
|
try {
|
||||||
|
if (committedHelper) {
|
||||||
|
if (rollbackHelper) atomicInstall(rollbackHelper, installedHelper, mosaicHome);
|
||||||
|
else unlinkSync(installedHelper);
|
||||||
|
rollbackHelper = undefined;
|
||||||
|
}
|
||||||
|
} catch (rollbackError) {
|
||||||
|
failures.push(`helper rollback failed: ${String(rollbackError)}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (committedTools) {
|
||||||
|
if (rollbackTools) atomicInstall(rollbackTools, installedTools, mosaicHome);
|
||||||
|
else unlinkSync(installedTools);
|
||||||
|
rollbackTools = undefined;
|
||||||
|
}
|
||||||
|
} catch (rollbackError) {
|
||||||
|
failures.push(`TOOLS rollback failed: ${String(rollbackError)}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (committedBackup && backupPath) {
|
||||||
|
unlinkSync(backupPath);
|
||||||
|
committedBackup = false;
|
||||||
|
}
|
||||||
|
} catch (rollbackError) {
|
||||||
|
failures.push(`backup rollback failed: ${String(rollbackError)}`);
|
||||||
|
}
|
||||||
|
for (const staged of [stagedBackup, stagedTools, stagedHelper, rollbackTools, rollbackHelper]) {
|
||||||
|
if (!staged) continue;
|
||||||
|
try {
|
||||||
|
unlinkSync(staged);
|
||||||
|
} catch {
|
||||||
|
failures.push(`staging cleanup failed: ${staged}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [created, directory] of [
|
||||||
|
[createdHelperDirectory, helperDirectory],
|
||||||
|
[createdToolsDirectory, dirname(helperDirectory)],
|
||||||
|
[createdHome, mosaicHome],
|
||||||
|
] as const) {
|
||||||
|
if (!created) continue;
|
||||||
|
try {
|
||||||
|
rmdirSync(directory);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
if (!isNodeErrorCode(cleanupError, 'ENOENT')) {
|
||||||
|
failures.push(`directory cleanup failed: ${directory}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const reason = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
changed: failures.length > 0,
|
||||||
|
backupPath: committedBackup ? backupPath : undefined,
|
||||||
|
reason: failures.length > 0 ? `${reason}; ${failures.join('; ')}` : reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-seed the framework from the freshly-installed package. Returns a result
|
* Re-seed the framework from the freshly-installed package. Returns a result
|
||||||
* describing what happened (so callers can message + decide on relaunch).
|
* describing what happened (so callers can message + decide on relaunch).
|
||||||
@@ -591,25 +953,20 @@ export function checkFrameworkDrift(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Best-effort parse of the fleet roster for agent names (used to relaunch
|
* Canonically parse the installed fleet roster for relaunch targets. JSON is
|
||||||
* durable agents after a re-seed). Returns [] when no roster exists.
|
* considered only when roster.yaml is genuinely absent; all other failures
|
||||||
|
* return no targets rather than guessing.
|
||||||
*/
|
*/
|
||||||
export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] {
|
export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] {
|
||||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
|
||||||
if (!existsSync(rosterPath)) return [];
|
|
||||||
let text: string;
|
|
||||||
try {
|
try {
|
||||||
text = readFileSync(rosterPath, 'utf-8');
|
const rosterPath = resolveInstalledFleetRosterPath(mosaicHome);
|
||||||
|
const source = readFileSync(rosterPath, 'utf8');
|
||||||
|
return parseFleetRosterV1(source, rosterPath.endsWith('.json') ? 'json' : 'yaml').agents.map(
|
||||||
|
(agent) => agent.name,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
// Roster agents are listed as `- name: <id>` entries under `agents:`.
|
|
||||||
const names: string[] = [];
|
|
||||||
for (const line of text.split('\n')) {
|
|
||||||
const m = line.match(/^\s*-?\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
|
|
||||||
if (m && m[1]) names.push(m[1]);
|
|
||||||
}
|
|
||||||
return names;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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