Compare commits
10 Commits
docs/758-f
...
fix/812-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb79bff91f | ||
|
|
c3a8383330 | ||
|
|
3585cdfad6 | ||
| 9ddc6fbda8 | |||
| 31607a4af6 | |||
| 32a0ffba13 | |||
| 8536454257 | |||
| 4f29cc604d | |||
| 3be443c96d | |||
| 59f5f51ffd |
@@ -42,6 +42,27 @@ steps:
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
||||
|
||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
|
||||
# keep mode is a single cp-based path that must not depend on rsync), and that a
|
||||
# corrupt/empty/missing manifest aborts fail-closed leaving operator files
|
||||
# untouched (B2/B3). The rollback gate proves a mid-sync failure is rolled back
|
||||
# from the pre-update snapshot (B1). The durable-snapshot gate (#791 PR2) proves
|
||||
# the retained, operator-scoped pre-update backup is taken before any mutation
|
||||
# (0700/0600, secret never logged, retention-pruned) and that the post-sync
|
||||
# verify net restores any operator file a manifest bug lets the sync touch. The
|
||||
# migration matrix pins the v2→v3 contract-file semantics. Pure bash, no
|
||||
# node_modules — runs early alongside sanitization.
|
||||
upgrade-guard:
|
||||
image: *node_image
|
||||
commands:
|
||||
- apk add --no-cache bash rsync
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
commands:
|
||||
@@ -50,6 +71,7 @@ steps:
|
||||
depends_on:
|
||||
- install
|
||||
- sanitization
|
||||
- upgrade-guard
|
||||
|
||||
# lint, format, and test are independent — run in parallel after typecheck
|
||||
lint:
|
||||
|
||||
@@ -2,24 +2,10 @@
|
||||
|
||||
## Fleet configuration management
|
||||
|
||||
- [Fleet configuration entry point](fleet/README.md) — desired-versus-observed decision tree and complete operator link map.
|
||||
- [Desired, derived, and observed state](fleet/concepts/desired-vs-observed-state.md) — roster authority, generation, ownership, and drift.
|
||||
- [Identity, class, and runtime](fleet/concepts/identity-class-runtime.md) — stable name, display alias, class, runtime, provider, and model separation.
|
||||
- [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).
|
||||
- [Generated environment boundary](fleet/reference/generated-env-boundary.md) — roster-derived launch projection, strict local data, legacy quarantine, and downstream interface evidence.
|
||||
- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — local-tmux schema v2 parsing and structural validation.
|
||||
- [Role classes and authority](fleet/reference/role-classes.md) — canonical role resolver and protected authority boundaries.
|
||||
- [Executable asset dispositions](fleet/migration/example-profile-disposition.md) — shipped v1 fixture/profile/service validation posture.
|
||||
|
||||
## 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 applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
|
||||
|
||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this 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-002 | done | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | #768 squash `a5e8e55`; shared resolver and canonical authority/alias validation delivered |
|
||||
| FCM-M1-003 | done | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | #770 squash `e9c4aa3`; shipped artifact disposition validation delivered |
|
||||
| FCM-M2-001 | done | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | #772 squash `191efae`; generated/local boundary and private quarantine delivered |
|
||||
| FCM-M2-002 | done | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | #773 squash `bc5e736`; generation-guarded atomic CRUD and recovery contracts delivered |
|
||||
| FCM-M3-001 | done | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | #785 squash `4990905`; exact roster-owned systemd/tmux reconcile and lifecycle contracts delivered |
|
||||
| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only |
|
||||
| FCM-M4-001 | 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 | HOLD: never starts a previously stopped agent or kills an unproven unmanaged session; not authorized by FCM-M5-001 |
|
||||
| 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 | HOLD: final #758 gate; quality, independent code/security review, validator certificate, merge-gate approval, and green CI remain out of M5-001 |
|
||||
| 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-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation |
|
||||
| FCM-M1-002 | done | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | #768 squash `a5e8e55`; shared resolver and canonical authority/alias validation delivered |
|
||||
| FCM-M1-003 | done | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | #770 squash `e9c4aa3`; shipped artifact disposition validation delivered |
|
||||
| FCM-M2-001 | done | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | #772 squash `191efae`; generated/local boundary and private quarantine delivered |
|
||||
| FCM-M2-002 | done | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | #773 squash `bc5e736`; generation-guarded atomic CRUD and recovery contracts delivered |
|
||||
| FCM-M3-001 | done | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | #785 squash `4990905`; exact roster-owned systemd/tmux reconcile and lifecycle contracts delivered |
|
||||
| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only |
|
||||
| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference |
|
||||
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session |
|
||||
| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral |
|
||||
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI |
|
||||
|
||||
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||
|
||||
|
||||
290
docs/design/791-upgrade-config-protection.md
Normal file
290
docs/design/791-upgrade-config-protection.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# Design — #791: Framework upgrades must not destroy operator-owned config under `~/.config/mosaic`
|
||||
|
||||
- **Issue:** mosaicstack/stack#791
|
||||
- **Branch:** `feat/791-upgrade-config-protection` (off `origin/main` `9745bc3f`)
|
||||
- **Author:** ms-791 worker lane
|
||||
- **Status:** Phase 1 — DESIGN, awaiting MS-LEAD confirmation before implementation
|
||||
- **Ratified scope (Mos-approved, not re-litigated):** deliver **(b) strict ownership separation [PRIMARY]** + **(a) transactional pre-update snapshot [safety net]** + **(d) regeneration-from-SSOT [recovery]**. **(c) periodic backup timer is DEFERRED** — noted as future work only.
|
||||
|
||||
---
|
||||
|
||||
## 1. Current updater behavior + exact wipe mechanism (evidence)
|
||||
|
||||
### 1.1 What runs on `mosaic update`
|
||||
|
||||
`mosaic update` re-seeds the framework by invoking the **bash installer** in sync-only, keep mode:
|
||||
|
||||
- `packages/mosaic/src/runtime/update-checker.ts:509` `buildReseedCommand()` returns
|
||||
`bash <frameworkRoot>/install.sh` with env `MOSAIC_SYNC_ONLY=1`, `MOSAIC_INSTALL_MODE=keep`,
|
||||
`MOSAIC_HOME=<mosaicHome>`.
|
||||
- The same `install.sh` is the direct/`tools/install.sh` upgrade path and the framework-vN migration path.
|
||||
|
||||
So the destructive surface is **`packages/mosaic/framework/install.sh`**.
|
||||
|
||||
### 1.2 The wipe
|
||||
|
||||
`sync_framework()` (`install.sh:177`) performs, in `keep` mode:
|
||||
|
||||
```
|
||||
rsync -a --delete --exclude .git --exclude .framework-version --exclude '*.pre-constitution.bak' \
|
||||
[--exclude "/$path" for each PRESERVE_PATHS entry] SOURCE_DIR/ TARGET_DIR/
|
||||
```
|
||||
|
||||
- `install.sh:199` — `rsync -a --delete`. **`--delete` prunes every path in `~/.config/mosaic`
|
||||
that is NOT present in the shipped framework source**, unless excluded.
|
||||
- `install.sh:47` — `PRESERVE_PATHS` is the **only** thing standing between `--delete` and operator
|
||||
data. It is a _denylist of exclusions_:
|
||||
```
|
||||
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")
|
||||
```
|
||||
- The cp-fallback (no rsync) is equally destructive: `install.sh:223`
|
||||
`find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ... -exec rm -rf {} +` then re-copies source, restoring
|
||||
only PRESERVE_PATHS globs.
|
||||
|
||||
**Root-cause model:** _"Everything under `~/.config/mosaic` is framework-owned and pruneable UNLESS
|
||||
explicitly preserved."_ Any operator path the list forgets is destroyed on the next upgrade.
|
||||
|
||||
### 1.3 The exact operator paths wiped
|
||||
|
||||
Cross-referencing the issue's operator-owned list against `PRESERVE_PATHS`:
|
||||
|
||||
| Operator path (issue #791) | In PRESERVE_PATHS? | Fate on `mosaic update` |
|
||||
| ----------------------------------------------------------------- | --------------------------------------- | ----------------------- |
|
||||
| `agents/*.conf` (per-agent runtime) | **NO** | **WIPED** |
|
||||
| `policy/*.md` (operator overlays) | **NO** | **WIPED** |
|
||||
| `*.local.md` (SOUL/USER/STANDARDS) | **NO** | **WIPED** |
|
||||
| harvester / SOP artifacts + timers | **NO** | **WIPED** |
|
||||
| `tools/_lib/credentials.json` | **NO** (`credentials/` dir ≠ this path) | **WIPED** |
|
||||
| `fleet/agents/*.env` | yes (`fleet/agents`, added by #631) | survives |
|
||||
| `memory/`, `fleet/roster.*`, `fleet/backlog`, `fleet/roles.local` | yes | survives |
|
||||
|
||||
The `fleet/agents`, `memory`, `fleet/backlog` entries were **retro-added after prior incidents**
|
||||
(#631). This whack-a-mole is the structural signature of a denylist.
|
||||
|
||||
**Stale-comment evidence:** `update-checker.ts:492` claims the reseed preserves
|
||||
"`SOUL/USER/*.local/credentials`" — but `PRESERVE_PATHS` contains **no `*.local` entry**. The code
|
||||
documents protection it does not deliver.
|
||||
|
||||
### 1.4 Second code path (TS) — already non-destructive, but drifted
|
||||
|
||||
`FileConfigAdapter.syncFramework()` (`packages/mosaic/src/config/file-adapter.ts:157`) →
|
||||
`syncDirectory()` (`packages/mosaic/src/platform/file-ops.ts:66`) is a **copy-overlay**: it copies
|
||||
source over target and skips preserved paths, but **never deletes** target paths absent from source
|
||||
(`file-ops.ts:77-109`). It is used by the wizard/init flow, not `mosaic update`.
|
||||
|
||||
Two problems remain:
|
||||
|
||||
1. Its `preservePaths` (`file-adapter.ts:164-185`) has **already diverged** from `install.sh` — it is
|
||||
**missing `fleet/backlog` and `fleet/roles.local`**. Two hand-maintained denylists, drifted. This
|
||||
is direct evidence for a single shared SSOT manifest.
|
||||
2. Even non-destructive, it will happily _overwrite_ an operator file that collides with a
|
||||
framework-shipped path unless that path is on its (incomplete) preserve list.
|
||||
|
||||
### 1.5 Existing snapshot is inadequate for rollback
|
||||
|
||||
`make_snapshot()`/`restore_snapshot()` (`install.sh:76-87`) copy `TARGET_DIR` to `mktemp -d` under
|
||||
`/tmp`, restore **only on `ERR/INT/TERM` trap**, and are **deleted on success** (`cleanup_snapshot`,
|
||||
`install.sh:345`). Consequences: ephemeral `/tmp`, no retention, no post-success rollback, and **no
|
||||
`mosaic restore`**. It is crash-safety only, not the transactional safety net #791 requires.
|
||||
|
||||
---
|
||||
|
||||
## 2. Fix (b) — Strict ownership separation [PRIMARY / root cause]
|
||||
|
||||
### 2.1 Ownership model (invert to allow-list)
|
||||
|
||||
Replace _"framework-owned unless preserved"_ with _"operator-owned unless framework-owned"_, resolved
|
||||
**per target path** with operator carve-outs winning inside shared framework subtrees.
|
||||
|
||||
Two declared lists, one SSOT data file shipped in the framework
|
||||
(`framework/framework-manifest.json`), consumed by **both** bash and TS:
|
||||
|
||||
- **`framework` globs** — paths the updater is entitled to create / overwrite / prune. Authored to
|
||||
match exactly what the framework ships in `packages/mosaic/framework/` (e.g. `CONSTITUTION.md`,
|
||||
`AGENTS.md`, `STANDARDS.md`, `TOOLS.md`, `guides/**`, `constitution/**`, `templates/**`, `tools/**`,
|
||||
`skills/**`, `mcp/**`, `defaults/**`, `fleet/examples/**`, `fleet/roles/**`, `fleet/profiles/**`,
|
||||
`fleet/roster.schema.json`).
|
||||
- **`operatorReserved` globs** — NEVER written or pruned, even nested inside a `framework` subtree;
|
||||
these **win** over `framework` (deny-wins / most-specific-wins). At minimum:
|
||||
`agents/**`, `policy/**`, `memory/**`, `sources/**`, `credentials/**`, `*.local.md`,
|
||||
`tools/_lib/credentials.json`, `fleet/roster.yaml`, `fleet/roster.json`, `fleet/agents/**`,
|
||||
`fleet/run/**`, `fleet/backlog/**`, `fleet/roles.local/**`, plus operator harvester/SOP artifacts.
|
||||
|
||||
### 2.2 Ownership resolution for a target path `P`
|
||||
|
||||
1. `P` matches `operatorReserved` → **operator-owned**: updater MUST NOT write, MUST NOT delete.
|
||||
2. else `P` matches `framework` → **framework-owned**: may overwrite; may prune **only if absent from
|
||||
the current SOURCE** (a genuinely retired framework file).
|
||||
3. else (matches neither) → **UNKNOWN ⇒ operator-owned by default (fail-safe)**: never delete.
|
||||
|
||||
Rule 3 is the actual root-cause fix: an operator path the manifest authors forget is still protected,
|
||||
because _unknown defaults to operator_. A denylist can never provide this guarantee.
|
||||
|
||||
### 2.3 Sync mechanism change (the mechanically-critical part)
|
||||
|
||||
`--delete` cannot express "prune only framework-owned" without re-enumerating every operator path
|
||||
(the denylist trap). So:
|
||||
|
||||
1. **Drop `--delete` from the bulk sync.** Copy `SOURCE → TARGET` non-destructively (writes/overwrites
|
||||
all framework files; deletes nothing). rsync without `--delete`, or the existing overlay copy.
|
||||
2. **Explicit manifest-scoped prune pass.** Iterate the **`framework` manifest** (not the whole tree);
|
||||
for each framework path present in `TARGET` but **absent in `SOURCE`**, delete it — after
|
||||
re-checking it does not match `operatorReserved`. Because the prune iterates only declared
|
||||
framework globs, operator/unknown paths are **structurally unreachable** by deletion.
|
||||
|
||||
This is implemented in both bash `sync_framework()` and TS `syncFramework()` from the shared manifest.
|
||||
A pure **prune-planner** function (TS) computes the delete-set from
|
||||
`(manifest, sourceListing, targetListing)` so the invariant is unit-testable in isolation.
|
||||
`PRESERVE_PATHS` becomes redundant (kept as a defense-in-depth alias mapping to `operatorReserved`, or
|
||||
removed) — either way the two lists stop drifting because they read one file.
|
||||
|
||||
### 2.4 HARD GATE test — "upgrade touches no path outside the manifest"
|
||||
|
||||
Filesystem-observation test in the existing `test-install-migration.sh` harness pattern (mktemp
|
||||
`MOSAIC_HOME`, `MOSAIC_SYNC_ONLY=1`), plus TS specs:
|
||||
|
||||
1. Seed a throwaway `TARGET` with a realistic operator mix — one sentinel per operator class:
|
||||
`agents/x.conf`, `policy/p.md`, `SOUL.local.md`, `memory/m.md`,
|
||||
`tools/_lib/credentials.json` (with a secret value), `fleet/agents/a.env`, `fleet/roster.yaml`,
|
||||
`harvester/sop.md`, **and a deliberately-unanticipated `unknown-operator-dir/x`**.
|
||||
2. Record hash+mtime of every sentinel.
|
||||
3. Run the upgrade from a `SOURCE` containing none of those operator paths.
|
||||
4. **Assert:** every sentinel exists, byte-identical, **mtime unchanged** (not even rewritten). The
|
||||
`unknown-operator-dir` surviving proves the fail-safe default — a denylist could not pass this case.
|
||||
5. **Positive controls:** framework files WERE updated; a retired framework file WAS pruned.
|
||||
6. **Property test** (TS prune-planner): for fuzzed operator paths, `deleteSet ⊆ {matches framework ∧
|
||||
in target ∧ not in source}` and `deleteSet ∩ operatorReserved = ∅`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Fix (a) — Transactional pre-update snapshot [safety net]
|
||||
|
||||
- **Destination:** `${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-ts>/`.
|
||||
**Outside `~/.config/mosaic`** (so no future sync can sweep it) and outside any repo.
|
||||
- **Perms:** dir `0700`, files `0600` — enforced with `umask 077` around the copy **and** explicit
|
||||
`chmod`. Never world-readable.
|
||||
- **Scope:** the operator-owned surface (`operatorReserved` paths that exist) — bounded; does not copy
|
||||
the framework tree.
|
||||
- **Timing:** taken before ANY mutation in the upgrade flow.
|
||||
- **Post-sync verify + selective restore:** after sync, diff the operator surface against the snapshot;
|
||||
since (b) should never touch operator paths, any diff means a manifest bug — restore the affected
|
||||
paths from the snapshot and warn loudly. This is precisely (a) catching a miss in (b).
|
||||
- **Retention:** keep N most-recent (default 5; `MOSAIC_BACKUP_RETENTION` override); prune older.
|
||||
- **`mosaic restore`:** `--list` (default, dry-run) enumerates snapshots by timestamp;
|
||||
`--from <ts>` restores that snapshot over the operator surface, confirmation-gated. Reports
|
||||
counts/paths only.
|
||||
- **Secret-safety:** snapshot copy and restore never emit file **contents**; only paths/counts.
|
||||
Tests assert `0700/0600` and that no secret value appears in stdout/stderr.
|
||||
|
||||
---
|
||||
|
||||
## 4. Fix (d) — Regeneration-from-SSOT [recovery]
|
||||
|
||||
The incident's live blast radius: `fleet/agents/*.env` (systemd `EnvironmentFile` sources) gone →
|
||||
`mosaic-agent@<name>` boots **unit defaults** on restart (because `EnvironmentFile=-...` is
|
||||
absent-tolerant) → **silent identity/runtime/workdir downgrade**.
|
||||
|
||||
The SSOT for those `.env` files is the roster. The reconciler **already** separates a
|
||||
`regenerate-projections-from-roster` projection phase from lifecycle
|
||||
(`packages/mosaic/src/fleet/fleet-reconciler.ts:93,234`; env rendering in
|
||||
`generated-env-boundary.ts:149-264`).
|
||||
|
||||
**`mosaic fleet regen`** is therefore a **thin recovery-framed wrapper over the existing projection
|
||||
phase** — it does NOT reimplement fleet logic and does NOT preempt in-flight FCM cards (M4/M5):
|
||||
|
||||
- Regenerates derivable config (per-agent `*.env.generated`, unit files) from roster SSOT.
|
||||
- **Preview-first:** dry-run default; `--write` to apply. Idempotent.
|
||||
- **Never restarts agents** (the recovery order forbids restart-before-verify).
|
||||
- Prints the runbook's next step (verify `EnvironmentFile` resolves, THEN restart).
|
||||
|
||||
Alternatively documentable as `install.sh --relink` per the issue; `mosaic fleet regen` is preferred
|
||||
because it reuses the merged reconciler plumbing.
|
||||
|
||||
---
|
||||
|
||||
## 5. Secret-safety approach (secrev surface)
|
||||
|
||||
- Snapshots/backups: `0700`/`0600`, outside any repo, never world-readable. (§3)
|
||||
- No secret **value** ever emitted to logs/stdout/stderr by snapshot, restore, sync, or regen —
|
||||
paths/counts only. Adversarial test: a secret value placed in `tools/_lib/credentials.json` must
|
||||
never appear in installer or command output.
|
||||
- `tools/_lib/credentials.json` is an explicit `operatorReserved` carve-out inside the framework-owned
|
||||
`tools/**` subtree — it is never overwritten or pruned.
|
||||
- The HARD GATE test doubles as a secret-safety test (asserts the credentials sentinel is untouched).
|
||||
|
||||
---
|
||||
|
||||
## 6. Test plan (TDD, tests-first, ≥85% on new code, co-located `*.spec.ts`)
|
||||
|
||||
1. **Manifest SSOT parity** — bash and TS resolve identical framework/operator sets from the one file;
|
||||
a test fails if either path hard-codes a divergent list.
|
||||
2. **Manifest completeness** — every path shipped in `framework/` is covered by a `framework` glob (so
|
||||
a new shipped file cannot silently fall outside the manifest and become un-prunable/undeclared).
|
||||
3. **HARD GATE** — upgrade touches nothing outside the manifest, incl. the unanticipated-path case
|
||||
(§2.4).
|
||||
4. **Prune-planner** unit + property tests (§2.4.6).
|
||||
5. **Snapshot** — perms `0700/0600`, correct destination, retention prune, secret value absent from
|
||||
output.
|
||||
6. **Restore** — `--list` / `--from` round-trip restores operator surface byte-exact; confirmation
|
||||
gate; no secret leakage.
|
||||
7. **Regen** — roster→env projection deterministic + idempotent; dry-run makes no writes; `--write`
|
||||
restores `*.env`; **never** issues a lifecycle/restart call.
|
||||
8. **Cross-path regression** — TS `syncFramework` and bash `install.sh` agree on a shared fixture
|
||||
(closes the current #631-style drift).
|
||||
|
||||
Gates before every push: `pnpm typecheck && pnpm lint && pnpm format:check` + mosaic package tests
|
||||
green. Never `--no-verify`.
|
||||
|
||||
---
|
||||
|
||||
## 7. web1 recovery runbook (operator-agnostic; web1 specifics live in the issue as evidence only)
|
||||
|
||||
For a currently-wiped fleet EnvironmentFile state — **do NOT service-restart while
|
||||
`fleet/agents/*.env` is absent** (a restart boots unit defaults and silently downgrades identity):
|
||||
|
||||
1. **Regenerate:** `mosaic fleet regen --write` — rebuild `~/.config/mosaic/fleet/agents/*.env` from
|
||||
roster SSOT.
|
||||
2. **Verify each unit resolves to the intended runtime/workdir** _before_ any restart:
|
||||
`systemctl --user show mosaic-agent@<name> -p EnvironmentFile` and confirm the generated env exists
|
||||
and carries the intended `MOSAIC_AGENT_*` runtime/workdir values.
|
||||
3. **Only then** `systemctl --user restart mosaic-agent@<name>`, one unit at a time.
|
||||
|
||||
If config (not just fleet env) was lost, `mosaic restore --list` → `mosaic restore --from <ts>` before
|
||||
step 1.
|
||||
|
||||
---
|
||||
|
||||
## 8. Proposed PR split (reviewable; DAG-ordered)
|
||||
|
||||
| PR | Scope | Depends | Review focus |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -------------------------- |
|
||||
| PR1 | **PRIMARY** — shared `framework-manifest.json` + ownership resolver + non-deleting sync + scoped prune (bash + TS) + **HARD GATE** + prune-planner tests | — | correctness (root fix) |
|
||||
| PR2 | **Safety net** — pre-update snapshot (`~/.local/state`, 0700/0600, retention) + post-sync verify/restore + `mosaic restore` | PR1 | **secrev** (backup/secret) |
|
||||
| PR3 | **Recovery** — `mosaic fleet regen` (projection-only, preview-first, no restart) + docs (upgrade-safety + recovery runbook) | PR1 | correctness + docs |
|
||||
|
||||
Rationale: PR1 closes the failure class on its own; if PR2/PR3 slip, the class stays fixed. Each PR is
|
||||
one reviewable unit with its own tests ≥85%. Independent review (author≠reviewer) on all; **secrev** on
|
||||
PR2 (and PR1's secret-sentinel assertions).
|
||||
|
||||
## 9. Deferred (noted per scope)
|
||||
|
||||
**(c) periodic backup timer** — a systemd user timer snapshotting operator dirs on a cadence
|
||||
(defense-in-depth for non-upgrade losses). Explicitly **out of scope now**; future phase.
|
||||
|
||||
## 10. Constraints honored
|
||||
|
||||
- **Framework-PR firewall:** manifest + logic are operator-agnostic; no SOUL/USER/operator specifics
|
||||
in framework code; web1 details are issue evidence only.
|
||||
- **Capacity-fill:** must not preempt M5-001 or #790; `fleet regen` reuses merged FCM-M3 plumbing and
|
||||
does not overlap FCM-M4/M5 migration cards.
|
||||
- **Delivery gates:** TDD tests-first, ≥85% new-code coverage, trunk-based squash PRs, independent
|
||||
review + secrev, completion = merged PR + descendant-main green + #791 closed.
|
||||
|
||||
---
|
||||
|
||||
**Requesting MS-LEAD confirmation of:** (1) the manifest allow-list + non-deleting-sync + scoped-prune
|
||||
approach as the (b) root-cause fix; (2) snapshot destination/retention + `mosaic restore` UX;
|
||||
(3) `mosaic fleet regen` as a projection-only wrapper; (4) the 3-PR split. Implementation begins only
|
||||
on your confirmation.
|
||||
@@ -5,27 +5,27 @@
|
||||
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
|
||||
named artifact exists, is linked from the fleet documentation entry point, and its evidence is
|
||||
recorded in the M5 closure report and linked deferral evidence.
|
||||
recorded in the M0 task/PR.
|
||||
|
||||
## M0 baseline acceptance
|
||||
|
||||
- [x] `docs/PRD.md` states the roster as desired-state SSOT; generated environment, systemd,
|
||||
- [ ] `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
|
||||
unsupported or quarantined legacy input.
|
||||
- [x] `docs/PRD.md` defines the required classes and authority boundary: `validator` certifies but
|
||||
- [ ] `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`
|
||||
capacity is lease-bounded; `interaction` is request/status only; instance names such as Tess
|
||||
and Ultron remain configurable.
|
||||
- [x] `docs/PRD.md` defines local lifecycle semantics for `enabled`, persisted desired state, and
|
||||
- [ ] `docs/PRD.md` defines local lifecycle semantics for `enabled`, persisted desired state, and
|
||||
observed state, including stopped-state preservation through migration, apply, and reboot.
|
||||
- [x] `docs/PRD.md` defines the generated-env/local-override boundary, explicitly denies arbitrary
|
||||
- [ ] `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.
|
||||
- [x] `docs/PRD.md` identifies the M1–M5 local-tmux scope and excludes remote reconciliation,
|
||||
- [ ] `docs/PRD.md` identifies the M1–M5 local-tmux scope and excludes remote reconciliation,
|
||||
connector mutation, secret references, arbitrary commands/channels, gateway convergence, and
|
||||
UI configuration storage.
|
||||
- [x] `docs/TASKS.md` contains the complete M0–M5 one-card/one-PR dependency DAG for #758 with
|
||||
- [ ] `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.
|
||||
- [x] `docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` classifies every current shipped
|
||||
- [ ] `docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` classifies every current shipped
|
||||
fleet example, profile, and service preset before M1 implementation starts.
|
||||
|
||||
## Required documentation IA for M1–M5
|
||||
@@ -72,28 +72,15 @@ recorded in the M5 closure report and linked deferral evidence.
|
||||
|
||||
## Cross-cutting evidence gates
|
||||
|
||||
- [x] Every retained or migrated YAML/JSON example, profile, and service preset validates through the
|
||||
same declared executable production parser/resolver path recorded by the disposition inventory;
|
||||
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
|
||||
- [ ] 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.
|
||||
- [ ] Every retired example/profile/service preset has a replacement link and deprecation note; no
|
||||
unresolved legacy class or tool-policy alias remains silently shipped.
|
||||
- [x] Documentation examples contain no secret values, arbitrary command override, or product-hardcoded
|
||||
- [ ] Documentation examples contain no secret values, arbitrary command override, or product-hardcoded
|
||||
Tess/Ultron identity.
|
||||
- [x] CLI snippets distinguish local fleet desired-state commands from the separate gateway-backed
|
||||
- [ ] CLI snippets distinguish local fleet desired-state commands from the separate gateway-backed
|
||||
`mosaic agent` catalog.
|
||||
- [x] Migration, quarantine, lifecycle, status, and troubleshooting documentation state that values of
|
||||
- [ ] Migration, quarantine, lifecycle, status, and troubleshooting documentation state that values of
|
||||
legacy sensitive keys are never printed.
|
||||
- [x] M5 documentation validation verifies required IA paths, local file and heading-fragment links,
|
||||
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.
|
||||
- [ ] M5 release review verifies links, schema/example validation, and that all checklist rows have
|
||||
owner/evidence or an explicit approved deferral.
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,42 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,61 +0,0 @@
|
||||
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
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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).
|
||||
@@ -14,9 +14,9 @@ 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 with `--dry-run`. It validates roster semantics, deterministic projections, private managed paths, exact holder ownership, and named-socket state without changing files or lifecycle state. `apply` and `reconcile` rebuild derived projections and enforce only persisted roster state: enabled `running` agents may start, while stopped or disabled agents are not started.
|
||||
|
||||
`start`, `stop`, and `restart` are explicit one-shot exact-service actions. They do not persist a lifecycle change. `update` preserves the agent's existing lifecycle, and no delivered operation changes durable lifecycle after creation.
|
||||
`start`, `stop`, and `restart` are explicit one-shot exact-service actions. They do not persist a lifecycle change. Roster CRUD is the only way to change persisted desired state.
|
||||
|
||||
Every command prints JSON. Observation commands report drift without mutation; `verify` exits non-zero on ownership mismatch, unmanaged sessions, or drift. A failed apply that wrote some derived projections reports `projections: "incomplete"` with bounded recovery to regenerate from the roster. A lifecycle failure after projections reports incomplete lifecycle work; it is never represented as a rollback or no-op.
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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).
|
||||
@@ -7,8 +7,6 @@
|
||||
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
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,25 +1,6 @@
|
||||
# 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:
|
||||
The local roster-v2 control plane is `mosaic fleet`.
|
||||
|
||||
```text
|
||||
mosaic fleet apply --expected-generation <n> [--dry-run]
|
||||
@@ -33,14 +14,14 @@ 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.
|
||||
`migrate-v1 preview` is non-mutating: it emits value-free v1 inventory, a canonical semantically
|
||||
validated v2 candidate when ready, sanitized environment dispositions, and non-executable recovery
|
||||
evidence. It has no write, apply, canary, or rollback option. Missing preview inputs also return one stable
|
||||
blocked JSON object and a non-zero exit, rather than Commander text. See
|
||||
[the migration preview contract](../migration/v1-to-v2.md).
|
||||
|
||||
## JSON and exit behavior
|
||||
`apply` and `reconcile` use roster desired state. `start`, `stop`, and `restart` are exact local one-shot lifecycle effects and never persist a desired-state edit. `status`, `verify`, and `doctor` are observational.
|
||||
|
||||
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.
|
||||
Commands emit one JSON object. Handled precondition errors emit `{ "error": { "code": "..." } }` and exit non-zero. Partial derived/lifecycle effects use explicit `authoritativeRoster`, `projections`, `lifecycle`, and bounded `recovery` fields; they never claim rollback. Any additive `cleanup` diagnostic also exits non-zero, even where known effects are complete: it is not a clean completion and the lock requires inspection before retry.
|
||||
|
||||
## 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.
|
||||
This control plane is separate from the gateway-backed `mosaic agent` catalog. It is local-only and rejects remote/connector lifecycle mutation, arbitrary command/channel/secret input, and unproven tmux ownership.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Fleet Generated Environment Boundary
|
||||
|
||||
**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** merged contract
|
||||
**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** unreleased/card-local
|
||||
|
||||
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
|
||||
@@ -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
|
||||
product release version has been evidenced for this interface set.
|
||||
|
||||
| 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 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 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 merged as #772 (`191efae`); no released product version is asserted here. | Render/write a roster-derived projection; local input is never authority. |
|
||||
| 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 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 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. |
|
||||
| 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. |
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
# 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.
|
||||
FCM-M3-001 uses the roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` fields as the only desired-state authority. Systemd, tmux, generated environment files, and heartbeats are derived or observed state.
|
||||
|
||||
| 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. |
|
||||
| Command | Desired-state write | Runtime effect | Preconditions |
|
||||
| --------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `fleet apply` / `fleet reconcile` | Never | Rebuilds projections, then starts only enabled agents desired `running`; stops disabled or desired-`stopped` roster agents | Current generation; private managed paths; valid projections; proven holder ownership; no unmanaged named-socket sessions |
|
||||
| `fleet start <name>` | Never | One-shot exact `mosaic-agent@<name>.service` start | Current generation; exact enabled roster name; proven ownership |
|
||||
| `fleet stop <name>` | Never | One-shot exact service stop | Current generation; exact roster name; proven ownership |
|
||||
| `fleet restart <name>` | Never | One-shot exact service restart | Current generation; exact roster name; proven ownership |
|
||||
|
||||
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.
|
||||
A stopped roster agent is never started by `apply` or `reconcile`. Direct lifecycle commands are explicit one-shot actions and do not change persisted desired state. Use roster CRUD with the explicit persisted-start option to change that desired state.
|
||||
|
||||
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.
|
||||
All mutations require `--expected-generation <n>` and acquire one private roster-adjacent reconciliation lock before projection or lifecycle effects. Missing or stale generations and concurrent writers fail before effects; the lock is released after success, partial failure, or thrown lifecycle failure. Stale, ownership, unmanaged-session, unsupported-runtime, path, projection, and lifecycle-precondition failures return stable redacted JSON errors and a non-zero exit. No command targets a fuzzy tmux name, arbitrary socket, arbitrary command, channel, secret, or generated file as authority.
|
||||
|
||||
@@ -50,20 +50,18 @@ agents:
|
||||
|
||||
## Root fields
|
||||
|
||||
| Field | Required | Default | Constraint | Meaning |
|
||||
| ------------ | -------- | ------- | --------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `version` | yes | none | integer constant `2` | Identifies this contract. Version `1` stays on the compatibility path pending explicit migration. |
|
||||
| `generation` | yes | none | positive safe integer | Desired-state generation and mutation/reconcile concurrency fence. |
|
||||
| `transport` | yes | none | constant `tmux` | M1–M5 support local tmux only. |
|
||||
| `tmux` | yes | none | strict object | Explicit local socket and holder-session configuration. |
|
||||
| `defaults` | yes | none | strict object | Default work directory and one supported local runtime. |
|
||||
| `runtimes` | yes | none | non-empty object | Declared local runtime reset policy map. |
|
||||
| `agents` | yes | none | non-empty array | Local fleet entries. Duplicate stable names are rejected. |
|
||||
| Field | Required | 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. |
|
||||
| `generation` | yes | positive safe integer | Desired-state generation. M2 uses it for mutation guards; M1 does not mutate it. |
|
||||
| `transport` | yes | constant `tmux` | M1–M5 support local tmux only. |
|
||||
| `tmux` | yes | strict object | Explicit local socket and holder-session configuration. |
|
||||
| `defaults` | yes | strict object | Default work directory and one supported local runtime. |
|
||||
| `runtimes` | yes | non-empty object | Declared local runtime reset policy map. |
|
||||
| `agents` | yes | non-empty array | Local fleet entries. Duplicate stable names are rejected. |
|
||||
|
||||
## Nested fields
|
||||
|
||||
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.
|
||||
|
||||
| Path | Required | Constraint |
|
||||
| ---------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `tmux.socket_name` | yes | `[A-Za-z0-9_.-]*`; empty string means the literal default tmux server, while a non-empty value names a socket |
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
# 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.
|
||||
`mosaic fleet status [name]`, `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, change desired state, start services, stop services, restart services, or mutate tmux.
|
||||
|
||||
## State dimensions
|
||||
The report distinguishes:
|
||||
|
||||
- **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.
|
||||
- `missing-session`: an enabled agent desired `running` has no exact roster-named tmux session.
|
||||
- `unexpected-session`: a desired-`stopped` agent still has its exact session.
|
||||
- `disabled-running`: a disabled roster agent has its exact session.
|
||||
- `unmanagedSessions`: sessions on the configured named socket that are neither the exact holder nor an exact roster agent.
|
||||
- `holder`: `owned`, `missing`, or `ownership-mismatch` after exact holder, private install identity, and complete global tmux environment validation.
|
||||
|
||||
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.
|
||||
`doctor` and `status` classify rather than adopt, destroy, or repair unmanaged state. `verify` is observational too, but exits non-zero if ownership cannot be proven, unmanaged sessions exist, or drift is present. Reconciliation fails closed under those conditions and never kills or adopts an unmanaged session.
|
||||
|
||||
@@ -98,6 +98,39 @@ Expected results:
|
||||
that means the unit ran, not that an agent pane is live. Treat tmux
|
||||
`has-session`, `list-panes`, process tree, and logs as the liveness evidence.
|
||||
|
||||
## Recovery — rebuild generated env projections
|
||||
|
||||
Each agent's `~/.config/mosaic/fleet/agents/<name>.env.generated` is a
|
||||
deterministic projection of `roster.yaml` (the SSOT) that the launcher
|
||||
(`start-agent-session.sh`) sources at start. If an upgrade or a manual mistake
|
||||
wipes or diverges those projections, rebuild them from the roster with
|
||||
`mosaic fleet regen` — do NOT restart the affected unit first.
|
||||
|
||||
```bash
|
||||
mosaic fleet regen # dry-run (default): show create/rebuild plan per agent
|
||||
mosaic fleet regen --json # same plan, machine-readable
|
||||
mosaic fleet regen --write # rebuild fleet/agents/<name>.env.generated on disk
|
||||
```
|
||||
|
||||
`regen` is projection-only and **never restarts an agent** — it has no path to
|
||||
systemd lifecycle. It is dry-run by default, deterministic/idempotent, uses the
|
||||
same roster→env mapping as `mosaic fleet reconcile`, and emits paths and counts
|
||||
only (never the projected `KEY=value` body). After `--write`, verify each unit
|
||||
resolves the intended values before restarting one unit at a time. The unit sets
|
||||
no `EnvironmentFile=` — `start-agent-session.sh` sources `.env.generated` itself —
|
||||
so verify the generated file directly and the launcher path, not a nonexistent
|
||||
`EnvironmentFile` property:
|
||||
|
||||
```bash
|
||||
test -f ~/.config/mosaic/fleet/agents/<name>.env.generated
|
||||
systemctl --user cat mosaic-agent@<name> | grep ExecStart
|
||||
systemctl --user restart mosaic-agent@<name>
|
||||
```
|
||||
|
||||
Full recovery runbook and the three-layer #791 protection model (manifest
|
||||
ownership → pre-update snapshot/restore → regen): see
|
||||
[Upgrade Safety & Recovery](./upgrade-safety-and-recovery.md).
|
||||
|
||||
## Release Preflight
|
||||
|
||||
Run this checklist before cutting or dogfooding a fleet release:
|
||||
|
||||
147
docs/guides/upgrade-safety-and-recovery.md
Normal file
147
docs/guides/upgrade-safety-and-recovery.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Upgrade Safety & Recovery
|
||||
|
||||
How Mosaic protects operator-owned configuration under `~/.config/mosaic` across
|
||||
framework upgrades, and how to recover if a projection is ever lost.
|
||||
|
||||
A framework upgrade runs `install.sh` in keep-mode (`MOSAIC_INSTALL_MODE=keep`,
|
||||
`MOSAIC_SYNC_ONLY=1`) to refresh framework-owned files in place. The incident
|
||||
this hardening addresses: an upgrade that silently overwrites or deletes a file
|
||||
the operator owns — credentials, personas, a roster, or a generated agent env —
|
||||
with no snapshot to fall back to.
|
||||
|
||||
Protection is layered. Each layer is independent; a later layer catches what an
|
||||
earlier one misses.
|
||||
|
||||
## Layer 1 — Manifest-owned sync (prevention)
|
||||
|
||||
The single source of truth for ownership is
|
||||
[`framework-manifest.txt`](../../packages/mosaic/framework/framework-manifest.txt).
|
||||
Both the bash installer and the TypeScript sync path resolve every path against
|
||||
this one file (parity is enforced by test), so they can never drift.
|
||||
|
||||
- Ownership is **allow-list, deny-wins**: a path is framework-owned only if a
|
||||
`[framework]` glob matches and no `[operator]` carve-out overrides it.
|
||||
- **Unknown paths default to operator** (fail-safe): a file the manifest never
|
||||
anticipated is treated as operator-owned and is never pruned.
|
||||
- Keep-mode does a non-deleting copy plus an explicit, manifest-scoped prune that
|
||||
only ever iterates framework globs — operator and unknown paths are
|
||||
structurally unreachable by the prune.
|
||||
|
||||
Result: a correct upgrade cannot touch operator config at all.
|
||||
|
||||
## Layer 2 — Durable pre-update snapshot + verify net (safety + rollback)
|
||||
|
||||
Before **any** mutation, the installer snapshots the operator-owned surface that
|
||||
exists into:
|
||||
|
||||
```
|
||||
${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-timestamp>/
|
||||
```
|
||||
|
||||
- `0700` directories / `0600` files (`umask 077`, scoped and restored),
|
||||
outside `~/.config/mosaic` and outside any repo.
|
||||
- **Fail-open**: a snapshot failure warns but never aborts the upgrade it
|
||||
protects.
|
||||
- Retention is `MOSAIC_BACKUP_RETENTION` snapshots (default 5).
|
||||
|
||||
After the sync, a **verify net** compares each snapshot file against its target
|
||||
and restores (with a loud warning) any operator file the upgrade diverged or
|
||||
removed — a divergence means a manifest bug slipped through Layer 1.
|
||||
|
||||
Inspect and restore snapshots with the CLI:
|
||||
|
||||
```bash
|
||||
mosaic restore --list # dry-run: enumerate snapshots by timestamp
|
||||
mosaic restore --from <UTC-timestamp> # restore the operator surface from one snapshot
|
||||
mosaic restore --from <ts> --dry-run # preview a specific restore without writing
|
||||
```
|
||||
|
||||
`mosaic restore` reports **counts and relative paths only** — it never emits file
|
||||
contents, so a secret in `tools/_lib/credentials.json` is never echoed. Restores
|
||||
are confirmation-gated (`--yes` or `MOSAIC_ASSUME_YES`) and write each leaf
|
||||
atomically with `O_NOFOLLOW` (a symlink swapped in after the snapshot fails
|
||||
closed rather than following out of the managed tree).
|
||||
|
||||
## Layer 3 — Regeneration from roster SSOT (recovery)
|
||||
|
||||
Some operator files are **derived** and do not need a byte-for-byte snapshot to
|
||||
recover — they can be rebuilt from their source of truth. The fleet's per-agent
|
||||
generated env projections are the prime case:
|
||||
|
||||
- `~/.config/mosaic/fleet/agents/<name>.env.generated` is a deterministic
|
||||
projection of `~/.config/mosaic/fleet/roster.yaml`.
|
||||
- The launcher (`start-agent-session.sh`, invoked by
|
||||
`mosaic-agent@<name>.service`) sources that generated projection to establish
|
||||
each agent's identity, runtime, model, and working directory. If it is missing
|
||||
or wrong, the agent cannot launch with its intended identity.
|
||||
|
||||
`mosaic fleet regen` rebuilds those projections from the roster SSOT:
|
||||
|
||||
```bash
|
||||
mosaic fleet regen # dry-run (default): show what would be rebuilt
|
||||
mosaic fleet regen --json # same, machine-readable
|
||||
mosaic fleet regen --write # rebuild the projections on disk
|
||||
```
|
||||
|
||||
- **Dry-run by default.** Nothing is written until you pass `--write`.
|
||||
- **Deterministic and idempotent** — the projection is a pure function of the
|
||||
roster, so repeated `--write` runs produce byte-identical files.
|
||||
- **Projection-only. It never restarts an agent.** Recovery order forbids
|
||||
restart-before-verify; `regen` has no path to systemd lifecycle at all.
|
||||
- **It rebuilds only `<name>.env.generated`** — it never writes, relocates, or
|
||||
deletes the operator-owned `.env` / `.env.local` surface.
|
||||
- It **validates the roster the same way `reconcile` does** (persona resolution
|
||||
and protected-class tool-policy match), so a hand-edited or corrupt roster is
|
||||
rejected rather than projected, and a `--write` takes the shared reconcile
|
||||
lock so it cannot race a concurrent reconcile.
|
||||
- Output is **paths and counts only** — the rendered `KEY=value` body is never
|
||||
echoed.
|
||||
|
||||
`regen` uses the exact same roster→env mapping as `mosaic fleet reconcile`, so a
|
||||
recovered projection matches what a normal reconcile would have written.
|
||||
|
||||
## Recovery runbook — wiped `fleet/agents/*.env.generated`
|
||||
|
||||
If an upgrade (or a manual mistake) has left an agent without its generated
|
||||
projection, **do not restart the unit first** — a launch against a missing
|
||||
projection fails closed, and any stale state must be corrected before restart,
|
||||
not after.
|
||||
|
||||
1. **Prefer a snapshot restore if one exists** (byte-exact operator state):
|
||||
|
||||
```bash
|
||||
mosaic restore --list
|
||||
mosaic restore --from <UTC-timestamp>
|
||||
```
|
||||
|
||||
2. **Otherwise regenerate the derived projections from the roster SSOT:**
|
||||
|
||||
```bash
|
||||
mosaic fleet regen # confirm the plan (create vs rebuild per agent)
|
||||
mosaic fleet regen --write # rebuild fleet/agents/<name>.env.generated
|
||||
```
|
||||
|
||||
3. **Verify each unit will resolve the intended runtime/workdir _before_ any
|
||||
restart.** The unit sets **no** `EnvironmentFile=` — it launches from a minimal
|
||||
environment and `start-agent-session.sh` sources `.env.generated` itself, so
|
||||
verify the generated file directly and confirm the launcher path:
|
||||
|
||||
```bash
|
||||
# Confirm fleet/agents/<name>.env.generated exists and carries the intended
|
||||
# MOSAIC_AGENT_* values (name, runtime, model, workdir, socket).
|
||||
test -f ~/.config/mosaic/fleet/agents/<name>.env.generated
|
||||
# Confirm the unit launches the session script that reads it.
|
||||
systemctl --user cat mosaic-agent@<name> | grep ExecStart
|
||||
```
|
||||
|
||||
4. **Only then restart, one unit at a time:**
|
||||
|
||||
```bash
|
||||
systemctl --user restart mosaic-agent@<name>
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- Design: [`docs/design/791-upgrade-config-protection.md`](../design/791-upgrade-config-protection.md)
|
||||
- Fleet operations: [`docs/guides/fleet-local-canary.md`](./fleet-local-canary.md)
|
||||
- Ownership SSOT: [`packages/mosaic/framework/framework-manifest.txt`](../../packages/mosaic/framework/framework-manifest.txt)
|
||||
@@ -1,54 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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.
|
||||
529
docs/scratchpads/791-upgrade-config-protection.md
Normal file
529
docs/scratchpads/791-upgrade-config-protection.md
Normal file
@@ -0,0 +1,529 @@
|
||||
# Scratchpad — #791 Upgrade config protection (ms-791 worker lane)
|
||||
|
||||
**Lane:** web1:ms-791 → reports to MS-LEAD (web1:mosaic-100). Do NOT contact Jason/Mos directly.
|
||||
**Worktree:** `/home/hermes/agent-work/stack-agents-dir-791`, branch `feat/791-upgrade-config-protection`
|
||||
off `origin/main` `9745bc3f` (verified exact head).
|
||||
|
||||
## Mission prompt (verbatim intent)
|
||||
Protect operator-owned config under `~/.config/mosaic` from framework-upgrade wipes. Ratified
|
||||
combination (Mos-approved, do NOT re-litigate): (b) strict ownership separation [PRIMARY] + (a)
|
||||
transactional pre-update snapshot [safety net] + (d) regeneration-from-SSOT [recovery]. (c) periodic
|
||||
timer DEFERRED. HARD GATE: unit test that an upgrade run touches NO path outside the manifest.
|
||||
Design-first: write design doc, send to MS-LEAD, WAIT for confirmation before impl.
|
||||
|
||||
## Session 1 (2026-07-16) — Phase 1 design
|
||||
|
||||
### Evidence gathered (wipe mechanism, file/line)
|
||||
- `mosaic update` → `update-checker.ts:509` `buildReseedCommand` → `bash install.sh`
|
||||
(`MOSAIC_SYNC_ONLY=1`, `MOSAIC_INSTALL_MODE=keep`).
|
||||
- Wipe = `packages/mosaic/framework/install.sh:199` `rsync -a --delete` + `PRESERVE_PATHS` denylist
|
||||
(`install.sh:47`). cp-fallback `install.sh:223` `find ... -exec rm -rf`.
|
||||
- Denylist gaps → WIPED: `agents/*.conf`, `policy/*.md`, `*.local.md`, harvester/SOP,
|
||||
`tools/_lib/credentials.json`.
|
||||
- Stale comment `update-checker.ts:492` claims `*.local` preserved — PRESERVE_PATHS has no such entry.
|
||||
- TS path `file-adapter.ts:157` → `file-ops.ts:66` `syncDirectory` = non-destructive copy-overlay, BUT
|
||||
its preserve list (`file-adapter.ts:164`) already DRIFTED from install.sh (missing `fleet/backlog`,
|
||||
`fleet/roles.local`). Evidence for single shared manifest SSOT.
|
||||
- Existing snapshot (`install.sh:76`) = /tmp, crash-trap only, deleted on success → inadequate; no
|
||||
`mosaic restore`.
|
||||
- `fleet-reconciler.ts:93,234` already has `regenerate-projections-from-roster` phase separate from
|
||||
lifecycle → `mosaic fleet regen` = thin projection-only wrapper (no restart), no FCM-M4/M5 preemption.
|
||||
|
||||
### Design decisions
|
||||
- **(b)** Invert to allow-list: shared `framework/framework-manifest.json` (framework globs +
|
||||
operatorReserved carve-outs); resolve per-path, deny-wins; **UNKNOWN ⇒ operator (fail-safe)**.
|
||||
Mechanism: drop `--delete`; non-deleting bulk copy + explicit manifest-scoped prune pass (iterate
|
||||
framework globs only → operator/unknown structurally unreachable). Pure prune-planner fn for tests.
|
||||
- **(a)** Snapshot to `~/.local/state/mosaic/backups/pre-update-<ts>/` 0700/0600, retention N=5,
|
||||
post-sync verify+restore, `mosaic restore --list/--from`. No secret values in output.
|
||||
- **(d)** `mosaic fleet regen` projection-only, preview-first, never restart.
|
||||
- HARD GATE test includes a deliberately-unanticipated operator path to prove fail-safe default.
|
||||
- **PR split:** PR1 manifest+guard (root fix, ships alone) → PR2 snapshot/restore (secrev) → PR3
|
||||
regen+docs. PR2/PR3 depend on PR1.
|
||||
|
||||
### Status
|
||||
Design doc written: `docs/design/791-upgrade-config-protection.md`. Sent to MS-LEAD.
|
||||
|
||||
## Session 1 (cont.) — MS-LEAD CONFIRMED → Phase 2 GO
|
||||
All 4 asks approved. Binding conditions:
|
||||
- TDD tests-first, red-first proof per PR; ≥85% new-code; co-located `*.spec.ts`; never `--no-verify`.
|
||||
- HARD GATE test (§2.4, unanticipated sentinel survives byte-identical + mtime unchanged) = MERGE-BLOCKING for PR1.
|
||||
- Manifest-completeness test (§6.2) required.
|
||||
- Bash+TS read ONE shared `framework-manifest.json`; parity test (§6.1) required (closes #631 drift class).
|
||||
- UNKNOWN⇒operator (rule 3) non-negotiable. Keep prune-planner PURE.
|
||||
- `fleet regen`: NEVER restart; dry-run default, `--write` to apply; "never issues restart" test mandatory.
|
||||
- Independent review every PR; PR2 dedicated secrev.
|
||||
- One PR at a time through DAG. Report PR1 exact head + red→green evidence for review commission.
|
||||
|
||||
### Now: implementing PR1 (manifest + resolver + non-deleting sync + scoped prune + guard tests).
|
||||
|
||||
## Session 2 (2026-07-16) — PR1 built, tests-first, red→green proven
|
||||
|
||||
Deviation noted to MS-LEAD in PR: manifest is `framework-manifest.txt` (line-oriented), NOT `.json`.
|
||||
Rationale: keep the bash installer free of a python3/jq dependency. The "ONE shared file, parity-
|
||||
tested" requirement is honored — `manifest-parity.spec.ts` drives the bash resolver as a subprocess
|
||||
and asserts byte-identical ownership vs the TS resolver over 34 probe paths spanning every class.
|
||||
|
||||
### PR1 artifacts
|
||||
- SSOT: `packages/mosaic/framework/framework-manifest.txt` ([framework]/[operator], deny-wins, fail-safe).
|
||||
- TS resolver: `src/framework/manifest.ts` (pure: parse/matchGlob/resolveOwnership/frameworkSubtreeRoots/
|
||||
planPrune) + `manifest.spec.ts` (18 tests incl. planPrune property test + §6.2 completeness).
|
||||
- Bash resolver: `framework/tools/_lib/manifest.sh` (compiled globs → fork-free `manifest_is_framework`;
|
||||
CLI `resolve|subtree-roots|classify`). Sourced by install.sh.
|
||||
- HARD GATE (§2.4): `framework/tools/quality/scripts/test-upgrade-manifest-guard.sh` — keep-mode reseed,
|
||||
10 operator sentinels (incl. unanticipated `unknown-operator-dir/x`, `harvester/sop.md`,
|
||||
`fleet/my-fleet.yaml`) survive byte-identical + mtime-unchanged; retired framework file pruned;
|
||||
secret value absent from output. RED=31 fail (orig install.sh) → GREEN=48 pass (fixed).
|
||||
- install.sh: keep mode now manifest-driven (`sync_framework_keep`, no `--delete`); overwrite unchanged.
|
||||
PRESERVE_PATHS denylist deleted.
|
||||
- TS sync: `file-ops.syncDirectory` gains `isOperatorOwned` guard; `file-adapter.syncFramework` derives
|
||||
it from `loadManifest` — hardcoded (drifted) preservePaths deleted. Fixture uses the REAL manifest.
|
||||
- Parity: `manifest-parity.spec.ts` (§6.1) — bash↔TS agree on 34 paths + subtree roots.
|
||||
- Migration matrix `test-install-migration.sh`: F6 flipped — `my-fleet.yaml` now MUST survive (fail-safe).
|
||||
- CI: new merge-blocking `upgrade-guard` step (`.woodpecker/ci.yml`) runs both bash suites (adds rsync).
|
||||
- update-checker.ts reseed comment corrected to the manifest model.
|
||||
|
||||
### Gates (all green)
|
||||
- `pnpm typecheck` ✓ · `pnpm lint` ✓ · `pnpm format:check` ✓
|
||||
- Full mosaic vitest: 1062 passed (cli-smoke needs `pnpm build` first — build-artifact dep, not this change).
|
||||
- HARD GATE 48/48 · migration 21/21 · parity 3/3 · manifest 18/18 · file-adapter 8/8.
|
||||
|
||||
### PR opened + reported (2026-07-16)
|
||||
- **PR #802** http://git.mosaicstack.dev/mosaicstack/stack/pulls/802 — base `main`@`9745bc3f`,
|
||||
head `34e55d4a` (commit `feat(mosaic): manifest-owned upgrade guard…`). 15 files, +1160/-142.
|
||||
- Reported PR head + red→green evidence to MS-LEAD (web1:mosaic-100); queued (lead busy).
|
||||
Standing by for the independent-review commission at head `34e55d4a`.
|
||||
- **TWO items flagged to MS-LEAD for decision (awaiting reply):**
|
||||
1. Deviation `.txt` vs `.json` — confirm accept (parity-tested) or convert to `.json`+jq.
|
||||
2. `pr-create -i 791` appended `Fixes #791` → would auto-close the tracking issue on PR1 merge
|
||||
while PR2/PR3 remain. Recommended edit to `Part of #791`; awaiting go-ahead to patch PR body.
|
||||
- DO NOT start PR2/PR3 until PR1 merges (DAG; one PR at a time).
|
||||
|
||||
### MS-LEAD ruling → #797 ledger-survival sentinel folded into PR1 (2026-07-16)
|
||||
MS-LEAD ruled both my decisions: (1) `.txt` format ACCEPTED (parity must be strict/merge-blocking incl.
|
||||
format edge cases + negative probe); (2) trailer `Fixes #791`→`Part of #791` APPROVED (patched PR #802
|
||||
body via Gitea API — tracking issue no longer auto-closes on PR1 merge). Plus Mos-ELEVATED merge-blocker
|
||||
(spec `~/agent-work/planning/epic-796/791-ledger-survival-sentinel-SPEC.md`): #797 Runtime Session Ledger
|
||||
must survive upgrade. Two coupled deliverables landed in PR1:
|
||||
- (i) Carve-out: `fleet/run/**` was ALREADY an explicit `[operator]` entry — glob matches the spec's
|
||||
pinned `fleet/run/**` EXACTLY, so NO divergence to route back to planner-opus. Strengthened its comment
|
||||
to name the ledger (`fleet/run/sessions/` events.ndjson + ledger.json) so it is unmistakably load-bearing.
|
||||
- (ii) HARD-GATE sentinel: seeded populated ledger (events.ndjson 3 events + ledger.json node+edge+gen,
|
||||
0600 under 0700) into test-upgrade-manifest-guard.sh sentinels; asserts byte-identical + mtime-unchanged
|
||||
+ dir-perms unchanged. Negative control (retired framework file IS pruned) relabeled explicitly.
|
||||
HARD GATE now 58/58 (was 48).
|
||||
- Decision-1 parity hardening: format-edge fixtures (comments/blanks/whitespace, duplicate+overlapping
|
||||
globs deny-wins, section/glob-ordering independence) + explicit UNKNOWN→operator negative probe, driven
|
||||
through BOTH resolvers via MANIFEST_FILE override. Parity 7/7 (was 3).
|
||||
- RED-FIRST honesty note: the bash ledger sentinel stays GREEN even against the pre-fix installer (the
|
||||
ledger was incidentally safe from the rsync --delete bug; overall pre-fix run 30/58 as expected). The
|
||||
carve-out's TRUE load-bearing value (deny-wins if framework ownership ever broadens to `fleet/**`) is
|
||||
isolated by a dedicated resolver-seam red→green in manifest.spec.ts: WITHOUT `fleet/run/**` operator
|
||||
entry + hypothetical `fleet/**` framework → ledger resolves framework and planPrune DELETES it (RED);
|
||||
WITH the carve-out → deny-wins → operator, unprunable (GREEN). manifest.spec.ts 21/21 (was 18).
|
||||
- Gates all green: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1069 passed · HARD GATE 58/58
|
||||
· migration 21/21. Committing FORWARD on the branch (NOT rebasing 34e55d4a out from under review).
|
||||
|
||||
### MS-LEAD REQUEST CHANGES @ 0a5e703a → B1/B2/B3 fixed red-first (2026-07-16)
|
||||
MS-LEAD returned REQUEST CHANGES (routed merge-blockers satisfied; 2 CRITICAL reliability defects from
|
||||
the commissioned independent review). Fixed forward on the branch, red-first:
|
||||
- **B1 (CRITICAL) — dead ERR trap.** install.sh had `set -euo pipefail` (no `-E`), so the
|
||||
`trap restore_snapshot ERR` never fired for a failure inside sync_framework_keep() (function body) —
|
||||
a mid-sync abort left a half-written target with NO rollback. Fix: `set -Eeuo pipefail` (errtrace) +
|
||||
disarm the trap at the top of restore_snapshot() to prevent re-entrancy. New gate
|
||||
`test-upgrade-rollback.sh`: injects a mid-sync `cp` EACCES (read-only divergent framework file);
|
||||
Part A asserts the shipped installer rolls back (restore message fires AND target byte-identical to
|
||||
pre-upgrade); Part B control strips `-E` and asserts the rollback message does NOT fire (dead trap) —
|
||||
self-verifying red→green. 7/7.
|
||||
- **B2/B3 (CRITICAL) — empty/unreadable/malformed manifest divergence.** Pre-fix: TS `parseManifest('')`
|
||||
returned `{framework:[],operator:[]}` (NO throw) → silent no-op "Installation complete"; bash aborted
|
||||
fragilely (the `_manifest_compile` `"${MANIFEST_OPERATOR[@]:-}"` artifact returned 1 with no message)
|
||||
AND the CLI dispatch swallowed manifest_load's rc (no `|| exit`) so `resolve` exited 0 resolving
|
||||
everything operator. Fix (fail-loud + identical both langs):
|
||||
* TS `parseManifest`: throw on zero framework entries; `loadManifest`: wrap read error →
|
||||
"Cannot read framework manifest …".
|
||||
* bash `manifest_load`: explicit unreadable guard (`[[ ! -r ]]`) + zero-`[framework]` guard, both loud
|
||||
stderr + return 1; `_manifest_compile` gets explicit `return 0` (kills the empty-array artifact);
|
||||
CLI dispatch `manifest_load … || exit 1`.
|
||||
* `finalize.ts`: wrap syncFramework → `spin.stop('Framework sync aborted …')` + rethrow (never falls
|
||||
through to "Installation complete").
|
||||
Tests: manifest.spec.ts +5 fail-closed (empty/comment-only/operator-only/empty-section/missing);
|
||||
manifest-parity.spec.ts +7 failure-mode parity (both reject empty/comment-only/operator-only/
|
||||
empty-section/entry-before-header/unknown-header/missing — TS throws, bash CLI exits non-zero+stderr);
|
||||
HARD GATE +4 end-to-end fail-closed matrices (empty/operator-only/malformed/missing → abort non-zero,
|
||||
manifest error surfaced, every operator sentinel byte-identical). RED proven by reverting
|
||||
manifest.ts+manifest.sh to HEAD → 12 new tests fail; restore → 40/40 green.
|
||||
- **Non-blocking addressed.** MEDIUM install.sh:222 find-empty now warns on a real failure instead of
|
||||
blanket `|| true`. LOW: corrected the "both destructive paths rsync vs cp" overstatement in the HARD
|
||||
GATE header + cp-fallback comment + ci.yml (keep mode is a single cp-based path; the rsync-present vs
|
||||
-absent runs prove rsync-independence). `.pre-constitution.bak` triage: single-shot backup is
|
||||
intentional (reconcile_framework_files backs up once), no change.
|
||||
- Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1081 passed (was 1069, +12) · HARD GATE
|
||||
118/118 (was 58) · rollback 7/7 (new) · migration 21/21. No --no-verify. Rollback test wired into
|
||||
ci.yml upgrade-guard. Committing FORWARD (no rebase of 34e55d4a/0a5e703a).
|
||||
|
||||
### Codex round 2 (pre-push self-review) → blockers A/B + should-fix C fixed red-first (2026-07-16)
|
||||
Before committing round 1 I re-ran codex on the change set; it surfaced two fresh reliability defects
|
||||
and one messaging defect on the SAME rollback/manifest path. Fixed forward, red-first:
|
||||
- **Blocker-A (CRITICAL) — signal trap resumed instead of terminating.** A bash INT/TERM handler that
|
||||
merely `restore_snapshot` (returns) does NOT terminate the script — execution RESUMES past the
|
||||
interrupt, cleans the snapshot and reports success, leaving a partial post-interrupt update. Fix:
|
||||
`trap 'restore_snapshot; exit 1' ERR INT TERM` so both the errtrace (ERR) and signal (INT/TERM) paths
|
||||
exit non-zero. Rollback test Part C: a `cp` shim that `kill -TERM $PPID` mid-sync then succeeds (so
|
||||
set -e never fires and only the signal path governs) → asserts abort non-zero + restore fires + does
|
||||
NOT print "file phase complete"; control strips `exit 1` and asserts the buggy resume-to-success.
|
||||
- **Blocker-B (CRITICAL) — degenerate `[framework]` section resolved everything operator.** A manifest
|
||||
whose framework entries are all empty / bare-dot (`/`, `./`, `.`, `..`) passed the non-empty guard yet
|
||||
yielded zero usable globs → nothing is framework → a keep-mode sync silently no-ops (bash resolved
|
||||
`operator`, exit 0). Fix (both langs, parity): reject when no entry has a char other than `/`/`.` —
|
||||
TS `isUsableFrameworkGlob` = `/[^/.]/.test(normalizeRel(glob))`, throws `ManifestError`; bash mirror
|
||||
loops `[[ "$(_manifest_norm "$_g")" =~ [^/.] ]]`, loud stderr + return 1. Tests: manifest.spec.ts
|
||||
`it.each(['/','./','.','..','/\n./'])` throw; parity +3 `expectBothReject` (root-slash/dot-slash/
|
||||
bare-dot). RED: reverting the guard makes `[framework]\n/` resolve `operator` exit 0.
|
||||
- **Should-fix-C — misleading abort message.** finalize.ts printed one generic "may be partially
|
||||
applied" for every sync failure. A `ManifestError` is a PRE-sync validation abort (manifest is
|
||||
validated before any copy) → nothing was written; conflating it with a mid-copy failure misdirects
|
||||
recovery. Fix: introduce `ManifestError` (exported from manifest.ts, thrown by every fail-closed
|
||||
parse/load path), and classify in finalize.ts — ManifestError → "no files were changed"; any other →
|
||||
"may be partially applied". New co-located `finalize-sync-abort.spec.ts` (3 tests) asserts both
|
||||
branches re-throw the original error + the correct message, and that config writes are never reached.
|
||||
RED proven by collapsing the classification → the ManifestError test fails.
|
||||
- Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1094 (was 1081, +3 finalize-abort;
|
||||
manifest specs already counted) · HARD GATE 193/193 · rollback 14/14 · migration 21/21.
|
||||
|
||||
### Codex round 3 (pre-push self-review) → blockers D1/D2 fixed red-first (2026-07-16)
|
||||
Re-ran codex again; it found two more rollback-path gaps `set -E` cannot catch. Fixed forward, red-first:
|
||||
- **Blocker-D1 (CRITICAL) — `find` scan failures swallowed by process substitution.** Both the overlay
|
||||
copy and the scoped prune consumed `< <(find … -print0)`. Bash does NOT propagate the producer's exit
|
||||
status to the `while`, so an EACCES/I/O failure mid-scan truncates the file list yet leaves the loop
|
||||
exiting 0 → a partial upgrade commits and reports success; the ERR/restore trap never fires. Fix:
|
||||
`_scan_or_die` runs `find … -print0 > "$tmp"` to completion, checks its status, and returns non-zero
|
||||
(→ ERR trap → restore) on failure; both loops now read from the checked temp file. Rollback test
|
||||
Part D: a `find` shim that fails every `-print0` scan → shipped installer aborts non-zero + restores +
|
||||
emits "Could not enumerate framework files" + target byte-identical; control neuters the `# D1-GUARD`
|
||||
`return 1` → find failure swallowed, upgrade wrongly reports "file phase complete", no rollback.
|
||||
- **Blocker-D2 (CRITICAL) — silent `set -e` exit on a failed target reset.** restore_snapshot did a bare
|
||||
`rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"` (trap disarmed, under set -e). If `rm`/`mkdir` fails —
|
||||
possibly after `rm` deleted part of the target — the script exits immediately, skipping the cp AND the
|
||||
recovery pointer, leaving a half-removed target and an orphaned snapshot the operator can't locate.
|
||||
Fix: `if ! rm -rf … || ! mkdir -p …; then fail "Snapshot restore could not reset … preserved at:
|
||||
$SNAPSHOT_DIR — copy it back …"; return 1; fi` (tested like the cp -a check; snapshot NOT deleted).
|
||||
Rollback test Part E: cp-poison triggers restore + an `rm` shim fails `rm -rf <TARGET>` → shipped
|
||||
emits the recovery pointer, the named snapshot dir survives, secret value never leaked; control deletes
|
||||
the recovery line → operator gets no pointer. RED: reverting D1+D2 → 7 shipped/control assertions fail.
|
||||
- Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1094 · HARD GATE 193/193 ·
|
||||
rollback 28/28 (was 14, +14 for D1/D2 with controls) · migration 21/21. shellcheck clean on new lines.
|
||||
No --no-verify. Committing FORWARD (no rebase of 34e55d4a/0a5e703a).
|
||||
|
||||
## Session 3 (2026-07-16) — PR1 MERGED, starting PR2 (durable snapshot + restore + secrev)
|
||||
PR1 (#802) squash-merged → main `32a0ffba`; issue #791 stays open (3-PR DAG umbrella). Independent Opus
|
||||
adversarial/security review APPROVED at head `af627e75` (Gitea RoR cmt 17892); lead ran rollback 28/28 +
|
||||
HARD GATE 193/193 green; CI #1877 green. PR2 UNBLOCKED.
|
||||
|
||||
PR2 branch: `feat/791-pr2-snapshot-restore` off `origin/main` 32a0ffba. Same treatment applies:
|
||||
tests-first red-first, independent review + durable Gitea Reviewer-of-Record comment BEFORE MS-LEAD runs
|
||||
the queue guard/merge. Report PR2 number + exact head when ready. PR body: `Part of #791` (NOT Fixes).
|
||||
|
||||
### PR2 scope (ratified §3/§5 of design doc, Mos-approved — do NOT re-litigate)
|
||||
- **(a) Durable pre-update snapshot** to `${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-ts>/`
|
||||
— OUTSIDE ~/.config/mosaic and any repo. Perms dir 0700 / files 0600 (umask 077 + explicit chmod).
|
||||
Scope = operator-owned surface that EXISTS (operatorReserved paths), not the framework tree. Taken
|
||||
BEFORE any mutation. Retention N=5 (`MOSAIC_BACKUP_RETENTION`), prune older.
|
||||
- **Post-sync verify + selective restore**: diff operator surface vs snapshot; (b) should never touch
|
||||
operator paths, so ANY diff = manifest bug → restore affected paths + warn loudly. (a) catches a (b) miss.
|
||||
- **`mosaic restore`** (TS CLI): `--list` (default, dry-run) enumerates snapshots by ts; `--from <ts>`
|
||||
restores over operator surface, confirmation-gated. Counts/paths only.
|
||||
- **Secret-safety (secrev)**: snapshot/restore NEVER emit file contents; only paths/counts. Tests assert
|
||||
0700/0600 AND that a secret value seeded in tools/_lib/credentials.json never appears in any output.
|
||||
|
||||
### PR2 implementation status (2026-07-16, ready-for-review)
|
||||
All three tasks implemented, red-first proven, unit-green:
|
||||
- **Task #10 — durable snapshot (install.sh)**: `backup_root()`/`enumerate_operator_files()`/
|
||||
`prune_durable_snapshots()`/`make_durable_snapshot()` wired into keep-mode main() after `manifest_load`,
|
||||
before any mutation. umask 077 + explicit chmod 700/600. UTC ts, collision suffix. FAIL-OPEN (a backup
|
||||
failure never aborts the upgrade it protects). Retention `MOSAIC_BACKUP_RETENTION` (default 5), in-place
|
||||
`sort -r -o` prune (no `mv` — stays inside the rsync-absent coreutils whitelist).
|
||||
- **Task #11 — post-sync verify net (install.sh)**: `verify_operator_surface()` runs after sync (trap
|
||||
disarmed), `cmp -s` each snapshot file vs target; restores any diverged/missing operator file + warns
|
||||
loudly (a divergence = manifest bug). VERIFY-NET wired before `cleanup_snapshot`.
|
||||
- **Task #12 — `mosaic restore` (TS)**: `src/commands/restore.ts` + co-located spec (19 tests).
|
||||
`--list` default (dry-run enumerate), `--from <ts>` confirmation-gated restore, `--dry-run`, `--yes`/
|
||||
`MOSAIC_ASSUME_YES`. Injectable `confirm` for testability (proceed/decline/env-bypass covered). Restored
|
||||
files forced 0600. Registered in `cli.ts`. Path convention mirrors install.sh `backup_root()`.
|
||||
- **CI**: `.woodpecker/ci.yml` upgrade-guard runs the new `test-upgrade-durable-snapshot.sh` gate.
|
||||
- **Gates green**: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1241 (+5) ·
|
||||
durable-snapshot 26/26 · manifest-guard 193/193 · rollback 28/28 · migration 21/21.
|
||||
Est. new-code coverage ≈93% (only the interactive readline default + process.exit-on-error uncovered).
|
||||
- Regression fixed: PR2's `date`/`sort`/`mv` broke the rsync-absent manifest-guard PATH whitelist →
|
||||
made date/sort fail-open, replaced `mv` with in-place `sort -o`, added `date sort` to the test whitelist
|
||||
+ isolated `XDG_STATE_HOME`. All 193 manifest-guard assertions green under restricted PATH.
|
||||
- Codex code-review + security-review (secrev) run on the uncommitted diff before commit.
|
||||
|
||||
### PR2 review round 1 — findings + remediations (2026-07-16, pre-PR)
|
||||
Codex code-review returned **request-changes** (1 blocker + 3 should-fix); Codex security-review returned
|
||||
**high** (1 high + 1 medium). Deduped to 5 distinct defects, ALL legitimate, ALL fixed FORWARD, each with
|
||||
a red-first regression test whose control neuters exactly the guard under test:
|
||||
|
||||
- **A · BLOCKER — verify net undid the legacy bin/ migration (install.sh).** On a pre-v2 install `bin/**`
|
||||
is operator-classified, so the durable snapshot captured it; `run_migrations()` deletes bin/ on purpose,
|
||||
but `verify_operator_surface()` then saw it "missing" and healed it back — the migration would be silently
|
||||
undone forever once the version stamps. **Fix:** `MIGRATION_REMOVED_PATHS[]` recorded by run_migrations
|
||||
(`bin`,`rails`) + `is_migration_removed()` skip in the verify loop (`# MIGRATION-SKIP-GUARD`).
|
||||
**Test:** Part 6 — v1 fixture with bin/; shipped keeps it removed + stamps v3; control (guard stripped)
|
||||
wrongly restores bin/tool.sh.
|
||||
- **B · HIGH (CWE-59) — restore/verify wrote secrets THROUGH a symlink (install.sh + restore.ts).** An
|
||||
attacker swapping an operator path (e.g. tools/_lib/credentials.json) for a symlink after the snapshot
|
||||
would make `cp`/`copyFileSync` write the snapshot's secret out through the link. **Fix (bash):** refuse a
|
||||
symlinked ancestor (`has_symlinked_parent`), drop a symlinked leaf before restore
|
||||
(`# SYMLINK-LEAF-GUARD`). **Fix (TS):** reuse audited `secure-file.ts` — `assertCanonicalContainment`
|
||||
+ `ensureManagedDirectory` on every dst, open the leaf `O_NOFOLLOW|O_CREAT|O_TRUNC` 0600 (ELOOP =
|
||||
fail-closed). **Tests:** Part 7 (shipped leaves external exfil target untouched, restores a real 0600
|
||||
file; control leaks the secret through the link) + restore.spec symlinked-leaf/ancestor cases (red-first).
|
||||
- **C · MEDIUM/should-fix (CWE-22) — `--from` traversal escaped the backup root (restore.ts).**
|
||||
`join(root, from)` accepted `../poison`. **Fix:** validate the selector against
|
||||
`^\d{8}T\d{6}Z(?:-\d+)?$`, build exactly `join(root,'pre-update-'+ts)`, `lstat` (reject symlinked snap
|
||||
dir). **Test:** restore.spec `it.each` of 6 malformed selectors + `--from ../poison` fail-closed (red-first).
|
||||
- **D · should-fix — verify `mkdir -p` unguarded under set -e (install.sh).** A parent replaced by a
|
||||
regular file aborted the installer before the recovery pointer printed. **Fix:** guard `mkdir -p`, warn
|
||||
+ `continue` on failure (keeps healing remaining files).
|
||||
- **E · should-fix — snapshot `umask 077` leaked process-global (install.sh).** Later sync copies/dirs
|
||||
inherited 0600/0700. **Fix:** save `old_umask`, restore on EVERY return path (`# UMASK-RESTORE-NORMAL`).
|
||||
**Test:** Part 8 — synced framework file is 0644 while the secret backup stays 0600; control (restore
|
||||
stripped) makes the synced file 0600.
|
||||
|
||||
**Full gate suite re-run after fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic
|
||||
vitest **1252** · restore.spec **30** · durable-snapshot **41** · manifest-guard 193 · rollback 28 ·
|
||||
migration 21. shellcheck clean on all new lines; new test markers mirror the existing `# VERIFY-NET`
|
||||
anchor convention. NOTE: codex self-review does NOT satisfy the independent-review gate — an independent
|
||||
(author≠reviewer) review + durable Gitea Reviewer-of-Record comment is still required before MS-LEAD merges.
|
||||
|
||||
## Session 4 (2026-07-16) — PR2 MERGED, PR3 built (fleet regen — recovery layer)
|
||||
PR2 (#811) squash-merged → main `31607a4a`; issue #791 stays open (final PR of the 3-PR DAG). Independent
|
||||
exact-head RoR at `d12c5f78` APPROVE (Gitea cmt 17904); #1882 green; busybox-portable Part 7 control fix
|
||||
verified in-Alpine. PR3 UNBLOCKED.
|
||||
|
||||
PR3 branch: `feat/791-pr3-fleet-regen` off `origin/main` 31607a4. Same discipline: tests-first red-first,
|
||||
independent review + durable Gitea RoR BEFORE MS-LEAD runs the queue guard/merge. PR body `Part of #791`.
|
||||
|
||||
### PR3 scope (ratified §4/§7 of design doc) — `mosaic fleet regen`
|
||||
Projection-only recovery command: rebuilds each `fleet/agents/<name>.env.generated` from `roster.yaml`
|
||||
(SSOT). Dry-run default; `--write` applies; `--json` machine output. Structural guarantee: NO code path to
|
||||
systemd lifecycle — **never restarts an agent**. Single-SSOT: reuses `projectRosterV2AgentGeneratedEnv`
|
||||
(extracted, shared with the reconciler apply path) so regen and reconcile cannot drift. Secrev: paths +
|
||||
counts only, never the rendered KEY=value body.
|
||||
|
||||
New files: `commands/fleet-regen-command.ts` (+ `.spec.ts`), guide `docs/guides/upgrade-safety-and-recovery.md`
|
||||
(three-layer model: PR1 manifest ownership → PR2 snapshot/restore → PR3 regen; do-NOT-restart-before-verify
|
||||
runbook), regen reference added to `docs/guides/fleet-local-canary.md`. Wired in `commands/fleet.ts`.
|
||||
|
||||
### Independent review (3 reviewers: subagent code-reviewer + codex code-review + codex security) → 4 fixes, red-first
|
||||
- **A · BLOCKER (codex) — regen mutated/deleted legacy operator env.** `applyPreparedAgentEnvironmentProjection`
|
||||
also writes `.env.local`/`.env.quarantine` and unlinks legacy `.env`. Violated projection-only contract.
|
||||
**Fix:** NEW generated-only boundary primitives `prepareGeneratedAgentEnvironmentProjection` +
|
||||
`applyPreparedGeneratedAgentEnvironmentProjection` (write ONLY `<name>.env.generated`). regen now has no
|
||||
code path that touches `.env`/`.env.local`/`.env.quarantine`. **Test:** projection-only leaves legacy `.env`
|
||||
verbatim, no local/quarantine fabricated.
|
||||
- **B · should-fix (codex + subagent + security) — partial write on mid-loop failure.** Interleaved
|
||||
prepare/apply left earlier agents written when a later agent failed prepare. **Fix:** PREPARE ALL agents
|
||||
before writing ANY (mirrors reconciler `defaultPrepareProjections`). **Test:** 2nd agent's projection
|
||||
pre-seeded 0644 → prepare rejects → coder0 NOT written, exit 1.
|
||||
- **C · subagent — semantic-validation bypass.** Default readRoster skipped `validateRosterV2Semantics`, so
|
||||
a tampered protected-class `tool_policy` would be silently projected. **Fix:** default readRoster now runs
|
||||
`validateRosterV2Semantics` (persona resolution + protected-class match), rolesDir/overrideDir defaults
|
||||
mirroring the reconciler. **Test:** merge-gate agent w/ tool_policy=code → fails closed, no write.
|
||||
- **D · MEDIUM (codex security, CWE-362) — concurrent-reconcile race.** regen `--write` wrote without the
|
||||
reconcile lock. **Fix:** `--write` acquires `acquirePrivateReconcileLock(mosaicHome)` for the whole
|
||||
read-prepare-apply sequence, released in `finally`; dry-run stays lock-free. **Test:** pre-held lock →
|
||||
regen fails closed, no write.
|
||||
|
||||
**Gate suite after fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest **1265**
|
||||
(regen spec 13, incl. 4 new red-first regressions). NOTE: codex self-review does NOT satisfy the
|
||||
independent-review gate — an independent (author≠reviewer) review + durable Gitea RoR is still required
|
||||
before MS-LEAD merges. STOP at PR-open for MS-LEAD's exact-head review; do NOT self-merge.
|
||||
|
||||
## Session 5 — PR3 review round 2 (finding L + M1/M2/M3), red-first fixes
|
||||
|
||||
Second review pass on the lock-cleanup plumbing surfaced one round-1 residual (L) and three round-2
|
||||
findings (M1 blocker, M2/M3 should-fix). All fixed red-first (RED proven per-finding, then GREEN).
|
||||
|
||||
- **L · should-fix (codex r1) — mutation-lock release swallowed unlink failures.** regen's
|
||||
`acquirePrivateRosterMutationLock` release copied CRUD's `unlink().catch(()=>{})`, hiding a stale
|
||||
`roster.yaml.mutation.lock`. **Fix:** its release PROPAGATES the unlink fault (finding-J stale-lock
|
||||
warning then fires for this lock too). **Test:** acquire real lock, `rm` it, assert `release()` rejects.
|
||||
- **M1 · BLOCKER (codex r2) — replacement-lock race.** The propagating release from L did an
|
||||
UNCONDITIONAL `unlink(lockPath)` without proving ownership. If the lock is cleared + re-created by
|
||||
another writer mid-op, regen deletes the STRANGER's live lock → a third writer enters → mutual
|
||||
exclusion defeated. **Fix (reuse, not reimplement):** generalized the reconciler's ownership-proving
|
||||
lock body into shared `acquirePrivateManagedRosterLock(mosaicHome, lockLeaf, busyMessage, openLock)`;
|
||||
`acquirePrivateReconcileLock` delegates to it (behavior-identical: same leaf/codes/messages), and a NEW
|
||||
hardened `acquirePrivateRosterMutationLock` (now in fleet-reconciler.ts, leaf `roster.yaml.mutation.lock`)
|
||||
records dev/ino + ownership token and RE-PROVES ownership (`assertLockOwnership`) before unlinking —
|
||||
fails closed as `lock-cleanup-failed` if replaced. Removed the crud-based export; reverted
|
||||
`acquireMutationLock` (fleet-agent-crud.ts) to its original inline empty-file/swallowing-release form
|
||||
(CRUD behavior intentionally unchanged). Compatibility: CRUD empty-file `wx` and regen tokened `wx`
|
||||
contend on the same path but never co-own (wx winner owns; loser → concurrent-mutation), so the token
|
||||
is only ever read back by the same regen invocation. **Test:** acquire, `rm`+recreate lock (new inode),
|
||||
assert `release()` rejects AND the replacement survives (not unlinked).
|
||||
- **M2 · should-fix (codex r2) — acquire-unwind fault dropped.** The acquire-failure catch discarded
|
||||
`releaseFleetLocks`' return (a possible fault on the already-held first lock). **Fix:** capture and
|
||||
augment — `const releaseFault = await releaseFleetLocks(releases); throw augmentWithLockCleanupFault(error, releaseFault);`
|
||||
(symmetric to finding J). **Test:** mutation lock acquires w/ faulting release + reconcile acquire
|
||||
throws → thrown error mentions stale/lock, nothing written.
|
||||
- **M3 · should-fix (codex r2 + subagent REQUEST-CHANGES) — cleanup warning named only reconcile lock.**
|
||||
Finding L made the mutation-lock release fault reachable, so the `cleanup` marker can originate from
|
||||
EITHER lock. **Fix:** `formatFleetRegenReport`'s WARNING now names BOTH `roster.yaml.mutation.lock` and
|
||||
`roster.yaml.reconcile.lock`, matching `augmentWithLockCleanupFault`. **Test:** fault the mutation-lock
|
||||
release specifically → report names both lock files.
|
||||
|
||||
**Refactor note (no cycle):** neither fleet-reconciler nor fleet-agent-crud imports the other; regen
|
||||
imports lock acquirers from fleet-reconciler and the projection mapping from fleet-reconciler. The two
|
||||
reconcile-lock reviewers reconciled: independent reviewer validated acquire-time empty-file compatibility
|
||||
(preserved), codex flagged RELEASE-time replacement race (closed by ownership proof) — non-contradictory.
|
||||
|
||||
**Gate suite after fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest **1275**
|
||||
(regen spec 23, incl. 7 red-first lock regressions E/F/G/K/L/M1/M2/M3). RED proven per-finding by
|
||||
temporary revert before re-applying each fix. Independent (author≠reviewer) review of M1/M2/M3 + codex
|
||||
code/security re-run in flight. STOP at PR-open for MS-LEAD's exact-head review + durable Gitea RoR; do
|
||||
NOT self-merge; #791 umbrella stays OPEN; PR body `Part of #791`.
|
||||
|
||||
### Round 3 review (after M1/M2/M3) — independent review PASS + codex residual-TOCTOU disposition
|
||||
Three reviewers on the post-M1/M2/M3 head:
|
||||
- **Independent (subagent, author≠reviewer) — PASS.** Verified M1/M2/M3 all correctly fixed; "never
|
||||
restarts" is STRUCTURAL (runner never referenced in executable code); no secrets; no deadlock (only
|
||||
regen holds both locks); tests meaningful (assert inode preservation + exact lock-file names). Raised:
|
||||
- **should-fix #1 (fixed, red-first):** generalizing the lock helper left `assertSafeLockLeafIfPresent`/
|
||||
`assertLockOwnership` hardcoding "reconciliation lock" in thrown messages → a MUTATION-lock fault
|
||||
misreported as the reconcile lock, undercutting M3's accurate-diagnosis goal. **Fix:** thread
|
||||
`lockLabel = fleet/<leaf>` through both helpers + the generic lock-io messages, so every fault names
|
||||
the actual lock file. Red-first: strengthened the M1 test to assert `/roster\.yaml\.mutation\.lock/`
|
||||
(RED: got "reconciliation lock"; GREEN after). Also resolves nit #3 (generic-message drift).
|
||||
- **nit #2 (fixed):** `FleetRegenResult.cleanup` JSDoc still said "the shared reconcile lock"; now names
|
||||
both locks (regen holds both).
|
||||
- **nit #4 (fixed):** removed the redundant duplicate `assertLockOwnership` call before unlink
|
||||
(pre-existing in merged main; harmless but dead — dropped since the fn was already being touched).
|
||||
- **Codex security — clean (risk: none).** Validates roster semantics, constrains env values, no shell
|
||||
eval, no secret output, generated-only writes, serialized against both locks.
|
||||
- **Codex code — request-changes, 1 "blocker": residual check-then-unlink TOCTOU.** Between the final
|
||||
`assertLockOwnership` and the path-based `unlink`, an external actor could vacate our inode and a new
|
||||
writer grab the path, so the unlink deletes the stranger's lock. **Disposition: documented known
|
||||
limitation, NOT fixed in PR3.** Rationale: (1) byte-identical to the MERGED, shipped reconcile-lock
|
||||
release on origin/main (fleet-reconciler.ts L654-659) — not introduced here; (2) UNREACHABLE within the
|
||||
`wx` writer protocol — no Mosaic writer removes a lock it doesn't own (wx fails EEXIST while our inode
|
||||
exists), so only external interference can vacate our inode in the sub-instruction window; (3) the
|
||||
ownership guard DOES close the reachable case (stale-lock reaper/operator cleared our lock + another
|
||||
writer took it BEFORE release began → fail closed, don't delete stranger's lock); (4) the true atomic
|
||||
fix — fd-held advisory lock (flock/lockf) adopted by ALL fleet writers (CRUD + reconcile + regen) — is
|
||||
a cross-cutting mechanism change touching merged CRUD + reconciler, out of scope for a projection-only
|
||||
recovery PR. Documented honestly in the acquirer doc + M1 test comment. **The binding independent
|
||||
review did NOT treat this as a blocker.** Recommendation to MS-LEAD: proceed to PR-open + spin a
|
||||
SEPARATE follow-up issue for the fd-advisory-lock migration; MS-LEAD adjudicates scope at exact-head
|
||||
review (merge authority).
|
||||
|
||||
**Gates after round-3 fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest
|
||||
**1275** (regen spec 23). Fresh codex code re-run in flight to confirm no NEW issues from the label fix.
|
||||
|
||||
---
|
||||
|
||||
## Session 6 — Round 4/5 convergence (stranded-lock robustness)
|
||||
|
||||
**Two independent reviewers converged on the SAME should-fix** on the init-failure cleanup path,
|
||||
strengthening confidence it was real:
|
||||
|
||||
- **Codex code-review-5 — 0 blockers, 1 should-fix.** "Stat failure after lock creation strands the new
|
||||
lock." When `handle.stat()` ITSELF fails right after the `wx` create (transient EIO/EBADF), `created`
|
||||
is `undefined`, so `removeOwnedLockLeafBestEffort` had `if (!created) return;` → no cleanup → the
|
||||
just-created `roster.yaml.mutation.lock`/`reconcile.lock` is stranded, permanently blocking future
|
||||
regen + CRUD. (Notably NO blocker, and the TOCTOU is no longer flagged in code-review as of r5.)
|
||||
- **Independent delta reviewer (author≠reviewer, pr-review-toolkit) — no blockers, same should-fix.**
|
||||
Independently flagged the identical `!created` gap; validated FIX 1 (label threading — no call site
|
||||
missed, codes unchanged, no test depended on old text) and FIX 2 (dev/ino-guarded cleanup, best-effort,
|
||||
happy-path release reuses captured dev/ino) as correct. Suggested an unconditional best-effort unlink
|
||||
in the `!created` branch; I took the **safer** variant below.
|
||||
- **Codex security-review-5 — 0 crit / 0 high / 1 medium.** The single medium is the SAME residual
|
||||
check-then-unlink TOCTOU already dispositioned in round 3 (its own remediation = "migrate every writer
|
||||
to an fd-held advisory lock" = the follow-up issue). No new security finding. No secrets.
|
||||
|
||||
**Fix (red-first, safer than an unconditional unlink):** thread the persisted random `token` into
|
||||
`removeOwnedLockLeafBestEffort`. Two independent ownership proofs now: primary dev/ino (unchanged), and a
|
||||
**fallback** when the post-create stat failed — read the leaf and unlink ONLY if its content equals our
|
||||
`randomUUID()` token. Only OUR lock carries that token, so a CRUD (empty) or differently-tokened
|
||||
replacement is never deleted. `tokenPersisted` guards passing the token (only after `writeFile` lands).
|
||||
Doubly-degenerate case (stat fails AND token write never landed) leaves the lock in place rather than
|
||||
risk deleting a stranger's file — requires two independent fs faults on a just-created fd; documented.
|
||||
|
||||
- **Red-first proof:** new test `does not strand the lock file when the post-create stat itself fails`
|
||||
injects a real `wx` create + a Proxy handle whose `stat()` rejects (writeFile/close succeed), asserts
|
||||
`exists(lockPath) === false`. RED before fix (`expected true to be false` — lock stranded); GREEN after.
|
||||
- **Also fixed (delta nit #3):** `fleet-regen-command.ts` `acquireRosterMutationLock` JSDoc said "CRUD's
|
||||
private lock"; the default is the reconciler's hardened ownership-proving acquirer for the same
|
||||
`fleet/roster.yaml.mutation.lock` path. Corrected.
|
||||
- **PR-description note (delta nit #2):** FIX 1 also collapsed a pre-existing duplicate back-to-back
|
||||
`assertLockOwnership` call in the release closure (identical args, no intervening logic) into one — a
|
||||
no-op simplification of merged code, not a behavior change. Called out so a future reader doesn't
|
||||
wonder if the duplicate had a purpose.
|
||||
|
||||
**Gates after round-4 fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest
|
||||
**1277** (regen spec now 25: +1 stat-failure stranded-lock regression). Residual TOCTOU still deferred to
|
||||
the fd-advisory-lock follow-up issue; MS-LEAD adjudicates scope at exact-head review (merge authority).
|
||||
|
||||
---
|
||||
|
||||
## Session 6 — Round 6 (persona-root wiring)
|
||||
|
||||
**Codex code-review-6 — 0 blockers, 1 should-fix (NEW, distinct from the lock work).** "Forward
|
||||
configured persona directories to regen." `registerFleetRegenCommand` was registered at
|
||||
`fleet.ts:2069` with only `{ runner, mosaicHome }`, discarding `deps.reconcileDeps.rolesDir` /
|
||||
`overrideDir`. The regen command ALREADY has those seams (validates roster semantics via
|
||||
`validateRosterV2Semantics({ rolesDir, overrideDir })`, defaulting to `<mosaicHome>/fleet/roles{,.local}`),
|
||||
but the top-level wiring never forwarded the configured roots. **Impact:** in a deployment with custom
|
||||
persona roots, `fleet reconcile` (which honors the overrides) would ACCEPT a roster while `fleet regen`
|
||||
REJECTS the same roster (persona resolution against the wrong default dir) — blocking the recovery
|
||||
command and violating the documented "resolves personas the SAME way reconcile does" contract.
|
||||
|
||||
**Fix (red-first):** forward `rolesDir`/`overrideDir` from `deps.reconcileDeps` into
|
||||
`registerFleetRegenCommand` at `fleet.ts:2069`. Red-first test `forwards configured persona roots
|
||||
(rolesDir/overrideDir) from reconcileDeps into regen`: seeds personas ONLY under a custom root, leaves
|
||||
the default `<home>/fleet/roles` empty, registers with `reconcileDeps: { rolesDir, overrideDir }`, and
|
||||
requires `fleet regen` to SUCCEED. RED before fix (`expected 1 not to be 1` — regen validated against the
|
||||
empty default and exited 1); GREEN after.
|
||||
|
||||
**Codex security-review-6 — 0 crit / 0 high / 1 medium.** Same residual check-then-unlink TOCTOU, now
|
||||
noted at BOTH the release closure and the init-cleanup path; remediation = fd-held advisory lock across
|
||||
all writers = the SAME deferred follow-up item. No new security finding, no secrets.
|
||||
|
||||
**Independent confirmation review of the token-fallback fix (Session 6/round 4) — PASS, no findings.**
|
||||
All 7 verification points confirmed; reviewer mechanically reverted `removeOwnedLockLeafBestEffort` to
|
||||
the pre-fix `if (!created) return;` and re-ran the new test → RED (`expected true to be false`),
|
||||
confirming the test genuinely pins the fix; restored after. No lint/type issues; doc-comment accurate.
|
||||
|
||||
**Gates after round-6 fix (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest
|
||||
**1278** (regen spec now 26: +1 persona-root wiring regression).
|
||||
|
||||
---
|
||||
|
||||
## Session 6 — Round 7 convergence (review CLOSED for PR-open)
|
||||
|
||||
- **Codex code-review-7 — 0 blockers, 1 should-fix = the residual TOCTOU** (previously a "blocker" in r3,
|
||||
dropped in r4/r5, now re-surfaced as a should-fix). **Codex security-review-7 — 0 crit / 0 high /
|
||||
1 medium = the SAME residual TOCTOU.** Codex has CONVERGED: the only remaining finding across both
|
||||
streams is that one race, whose own remediation is "fd-held advisory lock shared by all fleet writers"
|
||||
= the deferred follow-up. No new distinct finding; the wiring fix introduced nothing.
|
||||
- **Independent confirmation review of the persona-root wiring fix — PASS, no findings.** Reviewer
|
||||
mechanically reverted the two forwarded lines → RED (`Roster v2 agent "coder0" class "code" does not
|
||||
resolve to a readable persona` → exit 1), restored → GREEN (26 regen + 204 fleet tests). Confirmed the
|
||||
optional-chaining fallback preserves default-deployment behavior and no type/lint issue.
|
||||
|
||||
**Review disposition for PR-open:** ALL actionable findings fixed red-first across rounds 3–6 (label
|
||||
threading, stranded-lock on init failure, stat-failure strand, persona-root wiring). The residual
|
||||
check-then-unlink TOCTOU is the ONLY open item and is DEFERRED to a follow-up issue (fd-advisory-lock
|
||||
migration across CRUD + reconcile + regen) — byte-identical to merged origin/main's reconcile-lock
|
||||
release, unreachable within the `wx` writer protocol (no Mosaic writer removes a lock it doesn't own;
|
||||
only external `rm`/a stale-lock reaper can vacate the inode mid-release), and its true fix is a
|
||||
cross-cutting mechanism change out of scope for a projection-only recovery PR. Two independent human-agent
|
||||
reviews (author≠reviewer) treated it as non-blocking. MS-LEAD adjudicates scope at exact-head review
|
||||
(merge authority); recommendation = proceed to PR-open + spin the follow-up issue.
|
||||
|
||||
**Final gates (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest **1278**
|
||||
(regen spec 26). No secret values in any snapshot/projection/report output (counts + paths only). Regen
|
||||
NEVER issues a lifecycle/restart call (load-bearing recordingRunner gate). STOP at PR-open for MS-LEAD's
|
||||
exact-head review + durable Reviewer-of-Record before any merge; do NOT self-merge.
|
||||
64
docs/scratchpads/807-glpi-partial-content.md
Normal file
64
docs/scratchpads/807-glpi-partial-content.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Issue #807 — GLPI list wrappers accept HTTP 206
|
||||
|
||||
- **Branch:** `fix/807-glpi-206`
|
||||
- **Task:** Gitea issue #807
|
||||
- **Role:** Author-only worker reporting to `mosaic-100`; no self-review or merge
|
||||
- **Started:** 2026-07-16
|
||||
|
||||
## Objective
|
||||
|
||||
Fix the shipped GLPI ticket, computer, and user list wrappers so ranged responses with HTTP 206 Partial Content render successfully while genuine HTTP failures remain non-zero errors.
|
||||
|
||||
## Scope
|
||||
|
||||
- Modify only the three affected list wrappers and a focused shell regression test.
|
||||
- Do not touch `session-init.sh`, `ticket-create.sh`, or `docs/TASKS.md`.
|
||||
- Add task-local delivery evidence here as required by the mission protocol.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add a deterministic shell harness that copies each wrapper beside stubbed `session-init.sh`, credentials, and `curl` boundaries.
|
||||
2. Prove RED against the current 200-only gates: 206 must fail before the implementation change.
|
||||
3. Update all three status gates to accept exactly 200 or 206.
|
||||
4. Prove GREEN for 206 rendering and genuine 401/500 failures, then run repository quality gates.
|
||||
5. Commit with co-author attribution, run the push queue guard, push, and open a PR for independent review and merge by the team lead.
|
||||
|
||||
## Budget
|
||||
|
||||
- No explicit token cap supplied.
|
||||
- Soft estimate: 8K tokens; narrow single-worker execution with no exploratory scope.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Mission, task, PRD, QA, documentation, and code-review guidance loaded.
|
||||
- [x] RED regression evidence captured: `test-list-http-status.sh` exited 1; all three wrappers rejected 206 while retaining 401 failures.
|
||||
- [x] Implementation complete.
|
||||
- [x] Relevant tests and repository gates green.
|
||||
- [ ] Commit pushed and PR opened.
|
||||
|
||||
## Tests and evidence
|
||||
|
||||
- RED (before source fix): `packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → exit 1; ticket/computer/user 206 assertions failed, all 401 assertions passed.
|
||||
- GREEN: `bash -n packages/mosaic/framework/tools/glpi/{ticket-list.sh,computer-list.sh,user-list.sh,test-list-http-status.sh}` → pass.
|
||||
- GREEN: `shellcheck packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → pass.
|
||||
- GREEN: `packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → 6 assertions pass (206 renders and 401 errors for all three wrappers).
|
||||
- GREEN: `pnpm typecheck` → 42/42 tasks pass.
|
||||
- GREEN: `pnpm lint` → 23/23 tasks pass.
|
||||
- GREEN: `pnpm format:check` → all matched files pass.
|
||||
- Setup note: initial gate attempts could not start because the fresh worktree lacked dependencies; `pnpm install --frozen-lockfile --store-dir /home/hermes/.local/share/pnpm/store` restored the locked workspace dependencies without lockfile changes.
|
||||
|
||||
## Acceptance criteria mapping
|
||||
|
||||
| Criterion | Evidence |
|
||||
| --- | --- |
|
||||
| HTTP 206 succeeds and renders each ranged list | Focused test's three 206 render assertions pass |
|
||||
| Genuine HTTP failures remain non-zero with existing diagnostics | Focused test's three HTTP 401 assertions pass |
|
||||
| Only affected list wrappers change | Diff contains the three status predicates plus focused test/evidence; session and create wrappers untouched |
|
||||
|
||||
## Documentation decision
|
||||
|
||||
No operator/API documentation change is needed: this restores documented list behavior for a healthy GLPI response without changing command syntax, output, configuration, or public contracts. This task scratchpad records delivery evidence.
|
||||
|
||||
## Risks / blockers
|
||||
|
||||
- Existing dirty `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` are runtime-owned and will not be edited or committed.
|
||||
37
docs/scratchpads/808-agent-send-sender-identity.md
Normal file
37
docs/scratchpads/808-agent-send-sender-identity.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Issue #808 — agent-send sender identity
|
||||
|
||||
## Objective
|
||||
|
||||
Fix cross-socket `agent-send.sh` preambles so replies route to the real sender rather than a destination-socket holder session.
|
||||
|
||||
## Scope and acceptance criteria
|
||||
|
||||
- Prefer exported `MOSAIC_AGENT_NAME` as the authoritative sender session name.
|
||||
- If it is unset, query the sender's local/default tmux socket for `#S` without destination `-L` arguments.
|
||||
- Preserve `?` when sender identity cannot be determined.
|
||||
- Do not alter destination socket dispatch.
|
||||
- Add red-first regressions for all three identity paths.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Extend `agent-send.test.sh` with deterministic fake-tmux coverage.
|
||||
2. Run the test against the unpatched implementation and record RED evidence.
|
||||
3. Apply the minimal sender lookup fix only.
|
||||
4. Run the focused suite and repository quality gates.
|
||||
5. Commit, queue-guard, push, and open an author-only PR for independent review.
|
||||
|
||||
## Constraints and risks
|
||||
|
||||
- Worker lane is author-only: no self-review or merge.
|
||||
- `docs/TASKS.md` and mission state are orchestrator-owned and will not be modified.
|
||||
- Pre-existing runtime changes under `.mosaic/orchestrator/` are excluded from this work.
|
||||
- Budget: no explicit token cap; keep changes limited to the shell tool, sibling regression test, and this scratchpad.
|
||||
|
||||
## Evidence
|
||||
|
||||
- RED: `bash packages/mosaic/framework/tools/tmux/agent-send.test.sh` failed on the unpatched implementation with `PASS=12 FAIL=3`; it selected `destination-holder` instead of both `MOSAIC_AGENT_NAME=authoritative-agent` and local session `local-agent`. The genuinely unavailable sender case already exercised and preserved `?`.
|
||||
- GREEN: `bash packages/mosaic/framework/tools/tmux/agent-send.test.sh` passed with `PASS=15 FAIL=0`; coverage includes env authority, local/default tmux fallback across a destination `-L`, explicit rejection of the destination holder, and `?` fallback.
|
||||
- Syntax: `bash -n packages/mosaic/framework/tools/tmux/agent-send.sh packages/mosaic/framework/tools/tmux/agent-send.test.sh` passed.
|
||||
- Quality gates: `pnpm typecheck` (42/42 tasks), `pnpm lint` (23/23 tasks), and `pnpm format:check` all passed after installing the frozen lockfile dependencies. The first install attempt failed because pnpm's configured store pointed at `/root`; retrying with the existing user-owned store (`--store-dir /home/hermes/.local/share/pnpm/store`) succeeded without changing tracked dependency files.
|
||||
- Documentation: no public API or operator workflow changed; the source comment, regression-test contract, and this implementation record cover the internal bug fix.
|
||||
- Independent review: intentionally pending for the reviewer assigned by `mosaic-100`; this author-only lane will not self-review or merge.
|
||||
49
docs/scratchpads/812-pr-review-comment.md
Normal file
49
docs/scratchpads/812-pr-review-comment.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Issue #812 — durable Gitea PR review comments
|
||||
|
||||
- **Lane:** ms-812
|
||||
- **Branch:** `fix/812-pr-review-comment`
|
||||
- **Issue:** mosaicstack/stack#812
|
||||
- **Budget:** 15K working estimate; single focused shell-wrapper/test/docs change.
|
||||
|
||||
## Objective
|
||||
|
||||
Make the Gitea `comment` action in `packages/mosaic/framework/tools/git/pr-review.sh` use tea v0.11.1's supported `tea comment` command and report success only after provider read-back verifies the created comment against the intended repository, PR, and exact body.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add and commit a failing shell regression harness before production changes.
|
||||
2. Verify RED against the nonexistent `tea pr comment` fallback false-positive.
|
||||
3. Implement the minimal supported write plus ID-based provider read-back.
|
||||
4. Document that wrapper write output is not durable provenance until read-back succeeds.
|
||||
5. Run focused regression tests, touched-package tests, and repository quality gates.
|
||||
6. Run independent review, remediate findings, queue-guard, push, and open a PR. Stop at PR-open for coordinator review/merge.
|
||||
|
||||
## Progress checkpoints
|
||||
|
||||
- [x] RED regression committed and reported to mosaic-100 (`3585cdfa`)
|
||||
- [x] Initial minimal fix implemented (`c3a83833`)
|
||||
- [x] Focused and package tests green after dependency build
|
||||
- [ ] Independent review passed
|
||||
- [ ] PR opened and reported with exact head SHA
|
||||
|
||||
## Tests run
|
||||
|
||||
- RED: `bash packages/mosaic/framework/tools/git/test-pr-review-gitea-comment.sh` failed against the old path because `tea pr comment` fell back and the wrapper returned success.
|
||||
- GREEN: all `packages/mosaic/framework/tools/git/test-*.sh` harnesses passed.
|
||||
- `pnpm build` passed (23/23 tasks).
|
||||
- `pnpm --filter @mosaicstack/mosaic test` passed (67 files, 1278 tests).
|
||||
- Package and root typecheck/lint/format gates passed.
|
||||
- Codex security review found no issues.
|
||||
- Codex code review requested changes: tea v0.11.1 has no supported `tea api`; replace read-back with the existing host-scoped authenticated curl/API path when work resumes.
|
||||
|
||||
## Risks / blockers
|
||||
|
||||
- **Coordination hold:** Mos directed #812 to remain parked until in-flight #789 reaches terminal state because #789 actively uses `pr-review.sh`. Branch must be pushed but no PR opened.
|
||||
- Initial implementation's `tea api` read-back is not compatible with tea v0.11.1 and must be remediated before PR.
|
||||
- Tea output shape must provide a created comment ID in JSON; failure to parse must fail closed.
|
||||
- Read-back must not accept a pre-existing comment with the same body.
|
||||
- Existing approve/request-changes behavior must remain unchanged and covered.
|
||||
|
||||
## Final verification evidence
|
||||
|
||||
Parked pending #789 terminal. No PR opened.
|
||||
@@ -1,85 +0,0 @@
|
||||
# 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.
|
||||
@@ -47,6 +47,61 @@ export MOSAIC_ADMIN_PASSWORD="securepass123"
|
||||
mosaic gateway install
|
||||
```
|
||||
|
||||
## Runtime launchers
|
||||
|
||||
```bash
|
||||
mosaic claude # Launch Claude Code with Mosaic injection
|
||||
mosaic yolo claude # …with --dangerously-skip-permissions
|
||||
mosaic codex | opencode | pi
|
||||
```
|
||||
|
||||
### `mosaic claudex` (EXPERIMENTAL)
|
||||
|
||||
Runs GPT models **inside the Claude Code harness** by pointing Claude Code at a
|
||||
local [`claude-code-proxy`](https://github.com/raine/claude-code-proxy) that
|
||||
translates the Anthropic Messages API to a ChatGPT-subscription (Codex OAuth)
|
||||
backend. This is **not Anthropic Claude** — model behavior, tool use, and output
|
||||
quality may differ. Intended for evaluation, not production delivery.
|
||||
|
||||
```bash
|
||||
mosaic claudex # launch (prompts through the proxy readiness gate)
|
||||
mosaic yolo claudex # …with --dangerously-skip-permissions
|
||||
mosaic claudex --print "hello" # trailing args are forwarded to Claude Code
|
||||
```
|
||||
|
||||
**Prerequisite:** the `claude-code-proxy` binary must be installed and
|
||||
authenticated (`claude-code-proxy codex auth …`). `mosaic claudex` runs a
|
||||
preflight that verifies the binary, the OAuth state (triggering a device re-auth
|
||||
if needed), and a trusted local listener before launching; it **fails closed**
|
||||
if the proxy cannot be brought up with a verified identity.
|
||||
|
||||
**Isolation (never touches your real Claude state).** claudex always launches
|
||||
against an isolated `CLAUDE_CONFIG_DIR` (default `~/.config/mosaic/claudex/home`).
|
||||
The ambient `CLAUDE_CONFIG_DIR` is deliberately ignored, and a guard proves the
|
||||
resolved dir can never be — or live under — the real `~/.claude`. A claudex
|
||||
session therefore cannot mutate your normal Claude Code config.
|
||||
|
||||
**No token leakage.** claudex never reads the proxy's credential file. Claude
|
||||
Code is handed only `ANTHROPIC_AUTH_TOKEN=unused` pointed at the loopback proxy;
|
||||
the entire credential-bearing env family (`ANTHROPIC_*`, `AWS_*`, `GOOGLE_CLOUD_*`,
|
||||
`GOOGLE_APPLICATION_CREDENTIALS`, `*_TOKEN`, `*_KEY`, `*_SECRET`, …) is stripped
|
||||
from the composed environment. The Bedrock/Vertex routing switches
|
||||
(`CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_USE_VERTEX`, and the `_SKIP_*_AUTH`
|
||||
pair) are force-removed regardless of value — otherwise their mere presence
|
||||
would route Claude Code to the real Anthropic API via AWS/GCP and bypass the
|
||||
proxy. The proxy holds the real OAuth credential.
|
||||
|
||||
**Model tiers (override via env).**
|
||||
|
||||
| Tier | Env var | Default |
|
||||
| --------------------- | ---------------------------- | -------------- |
|
||||
| primary (opus/sonnet) | `ANTHROPIC_MODEL` | `gpt-5.6-sol` |
|
||||
| small/fast (haiku) | `ANTHROPIC_SMALL_FAST_MODEL` | `gpt-5.6-luna` |
|
||||
|
||||
Operator-provided values win over the defaults. Additional overrides:
|
||||
`MOSAIC_CLAUDEX_CONFIG_DIR` (isolated config dir), `ANTHROPIC_BASE_URL` (proxy
|
||||
endpoint).
|
||||
|
||||
## Hooks management
|
||||
|
||||
After running `mosaic wizard`, Claude hooks are installed in `~/.claude/hooks-config.json`.
|
||||
|
||||
85
packages/mosaic/framework/framework-manifest.txt
Normal file
85
packages/mosaic/framework/framework-manifest.txt
Normal file
@@ -0,0 +1,85 @@
|
||||
# Mosaic framework path-ownership manifest — SSOT for the updater.
|
||||
#
|
||||
# This single file is the source of truth consumed by BOTH the bash installer
|
||||
# (packages/mosaic/framework/install.sh) and the TypeScript config adapter
|
||||
# (packages/mosaic/src/config/file-adapter.ts). A parity test asserts both
|
||||
# paths resolve the same ownership from this file, so the two can never drift
|
||||
# (the failure mode that #631 patched by hand in two places).
|
||||
#
|
||||
# Format: one glob per line, relative to the mosaic home (~/.config/mosaic).
|
||||
# - Lines starting with '#' and blank lines are ignored.
|
||||
# - '[framework]' / '[operator]' switch the active section.
|
||||
# - '**' matches any depth; '*' matches within a single path segment.
|
||||
#
|
||||
# Ownership resolution for a path P (deny-wins / fail-safe):
|
||||
# 1. P matches an [operator] glob -> operator-owned.
|
||||
# 2. else P matches a [framework] glob -> framework-owned.
|
||||
# 3. else (matches neither) -> OPERATOR-OWNED BY DEFAULT.
|
||||
#
|
||||
# Rule 3 is the root-cause fix for #791: a path the manifest authors never
|
||||
# anticipated is protected because UNKNOWN defaults to operator. The updater
|
||||
# may only ever create/overwrite framework-owned paths, and may only prune a
|
||||
# framework-owned path that lives inside a shipped framework subtree and is
|
||||
# absent from the current framework source (a genuinely retired file).
|
||||
# Operator-owned and unknown paths are structurally unreachable by pruning.
|
||||
|
||||
[framework]
|
||||
# Top-level framework contract files (also reconciled from defaults/ on upgrade).
|
||||
CONSTITUTION.md
|
||||
AGENTS.md
|
||||
STANDARDS.md
|
||||
# Shipped framework subtrees — pruning is scoped to these roots.
|
||||
adapters/**
|
||||
constitution/**
|
||||
CONTRIBUTING.md
|
||||
defaults/**
|
||||
examples/**
|
||||
guides/**
|
||||
install.sh
|
||||
install.ps1
|
||||
LICENSE
|
||||
profiles/**
|
||||
runtime/**
|
||||
systemd/**
|
||||
templates/**
|
||||
tools/**
|
||||
# Fleet: only the framework-seeded fleet subtrees are framework-owned.
|
||||
fleet/README.md
|
||||
fleet/examples/**
|
||||
fleet/profiles/**
|
||||
fleet/roles/**
|
||||
fleet/roster.schema.json
|
||||
fleet/services/**
|
||||
# The manifest itself is framework-owned.
|
||||
framework-manifest.txt
|
||||
|
||||
[operator]
|
||||
# Identity / user-seeded contract files — generated by the wizard or seeded
|
||||
# once from defaults/, then owned by the operator. Never overwritten on upgrade.
|
||||
SOUL.md
|
||||
USER.md
|
||||
TOOLS.md
|
||||
# Local overlays (tighten-only) authored by the operator.
|
||||
*.local.md
|
||||
# Operator-owned trees the updater must never write over or prune.
|
||||
agents/**
|
||||
policy/**
|
||||
memory/**
|
||||
sources/**
|
||||
credentials/**
|
||||
# Secret-bearing operator file INSIDE the framework-owned tools/ subtree.
|
||||
# Listed explicitly so the deny-wins rule carves it out of tools/**.
|
||||
tools/_lib/credentials.json
|
||||
# Operator-owned fleet state (roster SSOT, per-agent env, heartbeats, backlog,
|
||||
# persona overrides). Losing these silently downgrades a running fleet (#791).
|
||||
fleet/roster.yaml
|
||||
fleet/roster.json
|
||||
fleet/agents/**
|
||||
# Runtime state, incl. the #797 Runtime Session Ledger at fleet/run/sessions/
|
||||
# (events.ndjson journal + ledger.json projection). This carve-out is the
|
||||
# mechanism that makes the ledger upgrade-safe: an upgrade that wiped it would
|
||||
# defeat its reason to exist. The HARD GATE (test-upgrade-manifest-guard.sh)
|
||||
# proves a populated ledger survives byte-identical + mtime-unchanged.
|
||||
fleet/run/**
|
||||
fleet/backlog/**
|
||||
fleet/roles.local/**
|
||||
@@ -1,5 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# -E (errtrace): the ERR trap must propagate INTO functions and command
|
||||
# substitutions. Without it the `trap restore_snapshot ERR` set below is dead
|
||||
# code for any failure inside sync_framework_keep() (its whole body runs in a
|
||||
# function) — a mid-sync failure would abort with a half-written target and NO
|
||||
# rollback (#791 B1). Keep -E first so every later function inherits the trap.
|
||||
set -Eeuo pipefail
|
||||
|
||||
# ─── Mosaic Framework Installer ──────────────────────────────────────────────
|
||||
#
|
||||
@@ -19,32 +24,19 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||
|
||||
# Files/dirs protected from rsync --delete during sync. NOTE: framework-owned
|
||||
# entries (CONSTITUTION/AGENTS/STANDARDS) ARE re-applied afterward by
|
||||
# reconcile_framework_files (overwrite + backup-once); the rest stay user-owned.
|
||||
# User-created content in these paths survives rsync --delete.
|
||||
#
|
||||
# fleet/* — the framework SEEDS fleet/examples, fleet/roles, fleet/profiles, and
|
||||
# fleet/roster.schema.json (synced normally — every fleet/roles/*.md role contract
|
||||
# and fleet/profiles/*.yaml system-type profile lands automatically via this sync,
|
||||
# so no per-file entry is needed; exact preserved roster paths are anchored to
|
||||
# the top level only and do NOT shadow fleet/profiles/*.yaml). The user's
|
||||
# own fleet files MUST
|
||||
# survive `mosaic update` (which runs this sync automatically): the active
|
||||
# rosters (`fleet/roster.yaml` and `fleet/roster.json`), per-agent env
|
||||
# (`fleet/agents/`), heartbeat run dir (`fleet/run/`), and the Mosaic-native
|
||||
# backlog-of-record store (`fleet/backlog/` — embedded PGlite data dir; see
|
||||
# packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update
|
||||
# wipes the operator's fleet AND their backlog. Glob entries are honored by
|
||||
# both the rsync path (`--exclude`) and the glob-aware cp fallback below.
|
||||
#
|
||||
# fleet/roles.local — the persona OVERRIDE layer (H4). Baseline personas in
|
||||
# fleet/roles/ are reseeded normally on every update (delivering new baseline
|
||||
# personas), so any local edit there would be clobbered. User customizations
|
||||
# and user-ADDED personas instead live in fleet/roles.local/ and MUST survive
|
||||
# `mosaic update` — they win over the baseline on merge (AC-NS-7; see
|
||||
# packages/mosaic/src/commands/fleet-personas.ts).
|
||||
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/roster.yaml" "fleet/roster.json" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")
|
||||
# Shared framework path-ownership manifest reader (#791). Parity with
|
||||
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
||||
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
||||
# shellcheck source=tools/_lib/manifest.sh
|
||||
source "$SOURCE_DIR/tools/_lib/manifest.sh"
|
||||
|
||||
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
|
||||
# denylist. It is derived from the shared framework-manifest.txt (#791): the
|
||||
# updater only ever creates/overwrites framework-owned paths and only prunes a
|
||||
# retired framework file inside a shipped framework subtree. Everything else —
|
||||
# every operator file, and every path the manifest never anticipated — is
|
||||
# operator-owned by default (fail-safe) and is never written or deleted. See
|
||||
# sync_framework_keep() below and packages/mosaic/src/framework/manifest.ts.
|
||||
|
||||
# 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).
|
||||
@@ -75,17 +67,267 @@ step() { echo -e "\n${BOLD}$1${RESET}"; }
|
||||
SNAPSHOT_DIR=""
|
||||
make_snapshot() {
|
||||
is_existing_install || return 0
|
||||
# mktemp -d creates the dir 0700 — the snapshot (which mirrors operator config,
|
||||
# possibly including secrets) is never world-readable.
|
||||
SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-snapshot-XXXXXX")"
|
||||
cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/" 2>/dev/null || true
|
||||
# The snapshot MUST be complete: restore rebuilds the target from it, so a
|
||||
# partial capture (unreadable file, disk-full, I/O error) would silently
|
||||
# discard whatever it missed. If cp -a cannot copy the whole tree, abort NOW —
|
||||
# before the restore trap is armed and before anything is mutated. Fail closed
|
||||
# rather than proceed with a snapshot we cannot trust (#791 blocker-2).
|
||||
if ! cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/"; then
|
||||
fail "Could not capture a complete pre-upgrade snapshot of $TARGET_DIR — aborting before any changes were made (fail-closed)."
|
||||
rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
restore_snapshot() {
|
||||
# Disarm the trap first: restore runs under `set -e`, and a non-zero step
|
||||
# inside it must not re-enter this handler (errtrace makes ERR fire in
|
||||
# functions now). One restore attempt, then let the script exit non-zero.
|
||||
trap - ERR INT TERM
|
||||
[[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] || return 0
|
||||
fail "Install interrupted/failed — restoring previous state from snapshot"
|
||||
rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"
|
||||
cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/" 2>/dev/null || true
|
||||
# Reset the target before rebuilding from the snapshot — but CHECK it. Under
|
||||
# `set -e` (trap already disarmed) a bare `rm -rf; mkdir -p` that fails would
|
||||
# exit the whole script immediately, after `rm` may have deleted part of the
|
||||
# target, WITHOUT ever printing the recovery pointer below — the operator would
|
||||
# be left with a half-removed target and no idea the snapshot survives in /tmp.
|
||||
# Test the reset explicitly (like the cp -a below), and on failure keep the
|
||||
# snapshot and tell the operator where it is (#791 blocker-D2).
|
||||
if ! rm -rf "$TARGET_DIR" || ! mkdir -p "$TARGET_DIR"; then
|
||||
fail "Snapshot restore could not reset $TARGET_DIR. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually."
|
||||
return 1
|
||||
fi
|
||||
# Surface an incomplete restore instead of swallowing it: the snapshot is the
|
||||
# last good copy, so if cp cannot fully rebuild the target we must NOT delete
|
||||
# the snapshot — point the operator at it for manual recovery (#791 blocker-2).
|
||||
if ! cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/"; then
|
||||
fail "Snapshot restore did not complete cleanly. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
cleanup_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] && rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""; }
|
||||
|
||||
# ─── durable operator-config snapshot (#791 PR2) ─────────────────────────────
|
||||
# A SECOND, independent safety layer, distinct from SNAPSHOT_DIR above:
|
||||
# • SNAPSHOT_DIR is ephemeral (/tmp, deleted on success) and mirrors the WHOLE
|
||||
# target for CRASH rollback if the sync aborts mid-write.
|
||||
# • DURABLE_SNAPSHOT_DIR is RETAINED, holds only the operator-owned surface, and
|
||||
# lives OUTSIDE the framework tree and any repo. It exists for the failure the
|
||||
# crash-rollback cannot see: a sync that finishes "successfully" yet a
|
||||
# manifest/logic bug let it modify an operator file. verify_operator_surface()
|
||||
# (post-sync) heals from it; `mosaic restore` recovers from it days later.
|
||||
# Path convention is mirrored in packages/mosaic/src/commands/restore.ts — keep
|
||||
# the two in sync (there is no shared code across the bash/TS boundary).
|
||||
DURABLE_SNAPSHOT_DIR=""
|
||||
backup_root() { printf '%s/mosaic/backups' "${XDG_STATE_HOME:-$HOME/.local/state}"; }
|
||||
|
||||
# Relative paths that a migration INTENTIONALLY removes from the target (e.g. the
|
||||
# legacy bin/ tree). Such a path is operator-classified by the manifest (unknown⇒
|
||||
# operator), so the durable snapshot captures it — but its post-migration absence
|
||||
# is correct, NOT a manifest bug. run_migrations() records each removal here so
|
||||
# verify_operator_surface() does not "heal" it back and silently undo the
|
||||
# migration (which would then be skipped forever once the version is stamped).
|
||||
MIGRATION_REMOVED_PATHS=()
|
||||
|
||||
# True (0) if $1 (a path relative to TARGET_DIR) equals or lives under a path a
|
||||
# migration deliberately removed this run.
|
||||
is_migration_removed() {
|
||||
local rel="$1" removed
|
||||
for removed in ${MIGRATION_REMOVED_PATHS[@]+"${MIGRATION_REMOVED_PATHS[@]}"}; do
|
||||
[[ -n "$removed" ]] || continue
|
||||
[[ "$rel" == "$removed" || "$rel" == "$removed"/* ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# True (0) if any parent directory of $1 (relative to TARGET_DIR) is a symlink.
|
||||
# Restoring THROUGH a symlinked ancestor would let cp write snapshot contents —
|
||||
# possibly secrets — outside the target (CWE-59), so the verify net refuses it.
|
||||
has_symlinked_parent() {
|
||||
local rel="$1" dir p seg
|
||||
dir="$(dirname "$rel")"
|
||||
[[ "$dir" == "." ]] && return 1
|
||||
p="$TARGET_DIR"
|
||||
local IFS='/'
|
||||
for seg in $dir; do
|
||||
[[ -n "$seg" ]] || continue
|
||||
p="$p/$seg"
|
||||
[[ -L "$p" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Emit (NUL-delimited, into file $1) the operator-owned relative paths that exist
|
||||
# under TARGET_DIR, classified via the shared manifest (deny-wins; unknown⇒
|
||||
# operator). Returns non-zero if the filesystem walk itself failed — we must
|
||||
# NEVER snapshot from a truncated scan (a `< <(find …)` process substitution
|
||||
# would hide that error; capture-then-check does not — cf. #791 blocker-D1).
|
||||
enumerate_operator_files() {
|
||||
local out="$1" scan abs rel
|
||||
scan="$(mktemp)"
|
||||
if ! find "$TARGET_DIR" -type f -print0 > "$scan"; then
|
||||
rm -f "$scan"
|
||||
return 1 # OP-SCAN-GUARD
|
||||
fi
|
||||
: > "$out"
|
||||
while IFS= read -r -d '' abs; do
|
||||
rel="${abs#"$TARGET_DIR"/}"
|
||||
# Not operator config: version marker and any VCS metadata.
|
||||
case "$rel" in .framework-version|.git|.git/*) continue ;; esac
|
||||
manifest_is_framework "$rel" || printf '%s\0' "$rel" >> "$out"
|
||||
done < "$scan"
|
||||
rm -f "$scan"
|
||||
}
|
||||
|
||||
# Retain only the newest MOSAIC_BACKUP_RETENTION (default 5) snapshots. The
|
||||
# pre-update-<UTC-ts> names sort lexicographically = chronologically, so a
|
||||
# reverse sort is newest-first. Pruning failures are non-fatal (they only leave
|
||||
# extra old backups); the enclosing find's status is still honored, not swallowed.
|
||||
prune_durable_snapshots() {
|
||||
local root keep list d i=0
|
||||
root="$(backup_root)"
|
||||
keep="${MOSAIC_BACKUP_RETENTION:-5}"
|
||||
[[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5
|
||||
list="$(mktemp)"
|
||||
if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then
|
||||
rm -f "$list"; return 0
|
||||
fi
|
||||
# Newest-first ordering needs `sort` (`-o` writes back in place — no `mv`
|
||||
# dependency); if it is somehow unavailable, leave the backups untouched rather
|
||||
# than risk pruning in an undefined order.
|
||||
if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then
|
||||
rm -f "$list"; return 0
|
||||
fi
|
||||
while IFS= read -r d; do
|
||||
[[ -n "$d" ]] || continue
|
||||
i=$((i + 1))
|
||||
(( i > keep )) && rm -rf "$d"
|
||||
done < "$list"
|
||||
rm -f "$list"
|
||||
}
|
||||
|
||||
# Take the durable pre-update snapshot BEFORE any mutation. Fail-OPEN: the durable
|
||||
# snapshot is a recovery bonus on top of the manifest (which already keeps the
|
||||
# sync out of operator paths) and the crash-rollback — so an un-writable backup
|
||||
# location warns and continues rather than blocking the upgrade. Everything it
|
||||
# creates is private (umask 077 + explicit 0700 dirs / 0600 files): the snapshot
|
||||
# mirrors operator config, which may hold secrets, and must never be world-readable.
|
||||
make_durable_snapshot() {
|
||||
is_existing_install || return 0
|
||||
local root ts dir list rel src dst count=0 old_umask
|
||||
root="$(backup_root)"
|
||||
# Fail-open if we cannot even stamp a timestamp: the durable snapshot is a
|
||||
# recovery bonus and must never be the thing that aborts an upgrade.
|
||||
ts="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || true)"
|
||||
if [[ -z "$ts" ]]; then
|
||||
warn "Durable snapshot skipped: no UTC timestamp available (upgrade continues)."
|
||||
return 0
|
||||
fi
|
||||
# umask 077 makes every dir/file the snapshot creates private from birth (it
|
||||
# mirrors operator config, which may hold secrets). It is PROCESS-global, so we
|
||||
# save and restore it around exactly this block — otherwise every later sync
|
||||
# copy and new framework dir would inherit 0600/0700 instead of 0644/0755.
|
||||
old_umask="$(umask)"
|
||||
umask 077
|
||||
if ! mkdir -p "$root"; then
|
||||
umask "$old_umask"
|
||||
warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)."
|
||||
return 0
|
||||
fi
|
||||
chmod 700 "$root" 2>/dev/null || true
|
||||
dir="$root/pre-update-$ts"
|
||||
if [[ -e "$dir" ]]; then # same-second re-run: disambiguate
|
||||
local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n"
|
||||
fi
|
||||
if ! mkdir -p "$dir"; then
|
||||
umask "$old_umask"
|
||||
warn "Durable snapshot skipped: cannot create $dir (upgrade continues)."
|
||||
return 0
|
||||
fi
|
||||
chmod 700 "$dir"
|
||||
list="$(mktemp)"
|
||||
if ! enumerate_operator_files "$list"; then
|
||||
umask "$old_umask"
|
||||
warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)."
|
||||
rm -f "$list"; rmdir "$dir" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
while IFS= read -r -d '' rel; do
|
||||
src="$TARGET_DIR/$rel"; dst="$dir/$rel"
|
||||
[[ -f "$src" ]] || continue
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
if ! cp "$src" "$dst"; then
|
||||
warn "Durable snapshot: could not copy operator file '$rel' (skipped)."
|
||||
continue
|
||||
fi
|
||||
chmod 600 "$dst" 2>/dev/null || true
|
||||
count=$((count + 1))
|
||||
done < "$list"
|
||||
rm -f "$list"
|
||||
# Tighten every dir the copy created (mkdir -p honors umask, but be explicit).
|
||||
find "$dir" -type d -exec chmod 700 {} + 2>/dev/null || true
|
||||
umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above)
|
||||
DURABLE_SNAPSHOT_DIR="$dir"
|
||||
ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)"
|
||||
prune_durable_snapshots
|
||||
}
|
||||
|
||||
# Post-sync safety net: a keep-mode upgrade must NEVER modify an operator file.
|
||||
# Compare every file in the durable snapshot to its current target counterpart;
|
||||
# any that changed (or vanished) was touched by a framework bug — restore it from
|
||||
# the snapshot and warn loudly. This does NOT abort: the framework itself synced
|
||||
# correctly; we only heal the operator collateral. Runs after the restore trap is
|
||||
# disarmed so its corrective copies can't spuriously trip a full rollback, and
|
||||
# every step is guarded so `set -e` cannot exit silently mid-heal (cf. blocker-D2).
|
||||
verify_operator_surface() {
|
||||
[[ -n "$DURABLE_SNAPSHOT_DIR" && -d "$DURABLE_SNAPSHOT_DIR" ]] || return 0
|
||||
local scan snap rel cur healed=0
|
||||
scan="$(mktemp)"
|
||||
if ! find "$DURABLE_SNAPSHOT_DIR" -type f -print0 > "$scan"; then
|
||||
rm -f "$scan"
|
||||
warn "Post-upgrade verify skipped: could not enumerate the pre-update snapshot at $DURABLE_SNAPSHOT_DIR."
|
||||
return 0
|
||||
fi
|
||||
while IFS= read -r -d '' snap; do
|
||||
rel="${snap#"$DURABLE_SNAPSHOT_DIR"/}"
|
||||
cur="$TARGET_DIR/$rel"
|
||||
# A migration may legitimately delete an operator-classified path (e.g. legacy
|
||||
# bin/). Its absence is intended — do not heal it back, or the migration is
|
||||
# silently undone and never re-runs once the version is stamped (#791 PR2).
|
||||
is_migration_removed "$rel" && continue # MIGRATION-SKIP-GUARD
|
||||
if [[ ! -e "$cur" ]] || ! cmp -s "$snap" "$cur"; then
|
||||
# Never restore THROUGH a symlink: an operator path swapped for a link would
|
||||
# otherwise let cp write snapshot contents (possibly secrets) outside the
|
||||
# target (CWE-59). Refuse a symlinked parent; drop a symlinked leaf and write
|
||||
# a real file in its place.
|
||||
if has_symlinked_parent "$rel"; then
|
||||
warn "Operator path '$rel' has a symlinked parent under $TARGET_DIR; refusing to restore through it (possible tampering) — recover it manually from $DURABLE_SNAPSHOT_DIR."
|
||||
continue
|
||||
fi
|
||||
[[ -L "$cur" ]] && rm -f "$cur" # SYMLINK-LEAF-GUARD
|
||||
# Guard mkdir too: under set -e (trap already disarmed) a bare failure would
|
||||
# exit the whole installer before the recovery pointer below is emitted.
|
||||
if ! mkdir -p "$(dirname "$cur")"; then
|
||||
warn "Operator file '$rel' was modified by the upgrade but could NOT be auto-restored (parent dir unavailable) — recover it manually from $DURABLE_SNAPSHOT_DIR."
|
||||
continue
|
||||
fi
|
||||
if cp "$snap" "$cur"; then
|
||||
chmod 600 "$cur" 2>/dev/null || true
|
||||
warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel"
|
||||
healed=$((healed + 1))
|
||||
else
|
||||
warn "Operator file '$rel' was modified by the upgrade but could NOT be auto-restored — recover it manually from $DURABLE_SNAPSHOT_DIR."
|
||||
fi
|
||||
fi
|
||||
done < "$scan"
|
||||
rm -f "$scan"
|
||||
if (( healed > 0 )); then
|
||||
warn "$healed operator file(s) were unexpectedly changed by this upgrade and were restored from the pre-update snapshot. A keep-mode upgrade must never modify operator files — this indicates a framework manifest bug; please report it (#791)."
|
||||
fi
|
||||
}
|
||||
|
||||
# Reconcile contract files after sync: framework-owned overwrite (backup-once),
|
||||
# user-seeded seed-if-absent.
|
||||
reconcile_framework_files() {
|
||||
@@ -184,63 +426,105 @@ sync_framework() {
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak")
|
||||
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
# Anchor to the transfer root (leading /) so we preserve the TOP-LEVEL
|
||||
# ~/.config/mosaic/<file> without also excluding defaults/<file> from sync
|
||||
# (reconcile_framework_files needs the freshly-synced defaults/ copies).
|
||||
for path in "${PRESERVE_PATHS[@]}"; do
|
||||
rsync_args+=(--exclude "/$path")
|
||||
done
|
||||
fi
|
||||
|
||||
rsync "${rsync_args[@]}" "$SOURCE_DIR/" "$TARGET_DIR/"
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
# The `mosaic update` path. Manifest-driven, never-deleting-outside-framework:
|
||||
# operator config is structurally protected (#791). No rsync --delete here.
|
||||
# The manifest is already loaded+validated in main() BEFORE the snapshot/trap
|
||||
# (a fail-closed manifest must abort without ever restoring over operator
|
||||
# files — see the pre-flight in main, #791 blocker-1).
|
||||
sync_framework_keep
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback: cp-based sync. Exact top-level preserved paths mirror the
|
||||
# root-anchored rsync excludes above.
|
||||
local preserve_tmp=""
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"
|
||||
local match rel
|
||||
for path in "${PRESERVE_PATHS[@]}"; do
|
||||
# Unquoted $path lets the glob expand against TARGET_DIR; nullglob makes a
|
||||
# non-matching pattern vanish instead of staying literal.
|
||||
shopt -s nullglob
|
||||
for match in "$TARGET_DIR/"$path; do
|
||||
[[ -e "$match" ]] || continue
|
||||
rel="${match#"$TARGET_DIR/"}"
|
||||
mkdir -p "$preserve_tmp/$(dirname "$rel")"
|
||||
cp -R "$match" "$preserve_tmp/$rel"
|
||||
done
|
||||
shopt -u nullglob
|
||||
done
|
||||
fi
|
||||
# overwrite mode — a full replace, chosen only for a fresh install or when the
|
||||
# operator explicitly asks to replace everything. No operator state to protect.
|
||||
sync_framework_overwrite
|
||||
}
|
||||
|
||||
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" -exec rm -rf {} +
|
||||
# Enumerate a NUL-delimited file list via `find` into the temp file $1, failing
|
||||
# CLOSED if find errors. We capture to a checked file instead of consuming
|
||||
# `< <(find …)` directly because a process substitution discards the producer's
|
||||
# exit status: an EACCES/I/O failure partway through a scan would truncate the
|
||||
# list yet leave the reading `while` loop exiting 0, so a partial upgrade would
|
||||
# commit and report success and the ERR/restore trap would never fire. Running
|
||||
# find to completion first, then checking its status, turns that silent
|
||||
# truncation into a fail-closed abort that the restore trap can act on (#791
|
||||
# blocker-D1). $1 after the shift is the scan root — named in the error.
|
||||
_scan_or_die() {
|
||||
local out="$1"; shift
|
||||
if ! find "$@" -print0 > "$out"; then
|
||||
fail "Could not enumerate framework files under '$1' — aborting before committing an incomplete sync (fail-closed)."
|
||||
return 1 # D1-GUARD
|
||||
fi
|
||||
}
|
||||
|
||||
# Keep-mode sync: create/refresh framework-owned files and prune only retired
|
||||
# framework files inside shipped framework subtrees. Operator-owned and unknown
|
||||
# paths (fail-safe default) are never written and never deleted — the #791 HARD
|
||||
# GATE. Single code path (no rsync) so it is byte-for-byte parity-testable.
|
||||
sync_framework_keep() {
|
||||
local src="$SOURCE_DIR" dst="$TARGET_DIR" abs rel root list
|
||||
|
||||
# 1) Overlay copy — every framework-owned source file, refreshed only when its
|
||||
# bytes changed (no mtime churn on unchanged files, never on operator files).
|
||||
# The source scan is captured fail-closed (#791 blocker-D1): a find failure
|
||||
# aborts the sync (→ ERR trap → restore) rather than silently truncating it.
|
||||
list="$(mktemp)"
|
||||
_scan_or_die "$list" "$src" -type f || { rm -f "$list"; return 1; }
|
||||
while IFS= read -r -d '' abs; do
|
||||
rel="${abs#"$src"/}"
|
||||
case "$rel" in
|
||||
.git|.git/*|.framework-version|*.pre-constitution.bak) continue ;;
|
||||
esac
|
||||
manifest_is_framework "$rel" || continue
|
||||
if [[ -f "$dst/$rel" ]] && cmp -s "$abs" "$dst/$rel"; then continue; fi
|
||||
[[ "$rel" == */* ]] && mkdir -p "$dst/${rel%/*}"
|
||||
cp "$abs" "$dst/$rel"
|
||||
done < "$list"
|
||||
rm -f "$list"
|
||||
|
||||
# 2) Scoped prune — within each shipped framework subtree root, remove
|
||||
# framework-owned target files the current source no longer ships. Operator
|
||||
# carve-outs (e.g. tools/_lib/credentials.json) resolve to operator and are
|
||||
# skipped; unknown paths resolve to operator too — both are unreachable here.
|
||||
# Each subtree scan is captured fail-closed for the same reason as the copy.
|
||||
while IFS= read -r root; do
|
||||
[[ -n "$root" && -d "$dst/$root" ]] || continue
|
||||
list="$(mktemp)"
|
||||
_scan_or_die "$list" "$dst/$root" -type f || { rm -f "$list"; return 1; }
|
||||
while IFS= read -r -d '' abs; do
|
||||
rel="${abs#"$dst"/}"
|
||||
case "$rel" in *.pre-constitution.bak) continue ;; esac
|
||||
[[ -f "$src/$rel" ]] && continue # still shipped
|
||||
manifest_is_framework "$rel" || continue
|
||||
rm -f "$abs"
|
||||
done < "$list"
|
||||
rm -f "$list"
|
||||
# Drop framework dirs left empty by the prune (never touches a dir that still
|
||||
# holds an operator file — those are never emptied). A genuine find failure
|
||||
# (unreadable dir) is surfaced as a warning rather than silently swallowed;
|
||||
# the "directory not empty" races we tolerate are ignored via -delete's own
|
||||
# rc, not by hiding stderr — so a real error is still visible to the operator.
|
||||
if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then
|
||||
warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
|
||||
fi
|
||||
done < <(manifest_subtree_roots)
|
||||
}
|
||||
|
||||
# Overwrite-mode sync: full replace. Only reached for a fresh install or an
|
||||
# explicit operator "replace everything" choice, so nothing is preserved.
|
||||
sync_framework_overwrite() {
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a --delete \
|
||||
--exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak" \
|
||||
"$SOURCE_DIR/" "$TARGET_DIR/"
|
||||
return
|
||||
fi
|
||||
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 \
|
||||
! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" \
|
||||
-exec rm -rf {} +
|
||||
cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/
|
||||
rm -rf "$TARGET_DIR/.git"
|
||||
|
||||
if [[ -n "$preserve_tmp" ]]; then
|
||||
# Restore by re-globbing the SAME patterns against preserve_tmp, so each
|
||||
# preserved item is restored at its own relative path (e.g. only
|
||||
# fleet/roster.yaml is replaced — the freshly-synced fleet/examples stays).
|
||||
for path in "${PRESERVE_PATHS[@]}"; do
|
||||
shopt -s nullglob
|
||||
for match in "$preserve_tmp/"$path; do
|
||||
[[ -e "$match" ]] || continue
|
||||
rel="${match#"$preserve_tmp/"}"
|
||||
rm -rf "$TARGET_DIR/$rel"
|
||||
mkdir -p "$TARGET_DIR/$(dirname "$rel")"
|
||||
cp -R "$match" "$TARGET_DIR/$rel"
|
||||
done
|
||||
shopt -u nullglob
|
||||
done
|
||||
rm -rf "$preserve_tmp"
|
||||
fi
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -261,6 +545,10 @@ run_migrations() {
|
||||
# Remove bin/ directory — all executables now live in the npm CLI.
|
||||
# Scripts that were in bin/ are now in tools/_scripts/.
|
||||
if [[ "$from_version" -lt 2 ]]; then
|
||||
# bin/ and the rails symlink are operator-classified by the manifest (unknown⇒
|
||||
# operator) and thus captured in the durable snapshot; record them as
|
||||
# intentional removals so the post-sync verify net does not restore them.
|
||||
MIGRATION_REMOVED_PATHS+=("bin" "rails")
|
||||
if [[ -d "$TARGET_DIR/bin" ]]; then
|
||||
ok "Removing legacy bin/ directory (executables now in npm CLI)"
|
||||
rm -rf "$TARGET_DIR/bin"
|
||||
@@ -311,9 +599,26 @@ else
|
||||
ok "Install mode: overwrite"
|
||||
fi
|
||||
|
||||
# Pre-flight (keep mode): load + validate the framework manifest BEFORE taking a
|
||||
# snapshot or arming the restore trap. A fail-closed manifest (missing / empty /
|
||||
# malformed) must abort here WITHOUT deleting or restoring over operator files —
|
||||
# the snapshot/restore path exists only for a genuine mid-sync mutation failure,
|
||||
# not for a validation failure that has touched nothing yet (#791 blocker-1).
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
manifest_load
|
||||
# Durable, operator-scoped backup taken BEFORE any mutation (#791 PR2). Kept
|
||||
# outside the framework tree; recovered later via `mosaic restore`. Fail-open.
|
||||
make_durable_snapshot
|
||||
fi
|
||||
|
||||
# Snapshot before any destructive file operation; restore on interrupt/failure.
|
||||
# The trap MUST exit after restoring: a bash INT/TERM handler that merely returns
|
||||
# does NOT terminate the script — execution would resume past the interrupt,
|
||||
# clear the snapshot, and report success, leaving a partial post-interrupt update
|
||||
# (#791 blocker-A). `restore_snapshot; exit 1` guarantees a non-zero exit for
|
||||
# both the errtrace (ERR) and signal (INT/TERM) paths.
|
||||
make_snapshot
|
||||
trap 'restore_snapshot' ERR INT TERM
|
||||
trap 'restore_snapshot; exit 1' ERR INT TERM
|
||||
|
||||
sync_framework
|
||||
|
||||
@@ -342,6 +647,10 @@ run_migrations
|
||||
|
||||
# File-system phase complete and consistent — clear the restore trap.
|
||||
trap - ERR INT TERM
|
||||
# Post-sync safety net: heal any operator file a manifest bug let the sync touch,
|
||||
# using the durable pre-update snapshot (#791 PR2). Runs with the trap disarmed so
|
||||
# a corrective copy can't spuriously trigger a full rollback.
|
||||
verify_operator_surface # VERIFY-NET (#791 PR2)
|
||||
cleanup_snapshot
|
||||
|
||||
# Testability / minimal-install hook: stop after the file-system phase, before any
|
||||
|
||||
253
packages/mosaic/framework/tools/_lib/manifest.sh
Normal file
253
packages/mosaic/framework/tools/_lib/manifest.sh
Normal file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared bash reader for framework-manifest.txt (#791).
|
||||
#
|
||||
# This is the bash half of the SSOT ownership resolver; the TypeScript half is
|
||||
# packages/mosaic/src/framework/manifest.ts. BOTH read the same
|
||||
# framework-manifest.txt and MUST resolve identical ownership for any path — the
|
||||
# parity test (manifest-parity.spec.ts) invokes this file's `resolve` CLI and
|
||||
# compares it against the TS resolver, so the two can never drift (the #631
|
||||
# two-copies failure class this closes).
|
||||
#
|
||||
# Ownership resolution (deny-wins / fail-safe):
|
||||
# 1. operator glob matches -> operator
|
||||
# 2. else framework glob -> framework
|
||||
# 3. else -> operator (UNKNOWN defaults to operator, #791)
|
||||
#
|
||||
# Globs are compiled once at load into exact-prefix checks or POSIX EREs, so the
|
||||
# hot resolver (manifest_is_framework) forks no subprocesses — the installer
|
||||
# calls it once per file across the whole tree.
|
||||
#
|
||||
# Usage as a library (source it, then):
|
||||
# manifest_load [manifest-file] # populates + compiles the manifest
|
||||
# manifest_is_framework <rel-path> # rc 0 = framework-owned, rc 1 = operator
|
||||
# manifest_resolve <rel-path> # echoes: framework | operator
|
||||
# manifest_subtree_roots # echoes shipped framework `dir/**` roots
|
||||
#
|
||||
# Usage as a CLI (parity harness):
|
||||
# bash manifest.sh resolve <rel-path>
|
||||
# bash manifest.sh subtree-roots
|
||||
# bash manifest.sh classify # reads paths on stdin -> "<own>\t<path>"
|
||||
|
||||
MANIFEST_FRAMEWORK=()
|
||||
MANIFEST_OPERATOR=()
|
||||
|
||||
# Compiled forms (parallel arrays). _*_KIND[i] is "exact" or "re".
|
||||
_MF_KIND=(); _MF_EXACT=(); _MF_RE=()
|
||||
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
|
||||
_MF_ROOTS=()
|
||||
|
||||
_manifest_default_root() { cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd; }
|
||||
|
||||
# Normalize a path/glob: backslashes -> slashes, strip leading ./ and /, strip
|
||||
# trailing / (mirrors normalizeRel in manifest.ts).
|
||||
_manifest_norm() {
|
||||
local p="$1"
|
||||
p="${p//\\//}"
|
||||
p="${p#./}"
|
||||
while [[ "$p" == /* ]]; do p="${p#/}"; done
|
||||
while [[ "$p" == */ ]]; do p="${p%/}"; done
|
||||
printf '%s' "$p"
|
||||
}
|
||||
|
||||
# Translate a normalized glob into a POSIX ERE body (mirrors globToRegExpBody).
|
||||
_manifest_glob_to_ere() {
|
||||
local pattern; pattern="$(_manifest_norm "$1")"
|
||||
local out="" c n i len=${#pattern} trailing
|
||||
for (( i = 0; i < len; i++ )); do
|
||||
c="${pattern:i:1}"
|
||||
if [[ "$c" == "*" ]]; then
|
||||
n="${pattern:i+1:1}"
|
||||
if [[ "$n" == "*" ]]; then
|
||||
i=$((i + 1))
|
||||
trailing=0
|
||||
if [[ "${pattern:i+1:1}" == "/" ]]; then i=$((i + 1)); trailing=1; fi
|
||||
if [[ "$out" == */ ]]; then
|
||||
out="${out%/}(/.*)?"
|
||||
elif [[ "$trailing" -eq 1 ]]; then
|
||||
out="$out(.*/)?"
|
||||
else
|
||||
out="$out.*"
|
||||
fi
|
||||
else
|
||||
out="$out[^/]*"
|
||||
fi
|
||||
else
|
||||
case "$c" in
|
||||
.|+|\?|^|\$|\{|\}|\(|\)|\||\[|\]|\\) out="$out\\$c" ;;
|
||||
*) out="$out$c" ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# Compile one raw glob into (kind, exact, re) appended to the given section.
|
||||
# $1 = raw glob, $2 = section letter (F|O).
|
||||
_manifest_compile_one() {
|
||||
local norm; norm="$(_manifest_norm "$1")"
|
||||
[[ -n "$norm" ]] || return 0
|
||||
if [[ "$norm" == *"*"* ]]; then
|
||||
local re="^$(_manifest_glob_to_ere "$norm")\$"
|
||||
if [[ "$2" == F ]]; then
|
||||
_MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re")
|
||||
else
|
||||
_MO_KIND+=(re); _MO_EXACT+=(""); _MO_RE+=("$re")
|
||||
fi
|
||||
else
|
||||
if [[ "$2" == F ]]; then
|
||||
_MF_KIND+=(exact); _MF_EXACT+=("$norm"); _MF_RE+=("")
|
||||
else
|
||||
_MO_KIND+=(exact); _MO_EXACT+=("$norm"); _MO_RE+=("")
|
||||
fi
|
||||
fi
|
||||
[[ "$2" == F && "$norm" == */"**" ]] && _MF_ROOTS+=("${norm%/**}")
|
||||
return 0
|
||||
}
|
||||
|
||||
_manifest_compile() {
|
||||
_MF_KIND=(); _MF_EXACT=(); _MF_RE=(); _MF_ROOTS=()
|
||||
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
|
||||
local g
|
||||
for g in "${MANIFEST_FRAMEWORK[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" F; done
|
||||
for g in "${MANIFEST_OPERATOR[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" O; done
|
||||
# Explicit success: an empty operator array makes the final `[[ -n "" ]] && …`
|
||||
# short-circuit to rc 1, which would otherwise become this function's (and
|
||||
# manifest_load's) return code — a spurious failure (#791 B2). Never rely on
|
||||
# the last loop's exit status here.
|
||||
return 0
|
||||
}
|
||||
|
||||
# Load + compile the manifest. Rejects a malformed file the same way
|
||||
# parseManifest() does (entry before a section header / unknown header).
|
||||
manifest_load() {
|
||||
local file="${1:-}"
|
||||
[[ -n "$file" ]] || file="$(_manifest_default_root)/framework-manifest.txt"
|
||||
# Fail CLOSED on a missing/unreadable manifest. Without this, `done < "$file"`
|
||||
# aborts on a raw redirection error with no explanation; downstream that reads
|
||||
# as "no framework paths" and an upgrade could no-op silently (#791 B2/B3).
|
||||
if [[ ! -r "$file" ]]; then
|
||||
echo "manifest: cannot read manifest file: $file — refusing to sync (fail-closed)." >&2
|
||||
return 1
|
||||
fi
|
||||
MANIFEST_FRAMEWORK=()
|
||||
MANIFEST_OPERATOR=()
|
||||
local section="" line
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line="${line#"${line%%[![:space:]]*}"}" # ltrim
|
||||
line="${line%"${line##*[![:space:]]}"}" # rtrim
|
||||
[[ -z "$line" || "${line:0:1}" == "#" ]] && continue
|
||||
case "$line" in
|
||||
"[framework]") section=framework; continue ;;
|
||||
"[operator]") section=operator; continue ;;
|
||||
"["*) echo "manifest: unknown section header: $line" >&2; return 1 ;;
|
||||
esac
|
||||
if [[ -z "$section" ]]; then
|
||||
echo "manifest: entry before any [section] header: $line" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$section" == framework ]]; then
|
||||
MANIFEST_FRAMEWORK+=("$line")
|
||||
else
|
||||
MANIFEST_OPERATOR+=("$line")
|
||||
fi
|
||||
done < "$file"
|
||||
# An empty or comment-only manifest defines NO framework-owned paths. Treating
|
||||
# that as valid would make every path resolve operator and an upgrade prune
|
||||
# nothing / write nothing — a silent no-op indistinguishable from success.
|
||||
# Fail loud instead, mirroring parseManifest()'s throw in manifest.ts (#791 B2).
|
||||
if [[ ${#MANIFEST_FRAMEWORK[@]} -eq 0 ]]; then
|
||||
echo "manifest: no [framework] entries in $file — refusing to sync (empty or malformed manifest)." >&2
|
||||
return 1
|
||||
fi
|
||||
# An entry like `/` or `./` normalizes to nothing and compiles to a glob that
|
||||
# matches no path — so a manifest whose only [framework] entries are degenerate
|
||||
# passes the count guard above but leaves the framework matcher empty: every
|
||||
# path resolves operator, the exact silent no-op we fail closed against. Require
|
||||
# at least one entry with a real (non-slash, non-dot) character. Mirrors
|
||||
# parseManifest()'s `isUsableFrameworkGlob` `/[^/.]/` test in manifest.ts (#791 blocker-B).
|
||||
local _g _usable=0
|
||||
for _g in "${MANIFEST_FRAMEWORK[@]:-}"; do
|
||||
if [[ "$(_manifest_norm "$_g")" =~ [^/.] ]]; then _usable=1; break; fi
|
||||
done
|
||||
if [[ "$_usable" -eq 0 ]]; then
|
||||
echo "manifest: no usable [framework] entries in $file (every entry is empty or a bare dot segment) — refusing to sync (malformed manifest)." >&2
|
||||
return 1
|
||||
fi
|
||||
_manifest_compile
|
||||
return 0
|
||||
}
|
||||
|
||||
# Fork-free: does $1 (a mosaic-home-relative path) match an operator glob?
|
||||
_mo_matches() {
|
||||
local path="$1" i n=${#_MO_KIND[@]} re pat
|
||||
for (( i = 0; i < n; i++ )); do
|
||||
if [[ "${_MO_KIND[i]}" == exact ]]; then
|
||||
pat="${_MO_EXACT[i]}"
|
||||
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
|
||||
else
|
||||
re="${_MO_RE[i]}"
|
||||
[[ "$path" =~ $re ]] && return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Fork-free: does $1 match a framework glob?
|
||||
_mf_matches() {
|
||||
local path="$1" i n=${#_MF_KIND[@]} re pat
|
||||
for (( i = 0; i < n; i++ )); do
|
||||
if [[ "${_MF_KIND[i]}" == exact ]]; then
|
||||
pat="${_MF_EXACT[i]}"
|
||||
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
|
||||
else
|
||||
re="${_MF_RE[i]}"
|
||||
[[ "$path" =~ $re ]] && return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# The installer hot path — no subshell. rc 0 = framework-owned, rc 1 = operator
|
||||
# (deny-wins / fail-safe). Assumes an already-clean POSIX relative path.
|
||||
manifest_is_framework() {
|
||||
_mo_matches "$1" && return 1
|
||||
_mf_matches "$1" && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# Echo the ownership of a path: framework | operator. Normalizes first, so it is
|
||||
# safe for CLI / test callers passing unnormalized input.
|
||||
manifest_resolve() {
|
||||
local path; path="$(_manifest_norm "$1")"
|
||||
if manifest_is_framework "$path"; then echo framework; else echo operator; fi
|
||||
}
|
||||
|
||||
# Echo each shipped framework subtree root (a `dir/**` entry, without the /**).
|
||||
manifest_subtree_roots() {
|
||||
local r
|
||||
for r in "${_MF_ROOTS[@]:-}"; do [[ -n "$r" ]] && printf '%s\n' "$r"; done
|
||||
}
|
||||
|
||||
# CLI dispatch — only when executed directly, never when sourced.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
set -o pipefail
|
||||
# Propagate a fail-closed manifest_load (missing/empty/malformed) as a non-zero
|
||||
# exit instead of continuing to resolve against empty compiled arrays — that is
|
||||
# what lets the parity test assert bash and TS reject the same bad inputs (#791 B2).
|
||||
manifest_load "${MANIFEST_FILE:-}" || exit 1
|
||||
cmd="${1:-}"
|
||||
case "$cmd" in
|
||||
resolve) manifest_resolve "${2:?path required}" ;;
|
||||
subtree-roots) manifest_subtree_roots ;;
|
||||
classify)
|
||||
while IFS= read -r p; do
|
||||
[[ -z "$p" ]] && continue
|
||||
printf '%s\t%s\n' "$(manifest_resolve "$p")" "$p"
|
||||
done
|
||||
;;
|
||||
*)
|
||||
echo "usage: manifest.sh {resolve <path>|subtree-roots|classify}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
9
packages/mosaic/framework/tools/git/README.md
Normal file
9
packages/mosaic/framework/tools/git/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Git provider wrappers
|
||||
|
||||
These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone, and CI operations.
|
||||
|
||||
## Durable review provenance
|
||||
|
||||
A successful provider write command—or a wrapper message based only on that command's exit code—is **not** durable review provenance. Review comments count as durable provenance only after the wrapper reads the created provider record back and verifies that it belongs to the intended repository and pull request and contains the exact submitted body (or verifies the provider-returned record ID).
|
||||
|
||||
`pr-review.sh` therefore fails closed when a Gitea comment cannot be written, its created comment ID cannot be identified, or provider read-back does not match. It reports comment success only after that read-back verification passes.
|
||||
@@ -101,8 +101,58 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
echo "Error: Comment required"
|
||||
exit 1
|
||||
fi
|
||||
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||
echo "Added comment to Gitea PR #$PR_NUMBER"
|
||||
|
||||
repo=$(get_repo_slug)
|
||||
host=$(get_remote_host)
|
||||
login=$(get_gitea_login_for_host "$host")
|
||||
|
||||
comment_json=$(tea comment "$PR_NUMBER" "$COMMENT" --login "$login" --repo "$repo" --output json)
|
||||
comment_id=$(printf '%s' "$comment_json" | python3 -c '
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
comment = json.load(sys.stdin)
|
||||
if isinstance(comment, list) and len(comment) == 1:
|
||||
comment = comment[0]
|
||||
comment_id = comment.get("id") if isinstance(comment, dict) else None
|
||||
if not isinstance(comment_id, int) or comment_id <= 0:
|
||||
raise ValueError("missing positive comment id")
|
||||
except (json.JSONDecodeError, ValueError) as error:
|
||||
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(comment_id)
|
||||
')
|
||||
|
||||
readback_json=$(tea api --login "$login" "/repos/$repo/issues/comments/$comment_id")
|
||||
EXPECTED_COMMENT_ID="$comment_id" EXPECTED_COMMENT_BODY="$COMMENT" EXPECTED_REPO="$repo" EXPECTED_PR_NUMBER="$PR_NUMBER" \
|
||||
python3 -c '
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
comment = json.load(sys.stdin)
|
||||
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
|
||||
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
|
||||
expected_repo = os.environ["EXPECTED_REPO"]
|
||||
expected_pr = os.environ["EXPECTED_PR_NUMBER"]
|
||||
issue_url = comment.get("issue_url", "") if isinstance(comment, dict) else ""
|
||||
issue_path = urlparse(issue_url).path.rstrip("/")
|
||||
expected_suffix = f"/repos/{expected_repo}/issues/{expected_pr}"
|
||||
if comment.get("id") != expected_id:
|
||||
raise ValueError("comment id mismatch")
|
||||
if comment.get("body") != expected_body:
|
||||
raise ValueError("comment body mismatch")
|
||||
if not issue_path.endswith(expected_suffix):
|
||||
raise ValueError("repository or PR mismatch")
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
||||
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
' <<< "$readback_json"
|
||||
|
||||
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unknown action: $ACTION"
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for durable Gitea PR review comments (#812).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-gitea-comment}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
LOG_FILE="$WORK_DIR/tea.log"
|
||||
OUTPUT_FILE="$WORK_DIR/output.log"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
|
||||
cat > "$BIN_DIR/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' "$*" >> "$PR_REVIEW_TEST_LOG"
|
||||
|
||||
if [[ "$*" == "login list --output json" ]]; then
|
||||
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "${PR_REVIEW_TEST_MODE:-}" in
|
||||
approve)
|
||||
[[ "$*" == "pr approve 123 --repo mosaicstack/stack --login mosaicstack" ]] || exit 90
|
||||
;;
|
||||
request-changes)
|
||||
[[ "$*" == "pr reject 123 --repo mosaicstack/stack --login mosaicstack --comment changes-required" ]] || exit 91
|
||||
;;
|
||||
legacy-fallback)
|
||||
if [[ "$*" == pr\ comment* ]]; then
|
||||
# tea v0.11.1 treats the nonexistent subcommand as `tea pr list` and exits 0.
|
||||
printf '%s\n' 'INDEX TITLE STATE'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$*" == comment* ]]; then
|
||||
printf '%s\n' 'supported comment write deliberately unavailable' >&2
|
||||
exit 23
|
||||
fi
|
||||
exit 92
|
||||
;;
|
||||
comment-success|readback-failure)
|
||||
if [[ "$*" == "comment 123 durable-body --login mosaicstack --repo mosaicstack/stack --output json" ]]; then
|
||||
printf '%s\n' '{"id":456,"body":"durable-body"}'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$*" == "api --login mosaicstack /repos/mosaicstack/stack/issues/comments/456" ]]; then
|
||||
if [[ "$PR_REVIEW_TEST_MODE" == "readback-failure" ]]; then
|
||||
printf '%s\n' '{"id":456,"body":"different-body","issue_url":"https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123"}'
|
||||
else
|
||||
printf '%s\n' '{"id":456,"body":"durable-body","issue_url":"https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123"}'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$*" == pr\ comment* ]]; then
|
||||
# Reproduce the old false-positive fallback if the wrapper regresses.
|
||||
printf '%s\n' 'INDEX TITLE STATE'
|
||||
exit 0
|
||||
fi
|
||||
exit 93
|
||||
;;
|
||||
write-failure)
|
||||
if [[ "$*" == "comment 123 durable-body --login mosaicstack --repo mosaicstack/stack --output json" ]]; then
|
||||
printf '%s\n' 'provider rejected comment' >&2
|
||||
exit 24
|
||||
fi
|
||||
exit 94
|
||||
;;
|
||||
*)
|
||||
exit 95
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea"
|
||||
|
||||
run_review() {
|
||||
local mode="$1" action="$2" comment="${3:-}"
|
||||
: > "$LOG_FILE"
|
||||
: > "$OUTPUT_FILE"
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
PR_REVIEW_TEST_LOG="$LOG_FILE" \
|
||||
PR_REVIEW_TEST_MODE="$mode" \
|
||||
"$SCRIPT_DIR/pr-review.sh" -n 123 -a "$action" ${comment:+-c "$comment"}
|
||||
) > "$OUTPUT_FILE" 2>&1
|
||||
}
|
||||
|
||||
run_review approve approve
|
||||
grep -q '^pr approve 123 --repo mosaicstack/stack --login mosaicstack$' "$LOG_FILE"
|
||||
grep -q 'Approved Gitea PR #123' "$OUTPUT_FILE"
|
||||
|
||||
run_review request-changes request-changes changes-required
|
||||
grep -q '^pr reject 123 --repo mosaicstack/stack --login mosaicstack --comment changes-required$' "$LOG_FILE"
|
||||
grep -q 'Requested changes on Gitea PR #123' "$OUTPUT_FILE"
|
||||
|
||||
if run_review legacy-fallback comment durable-body; then
|
||||
echo "The old nonexistent tea pr comment fallback returned success" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q '^pr comment ' "$LOG_FILE"; then
|
||||
echo "Wrapper invoked unsupported tea pr comment" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Added comment to Gitea PR' "$OUTPUT_FILE"; then
|
||||
echo "Wrapper reported success without durable persistence" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_review comment-success comment durable-body
|
||||
grep -q '^comment 123 durable-body --login mosaicstack --repo mosaicstack/stack --output json$' "$LOG_FILE"
|
||||
grep -q '^api --login mosaicstack /repos/mosaicstack/stack/issues/comments/456$' "$LOG_FILE"
|
||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||
|
||||
if run_review write-failure comment durable-body; then
|
||||
echo "Expected provider write failure to return nonzero" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||
echo "Write failure reported durable success" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if run_review readback-failure comment durable-body; then
|
||||
echo "Expected mismatched provider read-back to return nonzero" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||
echo "Read-back mismatch reported durable success" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-review.sh durable Gitea comment regression passed"
|
||||
@@ -37,7 +37,7 @@ response=$(curl -sk -w "\n%{http_code}" \
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
if [[ "$http_code" != "200" ]]; then
|
||||
if [[ "$http_code" != "200" && "$http_code" != "206" ]]; then
|
||||
echo "Error: Failed to list computers (HTTP $http_code)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
84
packages/mosaic/framework/tools/glpi/test-list-http-status.sh
Executable file
84
packages/mosaic/framework/tools/glpi/test-list-http-status.sh
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for #807: ranged GLPI list requests may return HTTP 206.
|
||||
#
|
||||
# Each shipped list wrapper must render a healthy 206 response and must retain
|
||||
# its non-zero error behavior for a genuine HTTP failure.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/glpi-list-http-status}"
|
||||
TOOL_DIR="$WORK_DIR/tools/glpi"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$TOOL_DIR" "$BIN_DIR" "$WORK_DIR/tools/_lib"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
for wrapper in ticket-list.sh computer-list.sh user-list.sh; do
|
||||
cp "$SCRIPT_DIR/$wrapper" "$TOOL_DIR/$wrapper"
|
||||
done
|
||||
|
||||
cat > "$WORK_DIR/tools/_lib/credentials.sh" <<'SH'
|
||||
load_credentials() {
|
||||
export GLPI_URL="https://glpi.test/apirest.php"
|
||||
export GLPI_APP_TOKEN="test-app-token"
|
||||
}
|
||||
SH
|
||||
|
||||
cat > "$TOOL_DIR/session-init.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' 'test-session-token'
|
||||
SH
|
||||
chmod +x "$TOOL_DIR/session-init.sh"
|
||||
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' '[{"id":42,"priority":3,"status":1,"name":"Regression fixture","date_mod":"2026-07-16 10:00:00","serial":"SER-42","states_id":1,"realname":"Fixture","firstname":"GLPI","is_active":1}]'
|
||||
printf '%s\n' "${GLPI_TEST_HTTP_CODE:?GLPI_TEST_HTTP_CODE is required}"
|
||||
SH
|
||||
chmod +x "$BIN_DIR/curl"
|
||||
|
||||
export MOSAIC_HOME="$WORK_DIR"
|
||||
export PATH="$BIN_DIR:$PATH"
|
||||
|
||||
wrappers=(ticket-list.sh computer-list.sh user-list.sh)
|
||||
resources=(tickets computers users)
|
||||
headings=(PRIORITY SERIAL USERNAME)
|
||||
fail=0
|
||||
|
||||
for index in "${!wrappers[@]}"; do
|
||||
wrapper="${wrappers[$index]}"
|
||||
resource="${resources[$index]}"
|
||||
heading="${headings[$index]}"
|
||||
path="$TOOL_DIR/$wrapper"
|
||||
|
||||
if ! output=$(GLPI_TEST_HTTP_CODE=206 bash "$path" 2>&1); then
|
||||
echo "FAIL: $wrapper rejected healthy HTTP 206" >&2
|
||||
fail=1
|
||||
elif [[ "$output" != *"$heading"* || "$output" != *"Regression fixture"* ]]; then
|
||||
echo "FAIL: $wrapper did not render the HTTP 206 list response" >&2
|
||||
fail=1
|
||||
else
|
||||
echo "PASS: $wrapper renders HTTP 206"
|
||||
fi
|
||||
|
||||
rc=0
|
||||
output=$(GLPI_TEST_HTTP_CODE=401 bash "$path" 2>&1) || rc=$?
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
echo "FAIL: $wrapper accepted HTTP 401" >&2
|
||||
fail=1
|
||||
elif [[ "$output" != *"Error: Failed to list $resource (HTTP 401)"* ]]; then
|
||||
echo "FAIL: $wrapper changed the HTTP failure diagnostic" >&2
|
||||
fail=1
|
||||
else
|
||||
echo "PASS: $wrapper rejects HTTP 401"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "ALL PASS: test-list-http-status.sh"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
@@ -55,7 +55,7 @@ response=$(curl -sk -w "\n%{http_code}" \
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
if [[ "$http_code" != "200" ]]; then
|
||||
if [[ "$http_code" != "200" && "$http_code" != "206" ]]; then
|
||||
echo "Error: Failed to list tickets (HTTP $http_code)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -37,7 +37,7 @@ response=$(curl -sk -w "\n%{http_code}" \
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
if [[ "$http_code" != "200" ]]; then
|
||||
if [[ "$http_code" != "200" && "$http_code" != "206" ]]; then
|
||||
echo "Error: Failed to list users (HTTP $http_code)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -61,8 +61,10 @@ MOSAIC_HOME="$T5" MOSAIC_INSTALL_MODE=bogus MOSAIC_SYNC_ONLY=1 bash "$INSTALL" >
|
||||
chk "F5 failure: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]"
|
||||
chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
|
||||
|
||||
# F6 — keep-mode re-seed (the `mosaic update` path) MUST preserve only the
|
||||
# exact user-owned roster paths while refreshing framework-owned schema/examples.
|
||||
# F6 — keep-mode re-seed (the `mosaic update` path) MUST preserve ALL user-owned
|
||||
# fleet state — including an unanticipated file the manifest never names, which
|
||||
# resolves to operator-owned by the #791 fail-safe — while refreshing the
|
||||
# framework-owned schema/examples.
|
||||
T6=$(mktemp -d); mkdir -p "$T6/fleet/examples" "$T6/fleet/run" "$T6/fleet/agents"
|
||||
printf '# persona\n' > "$T6/SOUL.md" # makes it a recognized existing install (→ keep mode)
|
||||
printf 'version: 1\nagents:\n - name: coder0\n' > "$T6/fleet/roster.yaml"
|
||||
@@ -75,13 +77,14 @@ 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/my-fleet.yaml" "$E6/my-fleet.expected"
|
||||
cp "$T6/fleet/run/coder0.hb" "$E6/run.expected"
|
||||
cp "$T6/fleet/agents/coder0.env" "$E6/agent.expected"
|
||||
echo 3 > "$T6/.framework-version"
|
||||
run "$T6" keep
|
||||
chk "F6 reseed: exact roster.yaml bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.yaml' '$E6/roster-yaml.expected'"
|
||||
chk "F6 reseed: exact roster.json bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.json' '$E6/roster-json.expected'"
|
||||
chk "F6 reseed: unrelated fleet YAML is not preserved" "[ ! -f '$T6/fleet/my-fleet.yaml' ]"
|
||||
chk "F6 reseed: unanticipated operator fleet file survives (fail-safe, #791)" "cmp -s '$T6/fleet/my-fleet.yaml' '$E6/my-fleet.expected'"
|
||||
chk "F6 reseed: per-agent env bytes survive" "cmp -s '$T6/fleet/agents/coder0.env' '$E6/agent.expected'"
|
||||
chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'"
|
||||
chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-upgrade-durable-snapshot.sh — the #791 PR2 regression gate.
|
||||
#
|
||||
# PR1 gave keep-mode upgrades two protections: the manifest (a keep-sync only
|
||||
# ever writes framework-owned paths — operator config is structurally untouched)
|
||||
# and an EPHEMERAL /tmp snapshot that rolls the whole target back if the sync
|
||||
# CRASHES mid-write. PR2 adds a third, independent layer for the case neither
|
||||
# covers: a "successful" upgrade that a manifest/logic bug silently let touch an
|
||||
# operator file. That layer is a DURABLE, operator-scoped pre-update snapshot:
|
||||
#
|
||||
# Part 1 (scope): before any mutation, the installer copies exactly the
|
||||
# operator-owned files that exist into a retained backup
|
||||
# under $XDG_STATE_HOME/mosaic/backups/pre-update-<ts>/ —
|
||||
# framework files are NOT captured.
|
||||
# Part 2 (perms): the backup root, snapshot dir and every nested dir are
|
||||
# 0700; every backed-up file is 0600 (never world-readable,
|
||||
# even though operator config may hold secrets).
|
||||
# Part 3 (no leak): a secret seeded into credentials.json is copied into the
|
||||
# snapshot (proving coverage) but its value never appears
|
||||
# on stdout/stderr — the snapshot reports counts/paths only.
|
||||
# Part 4 (retention): only the newest MOSAIC_BACKUP_RETENTION snapshots survive;
|
||||
# older ones are pruned.
|
||||
# Part 5 (verify net): if the upgrade DID modify an operator file (injected here
|
||||
# with a cp shim that scribbles on SOUL.md while a framework
|
||||
# file is copied), the post-sync verify restores that file
|
||||
# from the durable snapshot and warns loudly. The control —
|
||||
# the same installer with the verify call stripped — leaves
|
||||
# the corruption in place, proving the net is load-bearing.
|
||||
#
|
||||
# Usage: bash test-upgrade-durable-snapshot.sh
|
||||
set -uo pipefail
|
||||
|
||||
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
|
||||
INSTALL="$FW/install.sh"
|
||||
ORIG_PATH="$PATH"
|
||||
FRAMEWORK_VERSION="$(grep -m1 '^FRAMEWORK_VERSION=' "$INSTALL" | cut -d= -f2)"
|
||||
|
||||
# Control installers must live INSIDE $FW: install.sh derives SOURCE_DIR from its
|
||||
# own path and sources tools/_lib/manifest.sh relative to it, so a copy anywhere
|
||||
# else aborts before the sync. Each control is a shipped installer with one guard
|
||||
# line stripped (keyed off a `# <MARKER>` anchor), proving that guard load-bearing.
|
||||
# All controls share the .install-*.tmp.sh glob so one trap sweeps them on exit.
|
||||
VERIFYCTRL="$FW/.install-verifynet-control.tmp.sh"
|
||||
rm -f "$FW"/.install-*.tmp.sh
|
||||
trap 'rm -f "$FW"/.install-*.tmp.sh' EXIT
|
||||
|
||||
# mk_control <marker-regex> <name> — echo a control installer path ($FW-local) that
|
||||
# is $INSTALL with every line matching /<marker-regex>/ deleted.
|
||||
mk_control() {
|
||||
local path="$FW/.install-$2.tmp.sh"
|
||||
sed "/$1/d" "$INSTALL" > "$path"
|
||||
printf '%s' "$path"
|
||||
}
|
||||
|
||||
pass=0; fail=0
|
||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||
|
||||
SECRET='SUPER-SECRET-TOKEN-do-not-log-pr2'
|
||||
SOUL_ORIG='# persona'
|
||||
# A framework file the sync copies (source ships it; the seeded target omits it,
|
||||
# so the bytes differ and cp is attempted). The Part-5 shim keys off this path.
|
||||
POISON_REL='guides/E2E-DELIVERY.md'
|
||||
|
||||
# Seed a recognized keep-mode install holding four operator-owned files across
|
||||
# the identity file, an operator subtree, memory, and the credentials carve-out.
|
||||
seed_home() {
|
||||
local H="$1"
|
||||
mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory"
|
||||
printf '%s\n' "$SOUL_ORIG" > "$H/SOUL.md" # recognized install → keep mode
|
||||
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
|
||||
printf '# operator memory\n' > "$H/memory/note.md"
|
||||
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
|
||||
echo 3 > "$H/.framework-version"
|
||||
# Deliberately NO guides/E2E-DELIVERY.md so the sync copies it (framework file,
|
||||
# bytes differ) — that copy is where the Part-5 corruption shim fires.
|
||||
}
|
||||
|
||||
# A pre-v2 (legacy) keep-mode install: SOUL.md marks it recognized, and a bin/
|
||||
# tree with NO .framework-version makes installed_framework_version() report 1, so
|
||||
# the v1→v2 migration (which deletes bin/) runs. bin/ is unknown⇒operator, so the
|
||||
# durable snapshot captures it — the verify net must NOT heal the intended removal.
|
||||
seed_home_v1() {
|
||||
local H="$1"
|
||||
mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory" "$H/bin"
|
||||
printf '%s\n' "$SOUL_ORIG" > "$H/SOUL.md"
|
||||
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
|
||||
printf '#!/bin/sh\necho legacy\n' > "$H/bin/tool.sh"; chmod +x "$H/bin/tool.sh"
|
||||
# Deliberately NO .framework-version and NO guides/E2E-DELIVERY.md (see seed_home).
|
||||
}
|
||||
|
||||
# A cp shim that, while the framework POISON file is being copied during sync,
|
||||
# swaps the operator credentials file for a symlink pointing at an attacker-
|
||||
# readable file OUTSIDE the target — simulating post-snapshot tampering (CWE-59).
|
||||
# The durable snapshot already holds the real credentials (it is taken before any
|
||||
# sync), so the verify net must restore a REAL file in place WITHOUT following the
|
||||
# link (which would write the snapshot's secret out through it). $EXFIL_TARGET is
|
||||
# expanded at shim-write time from the caller's environment.
|
||||
#
|
||||
# PORTABILITY (why this shim, not the real `cp`): the CWE-59 leak this exercises is
|
||||
# `cp` writing THROUGH a symlinked destination. GNU/BSD cp — what a real operator
|
||||
# runs `mosaic update` under — follows the dest symlink and leaks. busybox cp (the
|
||||
# Alpine CI image) REPLACES a symlinked dest instead of following it, so under the
|
||||
# CI harness the leak vector simply does not exist and the negative control could
|
||||
# never reproduce it. This shim therefore emulates the real-target GNU cp behavior
|
||||
# PORTABLY: when the destination is a symlink it writes the source bytes through the
|
||||
# link via redirection (which follows symlinks on every coreutils, busybox included);
|
||||
# otherwise it delegates to the host's real cp unchanged. Both the shipped-case and
|
||||
# the negative control run through this identical shim, so the ONLY difference
|
||||
# between them remains the SYMLINK-LEAF-GUARD — the control stays load-bearing and
|
||||
# non-tautological. It does NOT touch install.sh (approved) or the real assertions:
|
||||
# with the guard present the symlinked leaf is dropped BEFORE this cp runs, so the
|
||||
# dest is a real file and the delegate path is taken exactly as on a GNU host.
|
||||
make_symlink_leaf_shim() {
|
||||
local dir="$1" home="$2"
|
||||
cat > "$dir/cp" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
dest="\${@: -1}"
|
||||
src="\${@:(-2):1}"
|
||||
case "\$dest" in
|
||||
*/$POISON_REL)
|
||||
rm -f "$home/tools/_lib/credentials.json"
|
||||
ln -s "$EXFIL_TARGET" "$home/tools/_lib/credentials.json"
|
||||
;;
|
||||
esac
|
||||
# Coreutils-agnostic emulation of GNU cp's follow-through-dest-symlink behavior.
|
||||
if [[ -L "\$dest" && -f "\$src" ]]; then
|
||||
cat "\$src" > "\$dest"
|
||||
exit \$?
|
||||
fi
|
||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||
SHIM
|
||||
chmod +x "$dir/cp"
|
||||
}
|
||||
|
||||
# A cp shim that, while the framework POISON file is being copied during sync,
|
||||
# also appends garbage to the operator SOUL.md — simulating a manifest bug that
|
||||
# writes outside the framework lane. The framework copy itself still succeeds
|
||||
# (real cp runs), so the sync completes 0 and the post-sync verify is what must
|
||||
# catch and undo the operator-file damage. The snapshot's own cp only ever
|
||||
# targets operator files (never guides/…), so it is never corrupted by this shim.
|
||||
make_corrupt_shim() {
|
||||
local dir="$1" home="$2"
|
||||
cat > "$dir/cp" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
dest="\${@: -1}"
|
||||
case "\$dest" in
|
||||
*/$POISON_REL) printf 'CORRUPTION-mid-sync\n' >> "$home/SOUL.md" 2>/dev/null || true ;;
|
||||
esac
|
||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||
SHIM
|
||||
chmod +x "$dir/cp"
|
||||
}
|
||||
|
||||
# Run one keep-mode, sync-only upgrade with $XDG_STATE_HOME redirected to a
|
||||
# throwaway dir (so the real ~/.local/state is never touched). Optional args:
|
||||
# $2 shim-maker (default none), $3 MOSAIC_BACKUP_RETENTION (default unset).
|
||||
# Echoes: "<exit>\t<out>\t<state-dir>\t<home>".
|
||||
# $4 seeder (default seed_home) — swap in seed_home_v1 for the migration case.
|
||||
run_snap() {
|
||||
local installer="$1" shim_maker="${2:-}" retention="${3:-}" seeder="${4:-seed_home}" H STATE OUT SHIM rc pathpre
|
||||
H=$(mktemp -d); STATE=$(mktemp -d); OUT=$(mktemp); pathpre="$ORIG_PATH"
|
||||
"$seeder" "$H"
|
||||
if [[ -n "$shim_maker" ]]; then
|
||||
SHIM=$(mktemp -d); "$shim_maker" "$SHIM" "$H"; pathpre="$SHIM:$ORIG_PATH"
|
||||
fi
|
||||
set +e
|
||||
env PATH="$pathpre" XDG_STATE_HOME="$STATE" \
|
||||
${retention:+MOSAIC_BACKUP_RETENTION="$retention"} \
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 \
|
||||
bash "$installer" >"$OUT" 2>&1
|
||||
rc=$?
|
||||
set -e 2>/dev/null || true
|
||||
[[ -n "$shim_maker" ]] && rm -rf "$SHIM"
|
||||
printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$STATE" "$H"
|
||||
}
|
||||
|
||||
# Resolve the single pre-update-* snapshot dir under a state dir (newest if many).
|
||||
snap_dir() {
|
||||
find "$1/mosaic/backups" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null \
|
||||
| LC_ALL=C sort -r | head -1
|
||||
}
|
||||
|
||||
echo "── Part 1/2/3: durable snapshot scope, perms, no-leak ──────────────────"
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL")
|
||||
SNAP="$(snap_dir "$STATE")"
|
||||
|
||||
chk "upgrade succeeds" "[ '$rc' -eq 0 ]"
|
||||
chk "exactly one pre-update snapshot created" "[ \$(find '$STATE/mosaic/backups' -maxdepth 1 -type d -name 'pre-update-*' | wc -l) -eq 1 ]"
|
||||
chk "snapshot: SOUL.md captured" "[ -f '$SNAP/SOUL.md' ]"
|
||||
chk "snapshot: operator subtree captured" "[ -f '$SNAP/agents/coder0.conf' ]"
|
||||
chk "snapshot: memory captured" "[ -f '$SNAP/memory/note.md' ]"
|
||||
chk "snapshot: credentials carve-out captured" "[ -f '$SNAP/tools/_lib/credentials.json' ]"
|
||||
chk "snapshot: SOUL.md bytes preserved" "[ \"\$(cat '$SNAP/SOUL.md')\" = '$SOUL_ORIG' ]"
|
||||
chk "snapshot: framework file NOT captured" "[ ! -e '$SNAP/CONSTITUTION.md' ] && [ ! -e '$SNAP/$POISON_REL' ]"
|
||||
|
||||
# Part 2 — permissions (0700 dirs, 0600 files); never world-readable.
|
||||
chk "perms: backup root is 0700" "[ \$(stat -c '%a' '$STATE/mosaic/backups') -eq 700 ]"
|
||||
chk "perms: snapshot dir is 0700" "[ \$(stat -c '%a' '$SNAP') -eq 700 ]"
|
||||
chk "perms: nested dir is 0700" "[ \$(stat -c '%a' '$SNAP/agents') -eq 700 ]"
|
||||
chk "perms: credentials backup is 0600" "[ \$(stat -c '%a' '$SNAP/tools/_lib/credentials.json') -eq 600 ]"
|
||||
chk "perms: SOUL.md backup is 0600" "[ \$(stat -c '%a' '$SNAP/SOUL.md') -eq 600 ]"
|
||||
|
||||
# Part 3 — the secret is backed up but never emitted to stdout/stderr.
|
||||
chk "no-leak: secret IS in the backup file" "grep -q '$SECRET' '$SNAP/tools/_lib/credentials.json'"
|
||||
chk "no-leak: secret NOT on stdout/stderr" "! grep -q '$SECRET' '$OUT'"
|
||||
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||
|
||||
echo "── Part 4: retention prune (MOSAIC_BACKUP_RETENTION) ───────────────────"
|
||||
# Pre-seed four dated snapshots, then take one real snapshot with retention=2:
|
||||
# only the two newest (the fresh real one + the newest pre-seeded) must survive.
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(
|
||||
H=$(mktemp -d); STATE=$(mktemp -d); OUT=$(mktemp)
|
||||
seed_home "$H"
|
||||
mkdir -p "$STATE/mosaic/backups"
|
||||
for ts in 20200101T000000Z 20210101T000000Z 20220101T000000Z 20230101T000000Z; do
|
||||
mkdir -p "$STATE/mosaic/backups/pre-update-$ts"
|
||||
done
|
||||
set +e
|
||||
env PATH="$ORIG_PATH" XDG_STATE_HOME="$STATE" MOSAIC_BACKUP_RETENTION=2 \
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 \
|
||||
bash "$INSTALL" >"$OUT" 2>&1
|
||||
rc=$?
|
||||
set -e 2>/dev/null || true
|
||||
printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$STATE" "$H"
|
||||
)
|
||||
chk "retention: upgrade succeeds" "[ '$rc' -eq 0 ]"
|
||||
chk "retention: pruned to exactly 2 snapshots" "[ \$(find '$STATE/mosaic/backups' -maxdepth 1 -type d -name 'pre-update-*' | wc -l) -eq 2 ]"
|
||||
chk "retention: newest pre-seeded survives" "[ -d '$STATE/mosaic/backups/pre-update-20230101T000000Z' ]"
|
||||
chk "retention: oldest pre-seeded pruned" "[ ! -d '$STATE/mosaic/backups/pre-update-20200101T000000Z' ]"
|
||||
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||
|
||||
echo "── Part 5: post-sync verify restores an operator file (+ control) ──────"
|
||||
# Shipped installer: the cp shim corrupts SOUL.md mid-sync; verify must restore it.
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" make_corrupt_shim)
|
||||
chk "verify: upgrade still succeeds" "[ '$rc' -eq 0 ]"
|
||||
chk "verify: SOUL.md restored to original" "[ \"\$(cat '$H/SOUL.md')\" = '$SOUL_ORIG' ]"
|
||||
chk "verify: no corruption remains in SOUL.md" "! grep -q 'CORRUPTION-mid-sync' '$H/SOUL.md'"
|
||||
chk "verify: loud restore warning emitted" "grep -qi 'restored from the pre-update snapshot' '$OUT'"
|
||||
chk "verify: secret still not leaked" "! grep -q '$SECRET' '$OUT'"
|
||||
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||
|
||||
# Control: strip the verify call → the corruption must SURVIVE (net is load-bearing).
|
||||
sed '/# VERIFY-NET/d' "$INSTALL" > "$VERIFYCTRL"
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$VERIFYCTRL" make_corrupt_shim)
|
||||
chk "control: SOUL.md corruption survives" "grep -q 'CORRUPTION-mid-sync' '$H/SOUL.md'"
|
||||
chk "control: no restore warning emitted" "! grep -qi 'restored from the pre-update snapshot' '$OUT'"
|
||||
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||
|
||||
echo "── Part 6: verify net honors an intentional migration removal (+ control) ─"
|
||||
# BLOCKER regression: on a pre-v2 install, bin/ is operator-classified so the durable
|
||||
# snapshot captures it — but the v1→v2 migration deletes bin/ ON PURPOSE. The verify
|
||||
# net must SKIP that removal (is_migration_removed), or it heals bin/ back and the
|
||||
# migration is silently undone forever once the version is stamped.
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" "" "" seed_home_v1)
|
||||
chk "migration: upgrade succeeds" "[ '$rc' -eq 0 ]"
|
||||
chk "migration: legacy bin/ stays removed" "[ ! -e '$H/bin' ]"
|
||||
chk "migration: operator SOUL.md untouched" "[ \"\$(cat '$H/SOUL.md')\" = '$SOUL_ORIG' ]"
|
||||
chk "migration: version stamped to $FRAMEWORK_VERSION" "[ \"\$(cat '$H/.framework-version')\" = '$FRAMEWORK_VERSION' ]"
|
||||
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||
|
||||
# Control: strip the MIGRATION-SKIP-GUARD → the verify net restores bin/ from the
|
||||
# snapshot, silently undoing the migration (proves the guard is load-bearing).
|
||||
MIGCTRL="$(mk_control 'MIGRATION-SKIP-GUARD' migration-control)"
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$MIGCTRL" "" "" seed_home_v1)
|
||||
chk "control: bin/ wrongly restored by verify" "[ -e '$H/bin/tool.sh' ]"
|
||||
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||
|
||||
echo "── Part 7: verify net never restores a secret through a symlink (+ control) ─"
|
||||
# HIGH (CWE-59) regression: an attacker who swaps an operator file for a symlink
|
||||
# AFTER the durable snapshot must not cause the verify net's restore to write the
|
||||
# snapshot's secret out THROUGH that link. The shipped net drops a symlinked leaf and
|
||||
# writes a real file in its place, leaving the external target untouched.
|
||||
EXFIL_DIR=$(mktemp -d); EXFIL_TARGET="$EXFIL_DIR/stolen"
|
||||
printf 'ATTACKER-PLACEHOLDER\n' > "$EXFIL_TARGET"
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" make_symlink_leaf_shim)
|
||||
chk "symlink-leaf: upgrade succeeds" "[ '$rc' -eq 0 ]"
|
||||
chk "symlink-leaf: secret NOT written through link" "! grep -q '$SECRET' '$EXFIL_TARGET'"
|
||||
chk "symlink-leaf: credentials.json is a real file" "[ -f '$H/tools/_lib/credentials.json' ] && [ ! -L '$H/tools/_lib/credentials.json' ]"
|
||||
chk "symlink-leaf: credentials.json restored intact" "grep -q '$SECRET' '$H/tools/_lib/credentials.json'"
|
||||
chk "symlink-leaf: secret not leaked to stdout/stderr" "! grep -q '$SECRET' '$OUT'"
|
||||
rm -rf "$STATE" "$H" "$EXFIL_DIR"; rm -f "$OUT"
|
||||
|
||||
# Control: strip the SYMLINK-LEAF-GUARD → cp follows the swapped-in link and writes
|
||||
# the snapshot secret out through it (proves the guard is load-bearing).
|
||||
EXFIL_DIR=$(mktemp -d); EXFIL_TARGET="$EXFIL_DIR/stolen"
|
||||
printf 'ATTACKER-PLACEHOLDER\n' > "$EXFIL_TARGET"
|
||||
LEAFCTRL="$(mk_control 'SYMLINK-LEAF-GUARD' symlinkleaf-control)"
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$LEAFCTRL" make_symlink_leaf_shim)
|
||||
chk "control: secret leaked through the symlink" "grep -q '$SECRET' '$EXFIL_TARGET'"
|
||||
rm -rf "$STATE" "$H" "$EXFIL_DIR"; rm -f "$OUT"
|
||||
|
||||
echo "── Part 8: snapshot umask 077 does not leak into synced files (+ control) ──"
|
||||
# SHOULD-FIX regression: umask 077 is process-global. Scoped to the snapshot it keeps
|
||||
# backups 0600; leaked past it, every later cp/mkdir inherits 0600/0700. A freshly-
|
||||
# synced framework file must be 0644 (per the ambient 022 umask) while the backup of
|
||||
# a secret stays 0600.
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(umask 022; run_snap "$INSTALL")
|
||||
SNAP="$(snap_dir "$STATE")"
|
||||
chk "umask: upgrade succeeds" "[ '$rc' -eq 0 ]"
|
||||
chk "umask: synced framework file is 0644" "[ \$(stat -c '%a' '$H/$POISON_REL') -eq 644 ]"
|
||||
chk "umask: backup of a secret stays 0600" "[ \$(stat -c '%a' '$SNAP/tools/_lib/credentials.json') -eq 600 ]"
|
||||
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||
|
||||
# Control: strip the UMASK-RESTORE-NORMAL line → umask 077 leaks past the snapshot,
|
||||
# so the newly-synced framework file is created 0600 (proves the restore matters).
|
||||
UMASKCTRL="$(mk_control 'UMASK-RESTORE-NORMAL' umask-control)"
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(umask 022; run_snap "$UMASKCTRL")
|
||||
chk "control: leaked umask makes synced file 0600" "[ \$(stat -c '%a' '$H/$POISON_REL') -eq 600 ]"
|
||||
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||
|
||||
echo ""
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
[ "$fail" -eq 0 ]
|
||||
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-upgrade-manifest-guard.sh — the #791 HARD GATE.
|
||||
#
|
||||
# Proves that a keep-mode framework upgrade (the `mosaic update` path:
|
||||
# install.sh with MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1) touches NO path
|
||||
# outside the framework-owned manifest. Every operator-owned sentinel — including
|
||||
# a deliberately UNANTICIPATED one the manifest never names — must survive
|
||||
# byte-identical with an unchanged mtime (not even rewritten). Framework files
|
||||
# must still update, and a retired framework file inside a shipped subtree must
|
||||
# still be pruned. No operator secret value may appear in installer output.
|
||||
#
|
||||
# Keep mode is a SINGLE code path (sync_framework_keep, a manifest-driven cp
|
||||
# overlay + scoped prune — no rsync). The matrix still runs twice, once with
|
||||
# rsync on PATH and once with it hidden, to prove the keep path is genuinely
|
||||
# rsync-independent: it must obey the manifest identically whether or not rsync
|
||||
# happens to be installed (rsync --delete is only ever used by overwrite mode,
|
||||
# which has no operator state to protect).
|
||||
#
|
||||
# It also runs a fail-closed matrix (#791 B2/B3): an empty, operator-only,
|
||||
# malformed, or missing manifest must ABORT the upgrade loudly and leave every
|
||||
# operator path untouched — never silently no-op to "complete".
|
||||
#
|
||||
# Usage: bash test-upgrade-manifest-guard.sh
|
||||
set -uo pipefail
|
||||
|
||||
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
|
||||
INSTALL="$FW/install.sh"
|
||||
|
||||
pass=0; fail=0
|
||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||
|
||||
# Redirect the #791 PR2 durable pre-update snapshot ($XDG_STATE_HOME/mosaic/backups)
|
||||
# into a throwaway so a keep-mode upgrade under test never writes into the real
|
||||
# ~/.local/state. This test asserts operator-surface fidelity, not backup content.
|
||||
export XDG_STATE_HOME
|
||||
XDG_STATE_HOME="$(mktemp -d)"
|
||||
trap 'rm -rf "$XDG_STATE_HOME"' EXIT
|
||||
|
||||
SECRET='SUPER-SECRET-TOKEN-do-not-log-3f9a'
|
||||
|
||||
# Seed a throwaway MOSAIC_HOME with an operator sentinel per ownership class.
|
||||
seed_home() {
|
||||
local H="$1"
|
||||
mkdir -p "$H/agents" "$H/policy" "$H/memory" "$H/tools/_lib" \
|
||||
"$H/fleet/agents" "$H/fleet/run/sessions" "$H/harvester" \
|
||||
"$H/unknown-operator-dir" "$H/guides"
|
||||
printf '# persona\n' > "$H/SOUL.md" # marks a recognized existing install → keep mode
|
||||
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
|
||||
printf '# operator policy\n' > "$H/policy/custom.md"
|
||||
printf '# soul overlay\n' > "$H/SOUL.local.md"
|
||||
printf '# operator memory\n' > "$H/memory/note.md"
|
||||
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
|
||||
printf 'MOSAIC_AGENT_NAME=coder0\n' > "$H/fleet/agents/coder0.env"
|
||||
printf 'version: 2\nagents:\n - name: coder0\n' > "$H/fleet/roster.yaml"
|
||||
printf '# harvester SOP\n' > "$H/harvester/sop.md"
|
||||
printf 'operator data the manifest never anticipated\n' > "$H/unknown-operator-dir/x"
|
||||
printf 'version: 1\nagents:\n - name: mine\n' > "$H/fleet/my-fleet.yaml"
|
||||
|
||||
# #797 Runtime Session Ledger (Mos-elevated to a #791 PR1 merge-blocker): a
|
||||
# populated ledger under fleet/run/sessions/ must survive the upgrade — a
|
||||
# runtime ledger an upgrade rsync can wipe is worthless. Seed it exactly as
|
||||
# #797 writes it: a non-empty append journal + a non-empty compacted
|
||||
# projection, files 0600 under a 0700 dir.
|
||||
printf '%s\n%s\n%s\n' \
|
||||
'{"seq":1,"kind":"session.spawn","node":"sess-42","generation":7}' \
|
||||
'{"seq":2,"kind":"lease.grant","node":"sess-42","lease":"web1"}' \
|
||||
'{"seq":3,"kind":"dispatch.create","from":"sess-42","to":"disp-9"}' \
|
||||
> "$H/fleet/run/sessions/events.ndjson"
|
||||
printf '%s\n' \
|
||||
'{"generation":7,"nodes":[{"id":"sess-42","kind":"session"}],"edges":[{"from":"sess-42","to":"disp-9","kind":"dispatch"}]}' \
|
||||
> "$H/fleet/run/sessions/ledger.json"
|
||||
chmod 0700 "$H/fleet/run" "$H/fleet/run/sessions"
|
||||
chmod 0600 "$H/fleet/run/sessions/events.ndjson" "$H/fleet/run/sessions/ledger.json"
|
||||
|
||||
# A retired framework file inside a shipped subtree (absent from source) — must be pruned.
|
||||
printf '# retired guide\n' > "$H/guides/RETIRED-OLD-GUIDE.md"
|
||||
echo 3 > "$H/.framework-version"
|
||||
}
|
||||
|
||||
OPERATOR_SENTINELS=(
|
||||
"agents/coder0.conf"
|
||||
"policy/custom.md"
|
||||
"SOUL.local.md"
|
||||
"memory/note.md"
|
||||
"tools/_lib/credentials.json"
|
||||
"fleet/agents/coder0.env"
|
||||
"fleet/roster.yaml"
|
||||
"harvester/sop.md"
|
||||
"unknown-operator-dir/x"
|
||||
"fleet/my-fleet.yaml"
|
||||
# #797 Runtime Session Ledger — populated journal + projection must survive.
|
||||
"fleet/run/sessions/events.ndjson"
|
||||
"fleet/run/sessions/ledger.json"
|
||||
)
|
||||
|
||||
run_matrix() {
|
||||
local label="$1"; shift # extra env / PATH override applied to the run
|
||||
local H E OUT rel before_hash after_hash before_mt after_mt
|
||||
H=$(mktemp -d); E=$(mktemp -d); OUT=$(mktemp)
|
||||
seed_home "$H"
|
||||
|
||||
# Snapshot hash + mtime of every operator sentinel before the upgrade.
|
||||
for rel in "${OPERATOR_SENTINELS[@]}"; do
|
||||
sha256sum "$H/$rel" | awk '{print $1}' > "$E/$(echo "$rel" | tr / _).hash"
|
||||
stat -c %Y "$H/$rel" > "$E/$(echo "$rel" | tr / _).mt"
|
||||
done
|
||||
|
||||
# Snapshot the ledger directory permission bits (#797 assert: perms unchanged).
|
||||
local before_dirperm after_dirperm
|
||||
before_dirperm=$(stat -c %a "$H/fleet/run/sessions")
|
||||
|
||||
# The upgrade under test (keep + sync-only = the `mosaic update` reseed path).
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 "$@" bash "$INSTALL" >"$OUT" 2>&1
|
||||
|
||||
# HARD GATE: every operator sentinel survives byte-identical AND mtime-unchanged.
|
||||
for rel in "${OPERATOR_SENTINELS[@]}"; do
|
||||
before_hash=$(cat "$E/$(echo "$rel" | tr / _).hash")
|
||||
before_mt=$(cat "$E/$(echo "$rel" | tr / _).mt")
|
||||
after_hash=$(sha256sum "$H/$rel" 2>/dev/null | awk '{print $1}')
|
||||
after_mt=$(stat -c %Y "$H/$rel" 2>/dev/null || echo MISSING)
|
||||
chk "[$label] operator sentinel survives byte-identical: $rel" \
|
||||
"[ -n '$after_hash' ] && [ '$before_hash' = '$after_hash' ]"
|
||||
chk "[$label] operator sentinel not rewritten (mtime unchanged): $rel" \
|
||||
"[ '$before_mt' = '$after_mt' ]"
|
||||
done
|
||||
|
||||
# #797 assert 7: the ledger directory's permission bits are unchanged.
|
||||
after_dirperm=$(stat -c %a "$H/fleet/run/sessions" 2>/dev/null || echo MISSING)
|
||||
chk "[$label] ledger dir perms unchanged (#797): $before_dirperm" \
|
||||
"[ '$before_dirperm' = '$after_dirperm' ]"
|
||||
|
||||
# Positive controls / negative controls — prove the test discriminates: the
|
||||
# upgrade DOES write and prune framework-owned paths, so the operator sentinels
|
||||
# (incl. the #797 ledger) survive because of the manifest, not because the
|
||||
# upgrade is a no-op.
|
||||
chk "[$label] positive control: framework file present after upgrade (guides synced)" \
|
||||
"[ -f '$H/guides/E2E-DELIVERY.md' ]"
|
||||
chk "[$label] negative control: retired framework file inside a subtree IS pruned" \
|
||||
"[ ! -f '$H/guides/RETIRED-OLD-GUIDE.md' ]"
|
||||
chk "[$label] manifest itself is installed" "[ -f '$H/framework-manifest.txt' ]"
|
||||
|
||||
# Secret-safety: the operator secret value never appears in installer output.
|
||||
chk "[$label] operator secret value absent from installer stdout/stderr" \
|
||||
"! grep -q '$SECRET' '$OUT'"
|
||||
|
||||
rm -rf "$H" "$E" "$OUT"
|
||||
}
|
||||
|
||||
# Fail-closed matrix (#791 B2/B3 + blocker-1): run install.sh from a COPY of the
|
||||
# framework so the shipped manifest can be corrupted. Every corruption must abort
|
||||
# the upgrade non-zero with a manifest error, leaving all operator sentinels
|
||||
# byte-identical AND on the SAME inode. The inode check is the load-bearing part:
|
||||
# manifest validation is hoisted BEFORE make_snapshot/the restore trap, so a bad
|
||||
# manifest must abort without ever snapshotting, deleting, and restoring the
|
||||
# target. Were validation still armed under the ERR trap, restore_snapshot would
|
||||
# rm -rf + rebuild the target — same bytes but a NEW inode (broken hard links,
|
||||
# changed ctime), which a content-only hash would miss (#791 blocker-1).
|
||||
run_failclosed() {
|
||||
local label="$1" mutate="$2"
|
||||
local SRC H E OUT rc rel before_hash after_hash before_ino after_ino key
|
||||
SRC=$(mktemp -d); H=$(mktemp -d); E=$(mktemp -d); OUT=$(mktemp)
|
||||
cp -a "$FW/." "$SRC/"
|
||||
case "$mutate" in
|
||||
empty) : > "$SRC/framework-manifest.txt" ;;
|
||||
operator-only) printf '[operator]\nSOUL.md\n*.local.md\n' > "$SRC/framework-manifest.txt" ;;
|
||||
malformed) printf 'stray.md\n[framework]\nguides/**\n' > "$SRC/framework-manifest.txt" ;;
|
||||
degenerate) printf '[framework]\n/\n./\n[operator]\nSOUL.md\n' > "$SRC/framework-manifest.txt" ;;
|
||||
missing) rm -f "$SRC/framework-manifest.txt" ;;
|
||||
esac
|
||||
|
||||
seed_home "$H"
|
||||
for rel in "${OPERATOR_SENTINELS[@]}"; do
|
||||
key=$(echo "$rel" | tr / _)
|
||||
sha256sum "$H/$rel" | awk '{print $1}' > "$E/$key.hash"
|
||||
stat -c '%i' "$H/$rel" > "$E/$key.ino"
|
||||
done
|
||||
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$SRC/install.sh" >"$OUT" 2>&1
|
||||
rc=$?
|
||||
|
||||
chk "[fail-closed:$label] upgrade aborts non-zero" "[ '$rc' -ne 0 ]"
|
||||
chk "[fail-closed:$label] refuses loudly with a manifest error" \
|
||||
"grep -qi 'manifest' '$OUT'"
|
||||
for rel in "${OPERATOR_SENTINELS[@]}"; do
|
||||
key=$(echo "$rel" | tr / _)
|
||||
before_hash=$(cat "$E/$key.hash")
|
||||
after_hash=$(sha256sum "$H/$rel" 2>/dev/null | awk '{print $1}')
|
||||
chk "[fail-closed:$label] operator sentinel untouched: $rel" \
|
||||
"[ -n '$after_hash' ] && [ '$before_hash' = '$after_hash' ]"
|
||||
before_ino=$(cat "$E/$key.ino")
|
||||
after_ino=$(stat -c '%i' "$H/$rel" 2>/dev/null)
|
||||
chk "[fail-closed:$label] operator sentinel not deleted/recreated (inode stable): $rel" \
|
||||
"[ -n '$after_ino' ] && [ '$before_ino' = '$after_ino' ]"
|
||||
done
|
||||
chk "[fail-closed:$label] operator secret value absent from output" \
|
||||
"! grep -q '$SECRET' '$OUT'"
|
||||
|
||||
chmod -R u+w "$SRC" "$H" 2>/dev/null || true
|
||||
rm -rf "$SRC" "$H" "$E" "$OUT"
|
||||
}
|
||||
|
||||
echo "#791 upgrade manifest guard (HARD GATE):"
|
||||
|
||||
# 1) rsync path (if available on this host).
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
run_matrix "rsync"
|
||||
else
|
||||
echo " · rsync not installed — skipping rsync-path matrix"
|
||||
fi
|
||||
|
||||
# 2) rsync-absent path — hide rsync behind a scratch PATH. Keep mode never calls
|
||||
# rsync, so this must resolve identically to run (1); it proves the keep path
|
||||
# does not silently depend on rsync being installed. (Provide the coreutils the
|
||||
# installer needs on the stripped PATH.)
|
||||
FBIN=$(mktemp -d)
|
||||
for t in bash cp find mktemp rm mkdir chmod cmp sed grep cat dirname basename stat sha256sum awk tr date sort; do
|
||||
p=$(command -v "$t" 2>/dev/null) && ln -s "$p" "$FBIN/$t"
|
||||
done
|
||||
run_matrix "rsync-absent" env "PATH=$FBIN"
|
||||
rm -rf "$FBIN"
|
||||
|
||||
# 3) fail-closed matrix (#791 B2/B3) — corrupt the shipped manifest four ways.
|
||||
run_failclosed "empty-manifest" empty
|
||||
run_failclosed "operator-only" operator-only
|
||||
run_failclosed "malformed-manifest" malformed
|
||||
run_failclosed "degenerate-framework" degenerate
|
||||
run_failclosed "missing-manifest" missing
|
||||
|
||||
echo
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
[ "$fail" -eq 0 ]
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-upgrade-rollback.sh — the #791 B1 regression gate.
|
||||
#
|
||||
# A keep-mode upgrade takes a pre-update snapshot and installs an ERR/INT/TERM
|
||||
# trap that restores it if the sync aborts midway (install.sh: make_snapshot +
|
||||
# `trap restore_snapshot`). That trap is only reached if `set -E` (errtrace) is
|
||||
# active — otherwise a failure INSIDE sync_framework_keep() (which runs entirely
|
||||
# in a function) never fires the trap, and the upgrade aborts leaving a
|
||||
# half-written target with NO rollback. This test proves:
|
||||
#
|
||||
# Part A (the gate): the shipped installer rolls back a mid-sync failure —
|
||||
# the restore message fires, the corrupted file is put
|
||||
# back, AND the whole target is byte-identical to its
|
||||
# pre-upgrade state.
|
||||
# Part B (the control): the SAME installer with `-E` stripped does NOT roll back
|
||||
# (dead trap) — the mid-sync corruption survives, proving
|
||||
# errtrace is load-bearing. If anyone removes `set -E`,
|
||||
# Part A goes red.
|
||||
#
|
||||
# The mid-sync failure is injected with a PATH-shadowing `cp` shim rather than
|
||||
# file permissions. The earlier 0400/EACCES approach was NOT portable: Woodpecker
|
||||
# runs steps as root (node:24-alpine has no USER directive), and root overwrites a
|
||||
# 0400 file, so the failure never fired and this gate silently passed (#791
|
||||
# blocker-3). The shim fails deterministically for one framework-owned
|
||||
# destination regardless of uid, and — like a real interrupted cp (disk-full
|
||||
# mid-write) — leaves a partially-written target behind, so rollback has real
|
||||
# damage to undo and the control has real damage to expose.
|
||||
#
|
||||
# Usage: bash test-upgrade-rollback.sh
|
||||
set -uo pipefail
|
||||
|
||||
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
|
||||
INSTALL="$FW/install.sh"
|
||||
ORIG_PATH="$PATH"
|
||||
|
||||
# The `-E`-stripped control installer must live INSIDE $FW: install.sh derives
|
||||
# SOURCE_DIR from its own path and `source`s $SOURCE_DIR/tools/_lib/manifest.sh,
|
||||
# so a copy anywhere else aborts at the source line before ever reaching the sync
|
||||
# loop — which would make the control a false negative. A root dotfile is
|
||||
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
|
||||
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
||||
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
||||
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
||||
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
||||
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||
|
||||
pass=0; fail=0
|
||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||
|
||||
SECRET='SUPER-SECRET-TOKEN-do-not-log-b1'
|
||||
# A framework-owned file the shim fails the copy of. The seeded target holds GOOD
|
||||
# bytes; source ships different bytes, so sync_framework_keep() attempts the copy
|
||||
# and the shim intercepts it. Root-level framework files sort before guides/, so
|
||||
# several framework files are already refreshed when the copy reaches this one.
|
||||
POISON_REL='guides/E2E-DELIVERY.md'
|
||||
GOOD='GOOD-REFERENCE-CONTENT-pre-upgrade-b1'
|
||||
GARBAGE='PARTIAL-WRITE-GARBAGE-mid-sync-b1'
|
||||
|
||||
# A `cp` shim: for the poisoned destination, simulate an interrupted copy — write
|
||||
# partial garbage to the target, then fail — otherwise delegate to the real cp
|
||||
# (resolved via the ORIGINAL PATH so make_snapshot/restore still work).
|
||||
make_cp_shim() {
|
||||
local dir="$1"
|
||||
cat > "$dir/cp" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
dest="\${@: -1}"
|
||||
case "\$dest" in
|
||||
*/$POISON_REL)
|
||||
printf '%s' '$GARBAGE' > "\$dest" 2>/dev/null || true
|
||||
exit 1 ;;
|
||||
esac
|
||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||
SHIM
|
||||
chmod +x "$dir/cp"
|
||||
}
|
||||
|
||||
# A `find` shim that fails every enumeration scan (`-print0`) as if it hit an
|
||||
# EACCES/I/O error partway — it emits the real (here: complete) list first, then
|
||||
# exits non-zero, exactly the class of failure a `< <(find …)` process
|
||||
# substitution silently swallows. All non-`-print0` finds (e.g. the -delete
|
||||
# sweep) delegate to the real find on the original PATH. Used to prove #791
|
||||
# blocker-D1: the shipped installer must honor find's exit status and roll back.
|
||||
make_find_fail_shim() {
|
||||
local dir="$1"
|
||||
cat > "$dir/find" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
for a in "\$@"; do
|
||||
if [ "\$a" = "-print0" ]; then
|
||||
env PATH="$ORIG_PATH" find "\$@" # emit the real list…
|
||||
exit 1 # …then fail as if the scan hit EACCES
|
||||
fi
|
||||
done
|
||||
exec env PATH="$ORIG_PATH" find "\$@"
|
||||
SHIM
|
||||
chmod +x "$dir/find"
|
||||
}
|
||||
|
||||
# An `rm` shim that fails ONLY `rm -rf <FAIL_RM_TARGET>` (the restore's target
|
||||
# reset) and delegates every other rm to the real one. Used to prove #791
|
||||
# blocker-D2: when the target reset inside restore_snapshot fails, the installer
|
||||
# must emit the manual-recovery pointer (snapshot path) instead of exiting
|
||||
# silently under `set -e`. FAIL_RM_TARGET is exported into the installer env.
|
||||
make_rm_fail_shim() {
|
||||
local dir="$1"
|
||||
cat > "$dir/rm" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
last="${@: -1}"
|
||||
if [ -n "${FAIL_RM_TARGET:-}" ] && [ "$last" = "$FAIL_RM_TARGET" ]; then
|
||||
exit 1
|
||||
fi
|
||||
exec env PATH="$ORIG_PATH_FOR_RM" rm "$@"
|
||||
SHIM
|
||||
chmod +x "$dir/rm"
|
||||
}
|
||||
|
||||
seed_home() {
|
||||
local H="$1"
|
||||
mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory" "$H/guides"
|
||||
printf '# persona\n' > "$H/SOUL.md" # recognized install → keep mode + snapshot
|
||||
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
|
||||
printf '# operator memory\n' > "$H/memory/note.md"
|
||||
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
|
||||
echo 3 > "$H/.framework-version"
|
||||
# Good pre-upgrade bytes; source ships different bytes, so cp is attempted.
|
||||
printf '%s' "$GOOD" > "$H/$POISON_REL"
|
||||
}
|
||||
|
||||
# Run one keep-mode upgrade against $1=installer, seeding a fresh home and a
|
||||
# byte-for-byte reference of the pre-upgrade state, with the cp shim first on
|
||||
# PATH. Echoes: "<exit>\t<out>\t<ref>\t<home>".
|
||||
run_upgrade() {
|
||||
local installer="$1" shim_maker="${2:-make_cp_shim}" H REF OUT SHIM rc
|
||||
H=$(mktemp -d); REF=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
||||
seed_home "$H"
|
||||
env PATH="$ORIG_PATH" cp -a "$H/." "$REF/" # pre-upgrade reference (real cp)
|
||||
"$shim_maker" "$SHIM"
|
||||
set +e
|
||||
PATH="$SHIM:$ORIG_PATH" \
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||
rc=$?
|
||||
set -e 2>/dev/null || true
|
||||
rm -rf "$SHIM"
|
||||
printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$REF" "$H"
|
||||
}
|
||||
|
||||
echo "#791 upgrade rollback (B1 regression gate):"
|
||||
|
||||
# ── Part A: the shipped installer must roll back a mid-sync failure ───────────
|
||||
IFS=$'\t' read -r rcA OUTA REFA HA < <(run_upgrade "$INSTALL")
|
||||
|
||||
chk "[shipped] upgrade aborts non-zero on the injected mid-sync failure" \
|
||||
"[ '$rcA' -ne 0 ]"
|
||||
chk "[shipped] restore_snapshot fires (rollback message present)" \
|
||||
"grep -q 'restoring previous state from snapshot' '$OUTA'"
|
||||
chk "[shipped] the corrupted file is restored to its pre-upgrade bytes" \
|
||||
"[ \"\$(cat '$HA/$POISON_REL')\" = '$GOOD' ]"
|
||||
chk "[shipped] target rolled back byte-identical to pre-upgrade state" \
|
||||
"diff -r '$REFA' '$HA' >/dev/null 2>&1"
|
||||
chk "[shipped] operator secret value absent from installer output" \
|
||||
"! grep -q '$SECRET' '$OUTA'"
|
||||
|
||||
# ── Part B: control — strip `-E`, the trap is dead, no rollback happens ───────
|
||||
# Proves errtrace is what makes the trap reachable. If `set -E` is ever removed
|
||||
# from install.sh, Part A's rollback assertions fail exactly like this control.
|
||||
sed 's/^set -Eeuo pipefail/set -euo pipefail/' "$INSTALL" > "$STRIPPED"
|
||||
chk "[control] the -E strip actually changed the installer" \
|
||||
"! cmp -s '$INSTALL' '$STRIPPED'"
|
||||
|
||||
IFS=$'\t' read -r rcB OUTB REFB HB < <(run_upgrade "$STRIPPED")
|
||||
chk "[control] without -E the upgrade still aborts non-zero" \
|
||||
"[ '$rcB' -ne 0 ]"
|
||||
# The load-bearing, deterministic proof of B1: without errtrace the ERR trap
|
||||
# never fires for a failure inside sync_framework_keep(), so no rollback runs.
|
||||
chk "[control] without -E the rollback message does NOT fire (dead trap)" \
|
||||
"! grep -q 'restoring previous state from snapshot' '$OUTB'"
|
||||
chk "[control] without -E the mid-sync corruption survives (no rollback)" \
|
||||
"[ \"\$(cat '$HB/$POISON_REL')\" = '$GARBAGE' ]"
|
||||
|
||||
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
||||
# A bash signal trap that merely returns lets the script continue past the
|
||||
# interrupt — restoring the snapshot, then resuming the sync and reporting
|
||||
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
|
||||
# fires and ONLY the signal path governs), and assert the shipped installer
|
||||
# restores AND exits without reporting success. The control strips `exit 1` from
|
||||
# the trap and shows the buggy resume-to-success.
|
||||
make_term_shim() {
|
||||
local dir="$1"
|
||||
cat > "$dir/cp" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
dest="\${@: -1}"
|
||||
case "\$dest" in
|
||||
*/$POISON_REL)
|
||||
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
|
||||
exec env PATH="$ORIG_PATH" cp "\$@" ;;
|
||||
esac
|
||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||
SHIM
|
||||
chmod +x "$dir/cp"
|
||||
}
|
||||
|
||||
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
|
||||
run_signal_upgrade() {
|
||||
local installer="$1" H OUT SHIM rc
|
||||
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
||||
seed_home "$H"
|
||||
make_term_shim "$SHIM"
|
||||
set +e
|
||||
PATH="$SHIM:$ORIG_PATH" \
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||
rc=$?
|
||||
set -e 2>/dev/null || true
|
||||
rm -rf "$SHIM"
|
||||
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
||||
}
|
||||
|
||||
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
|
||||
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
|
||||
"[ '$rcC' -ne 0 ]"
|
||||
chk "[signal] restore_snapshot fires on the interrupt" \
|
||||
"grep -q 'restoring previous state from snapshot' '$OUTC'"
|
||||
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
||||
"! grep -q 'file phase complete' '$OUTC'"
|
||||
|
||||
# Control: strip `exit 1` from the signal trap → the handler returns, the script
|
||||
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
|
||||
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
|
||||
"$INSTALL" > "$NOEXIT"
|
||||
chk "[control] the exit-strip actually changed the installer" \
|
||||
"! cmp -s '$INSTALL' '$NOEXIT'"
|
||||
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
||||
"grep -q 'file phase complete' '$OUTD'"
|
||||
|
||||
# ── Part D: a failed source/prune `find` scan must abort + roll back (D1) ─────
|
||||
# A `< <(find …)` process substitution discards find's exit status, so an
|
||||
# EACCES/I/O failure mid-scan would truncate the file list yet leave the reading
|
||||
# loop exiting 0 — a partial upgrade committed and reported as success, with the
|
||||
# ERR/restore trap never firing. The shipped installer captures the scan into a
|
||||
# checked temp file (_scan_or_die) and aborts on failure. We inject a `find` that
|
||||
# fails every `-print0` scan and assert the shipped installer rolls back.
|
||||
IFS=$'\t' read -r rcE OUTE REFE HE < <(run_upgrade "$INSTALL" make_find_fail_shim)
|
||||
chk "[find-fail] a failing framework scan aborts the upgrade non-zero" \
|
||||
"[ '$rcE' -ne 0 ]"
|
||||
chk "[find-fail] restore_snapshot fires on the aborted scan" \
|
||||
"grep -q 'restoring previous state from snapshot' '$OUTE'"
|
||||
chk "[find-fail] the abort is a fail-closed enumeration error (not a silent truncation)" \
|
||||
"grep -q 'Could not enumerate framework files' '$OUTE'"
|
||||
chk "[find-fail] target rolled back byte-identical to pre-upgrade state" \
|
||||
"diff -r '$REFE' '$HE' >/dev/null 2>&1"
|
||||
|
||||
# Control: neuter the D1 guard (turn its `return 1` into a no-op) so a find
|
||||
# failure is swallowed exactly as `< <(find …)` would — the scan appears to
|
||||
# succeed and the upgrade reports completion with NO rollback.
|
||||
sed 's/return 1 # D1-GUARD/: # D1-GUARD-DISABLED/' "$INSTALL" > "$D1CTRL"
|
||||
chk "[control] the D1-guard strip actually changed the installer" \
|
||||
"! cmp -s '$INSTALL' '$D1CTRL'"
|
||||
IFS=$'\t' read -r _rcF OUTF REFF HF < <(run_upgrade "$D1CTRL" make_find_fail_shim)
|
||||
chk "[control] with the D1 guard disabled the find failure is swallowed (no rollback)" \
|
||||
"! grep -q 'restoring previous state from snapshot' '$OUTF'"
|
||||
chk "[control] with the D1 guard disabled the upgrade wrongly reports success" \
|
||||
"grep -q 'file phase complete' '$OUTF'"
|
||||
|
||||
# ── Part E: a failed target reset inside restore must not exit silently (D2) ──
|
||||
# restore_snapshot resets the target (`rm -rf; mkdir -p`) before rebuilding from
|
||||
# the snapshot. Under `set -e` (trap disarmed) a bare reset that fails would exit
|
||||
# the whole script immediately — after `rm` may have deleted part of the target —
|
||||
# WITHOUT printing where the snapshot lives. We trigger a rollback (cp poison) AND
|
||||
# fail the target reset (rm shim), then assert the shipped installer emits the
|
||||
# manual-recovery pointer and preserves the snapshot.
|
||||
run_rmfail_upgrade() {
|
||||
local installer="$1" H OUT SHIM rc
|
||||
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
||||
seed_home "$H"
|
||||
make_cp_shim "$SHIM" # poison cp → triggers the abort + restore
|
||||
make_rm_fail_shim "$SHIM" # rm -rf <H> fails → exercises the D2 reset guard
|
||||
set +e
|
||||
PATH="$SHIM:$ORIG_PATH" ORIG_PATH_FOR_RM="$ORIG_PATH" FAIL_RM_TARGET="$H" \
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||
rc=$?
|
||||
set -e 2>/dev/null || true
|
||||
rm -rf "$SHIM"
|
||||
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
||||
}
|
||||
|
||||
IFS=$'\t' read -r rcG OUTG HG < <(run_rmfail_upgrade "$INSTALL")
|
||||
chk "[reset-fail] a failed target reset still aborts non-zero" \
|
||||
"[ '$rcG' -ne 0 ]"
|
||||
chk "[reset-fail] the manual-recovery pointer is emitted (not a silent set -e exit)" \
|
||||
"grep -q 'Snapshot restore could not reset' '$OUTG'"
|
||||
chk "[reset-fail] the recovery message points at a preserved snapshot dir" \
|
||||
"grep -q 'preserved at: .*mosaic-snapshot' '$OUTG'"
|
||||
SNAP_E="$(grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTG" | head -1)"
|
||||
chk "[reset-fail] the named snapshot directory actually survives for recovery" \
|
||||
"[ -n '$SNAP_E' ] && [ -d '$SNAP_E' ]"
|
||||
chk "[reset-fail] operator secret value never appears in installer output" \
|
||||
"! grep -q '$SECRET' '$OUTG'"
|
||||
|
||||
# Control: delete the D2 recovery line so a failed reset returns non-zero with NO
|
||||
# operator pointer — the observable defect (half-reset target, snapshot orphaned
|
||||
# in /tmp with no path told to the operator). Proves the message is load-bearing.
|
||||
sed '/Snapshot restore could not reset/d' "$INSTALL" > "$D2CTRL"
|
||||
chk "[control] the D2-recovery strip actually changed the installer" \
|
||||
"! cmp -s '$INSTALL' '$D2CTRL'"
|
||||
IFS=$'\t' read -r _rcH OUTH HH < <(run_rmfail_upgrade "$D2CTRL")
|
||||
chk "[control] without the D2 recovery line the operator gets no snapshot pointer" \
|
||||
"! grep -q 'Snapshot restore could not reset' '$OUTH'"
|
||||
[ -n "${SNAP_E:-}" ] && rm -rf "$SNAP_E"
|
||||
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
|
||||
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
|
||||
|
||||
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap).
|
||||
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
|
||||
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
|
||||
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
|
||||
echo
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
[ "$fail" -eq 0 ]
|
||||
@@ -122,12 +122,11 @@ fi
|
||||
|
||||
# Source label: this agent's host:session (auto-detected, overridable).
|
||||
if [ -z "$SRC_LABEL" ]; then
|
||||
tmux_cmd=(tmux)
|
||||
if [ -n "$SOCKET_NAME" ]; then
|
||||
tmux_cmd+=(-L "$SOCKET_NAME")
|
||||
fi
|
||||
src_host=$(hostname -s 2>/dev/null || echo "?")
|
||||
src_sess=$("${tmux_cmd[@]}" display-message -p '#S' 2>/dev/null || echo "?")
|
||||
src_sess=${MOSAIC_AGENT_NAME:-}
|
||||
if [ -z "$src_sess" ]; then
|
||||
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
|
||||
fi
|
||||
SRC_LABEL="${src_host}:${src_sess}"
|
||||
fi
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
# 5. invalid class => exit 3, nothing sent.
|
||||
# 6. --class with no value => exit 3.
|
||||
# 7. the documented consumer regex parses producer output for every class.
|
||||
# 8. MOSAIC_AGENT_NAME is authoritative for sender identity.
|
||||
# 9. sender fallback queries local tmux, never the destination -L socket.
|
||||
# 10. an undeterminable sender is stamped as "?".
|
||||
set -uo pipefail
|
||||
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
@@ -21,22 +24,45 @@ TOOL="$HERE/agent-send.sh"
|
||||
|
||||
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
|
||||
STUB=$(mktemp)
|
||||
trap 'rm -f "$STUB"' EXIT
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN"' EXIT
|
||||
cat >"$STUB" <<'STUB_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
b64=""
|
||||
while getopts "t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
|
||||
while getopts "L:t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
|
||||
printf '%s' "$b64" | base64 -d
|
||||
STUB_EOF
|
||||
chmod +x "$STUB"
|
||||
|
||||
# Fake tmux distinguishes the sender's default socket from a destination socket.
|
||||
cat >"$FAKE_BIN/tmux" <<'TMUX_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
case "${FAKE_TMUX_MODE:-sessions}" in
|
||||
unavailable) exit 1 ;;
|
||||
sessions)
|
||||
if [ "${1:-}" = "-L" ]; then
|
||||
printf '%s\n' 'destination-holder'
|
||||
else
|
||||
printf '%s\n' 'local-agent'
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
TMUX_EOF
|
||||
chmod +x "$FAKE_BIN/tmux"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
|
||||
no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
|
||||
|
||||
# Run the tool with the stub injected; echoes captured payload on stdout.
|
||||
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
|
||||
run_auto() {
|
||||
env -u MOSAIC_AGENT_NAME \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
|
||||
# Documented consumer grammar — the daemon will mirror exactly this.
|
||||
GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction)\] (.*)$'
|
||||
@@ -92,6 +118,30 @@ classic=$(run -s mos -m "plain body")
|
||||
[[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \
|
||||
&& ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]"
|
||||
|
||||
# 8. Exported pane identity wins even when dispatch targets another tmux socket.
|
||||
src_host=$(hostname -s)
|
||||
got=$(MOSAIC_AGENT_NAME=authoritative-agent FAKE_TMUX_MODE=sessions \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -L destination-socket -n dsthost -s mos -m "env identity")
|
||||
want="[$src_host:authoritative-agent -> dsthost:mos] env identity"
|
||||
[ "$got" = "$want" ] && ok "MOSAIC_AGENT_NAME is authoritative across sockets" \
|
||||
|| no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]"
|
||||
|
||||
# 9. Without the env identity, self-lookup uses local tmux, not destination -L.
|
||||
got=$(FAKE_TMUX_MODE=sessions run_auto -L destination-socket -s mos -m "local fallback")
|
||||
want="[$src_host:local-agent -> dsthost:mos] local fallback"
|
||||
[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \
|
||||
|| no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]"
|
||||
[[ "$got" != *":destination-holder ->"* ]] \
|
||||
&& ok "cross-socket fallback rejects destination holder identity" \
|
||||
|| no "cross-socket fallback rejects destination holder identity" "got=[$got]"
|
||||
|
||||
# 10. If neither env nor local tmux identifies the sender, preserve '?'.
|
||||
got=$(FAKE_TMUX_MODE=unavailable run_auto -L destination-socket -s mos -m "unknown fallback")
|
||||
want="[$src_host:? -> dsthost:mos] unknown fallback"
|
||||
[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \
|
||||
|| no "unknown sender falls back to ?" "got=[$got] want=[$want]"
|
||||
|
||||
echo "---"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
|
||||
@@ -17,6 +17,7 @@ import { registerConfigCommand } from './commands/config.js';
|
||||
import { registerFleetCommand } from './commands/fleet.js';
|
||||
import { registerMissionCommand } from './commands/mission.js';
|
||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||
import { registerRestoreCommand } from './commands/restore.js';
|
||||
// prdy is registered via launch.ts
|
||||
import { registerLaunchCommands } from './commands/launch.js';
|
||||
import { registerAuthCommand } from './commands/auth.js';
|
||||
@@ -406,6 +407,10 @@ registerStorageCommand(program);
|
||||
|
||||
registerUninstallCommand(program);
|
||||
|
||||
// ─── restore ─────────────────────────────────────────────────────────────────
|
||||
|
||||
registerRestoreCommand(program);
|
||||
|
||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||
|
||||
registerTelemetryCommand(program);
|
||||
|
||||
862
packages/mosaic/src/commands/claudex-proxy.spec.ts
Normal file
862
packages/mosaic/src/commands/claudex-proxy.spec.ts
Normal file
@@ -0,0 +1,862 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
CLAUDEX_PROXY_HOST,
|
||||
CLAUDEX_PROXY_PORT,
|
||||
CLAUDEX_PROXY_URL,
|
||||
CLAUDEX_PROXY_BINARY,
|
||||
CLAUDEX_HEALTH_PATH,
|
||||
CLAUDEX_HEALTH_URL,
|
||||
buildAuthStatusArgs,
|
||||
buildDeviceAuthArgs,
|
||||
buildServeArgs,
|
||||
parseAuthStatus,
|
||||
checkProxyBinary,
|
||||
checkAuthStatus,
|
||||
runDeviceReauth,
|
||||
probeLiveness,
|
||||
buildSystemdUnitContent,
|
||||
systemdUnitPath,
|
||||
installSystemdUnit,
|
||||
startNohupProxy,
|
||||
verifyListenerIdentity,
|
||||
runProxyPreflight,
|
||||
ensureProxyRunning,
|
||||
type AuthStatus,
|
||||
type ProxyRunResult,
|
||||
type SpawnedChild,
|
||||
type ListenerIdentity,
|
||||
} from './claudex-proxy.js';
|
||||
|
||||
/**
|
||||
* P1 — Proxy preflight + lifecycle helpers for `mosaic yolo claudex`.
|
||||
*
|
||||
* Security-relevant invariants exercised here:
|
||||
* - Liveness probe hits the proxy's dedicated `GET /healthz` and treats only a
|
||||
* 2xx as "alive" — a *proxy-specific* health contract, not arbitrary HTTP on
|
||||
* the port (CWE-345: a local port-squatter must not be trusted as the proxy).
|
||||
* This also honors spec gotcha #1 (never `curl -f` the root, which returns
|
||||
* non-2xx): `/healthz` returns 2xx when the proxy is up, so a healthy proxy is
|
||||
* never mistaken for dead and no duplicate proxy is spawned.
|
||||
* - Auth-status parsing NEVER surfaces OAuth token material — only a coarse
|
||||
* state + optional expiry — even if a token-shaped string appears in output.
|
||||
* - The systemd unit's ExecStart never interpolates an unvalidated path
|
||||
* (CWE-74: a CR/LF in the path could inject arbitrary systemd directives).
|
||||
* - The nohup fallback captures spawn's *async* error event instead of crashing.
|
||||
*/
|
||||
|
||||
describe('claudex-proxy constants', () => {
|
||||
it('pins the proxy endpoint to loopback :18765 (spec table)', () => {
|
||||
expect(CLAUDEX_PROXY_HOST).toBe('127.0.0.1');
|
||||
expect(CLAUDEX_PROXY_PORT).toBe(18765);
|
||||
expect(CLAUDEX_PROXY_URL).toBe('http://127.0.0.1:18765');
|
||||
expect(CLAUDEX_PROXY_BINARY).toBe('claude-code-proxy');
|
||||
});
|
||||
|
||||
it('exposes the dedicated /healthz liveness endpoint (not the root path)', () => {
|
||||
expect(CLAUDEX_HEALTH_PATH).toBe('/healthz');
|
||||
expect(CLAUDEX_HEALTH_URL).toBe('http://127.0.0.1:18765/healthz');
|
||||
});
|
||||
|
||||
it('builds the documented codex subcommand argv', () => {
|
||||
expect(buildAuthStatusArgs()).toEqual(['codex', 'auth', 'status']);
|
||||
expect(buildDeviceAuthArgs()).toEqual(['codex', 'auth', 'device']);
|
||||
expect(buildServeArgs()).toEqual(['serve', '--no-monitor']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAuthStatus', () => {
|
||||
it('reports valid on exit 0 with an authenticated marker', () => {
|
||||
const s = parseAuthStatus({
|
||||
status: 0,
|
||||
stdout: 'Authenticated as user; token valid',
|
||||
stderr: '',
|
||||
});
|
||||
expect(s.state).toBe('valid');
|
||||
});
|
||||
|
||||
it('reports expired when output mentions expiry', () => {
|
||||
const s = parseAuthStatus({ status: 0, stdout: 'Token expired 2 days ago', stderr: '' });
|
||||
expect(s.state).toBe('expired');
|
||||
});
|
||||
|
||||
it('reports unauthenticated when output says not logged in', () => {
|
||||
const s = parseAuthStatus({
|
||||
status: 1,
|
||||
stdout: '',
|
||||
stderr: 'not authenticated: run codex auth device',
|
||||
});
|
||||
expect(s.state).toBe('unauthenticated');
|
||||
});
|
||||
|
||||
it('reports unknown on an unrecognized non-zero exit', () => {
|
||||
const s = parseAuthStatus({ status: 2, stdout: 'weird', stderr: '' });
|
||||
expect(s.state).toBe('unknown');
|
||||
});
|
||||
|
||||
it('does NOT trust a signal-terminated check (status null) even with an auth-looking line', () => {
|
||||
// status: null means the process was killed by a signal — an INCOMPLETE
|
||||
// check. An auth-looking line that happened to be flushed must not be read
|
||||
// as valid, or preflight passes on a check that never finished.
|
||||
const s = parseAuthStatus({ status: null, stdout: 'Authenticated', stderr: '' });
|
||||
expect(s.state).toBe('unknown');
|
||||
});
|
||||
|
||||
it('extracts a best-effort expiry in days when present', () => {
|
||||
const s = parseAuthStatus({
|
||||
status: 0,
|
||||
stdout: 'Authenticated; expires in 9 days',
|
||||
stderr: '',
|
||||
});
|
||||
expect(s.state).toBe('valid');
|
||||
expect(s.expiresInDays).toBe(9);
|
||||
});
|
||||
|
||||
it('treats a clean exit 0 with no explicit markers as valid', () => {
|
||||
const s = parseAuthStatus({ status: 0, stdout: 'Session active for account foo', stderr: '' });
|
||||
expect(s.state).toBe('valid');
|
||||
expect(s.expiresInDays).toBeUndefined();
|
||||
});
|
||||
|
||||
it('NEVER retains token-shaped material from output', () => {
|
||||
const leaky = 'Authenticated. access_token=sk-abc123SECRETdeadbeef refresh_token=rt-9999';
|
||||
const s: AuthStatus = parseAuthStatus({ status: 0, stdout: leaky, stderr: '' });
|
||||
const serialized = JSON.stringify(s);
|
||||
expect(serialized).not.toContain('sk-abc123SECRETdeadbeef');
|
||||
expect(serialized).not.toContain('rt-9999');
|
||||
expect(serialized).not.toContain('access_token');
|
||||
expect(serialized).not.toContain('refresh_token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAuthStatus', () => {
|
||||
it('runs the status subcommand and parses the result', () => {
|
||||
const run = vi.fn(
|
||||
(_cmd: string, _args: string[]): ProxyRunResult => ({
|
||||
status: 0,
|
||||
stdout: 'Authenticated; expires in 7 days',
|
||||
stderr: '',
|
||||
}),
|
||||
);
|
||||
const s = checkAuthStatus(run);
|
||||
expect(run).toHaveBeenCalledWith(CLAUDEX_PROXY_BINARY, ['codex', 'auth', 'status']);
|
||||
expect(s.state).toBe('valid');
|
||||
expect(s.expiresInDays).toBe(7);
|
||||
});
|
||||
|
||||
it('surfaces unknown when the default runner cannot find the binary', () => {
|
||||
// Exercises the default spawnSync path against an absent binary: no throw,
|
||||
// status is non-zero/null → unknown. Deterministic on a box without the proxy.
|
||||
const s = checkAuthStatus();
|
||||
expect(['unknown', 'unauthenticated', 'valid', 'expired']).toContain(s.state);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runDeviceReauth', () => {
|
||||
it('spawns the device flow with inherited stdio (never captures the code/token)', () => {
|
||||
const calls: Array<{ cmd: string; args: string[]; opts: { stdio: string } }> = [];
|
||||
const status = runDeviceReauth((cmd, args, opts) => {
|
||||
calls.push({ cmd, args, opts });
|
||||
return { status: 0 };
|
||||
});
|
||||
expect(status).toBe(0);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.cmd).toBe(CLAUDEX_PROXY_BINARY);
|
||||
expect(calls[0]!.args).toEqual(['codex', 'auth', 'device']);
|
||||
// stdio 'inherit' is the security-critical bit: the device code streams to
|
||||
// the user's TTY; the launcher never pipes/captures it.
|
||||
expect(calls[0]!.opts.stdio).toBe('inherit');
|
||||
});
|
||||
|
||||
it('returns 1 when the child yields no status (absent binary)', () => {
|
||||
const status = runDeviceReauth(() => ({ status: null }));
|
||||
expect(status).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkProxyBinary', () => {
|
||||
it('resolves via the default `which` path (proxy absent → null)', () => {
|
||||
// Covers the default resolver; on CI/dev the proxy is not installed.
|
||||
const r = checkProxyBinary();
|
||||
expect(typeof r.present).toBe('boolean');
|
||||
if (!r.present) expect(r.path).toBeNull();
|
||||
});
|
||||
|
||||
it('reports present with the resolved path', () => {
|
||||
const r = checkProxyBinary(() => '/home/u/.local/bin/claude-code-proxy');
|
||||
expect(r.present).toBe(true);
|
||||
expect(r.path).toBe('/home/u/.local/bin/claude-code-proxy');
|
||||
});
|
||||
|
||||
it('reports absent when the resolver finds nothing', () => {
|
||||
const r = checkProxyBinary(() => null);
|
||||
expect(r.present).toBe(false);
|
||||
expect(r.path).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeLiveness (proxy-specific /healthz, not arbitrary HTTP)', () => {
|
||||
it('defaults to probing the /healthz endpoint, never the root path', async () => {
|
||||
const seen: string[] = [];
|
||||
await probeLiveness(undefined, async (u) => {
|
||||
seen.push(u);
|
||||
return { status: 200 };
|
||||
});
|
||||
expect(seen[0]).toBe(CLAUDEX_HEALTH_URL);
|
||||
expect(seen[0]).toContain('/healthz');
|
||||
});
|
||||
|
||||
it('treats a 200 on /healthz as alive', async () => {
|
||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 200 }));
|
||||
expect(live).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a 204 on /healthz as alive', async () => {
|
||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 204 }));
|
||||
expect(live).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a 404 as DEAD — does not trust an arbitrary responder on the port (CWE-345)', async () => {
|
||||
// The whole point: a random local process squatting :18765 will not honor the
|
||||
// proxy's /healthz contract, so a non-2xx there must not be mistaken for the proxy.
|
||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 404 }));
|
||||
expect(live).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a 500 as DEAD (unhealthy / not the proxy health contract)', async () => {
|
||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 500 }));
|
||||
expect(live).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a missing status as dead', async () => {
|
||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({}));
|
||||
expect(live).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a connection failure (reject) as dead', async () => {
|
||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
expect(live).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a timeout as dead', async () => {
|
||||
const never = () => new Promise<{ status?: number }>(() => {});
|
||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, never, 20);
|
||||
expect(live).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSystemdUnitContent', () => {
|
||||
it('emits a user unit that execs the given binary with serve args', () => {
|
||||
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
|
||||
expect(unit).toContain('[Unit]');
|
||||
expect(unit).toContain('[Service]');
|
||||
expect(unit).toContain('[Install]');
|
||||
expect(unit).toContain('/home/u/.local/bin/claude-code-proxy serve --no-monitor');
|
||||
expect(unit).toContain('WantedBy=default.target');
|
||||
});
|
||||
|
||||
it('never embeds credential material', () => {
|
||||
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
|
||||
expect(unit).not.toMatch(/token/i);
|
||||
expect(unit).not.toMatch(/auth\.json/i);
|
||||
});
|
||||
|
||||
it('rejects a path containing a newline (CWE-74 systemd directive injection)', () => {
|
||||
// A raw newline in ExecStart would let an attacker append arbitrary unit
|
||||
// directives — e.g. `ExecStartPost=curl evil`. Must be rejected outright.
|
||||
expect(() =>
|
||||
buildSystemdUnitContent('/bin/claude-code-proxy\nExecStartPost=/bin/rm -rf /'),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a path containing a carriage return', () => {
|
||||
expect(() => buildSystemdUnitContent('/bin/claude-code-proxy\rmalicious')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a path with other control characters', () => {
|
||||
expect(() => buildSystemdUnitContent('/bin/claude-code-proxy\x00nul')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a non-absolute path', () => {
|
||||
expect(() => buildSystemdUnitContent('claude-code-proxy')).toThrow();
|
||||
expect(() => buildSystemdUnitContent('')).toThrow();
|
||||
});
|
||||
|
||||
it('systemd-quotes a path that contains spaces', () => {
|
||||
const unit = buildSystemdUnitContent('/home/u/my apps/claude-code-proxy');
|
||||
expect(unit).toContain('ExecStart="/home/u/my apps/claude-code-proxy" serve --no-monitor');
|
||||
});
|
||||
|
||||
it('escapes embedded quotes and backslashes when quoting', () => {
|
||||
const unit = buildSystemdUnitContent('/home/u/we"ird\\dir/claude-code-proxy');
|
||||
// No unescaped closing quote can terminate the token early.
|
||||
expect(unit).toContain('ExecStart="/home/u/we\\"ird\\\\dir/claude-code-proxy" serve');
|
||||
});
|
||||
|
||||
it('leaves a clean absolute path unquoted (no needless churn)', () => {
|
||||
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
|
||||
expect(unit).toContain('ExecStart=/home/u/.local/bin/claude-code-proxy serve --no-monitor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('systemdUnitPath', () => {
|
||||
it('targets the systemd --user unit dir', () => {
|
||||
expect(systemdUnitPath('/home/u')).toBe(
|
||||
'/home/u/.config/systemd/user/claude-code-proxy.service',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installSystemdUnit', () => {
|
||||
it('writes the unit and returns true when daemon-reload succeeds', () => {
|
||||
let written: { path: string; content: string } | null = null;
|
||||
const ok = installSystemdUnit('/bin/claude-code-proxy', {
|
||||
home: '/home/u',
|
||||
writeUnit: (path, content) => {
|
||||
written = { path, content };
|
||||
},
|
||||
run: () => ({ status: 0, stdout: '', stderr: '' }),
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(written).not.toBeNull();
|
||||
expect(written!.path).toBe('/home/u/.config/systemd/user/claude-code-proxy.service');
|
||||
expect(written!.content).toContain('ExecStart=/bin/claude-code-proxy serve --no-monitor');
|
||||
});
|
||||
|
||||
it('returns false when daemon-reload fails (systemd --user unavailable)', () => {
|
||||
const ok = installSystemdUnit('/bin/claude-code-proxy', {
|
||||
home: '/home/u',
|
||||
writeUnit: () => {},
|
||||
run: () => ({ status: 1, stdout: '', stderr: 'Failed to connect to bus' }),
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when writing the unit throws', () => {
|
||||
const ok = installSystemdUnit('/bin/claude-code-proxy', {
|
||||
home: '/home/u',
|
||||
writeUnit: () => {
|
||||
throw new Error('EACCES');
|
||||
},
|
||||
run: () => ({ status: 0, stdout: '', stderr: '' }),
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to write a unit for an injection-bearing path (never writes a poisoned unit)', () => {
|
||||
const writeUnit = vi.fn();
|
||||
const ok = installSystemdUnit('/bin/claude-code-proxy\nExecStartPost=/bin/rm -rf /', {
|
||||
home: '/home/u',
|
||||
writeUnit,
|
||||
run: () => ({ status: 0, stdout: '', stderr: '' }),
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
// The poisoned unit content is never even produced, so nothing is written.
|
||||
expect(writeUnit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes to a real temp dir via the default writer', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'claudex-unit-'));
|
||||
try {
|
||||
const ok = installSystemdUnit('/bin/claude-code-proxy', {
|
||||
home,
|
||||
run: () => ({ status: 0, stdout: '', stderr: '' }),
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
const written = readFileSync(systemdUnitPath(home), 'utf8');
|
||||
expect(written).toContain('[Service]');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('runProxyPreflight', () => {
|
||||
const trustedListener = () => 'ok' as const;
|
||||
|
||||
it('is ok when binary present, auth valid, proxy live, and listener identity-verified', async () => {
|
||||
const report = await runProxyPreflight({
|
||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||
checkAuth: () => ({ state: 'valid' }),
|
||||
probe: async () => true,
|
||||
verifyListener: trustedListener,
|
||||
});
|
||||
expect(report.ok).toBe(true);
|
||||
expect(report.listenerVerdict).toBe('ok');
|
||||
expect(report.problems).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a missing binary', async () => {
|
||||
const report = await runProxyPreflight({
|
||||
checkBinary: () => ({ present: false, path: null }),
|
||||
checkAuth: () => ({ state: 'valid' }),
|
||||
probe: async () => true,
|
||||
verifyListener: trustedListener,
|
||||
});
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.problems.some((p) => /binary/i.test(p))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags expired auth (re-auth needed)', async () => {
|
||||
const report = await runProxyPreflight({
|
||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||
checkAuth: () => ({ state: 'expired' }),
|
||||
probe: async () => true,
|
||||
verifyListener: trustedListener,
|
||||
});
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.needsReauth).toBe(true);
|
||||
expect(report.problems.some((p) => /auth/i.test(p))).toBe(true);
|
||||
});
|
||||
|
||||
it('flags a dead proxy', async () => {
|
||||
const report = await runProxyPreflight({
|
||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||
checkAuth: () => ({ state: 'valid' }),
|
||||
probe: async () => false,
|
||||
verifyListener: trustedListener,
|
||||
});
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.live).toBe(false);
|
||||
});
|
||||
|
||||
it('flags an unknown auth state without marking it for re-auth', async () => {
|
||||
const report = await runProxyPreflight({
|
||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||
checkAuth: () => ({ state: 'unknown' }),
|
||||
probe: async () => true,
|
||||
verifyListener: trustedListener,
|
||||
});
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.needsReauth).toBe(false);
|
||||
expect(report.problems.some((p) => /could not determine/i.test(p))).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT pass preflight when the live responder fails identity verification (F2b)', async () => {
|
||||
// A squatter answering /healthz-2xx must not yield ok:true just because the
|
||||
// binary is installed and OAuth is valid — the identity gate holds here too.
|
||||
for (const verdict of ['foreign-user', 'wrong-exe', 'unknown'] as const) {
|
||||
const report = await runProxyPreflight({
|
||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||
checkAuth: () => ({ state: 'valid' }),
|
||||
probe: async () => true,
|
||||
verifyListener: () => verdict,
|
||||
});
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.live).toBe(true);
|
||||
expect(report.listenerVerdict).toBe(verdict);
|
||||
expect(report.problems.some((p) => /identity could not be verified/i.test(p))).toBe(true);
|
||||
// The identity problem is non-sensitive: port + verdict only, no token.
|
||||
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not verify listener identity when the proxy is dead (no listener to trust)', async () => {
|
||||
const verifyListener = vi.fn(() => 'ok' as const);
|
||||
const report = await runProxyPreflight({
|
||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||
checkAuth: () => ({ state: 'valid' }),
|
||||
probe: async () => false,
|
||||
verifyListener,
|
||||
});
|
||||
expect(verifyListener).not.toHaveBeenCalled();
|
||||
expect(report.listenerVerdict).toBe('unknown');
|
||||
expect(report.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('does not leak token material for any auth state', async () => {
|
||||
const report = await runProxyPreflight({
|
||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||
checkAuth: () => ({ state: 'expired' }),
|
||||
probe: async () => false,
|
||||
verifyListener: trustedListener,
|
||||
});
|
||||
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
|
||||
});
|
||||
|
||||
it('runs end-to-end with all real defaults (no proxy installed → not ok)', async () => {
|
||||
// Exercises the default checkBinary/checkAuth/probe closures against a box
|
||||
// with no proxy: absent binary, spawnSync status, real loopback probe that
|
||||
// fast-fails with ECONNREFUSED. Asserts shape only (never token material).
|
||||
const report = await runProxyPreflight();
|
||||
expect(typeof report.ok).toBe('boolean');
|
||||
expect(Array.isArray(report.problems)).toBe(true);
|
||||
expect(['valid', 'expired', 'unauthenticated', 'unknown']).toContain(report.auth.state);
|
||||
expect(JSON.stringify(report)).not.toMatch(/access_token|refresh_token|sk-/i);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A minimal fake ChildProcess for the nohup-fallback tests: records once()
|
||||
* handlers so a test can drive the async 'spawn'/'error' events, and tracks
|
||||
* whether the 'error' listener was already attached at the moment unref() ran
|
||||
* (the security-critical ordering from finding #1).
|
||||
*/
|
||||
function fakeChild() {
|
||||
const handlers: Record<string, (arg?: unknown) => void> = {};
|
||||
const state = { unreffed: false, errorHandlerAtUnref: false };
|
||||
const child = {
|
||||
once(event: string, listener: (arg?: unknown) => void) {
|
||||
handlers[event] = listener;
|
||||
return child;
|
||||
},
|
||||
unref() {
|
||||
state.unreffed = true;
|
||||
state.errorHandlerAtUnref = typeof handlers.error === 'function';
|
||||
},
|
||||
emit(event: string, arg?: unknown) {
|
||||
handlers[event]?.(arg);
|
||||
},
|
||||
};
|
||||
return {
|
||||
child: child as unknown as SpawnedChild & { emit(e: string, a?: unknown): void },
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
describe('startNohupProxy (finding #1 — async spawn error must not crash)', () => {
|
||||
it('resolves status 0 only after a confirmed spawn, and unrefs the child', async () => {
|
||||
const { child, state } = fakeChild();
|
||||
const spawnImpl = vi.fn((_cmd: string, _args: string[]) => {
|
||||
queueMicrotask(() => child.emit('spawn'));
|
||||
return child;
|
||||
});
|
||||
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
|
||||
expect(r.status).toBe(0);
|
||||
expect(state.unreffed).toBe(true);
|
||||
// The error listener MUST be registered before unref(), so an ENOENT that
|
||||
// arrives asynchronously can never become an unhandled 'error' crash.
|
||||
expect(state.errorHandlerAtUnref).toBe(true);
|
||||
expect(spawnImpl).toHaveBeenCalledWith('/bin/claude-code-proxy', ['serve', '--no-monitor'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
});
|
||||
|
||||
it('captures an async spawn error (ENOENT) as a failed start instead of crashing', async () => {
|
||||
const { child, state } = fakeChild();
|
||||
const spawnImpl = () => {
|
||||
queueMicrotask(() => child.emit('error', new Error('spawn claude-code-proxy ENOENT')));
|
||||
return child;
|
||||
};
|
||||
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('ENOENT');
|
||||
expect(state.unreffed).toBe(false); // never unref a child that failed to start
|
||||
});
|
||||
|
||||
it('captures a synchronous spawn throw as a failed start', async () => {
|
||||
const spawnImpl = () => {
|
||||
throw new Error('EACCES');
|
||||
};
|
||||
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('EACCES');
|
||||
});
|
||||
|
||||
it('ignores a late error after a successful spawn (settles once)', async () => {
|
||||
const { child } = fakeChild();
|
||||
const spawnImpl = () => {
|
||||
queueMicrotask(() => {
|
||||
child.emit('spawn');
|
||||
child.emit('error', new Error('late boom'));
|
||||
});
|
||||
return child;
|
||||
};
|
||||
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
|
||||
expect(r.status).toBe(0); // first settle wins; the late error cannot flip it
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE-345)', () => {
|
||||
const me: ListenerIdentity = {
|
||||
pid: 4242,
|
||||
uid: 1000,
|
||||
exePath: '/home/me/.local/bin/claude-code-proxy',
|
||||
};
|
||||
// Identity canonicalize for tests: fake paths don't exist on disk, so we map
|
||||
// each path to itself and exercise symlink resolution explicitly where needed.
|
||||
const idc = (p: string) => p;
|
||||
|
||||
it('accepts a listener owned by the current uid whose exe is the expected proxy path', () => {
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => me,
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: idc,
|
||||
});
|
||||
expect(verdict).toBe('ok');
|
||||
});
|
||||
|
||||
it('resolves symlinks on BOTH sides before comparing (canonical match → ok)', () => {
|
||||
// The listener exe and our resolved binary reach the same real file via
|
||||
// different symlink paths — a canonical comparison must accept it.
|
||||
const canon: Record<string, string> = {
|
||||
'/var/run/proxy.link': '/opt/proxy/bin/claude-code-proxy',
|
||||
'/home/me/.local/bin/claude-code-proxy': '/opt/proxy/bin/claude-code-proxy',
|
||||
};
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => ({ ...me, exePath: '/var/run/proxy.link' }),
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: (p) => canon[p] ?? null,
|
||||
});
|
||||
expect(verdict).toBe('ok');
|
||||
});
|
||||
|
||||
it('does NOT trust a same-uid process at the WRONG path with the right basename (F2a)', () => {
|
||||
// The squatter vector on a shared-uid host: right basename, wrong path. The
|
||||
// basename must NEVER be a trust signal when an expected exact path resolved.
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => ({ ...me, exePath: '/tmp/claude-code-proxy' }),
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: idc,
|
||||
});
|
||||
expect(verdict).toBe('wrong-exe');
|
||||
});
|
||||
|
||||
it('fails closed (unknown) when our own proxy binary path cannot be resolved (F2a)', () => {
|
||||
// No expected path → we cannot assert identity → refuse to trust (no basename
|
||||
// acceptance). Previously this returned `ok` by basename; that was a bypass.
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => me,
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => null,
|
||||
canonicalize: idc,
|
||||
});
|
||||
expect(verdict).toBe('unknown');
|
||||
});
|
||||
|
||||
it('fails closed (unknown) when a path cannot be canonicalized (F2a)', () => {
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => me,
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: () => null, // e.g. binary deleted out from under the listener
|
||||
});
|
||||
expect(verdict).toBe('unknown');
|
||||
});
|
||||
|
||||
it('rejects a listener owned by a DIFFERENT uid (foreign-user) — fail closed', () => {
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => ({ ...me, uid: 0 }),
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: idc,
|
||||
});
|
||||
expect(verdict).toBe('foreign-user');
|
||||
});
|
||||
|
||||
it('rejects a same-user listener whose exe is NOT the proxy (wrong-exe)', () => {
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => ({ ...me, exePath: '/usr/bin/nc' }),
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: idc,
|
||||
});
|
||||
expect(verdict).toBe('wrong-exe');
|
||||
});
|
||||
|
||||
it('returns unknown (fail closed) when the listener cannot be identified', () => {
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => null,
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: idc,
|
||||
});
|
||||
expect(verdict).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns unknown when the current uid is unavailable (non-posix)', () => {
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => me,
|
||||
currentUid: () => -1,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: idc,
|
||||
});
|
||||
expect(verdict).toBe('unknown');
|
||||
});
|
||||
|
||||
it('returns unknown when the listener exe path cannot be read', () => {
|
||||
const verdict = verifyListenerIdentity({
|
||||
identify: () => ({ ...me, exePath: null }),
|
||||
currentUid: () => 1000,
|
||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||
canonicalize: idc,
|
||||
});
|
||||
expect(verdict).toBe('unknown');
|
||||
});
|
||||
|
||||
it('runs with real defaults without throwing (identity may be unresolved → verdict)', () => {
|
||||
const verdict = verifyListenerIdentity();
|
||||
expect(['ok', 'foreign-user', 'wrong-exe', 'unknown']).toContain(verdict);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureProxyRunning', () => {
|
||||
const ok: ProxyRunResult = { status: 0, stdout: '', stderr: '' };
|
||||
const nohupOk = async (): Promise<ProxyRunResult> => ok;
|
||||
const trusted = () => 'ok' as const;
|
||||
|
||||
it('is a no-op when the proxy is already live AND identity-verified', async () => {
|
||||
const startSystemd = vi.fn(() => ok);
|
||||
const startNohup = vi.fn(nohupOk);
|
||||
const r = await ensureProxyRunning({
|
||||
probe: async () => true,
|
||||
verifyListener: trusted,
|
||||
startSystemd,
|
||||
startNohup,
|
||||
waitMs: async () => {},
|
||||
});
|
||||
expect(r.method).toBe('already');
|
||||
expect(r.live).toBe(true);
|
||||
expect(startSystemd).not.toHaveBeenCalled();
|
||||
expect(startNohup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed as untrusted when a responder holds :18765 but identity is NOT ours', async () => {
|
||||
// A foreign process answers /healthz but the listener is not our proxy
|
||||
// (foreign uid / wrong exe / unidentifiable). We must NOT trust it and must
|
||||
// NOT start a second proxy (the port is already taken) — fail closed.
|
||||
const startSystemd = vi.fn(() => ok);
|
||||
const startNohup = vi.fn(nohupOk);
|
||||
const r = await ensureProxyRunning({
|
||||
probe: async () => true,
|
||||
verifyListener: () => 'foreign-user',
|
||||
startSystemd,
|
||||
startNohup,
|
||||
waitMs: async () => {},
|
||||
});
|
||||
expect(r.method).toBe('untrusted');
|
||||
expect(r.live).toBe(false);
|
||||
expect(startSystemd).not.toHaveBeenCalled();
|
||||
expect(startNohup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts via systemd when available and then becomes trusted-live', async () => {
|
||||
let calls = 0;
|
||||
const r = await ensureProxyRunning({
|
||||
probe: async () => calls++ > 0, // dead first, live after start
|
||||
verifyListener: trusted,
|
||||
startSystemd: () => ok,
|
||||
startNohup: async () => {
|
||||
throw new Error('should not fall back');
|
||||
},
|
||||
waitMs: async () => {},
|
||||
});
|
||||
expect(r.method).toBe('systemd');
|
||||
expect(r.live).toBe(true);
|
||||
});
|
||||
|
||||
it('waits past a slow systemd bind before giving up (finding #2 — no duplicate proxy)', async () => {
|
||||
// systemd `start` returns 0 (job accepted) but the socket only binds on the
|
||||
// 4th probe — still well within the startup deadline. nohup must NOT run,
|
||||
// or two proxies would contend for :18765.
|
||||
let probes = 0;
|
||||
const startNohup = vi.fn(nohupOk);
|
||||
const r = await ensureProxyRunning({
|
||||
probe: async () => probes++ >= 3,
|
||||
verifyListener: trusted,
|
||||
startSystemd: () => ok,
|
||||
startNohup,
|
||||
waitMs: async () => {},
|
||||
settleMs: 10,
|
||||
startupDeadlineMs: 200,
|
||||
});
|
||||
expect(r.method).toBe('systemd');
|
||||
expect(r.live).toBe(true);
|
||||
expect(startNohup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT fall back to nohup after systemd accepts but never binds (finding #1 — dup-proxy race)', async () => {
|
||||
// systemctl start exit 0 means the job was ACCEPTED, not bound. If it binds
|
||||
// just after our deadline (or systemd restarts it), a nohup fallback would
|
||||
// create a SECOND proxy contending for :18765. Once systemd has accepted the
|
||||
// job we never spawn nohup — we report a managed-service startup failure.
|
||||
const startNohup = vi.fn(nohupOk);
|
||||
const r = await ensureProxyRunning({
|
||||
probe: async () => false, // never becomes live within the deadline
|
||||
verifyListener: trusted,
|
||||
startSystemd: () => ok,
|
||||
startNohup,
|
||||
waitMs: async () => {},
|
||||
settleMs: 10,
|
||||
startupDeadlineMs: 30,
|
||||
});
|
||||
expect(startNohup).not.toHaveBeenCalled();
|
||||
expect(r.method).toBe('failed');
|
||||
expect(r.live).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT trust a systemd-started responder whose identity cannot be verified', async () => {
|
||||
// Dead at first (so we reach the systemd start), then the socket binds — but
|
||||
// identity never verifies (e.g. a squatter beat systemd to the port). A live
|
||||
// responder that fails identity must never be reported as a successful start.
|
||||
let calls = 0;
|
||||
const startNohup = vi.fn(nohupOk);
|
||||
const r = await ensureProxyRunning({
|
||||
probe: async () => calls++ > 0,
|
||||
verifyListener: () => 'wrong-exe',
|
||||
startSystemd: () => ok,
|
||||
startNohup,
|
||||
waitMs: async () => {},
|
||||
settleMs: 10,
|
||||
startupDeadlineMs: 30,
|
||||
});
|
||||
expect(startNohup).not.toHaveBeenCalled();
|
||||
expect(r.method).toBe('failed');
|
||||
expect(r.live).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to nohup only when systemd start FAILS outright (not accepted)', async () => {
|
||||
let calls = 0;
|
||||
const r = await ensureProxyRunning({
|
||||
// A failed systemd start skips its post-start poll, so probes are:
|
||||
// #0 initial (dead), #1 after nohup (live). nohup fallback is reachable
|
||||
// ONLY because systemd never accepted the job (status 1).
|
||||
probe: async () => calls++ > 0,
|
||||
verifyListener: trusted,
|
||||
startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }),
|
||||
startNohup: nohupOk,
|
||||
waitMs: async () => {},
|
||||
settleMs: 10,
|
||||
startupDeadlineMs: 30,
|
||||
});
|
||||
expect(r.method).toBe('nohup');
|
||||
expect(r.live).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT trust a nohup-started responder whose identity cannot be verified', async () => {
|
||||
let calls = 0;
|
||||
const r = await ensureProxyRunning({
|
||||
probe: async () => calls++ > 0,
|
||||
verifyListener: () => 'unknown',
|
||||
startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }),
|
||||
startNohup: nohupOk,
|
||||
waitMs: async () => {},
|
||||
settleMs: 10,
|
||||
startupDeadlineMs: 30,
|
||||
});
|
||||
expect(r.method).toBe('failed');
|
||||
expect(r.live).toBe(false);
|
||||
});
|
||||
|
||||
it('reports failed when nothing brings the proxy up', async () => {
|
||||
const r = await ensureProxyRunning({
|
||||
probe: async () => false,
|
||||
verifyListener: trusted,
|
||||
startSystemd: () => ({ status: 1, stdout: '', stderr: '' }),
|
||||
startNohup: async () => ({ status: 1, stdout: '', stderr: '' }),
|
||||
waitMs: async () => {},
|
||||
settleMs: 10,
|
||||
startupDeadlineMs: 30,
|
||||
});
|
||||
expect(r.method).toBe('failed');
|
||||
expect(r.live).toBe(false);
|
||||
});
|
||||
});
|
||||
700
packages/mosaic/src/commands/claudex-proxy.ts
Normal file
700
packages/mosaic/src/commands/claudex-proxy.ts
Normal file
@@ -0,0 +1,700 @@
|
||||
/**
|
||||
* Claudex proxy preflight + lifecycle (P1 of `mosaic yolo claudex`).
|
||||
*
|
||||
* `raine/claude-code-proxy` runs a local server on 127.0.0.1:18765 that speaks
|
||||
* the Anthropic Messages API and translates to the ChatGPT/Codex backend using
|
||||
* ChatGPT-subscription OAuth. This module owns the *preflight* and *lifecycle*
|
||||
* concerns for the launcher: is the binary present, is OAuth valid, is the proxy
|
||||
* listening, and — if not — bring it up (systemd user unit preferred, nohup
|
||||
* fallback).
|
||||
*
|
||||
* Design: every function is pure or dependency-injected so the launch path is
|
||||
* fully unit-testable without touching a real process, socket, or the OAuth
|
||||
* token. Nothing here reads `~/.config/claude-code-proxy/codex/auth.json`; the
|
||||
* proxy holds the real credential and Claude Code only ever sees
|
||||
* `ANTHROPIC_AUTH_TOKEN=unused`. Parsed auth status is deliberately coarse
|
||||
* (state + optional expiry) so no token material can be retained or surfaced.
|
||||
*/
|
||||
|
||||
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
// ─── Endpoint / command constants (spec table) ──────────────────────────────
|
||||
|
||||
export const CLAUDEX_PROXY_HOST = '127.0.0.1';
|
||||
export const CLAUDEX_PROXY_PORT = 18765;
|
||||
export const CLAUDEX_PROXY_URL = `http://${CLAUDEX_PROXY_HOST}:${CLAUDEX_PROXY_PORT}`;
|
||||
export const CLAUDEX_PROXY_BINARY = 'claude-code-proxy';
|
||||
export const CLAUDEX_SYSTEMD_UNIT = 'claude-code-proxy.service';
|
||||
|
||||
/**
|
||||
* The proxy's dedicated liveness endpoint. We probe this — NOT the root path —
|
||||
* for two reasons: (1) the root returns non-2xx (spec gotcha #1), which is why
|
||||
* the original `curl -f` check spawned duplicate proxies; `/healthz` returns 2xx
|
||||
* when the proxy is healthy. (2) It is a *proxy-specific* contract, so a 2xx here
|
||||
* is a much stronger signal that the responder on :18765 is actually our proxy
|
||||
* and not some other local process squatting the port (CWE-345).
|
||||
*/
|
||||
export const CLAUDEX_HEALTH_PATH = '/healthz';
|
||||
export const CLAUDEX_HEALTH_URL = `${CLAUDEX_PROXY_URL}${CLAUDEX_HEALTH_PATH}`;
|
||||
|
||||
/** argv for `claude-code-proxy codex auth status`. */
|
||||
export function buildAuthStatusArgs(): string[] {
|
||||
return ['codex', 'auth', 'status'];
|
||||
}
|
||||
|
||||
/** argv for `claude-code-proxy codex auth device` (device-code re-auth flow). */
|
||||
export function buildDeviceAuthArgs(): string[] {
|
||||
return ['codex', 'auth', 'device'];
|
||||
}
|
||||
|
||||
/** argv for `claude-code-proxy serve --no-monitor`. */
|
||||
export function buildServeArgs(): string[] {
|
||||
return ['serve', '--no-monitor'];
|
||||
}
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AuthState = 'valid' | 'expired' | 'unauthenticated' | 'unknown';
|
||||
|
||||
/**
|
||||
* Coarse OAuth status. Intentionally carries NO token material — only a state
|
||||
* and an optional best-effort expiry-in-days for user-facing messaging.
|
||||
*/
|
||||
export interface AuthStatus {
|
||||
state: AuthState;
|
||||
expiresInDays?: number;
|
||||
}
|
||||
|
||||
export interface ProxyRunResult {
|
||||
status: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/** Runs a command synchronously and returns its captured result. */
|
||||
export type CommandRunner = (cmd: string, args: string[]) => ProxyRunResult;
|
||||
|
||||
/** Minimal fetch shape used for the liveness probe (any HTTP response = alive). */
|
||||
export type FetchLike = (
|
||||
url: string,
|
||||
init?: { signal?: AbortSignal },
|
||||
) => Promise<{ status?: number }>;
|
||||
|
||||
// ─── Binary presence ─────────────────────────────────────────────────────────
|
||||
|
||||
function defaultWhich(cmd: string): string | null {
|
||||
try {
|
||||
return execFileSync('which', [cmd], { encoding: 'utf8' }).trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function checkProxyBinary(resolve: (cmd: string) => string | null = defaultWhich): {
|
||||
present: boolean;
|
||||
path: string | null;
|
||||
} {
|
||||
const path = resolve(CLAUDEX_PROXY_BINARY);
|
||||
return { present: path !== null && path !== '', path: path || null };
|
||||
}
|
||||
|
||||
// ─── Auth status ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse `claude-code-proxy codex auth status` output into a coarse state.
|
||||
*
|
||||
* The proxy's exact wording is not contractually pinned, so this matches
|
||||
* tolerantly on well-known markers and falls back on the exit code. It never
|
||||
* copies the raw output onto the result — only a state and an optional expiry —
|
||||
* so token-shaped strings in the output cannot leak downstream.
|
||||
*/
|
||||
export function parseAuthStatus(result: ProxyRunResult): AuthStatus {
|
||||
const text = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
||||
|
||||
const expired = /\bexpired\b|token has expired|expires?d? \d+ days? ago/.test(text);
|
||||
const unauth =
|
||||
/not authenticated|not logged in|no (?:auth|credentials|token)|please (?:log ?in|authenticate)|run .*auth device/.test(
|
||||
text,
|
||||
);
|
||||
const authed = /\bauthenticated\b|logged in|token valid|valid until|expires? in/.test(text);
|
||||
|
||||
let state: AuthState;
|
||||
if (expired) {
|
||||
state = 'expired';
|
||||
} else if (unauth) {
|
||||
state = 'unauthenticated';
|
||||
} else if (authed && result.status === 0) {
|
||||
// A `null` status means the check was killed by a signal — an INCOMPLETE
|
||||
// run. We require a clean exit 0 for `valid`; a partially-flushed auth line
|
||||
// from a signal-terminated check must never be trusted (finding #3).
|
||||
state = 'valid';
|
||||
} else if (result.status === 0) {
|
||||
state = 'valid';
|
||||
} else {
|
||||
state = 'unknown';
|
||||
}
|
||||
|
||||
const status: AuthStatus = { state };
|
||||
const days = /expires? in (\d+) days?/.exec(text);
|
||||
if (state === 'valid' && days) {
|
||||
status.expiresInDays = Number(days[1]);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function defaultRun(cmd: string, args: string[]): ProxyRunResult {
|
||||
const r = spawnSync(cmd, args, { encoding: 'utf8' });
|
||||
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
||||
}
|
||||
|
||||
export function checkAuthStatus(run: CommandRunner = defaultRun): AuthStatus {
|
||||
return parseAuthStatus(run(CLAUDEX_PROXY_BINARY, buildAuthStatusArgs()));
|
||||
}
|
||||
|
||||
/** Spawn shape for the interactive device re-auth flow. */
|
||||
export type InheritSpawn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: { stdio: 'inherit' },
|
||||
) => { status: number | null };
|
||||
|
||||
function defaultInheritSpawn(cmd: string, args: string[], opts: { stdio: 'inherit' }) {
|
||||
return spawnSync(cmd, args, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the device-code re-auth flow (`claude-code-proxy codex auth device`).
|
||||
*
|
||||
* Deliberately `stdio: 'inherit'` so the device code the proxy prints goes
|
||||
* straight to the user's terminal — the launcher NEVER captures, stores, or logs
|
||||
* it, and never observes the resulting OAuth token (the proxy persists that to
|
||||
* its own config). Returns the child's exit status; 1 on an absent binary.
|
||||
*/
|
||||
export function runDeviceReauth(spawnImpl: InheritSpawn = defaultInheritSpawn): number {
|
||||
const r = spawnImpl(CLAUDEX_PROXY_BINARY, buildDeviceAuthArgs(), { stdio: 'inherit' });
|
||||
return r.status ?? 1;
|
||||
}
|
||||
|
||||
// ─── Liveness (probe the proxy-specific /healthz; require 2xx) ────────────────
|
||||
|
||||
/**
|
||||
* Probe the proxy for liveness by hitting its dedicated `GET /healthz` endpoint
|
||||
* and requiring a 2xx response.
|
||||
*
|
||||
* This is a LIVENESS check only — it answers "is a healthy proxy responding?",
|
||||
* not "is that responder actually ours?". Requiring a 2xx on the proxy's own
|
||||
* `/healthz` contract (rather than "any HTTP response = alive") resolves spec
|
||||
* gotcha #1: the root path returns non-2xx, but `/healthz` returns 2xx when
|
||||
* healthy, so a live proxy is never mistaken for dead and no duplicate proxy is
|
||||
* spawned.
|
||||
*
|
||||
* Residual risk (CWE-345): the proxy binds loopback with NO client
|
||||
* authentication, so on a shared host a local process could occupy :18765 and
|
||||
* serve a 2xx here. A 2xx therefore does NOT by itself establish that the
|
||||
* listener is our proxy. Identity is verified SEPARATELY and at every trust
|
||||
* point by {@link verifyListenerIdentity} (OS-level uid + executable check),
|
||||
* which fails closed when identity can't be established. See
|
||||
* {@link ensureProxyRunning}. (Broader multi-user hardening — a persistent
|
||||
* warning when a foreign listener is seen — is tracked for a later phase.)
|
||||
*/
|
||||
export async function probeLiveness(
|
||||
url: string = CLAUDEX_HEALTH_URL,
|
||||
fetchImpl: FetchLike = fetch as unknown as FetchLike,
|
||||
timeoutMs = 1500,
|
||||
): Promise<boolean> {
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// Bound the probe with our own timeout race rather than trusting the fetch
|
||||
// implementation to honor the abort signal — a hung socket (or a fetch that
|
||||
// ignores the signal) must never wedge the launcher. We still abort() so a
|
||||
// signal-aware fetch tears the request down promptly.
|
||||
const timeout = new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
const probe = fetchImpl(url, { signal: controller.signal })
|
||||
.then((res) => typeof res.status === 'number' && res.status >= 200 && res.status < 300)
|
||||
.catch(() => false); // connection refused / aborted → dead
|
||||
|
||||
try {
|
||||
return await Promise.race([probe, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Listener identity (OS-level, CWE-345 mitigation) ─────────────────────────
|
||||
|
||||
/**
|
||||
* The result of verifying who actually owns the :18765 listener.
|
||||
* - `ok` — same-user process running the expected proxy binary.
|
||||
* - `foreign-user` — a process owned by a DIFFERENT uid holds the port.
|
||||
* - `wrong-exe` — same-user, but the executable is not the proxy.
|
||||
* - `unknown` — identity could not be established (fail closed).
|
||||
*/
|
||||
export type ListenerVerdict = 'ok' | 'foreign-user' | 'wrong-exe' | 'unknown';
|
||||
|
||||
/** OS-level identity of the process bound to the proxy port. */
|
||||
export interface ListenerIdentity {
|
||||
pid: number;
|
||||
uid: number;
|
||||
/** Absolute path of the process executable, or null if unreadable. */
|
||||
exePath: string | null;
|
||||
}
|
||||
|
||||
export interface VerifyListenerDeps {
|
||||
/** Resolve the process bound to the proxy port (null → unidentifiable). */
|
||||
identify?: () => ListenerIdentity | null;
|
||||
/** The current process uid (-1 when unavailable, e.g. non-posix). */
|
||||
currentUid?: () => number;
|
||||
/** The expected proxy executable path (null when it can't be resolved). */
|
||||
expectedExe?: () => string | null;
|
||||
/** Canonicalize a path (resolve symlinks); null when it can't be resolved. */
|
||||
canonicalize?: (p: string) => string | null;
|
||||
}
|
||||
|
||||
/** Resolve a path through symlinks to its canonical form; null on any failure. */
|
||||
function defaultCanonicalize(p: string): string | null {
|
||||
try {
|
||||
return realpathSync(p);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the process listening on the proxy port via `ss` + `/proc`. Every
|
||||
* failure path returns null so the caller fails closed. Reads no credential
|
||||
* material — only pid/uid/exe path of the listener.
|
||||
*/
|
||||
function defaultIdentifyListener(port: number = CLAUDEX_PROXY_PORT): ListenerIdentity | null {
|
||||
try {
|
||||
const out = execFileSync('ss', ['-H', '-ltnp', `sport = :${port}`], { encoding: 'utf8' });
|
||||
const pidMatch = /pid=(\d+)/.exec(out);
|
||||
if (!pidMatch) return null;
|
||||
const pid = Number(pidMatch[1]);
|
||||
if (!Number.isInteger(pid) || pid <= 0) return null;
|
||||
|
||||
const status = readFileSync(`/proc/${pid}/status`, 'utf8');
|
||||
const uidLine = /^Uid:\s*(\d+)/m.exec(status);
|
||||
if (!uidLine) return null;
|
||||
const uid = Number(uidLine[1]);
|
||||
|
||||
let exePath: string | null = null;
|
||||
try {
|
||||
exePath = readlinkSync(`/proc/${pid}/exe`);
|
||||
} catch {
|
||||
exePath = null;
|
||||
}
|
||||
return { pid, uid, exePath };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the process owning :18765 is genuinely OUR proxy before trusting
|
||||
* it. The proxy binds loopback with NO client authentication, so on a shared
|
||||
* host any local process could squat the port and a liveness 2xx alone does not
|
||||
* prove identity (CWE-345). We FAIL CLOSED (`unknown`) whenever identity cannot
|
||||
* be established. This needs no upstream shared-secret/unix-socket support from
|
||||
* `claude-code-proxy`.
|
||||
*
|
||||
* The executable path is the trust boundary that matters: on a shared-uid host
|
||||
* (every agent session runs as the same operator) same-uid is NOT sufficient, so
|
||||
* we require an EXACT canonical-path match against our resolved proxy binary and
|
||||
* canonicalize both sides for symlinks. There is deliberately NO basename
|
||||
* fallback — a same-uid process running `/tmp/claude-code-proxy` (right name,
|
||||
* wrong path) must never be trusted. If our own binary path can't be resolved,
|
||||
* or either path can't be canonicalized, we fail closed rather than downgrade to
|
||||
* a weaker check.
|
||||
*/
|
||||
export function verifyListenerIdentity(deps: VerifyListenerDeps = {}): ListenerVerdict {
|
||||
const identify = deps.identify ?? (() => defaultIdentifyListener());
|
||||
const currentUid =
|
||||
deps.currentUid ?? (() => (typeof process.getuid === 'function' ? process.getuid() : -1));
|
||||
const expectedExe = deps.expectedExe ?? (() => checkProxyBinary().path);
|
||||
const canonicalize = deps.canonicalize ?? defaultCanonicalize;
|
||||
|
||||
const id = identify();
|
||||
if (!id) return 'unknown'; // can't see the listener → don't trust it
|
||||
const uid = currentUid();
|
||||
if (uid < 0) return 'unknown'; // can't establish our own identity → fail closed
|
||||
if (id.uid !== uid) return 'foreign-user'; // someone else's process holds the port
|
||||
if (!id.exePath) return 'unknown'; // can't confirm the executable → fail closed
|
||||
|
||||
const expected = expectedExe();
|
||||
if (!expected) return 'unknown'; // can't resolve our own binary → fail closed
|
||||
const expectedReal = canonicalize(expected);
|
||||
const actualReal = canonicalize(id.exePath);
|
||||
if (!expectedReal || !actualReal) return 'unknown'; // uncanonicalizable → fail closed
|
||||
return actualReal === expectedReal ? 'ok' : 'wrong-exe';
|
||||
}
|
||||
|
||||
// ─── systemd user unit ───────────────────────────────────────────────────────
|
||||
|
||||
export function systemdUnitPath(home: string = homedir()): string {
|
||||
return join(home, '.config', 'systemd', 'user', CLAUDEX_SYSTEMD_UNIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a path destined for a systemd `ExecStart=` line. A raw newline (or
|
||||
* other control character) in the path would let an attacker inject arbitrary
|
||||
* unit directives (e.g. an extra `ExecStartPost=`), a CWE-74 command injection.
|
||||
* We require a plain absolute path and reject any control character outright.
|
||||
*/
|
||||
function validateExecPath(binaryPath: string): string {
|
||||
if (typeof binaryPath !== 'string' || binaryPath.length === 0) {
|
||||
throw new Error('systemd ExecStart: binary path is empty');
|
||||
}
|
||||
if (!binaryPath.startsWith('/')) {
|
||||
throw new Error(
|
||||
`systemd ExecStart: binary path must be absolute: ${JSON.stringify(binaryPath)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (/[\x00-\x1f\x7f]/.test(binaryPath)) {
|
||||
throw new Error('systemd ExecStart: binary path contains control characters');
|
||||
}
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a validated path for a systemd `ExecStart=` token. systemd only needs
|
||||
* quoting when the token carries whitespace or quote/backslash characters; a
|
||||
* clean path is emitted verbatim. When quoting, we escape backslashes and double
|
||||
* quotes per systemd's C-style rules so the token cannot be terminated early.
|
||||
*/
|
||||
function systemdQuoteExec(path: string): string {
|
||||
if (!/[\s"'\\]/.test(path)) {
|
||||
return path;
|
||||
}
|
||||
const escaped = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the `claude-code-proxy.service` user unit. Contains no credential
|
||||
* material — the proxy reads its own OAuth token from its config dir at runtime.
|
||||
* The binary path is validated (absolute, no control characters) and systemd-
|
||||
* quoted so it cannot inject unit directives.
|
||||
*/
|
||||
export function buildSystemdUnitContent(binaryPath: string): string {
|
||||
const exec = `${systemdQuoteExec(validateExecPath(binaryPath))} ${buildServeArgs().join(' ')}`;
|
||||
return [
|
||||
'[Unit]',
|
||||
'Description=claude-code-proxy (Anthropic->Codex translation proxy for mosaic claudex)',
|
||||
'After=network-online.target',
|
||||
'Wants=network-online.target',
|
||||
'',
|
||||
'[Service]',
|
||||
'Type=simple',
|
||||
`ExecStart=${exec}`,
|
||||
'Restart=on-failure',
|
||||
'RestartSec=2',
|
||||
'',
|
||||
'[Install]',
|
||||
'WantedBy=default.target',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the user unit and reload the systemd --user daemon. Returns false when
|
||||
* systemd --user is unavailable (the caller then falls back to nohup).
|
||||
*/
|
||||
export function installSystemdUnit(
|
||||
binaryPath: string,
|
||||
deps: {
|
||||
home?: string;
|
||||
writeUnit?: (path: string, content: string) => void;
|
||||
run?: CommandRunner;
|
||||
} = {},
|
||||
): boolean {
|
||||
const home = deps.home ?? homedir();
|
||||
const write =
|
||||
deps.writeUnit ??
|
||||
((path: string, content: string) => {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
});
|
||||
const run = deps.run ?? defaultRun;
|
||||
|
||||
try {
|
||||
write(systemdUnitPath(home), buildSystemdUnitContent(binaryPath));
|
||||
const reload = run('systemctl', ['--user', 'daemon-reload']);
|
||||
return reload.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Preflight report ────────────────────────────────────────────────────────
|
||||
|
||||
export interface PreflightReport {
|
||||
binaryPresent: boolean;
|
||||
binaryPath: string | null;
|
||||
auth: AuthStatus;
|
||||
live: boolean;
|
||||
/** OS-level identity verdict for the :18765 listener (`unknown` when dead). */
|
||||
listenerVerdict: ListenerVerdict;
|
||||
needsReauth: boolean;
|
||||
ok: boolean;
|
||||
problems: string[];
|
||||
}
|
||||
|
||||
export interface PreflightDeps {
|
||||
checkBinary?: () => { present: boolean; path: string | null };
|
||||
checkAuth?: () => AuthStatus;
|
||||
probe?: () => Promise<boolean>;
|
||||
verifyListener?: () => ListenerVerdict;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the preflight checks into a single structured report. `ok` is true
|
||||
* only when the binary is present, OAuth is valid, the proxy responds, AND the
|
||||
* responding listener's OS-level identity verifies as our proxy.
|
||||
*
|
||||
* The identity gate lives here too, not only in {@link ensureProxyRunning}: any
|
||||
* consumer of this report (notably the phase-2 launch path) would otherwise
|
||||
* treat a `/healthz`-2xx squatter as healthy and route Claude traffic to it
|
||||
* (CWE-345). A liveness 2xx is necessary but not sufficient — a live responder
|
||||
* that fails identity fails the preflight.
|
||||
*/
|
||||
export async function runProxyPreflight(deps: PreflightDeps = {}): Promise<PreflightReport> {
|
||||
const checkBinary = deps.checkBinary ?? (() => checkProxyBinary());
|
||||
const checkAuth = deps.checkAuth ?? (() => checkAuthStatus());
|
||||
const probe = deps.probe ?? (() => probeLiveness());
|
||||
const verifyListener = deps.verifyListener ?? (() => verifyListenerIdentity());
|
||||
|
||||
const bin = checkBinary();
|
||||
const auth = checkAuth();
|
||||
const live = await probe();
|
||||
// Only meaningful when something is actually responding; a dead port has no
|
||||
// listener identity to establish.
|
||||
const listenerVerdict: ListenerVerdict = live ? verifyListener() : 'unknown';
|
||||
|
||||
const problems: string[] = [];
|
||||
if (!bin.present) {
|
||||
problems.push(
|
||||
`claude-code-proxy binary not found in PATH. Install it before launching claudex.`,
|
||||
);
|
||||
}
|
||||
const needsReauth = auth.state === 'expired' || auth.state === 'unauthenticated';
|
||||
if (needsReauth) {
|
||||
problems.push(
|
||||
`claude-code-proxy OAuth is ${auth.state}. Re-auth with: ${CLAUDEX_PROXY_BINARY} ${buildDeviceAuthArgs().join(' ')}`,
|
||||
);
|
||||
} else if (auth.state === 'unknown') {
|
||||
problems.push('Could not determine claude-code-proxy OAuth status.');
|
||||
}
|
||||
if (!live) {
|
||||
problems.push(`No proxy responding on ${CLAUDEX_PROXY_URL}.`);
|
||||
} else if (listenerVerdict !== 'ok') {
|
||||
// Non-sensitive: names the port and the verdict only — never any listener
|
||||
// command line, token, or other process detail.
|
||||
problems.push(
|
||||
`A process is listening on ${CLAUDEX_PROXY_URL} but its identity could not be verified as ${CLAUDEX_PROXY_BINARY} (${listenerVerdict}). Refusing to trust it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const ok = bin.present && auth.state === 'valid' && live && listenerVerdict === 'ok';
|
||||
return {
|
||||
binaryPresent: bin.present,
|
||||
binaryPath: bin.path,
|
||||
auth,
|
||||
live,
|
||||
listenerVerdict,
|
||||
needsReauth,
|
||||
ok,
|
||||
problems,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Lifecycle: ensure the proxy is running ──────────────────────────────────
|
||||
|
||||
export type ProxyStartMethod = 'already' | 'systemd' | 'nohup' | 'untrusted' | 'failed';
|
||||
|
||||
export interface EnsureProxyResult {
|
||||
live: boolean;
|
||||
method: ProxyStartMethod;
|
||||
}
|
||||
|
||||
/** Minimal spawned-child shape used by the nohup fallback (testable seam). */
|
||||
export interface SpawnedChild {
|
||||
once(event: string, listener: (arg?: unknown) => void): unknown;
|
||||
unref(): void;
|
||||
}
|
||||
|
||||
/** Spawn shape for the detached fallback process. */
|
||||
export type SpawnLike = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: { detached: boolean; stdio: 'ignore' },
|
||||
) => SpawnedChild;
|
||||
|
||||
export interface StartNohupDeps {
|
||||
resolveBin?: () => string;
|
||||
spawnImpl?: SpawnLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the proxy as a detached background process (the fallback when no systemd
|
||||
* user unit is available).
|
||||
*
|
||||
* `spawn()` reports launch failures (ENOENT/EACCES) ASYNCHRONOUSLY via the
|
||||
* child's `error` event, which a `try/catch` cannot see. If left unhandled that
|
||||
* event throws and crashes the launcher. So we: (1) attach the `error` listener
|
||||
* BEFORE `unref()`, capturing a failed launch as a non-zero result instead of a
|
||||
* crash; and (2) resolve success only after the child's `spawn` event fires —
|
||||
* never optimistically before the process is known to have started.
|
||||
*/
|
||||
export function startNohupProxy(deps: StartNohupDeps = {}): Promise<ProxyRunResult> {
|
||||
const resolveBin = deps.resolveBin ?? (() => checkProxyBinary().path ?? CLAUDEX_PROXY_BINARY);
|
||||
const spawnImpl =
|
||||
deps.spawnImpl ?? ((cmd, args, opts) => spawn(cmd, args, opts) as unknown as SpawnedChild);
|
||||
|
||||
return new Promise<ProxyRunResult>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (r: ProxyRunResult) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(r);
|
||||
}
|
||||
};
|
||||
|
||||
let child: SpawnedChild;
|
||||
try {
|
||||
child = spawnImpl(resolveBin(), buildServeArgs(), { detached: true, stdio: 'ignore' });
|
||||
} catch (err) {
|
||||
finish({ status: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Register error handling BEFORE unref so an async spawn failure is caught.
|
||||
child.once('error', (err) => {
|
||||
finish({
|
||||
status: 1,
|
||||
stdout: '',
|
||||
stderr: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
child.once('spawn', () => {
|
||||
child.unref();
|
||||
finish({ status: 0, stdout: '', stderr: '' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface EnsureProxyDeps {
|
||||
probe?: () => Promise<boolean>;
|
||||
/** OS-level identity check for the process holding the proxy port. */
|
||||
verifyListener?: () => ListenerVerdict;
|
||||
startSystemd?: () => ProxyRunResult;
|
||||
startNohup?: () => Promise<ProxyRunResult>;
|
||||
waitMs?: (ms: number) => Promise<void>;
|
||||
/** Interval between liveness polls while waiting for a start to bind. */
|
||||
settleMs?: number;
|
||||
/** Total budget to wait for a started proxy to bind its socket. */
|
||||
startupDeadlineMs?: number;
|
||||
}
|
||||
|
||||
function defaultWait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function defaultStartSystemd(): ProxyRunResult {
|
||||
return defaultRun('systemctl', ['--user', 'start', CLAUDEX_SYSTEMD_UNIT]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for a TRUSTED-live proxy up to a bounded startup deadline. A start command
|
||||
* returning 0 only means the job was ACCEPTED, not that the socket is bound — so
|
||||
* we keep probing at `intervalMs` until either the deadline elapses or the port
|
||||
* both responds AND passes the OS-level identity check. Liveness alone is not
|
||||
* enough: a responder that fails identity (a squatter) must never be trusted.
|
||||
*/
|
||||
async function waitForTrusted(
|
||||
probe: () => Promise<boolean>,
|
||||
verifyListener: () => ListenerVerdict,
|
||||
waitMs: (ms: number) => Promise<void>,
|
||||
intervalMs: number,
|
||||
deadlineMs: number,
|
||||
): Promise<boolean> {
|
||||
let elapsed = 0;
|
||||
while (elapsed < deadlineMs) {
|
||||
await waitMs(intervalMs);
|
||||
elapsed += intervalMs;
|
||||
if ((await probe()) && verifyListener() === 'ok') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a proxy is listening. No-op when already live. Otherwise prefer the
|
||||
* systemd user unit, then fall back to a detached background process.
|
||||
*
|
||||
* Every trust point is gated on OS-level listener identity, not just liveness:
|
||||
* the proxy has no client authentication, so on a shared host a local process
|
||||
* could squat :18765 and a 2xx `/healthz` alone would not prove it is our proxy
|
||||
* (CWE-345, finding #2). We only trust a responder whose owning process is the
|
||||
* current uid running the expected proxy binary; otherwise we fail closed.
|
||||
*
|
||||
* If a responder is already present but its identity does NOT verify, we return
|
||||
* `untrusted` WITHOUT starting anything — the port is taken, so spawning would
|
||||
* only create contention, and we must never route Claude traffic through an
|
||||
* unverified listener.
|
||||
*
|
||||
* After a start command is accepted we poll to a bounded startup deadline before
|
||||
* giving up: `systemctl start` exit 0 means the job was accepted, not that the
|
||||
* socket bound within one probe interval. Critically, once systemd ACCEPTS the
|
||||
* job we do NOT fall back to nohup even if it never becomes trusted-live in the
|
||||
* deadline (finding #1): the accepted unit may bind late or be restarted by
|
||||
* systemd, and a second proxy would then contend for :18765 — the very
|
||||
* duplicate-proxy outcome this function exists to prevent. nohup is reachable
|
||||
* only when systemd never accepted the job at all.
|
||||
*/
|
||||
export async function ensureProxyRunning(deps: EnsureProxyDeps = {}): Promise<EnsureProxyResult> {
|
||||
const probe = deps.probe ?? (() => probeLiveness());
|
||||
const verifyListener = deps.verifyListener ?? (() => verifyListenerIdentity());
|
||||
const startSystemd = deps.startSystemd ?? defaultStartSystemd;
|
||||
const startNohup = deps.startNohup ?? (() => startNohupProxy());
|
||||
const waitMs = deps.waitMs ?? defaultWait;
|
||||
const settleMs = deps.settleMs ?? 500;
|
||||
const startupDeadlineMs = deps.startupDeadlineMs ?? 5000;
|
||||
|
||||
if (await probe()) {
|
||||
// Something answers on :18765 — trust it ONLY if it is provably our proxy.
|
||||
return verifyListener() === 'ok'
|
||||
? { live: true, method: 'already' }
|
||||
: { live: false, method: 'untrusted' };
|
||||
}
|
||||
|
||||
const systemd = startSystemd();
|
||||
if (systemd.status === 0) {
|
||||
// systemd accepted the job. Wait for a trusted-live bind, but never fall
|
||||
// back to nohup afterward — that would risk a duplicate proxy (finding #1).
|
||||
if (await waitForTrusted(probe, verifyListener, waitMs, settleMs, startupDeadlineMs)) {
|
||||
return { live: true, method: 'systemd' };
|
||||
}
|
||||
return { live: false, method: 'failed' };
|
||||
}
|
||||
|
||||
const nohup = await startNohup();
|
||||
if (nohup.status === 0) {
|
||||
if (await waitForTrusted(probe, verifyListener, waitMs, settleMs, startupDeadlineMs)) {
|
||||
return { live: true, method: 'nohup' };
|
||||
}
|
||||
}
|
||||
|
||||
return { live: false, method: 'failed' };
|
||||
}
|
||||
732
packages/mosaic/src/commands/claudex.spec.ts
Normal file
732
packages/mosaic/src/commands/claudex.spec.ts
Normal file
@@ -0,0 +1,732 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, symlinkSync, rmSync, lstatSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
CLAUDEX_CONFIG_DIR_ENV,
|
||||
CLAUDEX_DEFAULT_PRIMARY_MODEL,
|
||||
CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
|
||||
CLAUDEX_CREDENTIAL_ENV_RE,
|
||||
defaultClaudexConfigDir,
|
||||
assertIsolatedConfigDir,
|
||||
resolveClaudexConfigDir,
|
||||
resolveClaudexModels,
|
||||
buildClaudexEnv,
|
||||
buildClaudexBanner,
|
||||
buildClaudexContractNote,
|
||||
runClaudexProxyGate,
|
||||
launchClaudex,
|
||||
type ClaudexHarnessAdapter,
|
||||
} from './claudex.js';
|
||||
import { CLAUDEX_PROXY_URL, type PreflightReport } from './claudex-proxy.js';
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeReport(overrides: Partial<PreflightReport> = {}): PreflightReport {
|
||||
return {
|
||||
binaryPresent: true,
|
||||
binaryPath: '/usr/bin/claude-code-proxy',
|
||||
auth: { state: 'valid' },
|
||||
live: true,
|
||||
listenerVerdict: 'ok',
|
||||
needsReauth: false,
|
||||
ok: true,
|
||||
problems: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function okAdapter(overrides: Partial<ClaudexHarnessAdapter> = {}): ClaudexHarnessAdapter {
|
||||
return {
|
||||
harnessPreflight: () => {},
|
||||
composePrompt: () => '# Composed Claude contract',
|
||||
exec: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Identity canonicalizer + no-op FS deps so config-dir logic is tested purely.
|
||||
const idCanon = (p: string): string => p;
|
||||
const noFsDeps = { canonicalize: idCanon, mkdir: () => {}, isSymlink: () => false };
|
||||
|
||||
// ─── isolated config dir (HARD SECURITY REQ 1 — provable isolation) ───────────
|
||||
|
||||
describe('defaultClaudexConfigDir', () => {
|
||||
it('is namespaced under the mosaic home, never ~/.claude', () => {
|
||||
const dir = defaultClaudexConfigDir('/home/agent/.config/mosaic');
|
||||
expect(dir).toBe(join('/home/agent/.config/mosaic', 'claudex', 'home'));
|
||||
expect(dir).not.toBe(join(homedir(), '.claude'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertIsolatedConfigDir — the isolation guard is provable', () => {
|
||||
const realClaude = '/home/agent/.claude';
|
||||
|
||||
it('accepts a dir that does not resolve to ~/.claude', () => {
|
||||
const safe = '/home/agent/.config/mosaic/claudex/home';
|
||||
expect(
|
||||
assertIsolatedConfigDir(safe, { realClaudeDir: realClaude, canonicalize: idCanon }),
|
||||
).toBe(safe);
|
||||
});
|
||||
|
||||
it('REJECTS a candidate that is literally ~/.claude', () => {
|
||||
expect(() =>
|
||||
assertIsolatedConfigDir(realClaude, { realClaudeDir: realClaude, canonicalize: idCanon }),
|
||||
).toThrow(/refusing/i);
|
||||
});
|
||||
|
||||
it('REJECTS a descendant of ~/.claude (would pollute the real tree)', () => {
|
||||
expect(() =>
|
||||
assertIsolatedConfigDir('/home/agent/.claude/projects/x', {
|
||||
realClaudeDir: realClaude,
|
||||
canonicalize: idCanon,
|
||||
}),
|
||||
).toThrow(/refusing/i);
|
||||
});
|
||||
|
||||
it('REJECTS a candidate that canonically resolves to ~/.claude (symlink, both sides canonicalized)', () => {
|
||||
const canon = (p: string): string => (p === '/home/agent/link' ? realClaude : p);
|
||||
expect(() =>
|
||||
assertIsolatedConfigDir('/home/agent/link', {
|
||||
realClaudeDir: realClaude,
|
||||
canonicalize: canon,
|
||||
}),
|
||||
).toThrow(/refusing/i);
|
||||
});
|
||||
|
||||
it('canonicalizes the ~/.claude side too (real dir itself may be a symlink)', () => {
|
||||
// realClaudeDir is a symlink whose canonical target equals the candidate's target.
|
||||
const canon = (p: string): string =>
|
||||
p === '/home/agent/.claude' || p === '/home/agent/link' ? '/canonical/claude' : p;
|
||||
expect(() =>
|
||||
assertIsolatedConfigDir('/home/agent/link', {
|
||||
realClaudeDir: realClaude,
|
||||
canonicalize: canon,
|
||||
}),
|
||||
).toThrow(/refusing/i);
|
||||
});
|
||||
|
||||
it('REJECTS an empty or whitespace candidate (fail closed)', () => {
|
||||
expect(() =>
|
||||
assertIsolatedConfigDir('', { realClaudeDir: realClaude, canonicalize: idCanon }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
assertIsolatedConfigDir(' ', { realClaudeDir: realClaude, canonicalize: idCanon }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('REJECTS a relative candidate (must be absolute)', () => {
|
||||
expect(() =>
|
||||
assertIsolatedConfigDir('relative/dir', { realClaudeDir: realClaude, canonicalize: idCanon }),
|
||||
).toThrow(/absolute/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClaudexConfigDir', () => {
|
||||
it('uses the namespaced default and never the ambient CLAUDE_CONFIG_DIR', () => {
|
||||
// Ambient CLAUDE_CONFIG_DIR is deliberately ignored (it could be ~/.claude).
|
||||
const env = { CLAUDE_CONFIG_DIR: join(homedir(), '.claude') };
|
||||
const dir = resolveClaudexConfigDir(env, {
|
||||
mosaicHome: '/home/agent/.config/mosaic',
|
||||
realClaudeDir: '/home/agent/.claude',
|
||||
...noFsDeps,
|
||||
});
|
||||
expect(dir).toBe(join('/home/agent/.config/mosaic', 'claudex', 'home'));
|
||||
});
|
||||
|
||||
it('honors the dedicated override env when it is safe', () => {
|
||||
const env = { [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/custom-claudex' };
|
||||
const dir = resolveClaudexConfigDir(env, {
|
||||
mosaicHome: '/home/agent/.config/mosaic',
|
||||
realClaudeDir: '/home/agent/.claude',
|
||||
...noFsDeps,
|
||||
});
|
||||
expect(dir).toBe('/home/agent/custom-claudex');
|
||||
});
|
||||
|
||||
it('REJECTS a dedicated override that points at ~/.claude (before creating anything)', () => {
|
||||
const mkdir = vi.fn();
|
||||
const env = { [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/.claude' };
|
||||
expect(() =>
|
||||
resolveClaudexConfigDir(env, {
|
||||
mosaicHome: '/home/agent/.config/mosaic',
|
||||
realClaudeDir: '/home/agent/.claude',
|
||||
canonicalize: idCanon,
|
||||
mkdir,
|
||||
isSymlink: () => false,
|
||||
}),
|
||||
).toThrow(/refusing/i);
|
||||
expect(mkdir).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOCTOU: REJECTS when the created target is itself a symlink (pre-created race)', () => {
|
||||
const env = {};
|
||||
expect(() =>
|
||||
resolveClaudexConfigDir(env, {
|
||||
mosaicHome: '/home/agent/.config/mosaic',
|
||||
realClaudeDir: '/home/agent/.claude',
|
||||
canonicalize: idCanon,
|
||||
mkdir: () => {},
|
||||
isSymlink: () => true, // the just-ensured dir is a symlink → fail closed
|
||||
}),
|
||||
).toThrow(/refusing|symlink/i);
|
||||
});
|
||||
|
||||
it('real-FS: creates the isolated dir 0700 and returns its canonical path', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
|
||||
try {
|
||||
const mosaicHome = join(root, '.config', 'mosaic');
|
||||
const dir = resolveClaudexConfigDir({}, { mosaicHome, realClaudeDir: join(root, '.claude') });
|
||||
expect(dir).toBe(join(mosaicHome, 'claudex', 'home'));
|
||||
const st = lstatSync(dir);
|
||||
expect(st.isDirectory()).toBe(true);
|
||||
// 0700 (owner-only) — mask off the type bits.
|
||||
expect(st.mode & 0o777).toBe(0o700);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('real-FS: catches an override whose ancestor symlinks into ~/.claude', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
|
||||
try {
|
||||
const realClaudeDir = join(root, 'dot-claude');
|
||||
mkdirSync(realClaudeDir, { recursive: true });
|
||||
const link = join(root, 'link'); // link -> dot-claude
|
||||
symlinkSync(realClaudeDir, link, 'dir');
|
||||
const override = join(link, 'sub'); // resolves under ~/.claude
|
||||
expect(() =>
|
||||
resolveClaudexConfigDir(
|
||||
{ [CLAUDEX_CONFIG_DIR_ENV]: override },
|
||||
{ mosaicHome: join(root, '.config', 'mosaic'), realClaudeDir },
|
||||
),
|
||||
).toThrow(/refusing/i);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('FAIL CLOSED: default canonicalizer rethrows a non-ENOENT error (ELOOP) instead of a literal fallback', () => {
|
||||
// A symlink loop makes realpathSync throw ELOOP. The guard must NOT swallow
|
||||
// it as "does not exist yet, keep walking up" and return a literal path —
|
||||
// it must fail closed. (REQ 1: fails CLOSED on any uncertainty.)
|
||||
const root = mkdtempSync(join(tmpdir(), 'claudex-loop-'));
|
||||
try {
|
||||
const a = join(root, 'a');
|
||||
const b = join(root, 'b');
|
||||
symlinkSync(b, a, 'dir'); // a -> b
|
||||
symlinkSync(a, b, 'dir'); // b -> a (loop)
|
||||
const looped = join(a, 'home'); // canonicalizing this hits ELOOP
|
||||
// No canonicalize dep → the real defaultCanonicalizeIntended runs.
|
||||
expect(() =>
|
||||
assertIsolatedConfigDir(looped, { realClaudeDir: join(root, '.claude') }),
|
||||
).toThrow();
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('FAIL CLOSED: default isSymlink rethrows a non-ENOENT error (ENOTDIR) rather than reporting "not a symlink"', () => {
|
||||
// A candidate whose parent is a regular FILE makes lstat throw ENOTDIR.
|
||||
// The post-create symlink check must fail closed, not treat it as safe.
|
||||
const root = mkdtempSync(join(tmpdir(), 'claudex-notdir-'));
|
||||
try {
|
||||
const file = join(root, 'afile');
|
||||
writeFileSync(file, 'x');
|
||||
const candidate = join(file, 'child'); // parent is a file → ENOTDIR on lstat
|
||||
expect(() =>
|
||||
// Bypass the guard/mkdir side-effects; only the default isSymlink runs live.
|
||||
resolveClaudexConfigDir(
|
||||
{ [CLAUDEX_CONFIG_DIR_ENV]: candidate },
|
||||
{
|
||||
realClaudeDir: join(root, '.claude'),
|
||||
canonicalize: idCanon,
|
||||
mkdir: () => {},
|
||||
// isSymlink omitted → real defaultIsSymlink runs on the ENOTDIR path.
|
||||
},
|
||||
),
|
||||
).toThrow();
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('FAIL CLOSED: an injected canonicalize throwing EACCES is not swallowed', () => {
|
||||
const eacces = Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||
expect(() =>
|
||||
resolveClaudexConfigDir(
|
||||
{ [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/custom-claudex' },
|
||||
{
|
||||
realClaudeDir: '/home/agent/.claude',
|
||||
canonicalize: () => {
|
||||
throw eacces;
|
||||
},
|
||||
mkdir: () => {},
|
||||
isSymlink: () => false,
|
||||
},
|
||||
),
|
||||
).toThrow(/permission denied/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── model-tier map (P3) ──────────────────────────────────────────────────────
|
||||
|
||||
describe('resolveClaudexModels', () => {
|
||||
it('defaults primary=sol / smallFast=luna', () => {
|
||||
expect(resolveClaudexModels({})).toEqual({
|
||||
primary: CLAUDEX_DEFAULT_PRIMARY_MODEL,
|
||||
smallFast: CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
|
||||
});
|
||||
expect(CLAUDEX_DEFAULT_PRIMARY_MODEL).toBe('gpt-5.6-sol');
|
||||
expect(CLAUDEX_DEFAULT_SMALL_FAST_MODEL).toBe('gpt-5.6-luna');
|
||||
});
|
||||
|
||||
it('env-provided values WIN over defaults', () => {
|
||||
expect(
|
||||
resolveClaudexModels({ ANTHROPIC_MODEL: 'gpt-x', ANTHROPIC_SMALL_FAST_MODEL: 'gpt-y' }),
|
||||
).toEqual({ primary: 'gpt-x', smallFast: 'gpt-y' });
|
||||
});
|
||||
|
||||
it('ignores blank env values (falls back to defaults)', () => {
|
||||
expect(
|
||||
resolveClaudexModels({ ANTHROPIC_MODEL: ' ', ANTHROPIC_SMALL_FAST_MODEL: '' }),
|
||||
).toEqual({
|
||||
primary: CLAUDEX_DEFAULT_PRIMARY_MODEL,
|
||||
smallFast: CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── env injection (HARD SECURITY REQ 2 — zero token leakage) ─────────────────
|
||||
|
||||
describe('buildClaudexEnv — zero token leakage', () => {
|
||||
const models = { primary: 'gpt-5.6-sol', smallFast: 'gpt-5.6-luna' };
|
||||
const configDir = '/home/agent/.config/mosaic/claudex/home';
|
||||
|
||||
it('sets only ANTHROPIC_AUTH_TOKEN=unused and points at the loopback proxy', () => {
|
||||
const env = buildClaudexEnv({}, { configDir, models });
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
|
||||
expect(env.CLAUDE_CONFIG_DIR).toBe(configDir);
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol');
|
||||
expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('gpt-5.6-luna');
|
||||
});
|
||||
|
||||
it('OVERWRITES an inherited real auth token with the literal "unused"', () => {
|
||||
const env = buildClaudexEnv(
|
||||
{ ANTHROPIC_AUTH_TOKEN: 'sk-ant-realsecret-should-never-flow' },
|
||||
{ configDir, models },
|
||||
);
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
||||
});
|
||||
|
||||
it('DELETES ANTHROPIC_API_KEY so no real Anthropic key reaches the local proxy', () => {
|
||||
const env = buildClaudexEnv(
|
||||
{ ANTHROPIC_API_KEY: 'sk-ant-api03-realkey' },
|
||||
{ configDir, models },
|
||||
);
|
||||
expect('ANTHROPIC_API_KEY' in env).toBe(false);
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sweeps the WHOLE credential-bearing env family (token/api-key/secret/oauth), not just two', () => {
|
||||
const env = buildClaudexEnv(
|
||||
{
|
||||
ANTHROPIC_API_KEY: 'sk-ant-api03-leak',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-leak',
|
||||
SOME_SERVICE_TOKEN: 'tok-leak',
|
||||
VENDOR_API_KEY: 'key-leak',
|
||||
DB_SECRET: 'secret-leak',
|
||||
HARMLESS: 'kept',
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
{ configDir, models },
|
||||
);
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
||||
expect(env.SOME_SERVICE_TOKEN).toBeUndefined();
|
||||
expect(env.VENDOR_API_KEY).toBeUndefined();
|
||||
expect(env.DB_SECRET).toBeUndefined();
|
||||
// Non-credential vars the harness needs are preserved.
|
||||
expect(env.HARMLESS).toBe('kept');
|
||||
expect(env.PATH).toBe('/usr/bin');
|
||||
});
|
||||
|
||||
it('neutralizes Bedrock/Vertex provider switches so Claude cannot bypass the proxy (REQ 2)', () => {
|
||||
// CLAUDE_CODE_USE_BEDROCK / _USE_VERTEX are ROUTING switches: their mere
|
||||
// presence makes Claude Code route to AWS Bedrock / GCP Vertex against the
|
||||
// ambient cloud credential chain — reaching the real Anthropic API and
|
||||
// bypassing ANTHROPIC_BASE_URL (the loopback proxy) entirely. They MUST be
|
||||
// gone from the composed env regardless of the launching env.
|
||||
const env = buildClaudexEnv(
|
||||
{
|
||||
CLAUDE_CODE_USE_BEDROCK: '1',
|
||||
CLAUDE_CODE_USE_VERTEX: '1',
|
||||
CLAUDE_CODE_SKIP_BEDROCK_AUTH: '1',
|
||||
CLAUDE_CODE_SKIP_VERTEX_AUTH: '1',
|
||||
AWS_ACCESS_KEY_ID: 'AKIAREAL',
|
||||
AWS_SECRET_ACCESS_KEY: 'realsecret',
|
||||
AWS_SESSION_TOKEN: 'realsession',
|
||||
AWS_BEARER_TOKEN_BEDROCK: 'bearer-bedrock-real',
|
||||
AWS_REGION: 'us-east-1',
|
||||
GOOGLE_APPLICATION_CREDENTIALS: '/home/agent/gcp.json',
|
||||
GOOGLE_CLOUD_ACCESS_TOKEN: 'gcp-token-real',
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
{ configDir, models },
|
||||
);
|
||||
// Routing switches gone by construction.
|
||||
expect('CLAUDE_CODE_USE_BEDROCK' in env).toBe(false);
|
||||
expect('CLAUDE_CODE_USE_VERTEX' in env).toBe(false);
|
||||
expect('CLAUDE_CODE_SKIP_BEDROCK_AUTH' in env).toBe(false);
|
||||
expect('CLAUDE_CODE_SKIP_VERTEX_AUTH' in env).toBe(false);
|
||||
// Cloud credentials swept — none of the Claude-capable creds survive.
|
||||
expect(env.AWS_ACCESS_KEY_ID).toBeUndefined();
|
||||
expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined();
|
||||
expect(env.AWS_SESSION_TOKEN).toBeUndefined();
|
||||
expect(env.AWS_BEARER_TOKEN_BEDROCK).toBeUndefined();
|
||||
expect(env.AWS_REGION).toBeUndefined();
|
||||
expect(env.GOOGLE_APPLICATION_CREDENTIALS).toBeUndefined();
|
||||
expect(env.GOOGLE_CLOUD_ACCESS_TOKEN).toBeUndefined();
|
||||
// The proxy routing is still the only path.
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
||||
expect(env.PATH).toBe('/usr/bin');
|
||||
});
|
||||
|
||||
it('closes the mid-string _KEY / _SECRET gap (STRIPE_SECRET_KEY, SSH_PRIVATE_KEY)', () => {
|
||||
const env = buildClaudexEnv(
|
||||
{
|
||||
STRIPE_SECRET_KEY: 'sk-live-real',
|
||||
SSH_PRIVATE_KEY: '-----BEGIN OPENSSH PRIVATE KEY-----',
|
||||
HARMLESS: 'kept',
|
||||
},
|
||||
{ configDir, models },
|
||||
);
|
||||
expect(env.STRIPE_SECRET_KEY).toBeUndefined();
|
||||
expect(env.SSH_PRIVATE_KEY).toBeUndefined();
|
||||
expect(env.HARMLESS).toBe('kept');
|
||||
});
|
||||
|
||||
it('no credential-NAMED key in the composed env carries a real-looking value', () => {
|
||||
const env = buildClaudexEnv(
|
||||
{
|
||||
ANTHROPIC_API_KEY: 'sk-ant-api03-leak',
|
||||
ANTHROPIC_AUTH_TOKEN: 'access_token_leak',
|
||||
SOME_JWT_TOKEN: 'eyJhbGciOiJIUzI1NiJ9.payload.sig',
|
||||
REFRESH_SECRET: 'refresh_token_value',
|
||||
},
|
||||
{ configDir, models },
|
||||
);
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
if (CLAUDEX_CREDENTIAL_ENV_RE.test(name)) {
|
||||
// Any surviving credential-named var must carry only a safe sentinel value.
|
||||
expect(value).not.toMatch(/sk-(ant|proj)-/);
|
||||
expect(value).not.toMatch(/access_token|refresh_token/);
|
||||
expect(value).not.toMatch(/eyJ[A-Za-z0-9_-]+\./); // JWT
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('honors a caller-provided baseUrl override (loopback default otherwise)', () => {
|
||||
const env = buildClaudexEnv({}, { configDir, models, baseUrl: 'http://127.0.0.1:9999' });
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9999');
|
||||
});
|
||||
|
||||
it('returns a fresh object without mutating the base env', () => {
|
||||
const base = { EXISTING: 'kept' };
|
||||
const env = buildClaudexEnv(base, { configDir, models });
|
||||
expect(env.EXISTING).toBe('kept');
|
||||
expect(base).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
|
||||
|
||||
describe('buildClaudexBanner / buildClaudexContractNote', () => {
|
||||
const models = { primary: 'gpt-5.6-sol', smallFast: 'gpt-5.6-luna' };
|
||||
|
||||
it('banner marks EXPERIMENTAL and names the models + proxy', () => {
|
||||
const banner = buildClaudexBanner(models);
|
||||
expect(banner).toMatch(/EXPERIMENTAL/);
|
||||
expect(banner).toMatch(/gpt-5\.6-sol/);
|
||||
expect(banner).toMatch(/gpt-5\.6-luna/);
|
||||
expect(banner).toMatch(/claude-code-proxy/);
|
||||
expect(banner).not.toMatch(/unused/); // no token material in the banner
|
||||
});
|
||||
|
||||
it('contract note classifies the runtime as EXPERIMENTAL GPT-via-proxy', () => {
|
||||
const note = buildClaudexContractNote(models);
|
||||
expect(note).toMatch(/EXPERIMENTAL/);
|
||||
expect(note).toMatch(/gpt-5\.6-sol/);
|
||||
expect(note).toMatch(/not.*Anthropic/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── proxy gate ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('runClaudexProxyGate', () => {
|
||||
it('is ok when the first preflight already passes', async () => {
|
||||
const preflight = vi.fn().mockResolvedValue(makeReport());
|
||||
const ensureProxy = vi.fn();
|
||||
const reauth = vi.fn();
|
||||
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth });
|
||||
expect(gate.ok).toBe(true);
|
||||
expect(ensureProxy).not.toHaveBeenCalled();
|
||||
expect(reauth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails fast when the binary is missing (no reauth, no start)', async () => {
|
||||
const preflight = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
makeReport({ binaryPresent: false, ok: false, problems: ['binary not found'] }),
|
||||
);
|
||||
const ensureProxy = vi.fn();
|
||||
const reauth = vi.fn();
|
||||
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth });
|
||||
expect(gate.ok).toBe(false);
|
||||
expect(reauth).not.toHaveBeenCalled();
|
||||
expect(ensureProxy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs device reauth then re-preflights when OAuth needs it', async () => {
|
||||
const preflight = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
makeReport({
|
||||
auth: { state: 'expired' },
|
||||
needsReauth: true,
|
||||
ok: false,
|
||||
problems: ['expired'],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(makeReport());
|
||||
const reauth = vi.fn().mockReturnValue(0);
|
||||
const gate = await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
|
||||
expect(reauth).toHaveBeenCalledTimes(1);
|
||||
expect(preflight).toHaveBeenCalledTimes(2);
|
||||
expect(gate.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT reauth when auth is already valid', async () => {
|
||||
const preflight = vi.fn().mockResolvedValue(makeReport());
|
||||
const reauth = vi.fn();
|
||||
await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
|
||||
expect(reauth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts when device reauth fails', async () => {
|
||||
const preflight = vi.fn().mockResolvedValue(
|
||||
makeReport({
|
||||
auth: { state: 'unauthenticated' },
|
||||
needsReauth: true,
|
||||
ok: false,
|
||||
problems: ['unauth'],
|
||||
}),
|
||||
);
|
||||
const reauth = vi.fn().mockReturnValue(1);
|
||||
const gate = await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
|
||||
expect(gate.ok).toBe(false);
|
||||
expect(gate.problems.join(' ')).toMatch(/re-auth/i);
|
||||
});
|
||||
|
||||
it('starts the proxy then re-preflights when nothing is live', async () => {
|
||||
const preflight = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
makeReport({ live: false, listenerVerdict: 'unknown', ok: false, problems: ['dead'] }),
|
||||
)
|
||||
.mockResolvedValueOnce(makeReport());
|
||||
const ensureProxy = vi.fn().mockResolvedValue({ live: true, method: 'systemd' });
|
||||
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth: vi.fn() });
|
||||
expect(ensureProxy).toHaveBeenCalledTimes(1);
|
||||
expect(gate.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('aborts (non-sensitive) when the proxy cannot come up trusted', async () => {
|
||||
const preflight = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
makeReport({ live: false, listenerVerdict: 'unknown', ok: false, problems: ['dead'] }),
|
||||
);
|
||||
const ensureProxy = vi.fn().mockResolvedValue({ live: false, method: 'untrusted' });
|
||||
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth: vi.fn() });
|
||||
expect(gate.ok).toBe(false);
|
||||
expect(gate.problems.join(' ')).toMatch(/untrusted/);
|
||||
// Non-sensitive: no token material in surfaced problems.
|
||||
expect(gate.problems.join(' ')).not.toMatch(/access_token|refresh_token|sk-/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── launch orchestration (fail-closed ordering) ──────────────────────────────
|
||||
|
||||
describe('launchClaudex', () => {
|
||||
const baseDeps = {
|
||||
baseEnv: {},
|
||||
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
|
||||
resolveConfigDir: () => '/home/agent/.config/mosaic/claudex/home',
|
||||
log: () => {},
|
||||
errorLog: () => {},
|
||||
fail: (() => {
|
||||
throw new Error('exit');
|
||||
}) as (code: number) => never,
|
||||
};
|
||||
|
||||
it('yolo=true passes --dangerously-skip-permissions + injected env to claude', async () => {
|
||||
const exec = vi.fn();
|
||||
await launchClaudex(['--print', 'hi'], true, okAdapter({ exec }), baseDeps);
|
||||
expect(exec).toHaveBeenCalledTimes(1);
|
||||
const [cmd, args, env] = exec.mock.calls[0]!;
|
||||
expect(cmd).toBe('claude');
|
||||
expect(args[0]).toBe('--dangerously-skip-permissions');
|
||||
expect(args).toContain('--append-system-prompt');
|
||||
expect(args).toContain('--print');
|
||||
expect(args).toContain('hi');
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
|
||||
expect(env.CLAUDE_CONFIG_DIR).toBe('/home/agent/.config/mosaic/claudex/home');
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol');
|
||||
});
|
||||
|
||||
it('non-yolo omits --dangerously-skip-permissions but still injects the proxy env', async () => {
|
||||
const exec = vi.fn();
|
||||
await launchClaudex([], false, okAdapter({ exec }), baseDeps);
|
||||
const [, args, env] = exec.mock.calls[0]!;
|
||||
expect(args).not.toContain('--dangerously-skip-permissions');
|
||||
expect(args[0]).toBe('--append-system-prompt');
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
||||
});
|
||||
|
||||
it('appends the EXPERIMENTAL contract note to the composed prompt', async () => {
|
||||
const exec = vi.fn();
|
||||
await launchClaudex([], true, okAdapter({ exec, composePrompt: () => '# BASE' }), baseDeps);
|
||||
const args = exec.mock.calls[0]![1] as string[];
|
||||
const promptIdx = args.indexOf('--append-system-prompt') + 1;
|
||||
expect(args[promptIdx]).toContain('# BASE');
|
||||
expect(args[promptIdx]).toMatch(/EXPERIMENTAL/);
|
||||
});
|
||||
|
||||
it('runs the harness preflight BEFORE the proxy gate and exec', async () => {
|
||||
const order: string[] = [];
|
||||
const adapter = okAdapter({
|
||||
harnessPreflight: () => order.push('preflight'),
|
||||
exec: () => order.push('exec'),
|
||||
});
|
||||
await launchClaudex([], true, adapter, {
|
||||
...baseDeps,
|
||||
proxyGate: () => {
|
||||
order.push('gate');
|
||||
return Promise.resolve({ ok: true, report: makeReport(), problems: [] });
|
||||
},
|
||||
});
|
||||
expect(order).toEqual(['preflight', 'gate', 'exec']);
|
||||
});
|
||||
|
||||
it('FAIL CLOSED: exits WITHOUT exec when the proxy gate fails', async () => {
|
||||
const exec = vi.fn();
|
||||
const errors: string[] = [];
|
||||
await expect(
|
||||
launchClaudex([], true, okAdapter({ exec }), {
|
||||
...baseDeps,
|
||||
proxyGate: () =>
|
||||
Promise.resolve({ ok: false, report: makeReport({ ok: false }), problems: ['no proxy'] }),
|
||||
errorLog: (m: string) => errors.push(m),
|
||||
}),
|
||||
).rejects.toThrow('exit');
|
||||
expect(exec).not.toHaveBeenCalled();
|
||||
expect(errors.join('\n')).toMatch(/no proxy/);
|
||||
});
|
||||
|
||||
it('FAIL CLOSED: exits WITHOUT exec when the config-dir guard throws', async () => {
|
||||
const exec = vi.fn();
|
||||
await expect(
|
||||
launchClaudex([], true, okAdapter({ exec }), {
|
||||
...baseDeps,
|
||||
resolveConfigDir: () => {
|
||||
throw new Error('refusing to use ~/.claude');
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('exit');
|
||||
expect(exec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FAIL CLOSED: reports a non-Error throw via String() and still aborts', async () => {
|
||||
const exec = vi.fn();
|
||||
const errors: string[] = [];
|
||||
await expect(
|
||||
launchClaudex([], true, okAdapter({ exec }), {
|
||||
...baseDeps,
|
||||
resolveConfigDir: () => {
|
||||
// A non-Error throw exercises the String(err) branch of the catch.
|
||||
throw { toString: () => 'string-shaped failure' };
|
||||
},
|
||||
errorLog: (m: string) => errors.push(m),
|
||||
}),
|
||||
).rejects.toThrow('exit');
|
||||
expect(exec).not.toHaveBeenCalled();
|
||||
expect(errors.join('\n')).toMatch(/string-shaped failure/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── production DI defaults (fallback-branch coverage; no real proxy touched) ──
|
||||
|
||||
describe('production dependency defaults', () => {
|
||||
it('assertIsolatedConfigDir defaults realClaudeDir to ~/.claude', () => {
|
||||
const safe = join(tmpdir(), 'mosaic-claudex-default-real', 'home');
|
||||
// Only canonicalize injected; realClaudeDir falls back to ~/.claude.
|
||||
expect(assertIsolatedConfigDir(safe, { canonicalize: idCanon })).toBe(safe);
|
||||
});
|
||||
|
||||
it('assertIsolatedConfigDir default canonicalizer resolves a non-existent path', () => {
|
||||
// No canonicalize dep → exercises the real realpath-longest-ancestor walk
|
||||
// (including the not-yet-existing tail), on a path safely outside ~/.claude.
|
||||
const safe = join(tmpdir(), 'mosaic-claudex-canon', 'nested', 'home');
|
||||
expect(assertIsolatedConfigDir(safe)).toContain('mosaic-claudex-canon');
|
||||
});
|
||||
|
||||
it('resolveClaudexConfigDir defaults mosaicHome when not injected', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
|
||||
try {
|
||||
const target = join(dir, 'home');
|
||||
// Override env points elsewhere; mosaicHome dep omitted → MOSAIC_HOME default path is exercised.
|
||||
const out = resolveClaudexConfigDir(
|
||||
{ [CLAUDEX_CONFIG_DIR_ENV]: target },
|
||||
{ canonicalize: idCanon },
|
||||
);
|
||||
expect(out).toBe(target);
|
||||
expect(lstatSync(target).isDirectory()).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('runClaudexProxyGate defaults ensureProxy/reauth/log without invoking them on a missing binary', async () => {
|
||||
// Only preflight injected; binary missing → returns before the default
|
||||
// ensureProxy/reauth thunks could ever reach the real proxy.
|
||||
const preflight = vi
|
||||
.fn()
|
||||
.mockResolvedValue(makeReport({ binaryPresent: false, ok: false, problems: ['missing'] }));
|
||||
const gate = await runClaudexProxyGate({ preflight });
|
||||
expect(gate.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('launchClaudex defaults log/errorLog/fail/baseEnv/models/buildEnv on the success path', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
const exec = vi.fn();
|
||||
// Inject only the boundaries that would touch the real proxy/FS; let the
|
||||
// rest default. Success path never calls fail/errorLog.
|
||||
await launchClaudex([], true, okAdapter({ exec }), {
|
||||
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
|
||||
resolveConfigDir: () => join(tmpdir(), 'claudex-default-launch'),
|
||||
});
|
||||
expect(exec).toHaveBeenCalledTimes(1);
|
||||
const [, , env] = exec.mock.calls[0]!;
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
||||
expect(env.ANTHROPIC_MODEL).toBe(CLAUDEX_DEFAULT_PRIMARY_MODEL);
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
464
packages/mosaic/src/commands/claudex.ts
Normal file
464
packages/mosaic/src/commands/claudex.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* Claudex launch composition (P2–P4 of `mosaic yolo claudex`).
|
||||
*
|
||||
* Builds the isolated launch environment for running GPT models inside the
|
||||
* Claude Code harness via `raine/claude-code-proxy` (ChatGPT-subscription OAuth).
|
||||
* PR-1 (`claudex-proxy.ts`) owns the proxy preflight/lifecycle; this module owns
|
||||
* the *composition* the launcher hands to Claude Code:
|
||||
*
|
||||
* P2 isolated CLAUDE_CONFIG_DIR (provably never the real ~/.claude) + env
|
||||
* injection that leaks ZERO token material;
|
||||
* P3 the model-tier map (primary → gpt-5.6-sol, small/fast → gpt-5.6-luna,
|
||||
* operator env values win);
|
||||
* P4 the EXPERIMENTAL classification banner + composed-contract note.
|
||||
*
|
||||
* Two hard security invariants (secrev-enforced):
|
||||
* REQ 1 — Provable isolation. {@link assertIsolatedConfigDir} makes the
|
||||
* CLAUDE_CONFIG_DIR seam incapable of resolving to `~/.claude` (or any
|
||||
* descendant of it); it canonicalizes both sides, rejects descendants,
|
||||
* and — after ensuring the dir — re-checks and rejects a symlinked
|
||||
* target (TOCTOU). Fails CLOSED on any uncertainty.
|
||||
* REQ 2 — Zero token leakage. This module never reads the proxy's
|
||||
* `auth.json`; {@link buildClaudexEnv} strips the ENTIRE
|
||||
* credential-bearing env family and hands Claude Code only
|
||||
* `ANTHROPIC_AUTH_TOKEN=unused`. The proxy holds the real credential.
|
||||
*
|
||||
* Every side-effecting boundary is dependency-injected so the launch path is
|
||||
* unit-testable without spawning Claude Code or touching a real config dir.
|
||||
*/
|
||||
|
||||
import { lstatSync, mkdirSync, realpathSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import {
|
||||
CLAUDEX_PROXY_URL,
|
||||
ensureProxyRunning,
|
||||
runDeviceReauth,
|
||||
runProxyPreflight,
|
||||
type EnsureProxyResult,
|
||||
type PreflightReport,
|
||||
} from './claudex-proxy.js';
|
||||
|
||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
|
||||
// ─── Isolated CLAUDE_CONFIG_DIR (HARD SECURITY REQ 1) ─────────────────────────
|
||||
|
||||
/** Dedicated override env for the isolated config dir. The ambient
|
||||
* `CLAUDE_CONFIG_DIR` is deliberately NOT honored — it may already point at the
|
||||
* real `~/.claude` of the launching session. */
|
||||
export const CLAUDEX_CONFIG_DIR_ENV = 'MOSAIC_CLAUDEX_CONFIG_DIR';
|
||||
|
||||
/** The default isolated config dir — structurally under the mosaic home, so it
|
||||
* can never equal `~/.claude`. */
|
||||
export function defaultClaudexConfigDir(mosaicHome: string = MOSAIC_HOME): string {
|
||||
return join(mosaicHome, 'claudex', 'home');
|
||||
}
|
||||
|
||||
export interface ConfigDirDeps {
|
||||
/** The real Claude state dir to protect (default `~/.claude`). */
|
||||
realClaudeDir?: string;
|
||||
/** Resolve a path to canonical form, resolving symlinks on the longest
|
||||
* existing ancestor (so a not-yet-created dir still canonicalizes). */
|
||||
canonicalize?: (p: string) => string;
|
||||
/** Ensure the isolated dir exists (mkdir -p, owner-only 0700). */
|
||||
mkdir?: (p: string) => void;
|
||||
/** Whether a path is itself a symlink (lstat). */
|
||||
isSymlink?: (p: string) => boolean;
|
||||
}
|
||||
|
||||
/** Resolve a path to canonical form, resolving symlinks on the LONGEST EXISTING
|
||||
* ancestor and re-appending the not-yet-existing tail. A symlinked ancestor that
|
||||
* points into `~/.claude` is therefore caught even before the leaf exists. */
|
||||
function defaultCanonicalizeIntended(p: string): string {
|
||||
const abs = resolve(p);
|
||||
let existing = abs;
|
||||
const tail: string[] = [];
|
||||
// Walk up until we hit an existing ancestor (or the filesystem root).
|
||||
for (;;) {
|
||||
try {
|
||||
const real = realpathSync(existing);
|
||||
return tail.length > 0 ? join(real, ...tail) : real;
|
||||
} catch (err) {
|
||||
// ONLY a genuine "does not exist yet" (ENOENT) justifies walking up to an
|
||||
// existing ancestor. Any other errno (ELOOP, EACCES, ENOTDIR, …) means we
|
||||
// cannot establish the canonical form — fail CLOSED rather than fall back
|
||||
// to a possibly-wrong literal path.
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
const parent = dirname(existing);
|
||||
if (parent === existing) return abs; // reached root without an existing prefix
|
||||
tail.unshift(existing.slice(parent.length + 1));
|
||||
existing = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defaultMkdir(p: string): void {
|
||||
mkdirSync(p, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
function defaultIsSymlink(p: string): boolean {
|
||||
try {
|
||||
return lstatSync(p).isSymbolicLink();
|
||||
} catch (err) {
|
||||
// A missing path is genuinely "not a symlink"; anything else (EACCES, ELOOP,
|
||||
// ENOTDIR, …) is uncertainty the guard must not swallow — fail CLOSED.
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when `child` is `parent` itself or a descendant of it (path-wise). */
|
||||
function isWithin(child: string, parent: string): boolean {
|
||||
if (child === parent) return true;
|
||||
const rel = relative(parent, child);
|
||||
return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard: prove that `candidate` is a legitimate ISOLATED config dir and can
|
||||
* never be, resolve to, or live under the real `~/.claude`. Canonicalizes BOTH
|
||||
* sides (either may be a symlink), rejects `~/.claude` and any descendant, and
|
||||
* fails CLOSED (throws) on an empty/relative candidate. Returns the canonical
|
||||
* isolated path on success.
|
||||
*/
|
||||
export function assertIsolatedConfigDir(candidate: string, deps: ConfigDirDeps = {}): string {
|
||||
const canonicalize = deps.canonicalize ?? defaultCanonicalizeIntended;
|
||||
const realClaudeDir = deps.realClaudeDir ?? join(homedir(), '.claude');
|
||||
|
||||
if (typeof candidate !== 'string' || candidate.trim() === '') {
|
||||
throw new Error('claudex: isolated CLAUDE_CONFIG_DIR must be a non-empty path (fail closed).');
|
||||
}
|
||||
if (!isAbsolute(candidate)) {
|
||||
throw new Error(
|
||||
`claudex: isolated CLAUDE_CONFIG_DIR must be an absolute path: ${JSON.stringify(candidate)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const canonCandidate = canonicalize(candidate);
|
||||
const canonReal = canonicalize(realClaudeDir);
|
||||
|
||||
// Compare canonical forms AND raw resolved forms — belt and suspenders so a
|
||||
// canonicalizer that no-ops on a nonexistent real dir still catches the literal.
|
||||
if (isWithin(canonCandidate, canonReal) || isWithin(resolve(candidate), resolve(realClaudeDir))) {
|
||||
throw new Error(
|
||||
`claudex: refusing to use the real Claude config dir (or a descendant of it) as the ` +
|
||||
`isolated CLAUDE_CONFIG_DIR. Resolved to ${JSON.stringify(canonCandidate)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return canonCandidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the isolated CLAUDE_CONFIG_DIR: pick the dedicated override or the
|
||||
* namespaced default, run the pre-create guard, ensure the dir (0700), then
|
||||
* RE-CHECK after creation — reject a symlinked target and re-run the guard on
|
||||
* the now-existing (fully canonicalizable) path. This closes the pre-created
|
||||
* symlink race (TOCTOU). Every failure throws (fail closed).
|
||||
*/
|
||||
export function resolveClaudexConfigDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
deps: ConfigDirDeps & { mosaicHome?: string } = {},
|
||||
): string {
|
||||
const mosaicHome = deps.mosaicHome ?? MOSAIC_HOME;
|
||||
const mkdir = deps.mkdir ?? defaultMkdir;
|
||||
const isSymlink = deps.isSymlink ?? defaultIsSymlink;
|
||||
|
||||
const override = env[CLAUDEX_CONFIG_DIR_ENV]?.trim();
|
||||
const candidate =
|
||||
override && override.length > 0 ? override : defaultClaudexConfigDir(mosaicHome);
|
||||
|
||||
// Pre-create guard (before touching the filesystem).
|
||||
assertIsolatedConfigDir(candidate, deps);
|
||||
|
||||
// Ensure the dir, then re-verify against the post-create reality.
|
||||
mkdir(candidate);
|
||||
if (isSymlink(candidate)) {
|
||||
throw new Error(
|
||||
'claudex: refusing to use the isolated CLAUDE_CONFIG_DIR — the target is a symlink ' +
|
||||
'(possible pre-created race). Fail closed.',
|
||||
);
|
||||
}
|
||||
// Re-run the guard now that the leaf exists so canonicalization reflects any
|
||||
// symlinked ancestor introduced between the pre-check and mkdir.
|
||||
return assertIsolatedConfigDir(candidate, deps);
|
||||
}
|
||||
|
||||
// ─── Model-tier map (P3) ──────────────────────────────────────────────────────
|
||||
|
||||
export const CLAUDEX_DEFAULT_PRIMARY_MODEL = 'gpt-5.6-sol';
|
||||
export const CLAUDEX_DEFAULT_SMALL_FAST_MODEL = 'gpt-5.6-luna';
|
||||
|
||||
export interface ClaudexModels {
|
||||
/** Primary tier (opus/sonnet) → ANTHROPIC_MODEL. */
|
||||
primary: string;
|
||||
/** Small/fast tier (haiku) → ANTHROPIC_SMALL_FAST_MODEL. */
|
||||
smallFast: string;
|
||||
}
|
||||
|
||||
/** Resolve the model-tier map. Operator-provided env values WIN over defaults;
|
||||
* blank values fall back to the defaults. */
|
||||
export function resolveClaudexModels(env: NodeJS.ProcessEnv = process.env): ClaudexModels {
|
||||
const primary = env['ANTHROPIC_MODEL']?.trim() || CLAUDEX_DEFAULT_PRIMARY_MODEL;
|
||||
const smallFast = env['ANTHROPIC_SMALL_FAST_MODEL']?.trim() || CLAUDEX_DEFAULT_SMALL_FAST_MODEL;
|
||||
return { primary, smallFast };
|
||||
}
|
||||
|
||||
// ─── Env injection (HARD SECURITY REQ 2 — zero token leakage) ─────────────────
|
||||
|
||||
/**
|
||||
* Names of env vars considered credential-bearing. The whole family is stripped
|
||||
* from the composed env so no real Anthropic key, OAuth token, or third-party /
|
||||
* cloud credential can reach the local proxy or be used by Claude Code to bypass
|
||||
* it. We then re-add ONLY the safe claudex vars (`ANTHROPIC_MODEL`,
|
||||
* `_SMALL_FAST_MODEL`, `_BASE_URL`, `_AUTH_TOKEN=unused`). A name-pattern sweep
|
||||
* can't miss a specific var a short denylist forgot, while still preserving the
|
||||
* arbitrary non-credential env the harness/MCP/hooks require (PATH, HOME, XDG,
|
||||
* terminal, proxies, …), which a strict allowlist would fragilely drop.
|
||||
*
|
||||
* The cloud-provider families (`AWS_*`, `GOOGLE_APPLICATION_CREDENTIALS`,
|
||||
* `GOOGLE_CLOUD_*`, `GCP_*`) are included because Claude Code can route to the
|
||||
* real Anthropic API via AWS Bedrock / GCP Vertex using the ambient cloud
|
||||
* credential chain — a Claude-capable credential that must never survive into a
|
||||
* claudex launch. `_KEY$` / `_SECRET` (not just the `_API_KEY$` tail) close the
|
||||
* mid-string gap (`STRIPE_SECRET_KEY`, `SSH_PRIVATE_KEY`, `AWS_SECRET_ACCESS_KEY`).
|
||||
*/
|
||||
export const CLAUDEX_CREDENTIAL_ENV_RE =
|
||||
/^ANTHROPIC_|^CLAUDE_CODE_OAUTH|^AWS_|^GOOGLE_APPLICATION_CREDENTIALS$|^GOOGLE_CLOUD_|^GCP_|_API_?KEY$|_KEY$|_TOKEN$|_SECRET/i;
|
||||
|
||||
/**
|
||||
* Provider ROUTING switches whose mere PRESENCE (independent of any credential)
|
||||
* makes Claude Code bypass `ANTHROPIC_BASE_URL` (the loopback proxy) and talk to
|
||||
* the real Anthropic API via Bedrock/Vertex. A name-pattern is the wrong model
|
||||
* for a boolean switch, so these are force-deleted by exact name — REGARDLESS of
|
||||
* value — after the credential sweep. (REQ 2: isolation must hold for any
|
||||
* launching env, including a Bedrock/Vertex-configured enterprise host.)
|
||||
*/
|
||||
export const CLAUDEX_FORCED_UNSET_ENV = [
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
|
||||
'CLAUDE_CODE_SKIP_VERTEX_AUTH',
|
||||
] as const;
|
||||
|
||||
export interface BuildClaudexEnvOptions {
|
||||
configDir: string;
|
||||
models: ClaudexModels;
|
||||
/** Override the proxy base URL (defaults to the PR-1 loopback constant). */
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the launch env for Claude Code. Returns a FRESH object (never mutates
|
||||
* the base env). Strips the entire credential-bearing family AND force-deletes
|
||||
* the Bedrock/Vertex routing switches (REQ 2), then sets the isolated config dir
|
||||
* and the proxy routing. Claude Code sees only `ANTHROPIC_AUTH_TOKEN=unused`
|
||||
* pointed at the loopback proxy; the proxy holds the real OAuth credential, which
|
||||
* this module never reads.
|
||||
*/
|
||||
export function buildClaudexEnv(
|
||||
baseEnv: NodeJS.ProcessEnv,
|
||||
opts: BuildClaudexEnvOptions,
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const [name, value] of Object.entries(baseEnv)) {
|
||||
if (CLAUDEX_CREDENTIAL_ENV_RE.test(name)) continue; // drop the whole credential family
|
||||
env[name] = value;
|
||||
}
|
||||
|
||||
// Force-delete routing switches by exact name — their presence (not their
|
||||
// value) is what would route Claude Code off the proxy to the real API.
|
||||
for (const name of CLAUDEX_FORCED_UNSET_ENV) delete env[name];
|
||||
|
||||
env['CLAUDE_CONFIG_DIR'] = opts.configDir;
|
||||
env['ANTHROPIC_BASE_URL'] = opts.baseUrl ?? CLAUDEX_PROXY_URL;
|
||||
env['ANTHROPIC_AUTH_TOKEN'] = 'unused';
|
||||
env['ANTHROPIC_MODEL'] = opts.models.primary;
|
||||
env['ANTHROPIC_SMALL_FAST_MODEL'] = opts.models.smallFast;
|
||||
return env;
|
||||
}
|
||||
|
||||
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
|
||||
|
||||
/** Console banner shown at launch. Contains no token material by construction. */
|
||||
export function buildClaudexBanner(models: ClaudexModels): string {
|
||||
return [
|
||||
'',
|
||||
' ┌─────────────────────────────────────────────────────────────────────┐',
|
||||
' │ ⚠ EXPERIMENTAL — mosaic claudex │',
|
||||
' └─────────────────────────────────────────────────────────────────────┘',
|
||||
` Running GPT models inside the Claude Code harness via claude-code-proxy`,
|
||||
` (ChatGPT-subscription OAuth). This is NOT Anthropic Claude.`,
|
||||
` primary : ${models.primary}`,
|
||||
` small/fast : ${models.smallFast}`,
|
||||
` Model behavior, tool use, and output quality may differ from Claude.`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Markdown note appended to the composed runtime contract so the model itself
|
||||
* knows it is running the EXPERIMENTAL GPT-via-proxy configuration. */
|
||||
export function buildClaudexContractNote(models: ClaudexModels): string {
|
||||
return [
|
||||
'# EXPERIMENTAL Runtime — claudex (GPT via claude-code-proxy)',
|
||||
'',
|
||||
'You are running in Mosaic **claudex** mode: the Claude Code harness is wired to',
|
||||
'GPT models through a local `claude-code-proxy` (ChatGPT-subscription OAuth). This',
|
||||
"runtime is NOT Anthropic's Claude API and is not Claude.",
|
||||
'',
|
||||
`- Primary model: \`${models.primary}\``,
|
||||
`- Small/fast model: \`${models.smallFast}\``,
|
||||
'',
|
||||
'Some Claude-specific harness assumptions may not hold under GPT models — verify',
|
||||
'tool output carefully. This classification is EXPERIMENTAL and is not intended for',
|
||||
'production delivery without explicit operator sign-off.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Proxy gate ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProxyGateResult {
|
||||
ok: boolean;
|
||||
report: PreflightReport;
|
||||
/** Non-sensitive problems suitable for surfacing to the operator. */
|
||||
problems: string[];
|
||||
}
|
||||
|
||||
export interface ProxyGateDeps {
|
||||
preflight?: () => Promise<PreflightReport>;
|
||||
ensureProxy?: () => Promise<EnsureProxyResult>;
|
||||
reauth?: () => number;
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the proxy readiness gate: preflight → (device reauth if OAuth needs it) →
|
||||
* (start the proxy if nothing trusted is live) → re-preflight. Returns `ok` only
|
||||
* when the final preflight passes (binary present, OAuth valid, a TRUSTED-live
|
||||
* listener — identity verified by PR-1's `verifyListenerIdentity`). All surfaced
|
||||
* problems are non-sensitive (port + verdict only; never a token).
|
||||
*/
|
||||
export async function runClaudexProxyGate(deps: ProxyGateDeps = {}): Promise<ProxyGateResult> {
|
||||
const preflight = deps.preflight ?? (() => runProxyPreflight());
|
||||
const ensureProxy = deps.ensureProxy ?? (() => ensureProxyRunning());
|
||||
const reauth = deps.reauth ?? (() => runDeviceReauth());
|
||||
const log = deps.log ?? (() => {});
|
||||
|
||||
let report = await preflight();
|
||||
|
||||
// A missing binary is unrecoverable here — don't attempt reauth or a start.
|
||||
if (!report.binaryPresent) {
|
||||
return { ok: false, report, problems: report.problems };
|
||||
}
|
||||
|
||||
if (report.needsReauth) {
|
||||
log('claudex: claude-code-proxy OAuth needs re-authentication — starting device flow…');
|
||||
const code = reauth();
|
||||
if (code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
report,
|
||||
problems: [...report.problems, 'claudex: device re-authentication did not complete.'],
|
||||
};
|
||||
}
|
||||
report = await preflight();
|
||||
}
|
||||
|
||||
if (!report.live) {
|
||||
log('claudex: no trusted claude-code-proxy responding — starting it…');
|
||||
const started = await ensureProxy();
|
||||
if (!started.live) {
|
||||
return {
|
||||
ok: false,
|
||||
report,
|
||||
problems: [
|
||||
...report.problems,
|
||||
`claudex: could not bring up a trusted claude-code-proxy (${started.method}).`,
|
||||
],
|
||||
};
|
||||
}
|
||||
report = await preflight();
|
||||
}
|
||||
|
||||
return { ok: report.ok, report, problems: report.problems };
|
||||
}
|
||||
|
||||
// ─── Launch orchestration ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The launch.ts-provided seam. Keeps `claudex.ts` free of a circular import back
|
||||
* into `launch.ts` while letting the orchestration reuse the harness preflight,
|
||||
* the composed runtime contract, and the process-replacing exec.
|
||||
*/
|
||||
export interface ClaudexHarnessAdapter {
|
||||
/** Runs the Claude-harness preflight (mosaic home, SOUL, `claude` on PATH,
|
||||
* sequential-thinking). May terminate the process on a hard failure. */
|
||||
harnessPreflight: () => void;
|
||||
/** Compose the full Claude runtime contract (== `composeContract('claude')`). */
|
||||
composePrompt: () => string;
|
||||
/** Replace the current process with `claude` using the composed env. */
|
||||
exec: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
|
||||
}
|
||||
|
||||
export interface LaunchClaudexDeps {
|
||||
baseEnv?: NodeJS.ProcessEnv;
|
||||
proxyGate?: () => Promise<ProxyGateResult>;
|
||||
resolveConfigDir?: () => string;
|
||||
models?: () => ClaudexModels;
|
||||
buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv;
|
||||
log?: (message: string) => void;
|
||||
errorLog?: (message: string) => void;
|
||||
fail?: (code: number) => never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrate a `mosaic [yolo] claudex` launch. Runs the harness preflight, the
|
||||
* proxy gate, composes the isolated env (REQ 1 + REQ 2), appends the EXPERIMENTAL
|
||||
* note, and exec's Claude Code. FAIL CLOSED: on any gate failure or guard throw
|
||||
* it reports non-sensitive detail and exits WITHOUT reaching exec.
|
||||
*/
|
||||
export async function launchClaudex(
|
||||
args: string[],
|
||||
yolo: boolean,
|
||||
adapter: ClaudexHarnessAdapter,
|
||||
deps: LaunchClaudexDeps = {},
|
||||
): Promise<void> {
|
||||
const log = deps.log ?? ((m: string) => console.log(m));
|
||||
const errorLog = deps.errorLog ?? ((m: string) => console.error(m));
|
||||
const fail = deps.fail ?? ((code: number) => process.exit(code));
|
||||
const baseEnv = deps.baseEnv ?? process.env;
|
||||
const proxyGate = deps.proxyGate ?? (() => runClaudexProxyGate({ log }));
|
||||
const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv));
|
||||
const models = deps.models ?? (() => resolveClaudexModels(baseEnv));
|
||||
const buildEnv = deps.buildEnv ?? buildClaudexEnv;
|
||||
|
||||
try {
|
||||
// Harness readiness first (claude on PATH, mosaic home, sequential-thinking).
|
||||
adapter.harnessPreflight();
|
||||
|
||||
// Proxy readiness (binary, OAuth, trusted-live listener).
|
||||
const gate = await proxyGate();
|
||||
if (!gate.ok) {
|
||||
errorLog('[mosaic] claudex preflight failed:');
|
||||
for (const problem of gate.problems) errorLog(` - ${problem}`);
|
||||
return fail(1);
|
||||
}
|
||||
|
||||
// Compose the isolated launch env (guard throws → caught below, fail closed).
|
||||
const resolvedModels = models();
|
||||
const configDir = resolveConfigDir();
|
||||
const env = buildEnv(baseEnv, { configDir, models: resolvedModels });
|
||||
const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`;
|
||||
|
||||
log(buildClaudexBanner(resolvedModels));
|
||||
|
||||
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
||||
cliArgs.push('--append-system-prompt', prompt, ...args);
|
||||
adapter.exec('claude', cliArgs, env);
|
||||
} catch (err) {
|
||||
errorLog(
|
||||
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return fail(1);
|
||||
}
|
||||
}
|
||||
885
packages/mosaic/src/commands/fleet-regen-command.spec.ts
Normal file
885
packages/mosaic/src/commands/fleet-regen-command.spec.ts
Normal file
@@ -0,0 +1,885 @@
|
||||
import { chmod, mkdir, mkdtemp, open, readFile, rm, stat, 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 FleetCommandDeps } from './fleet.js';
|
||||
import { executeFleetRegen, formatFleetRegenReport } from './fleet-regen-command.js';
|
||||
import {
|
||||
acquirePrivateRosterMutationLock,
|
||||
projectRosterV2AgentGeneratedEnv,
|
||||
} from '../fleet/fleet-reconciler.js';
|
||||
import { applyPreparedGeneratedAgentEnvironmentProjection } from '../fleet/generated-env-boundary.js';
|
||||
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
||||
|
||||
// A two-agent roster-v2 SSOT. `mosaic fleet regen` must rebuild each agent's
|
||||
// `fleet/agents/<name>.env.generated` projection from exactly this source and
|
||||
// nothing else — deterministically, without ever touching agent lifecycle.
|
||||
const rosterYaml = `
|
||||
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
|
||||
- name: coder1
|
||||
alias: Coder 1
|
||||
class: code
|
||||
runtime: pi
|
||||
provider: openai
|
||||
model: gpt-5.6-sol
|
||||
reasoning: medium
|
||||
tool_policy: code
|
||||
working_directory: /srv/other
|
||||
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(withRoster = rosterYaml): Promise<string> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-regen-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'), withRoster, { mode: 0o600 });
|
||||
// Semantic roster validation (matching `fleet reconcile`) resolves each agent
|
||||
// class to a persona; seed the classes the fixtures reference.
|
||||
for (const klass of ['code', 'merge-gate']) {
|
||||
await writeFile(
|
||||
join(cleanup, 'fleet', 'roles', `${klass}.md`),
|
||||
`\`class: ${klass}\`\n\n# ${klass} persona\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
/**
|
||||
* A runner spy that records EVERY invocation. `regen` is projection-only and
|
||||
* must never issue a lifecycle/restart call, so a non-empty call log is a
|
||||
* hard failure — this is the load-bearing "never restarts" gate.
|
||||
*/
|
||||
function recordingRunner(
|
||||
calls: string[][],
|
||||
): (command: string, args: string[]) => Promise<CommandResult> {
|
||||
return async (command: string, args: string[]): Promise<CommandResult> => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
}
|
||||
|
||||
function program(mosaicHome: string, runner: FleetCommandDeps['runner']): Command {
|
||||
const result = new Command();
|
||||
result.exitOverride();
|
||||
registerFleetCommand(result, { mosaicHome, runner });
|
||||
return result;
|
||||
}
|
||||
|
||||
function capture(): string[] {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||
lines.push(value);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('projectRosterV2AgentGeneratedEnv', (): void => {
|
||||
it('maps a roster-v2 agent to exactly the eight generated projection keys', (): void => {
|
||||
const roster = parseRosterV2(rosterYaml, 'yaml');
|
||||
const agent = roster.agents.find((candidate) => candidate.name === 'coder0');
|
||||
expect(agent).toBeDefined();
|
||||
const values = projectRosterV2AgentGeneratedEnv(roster, agent!);
|
||||
expect(values).toEqual({
|
||||
MOSAIC_AGENT_NAME: 'coder0',
|
||||
MOSAIC_AGENT_CLASS: 'code',
|
||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||
MOSAIC_AGENT_MODEL: 'gpt-5.6-sol',
|
||||
MOSAIC_AGENT_REASONING: 'high',
|
||||
MOSAIC_AGENT_TOOL_POLICY: 'code',
|
||||
MOSAIC_AGENT_WORKDIR: '/srv/mosaic',
|
||||
MOSAIC_TMUX_SOCKET: 'mosaic-fleet',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mosaic fleet regen', (): void => {
|
||||
it('is dry-run by default: reports the plan and writes nothing', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const lines = capture();
|
||||
|
||||
await program(home, recordingRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
const result = JSON.parse(lines.pop() ?? '{}');
|
||||
expect(result).toMatchObject({
|
||||
mode: 'dry-run',
|
||||
generation: 7,
|
||||
agentCount: 2,
|
||||
written: 0,
|
||||
});
|
||||
expect(result.agents.map((agent: { name: string }) => agent.name)).toEqual([
|
||||
'coder0',
|
||||
'coder1',
|
||||
]);
|
||||
expect(
|
||||
result.agents.every((agent: { disposition: string }) => agent.disposition === 'create'),
|
||||
).toBe(true);
|
||||
// Nothing on disk.
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(false);
|
||||
// No lifecycle/restart call — ever.
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('forwards configured persona roots (rolesDir/overrideDir) from reconcileDeps into regen', async (): Promise<void> => {
|
||||
// Codex r6: regen must resolve personas the SAME way reconcile does. If the
|
||||
// top-level registration drops reconcileDeps.rolesDir/overrideDir, a deployment
|
||||
// with custom persona roots gets reconcile ACCEPTING a roster while regen
|
||||
// REJECTS it (validating against the wrong default `<home>/fleet/roles`),
|
||||
// blocking the recovery command. Pin the wiring: seed personas ONLY under the
|
||||
// custom roots, leave the default roles dir empty, require regen to succeed.
|
||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-regen-wiring-'));
|
||||
cleanup = home;
|
||||
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
||||
await mkdir(join(home, directory), { recursive: true, mode: 0o700 });
|
||||
await chmod(join(home, directory), 0o700);
|
||||
}
|
||||
await chmod(home, 0o700);
|
||||
await writeFile(join(home, 'fleet', 'roster.yaml'), rosterYaml, { mode: 0o600 });
|
||||
// Personas live ONLY under the configured roots — the default `fleet/roles`
|
||||
// stays empty, so broken wiring fails persona resolution.
|
||||
const customRoles = join(home, 'custom-roles');
|
||||
const customOverride = join(home, 'custom-roles.local');
|
||||
await mkdir(customRoles, { recursive: true, mode: 0o700 });
|
||||
await mkdir(customOverride, { recursive: true, mode: 0o700 });
|
||||
for (const klass of ['code', 'merge-gate']) {
|
||||
await writeFile(join(customRoles, `${klass}.md`), `\`class: ${klass}\`\n\n# ${klass}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
const command = new Command();
|
||||
command.exitOverride();
|
||||
registerFleetCommand(command, {
|
||||
mosaicHome: home,
|
||||
runner: recordingRunner([]),
|
||||
reconcileDeps: { rolesDir: customRoles, overrideDir: customOverride },
|
||||
});
|
||||
const lines = capture();
|
||||
|
||||
await command.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']);
|
||||
|
||||
// Regen validated against the CONFIGURED roots → success. Broken wiring
|
||||
// resolves against the empty default and fails (exitCode 1, no JSON result).
|
||||
expect(process.exitCode).not.toBe(1);
|
||||
const result = JSON.parse(lines.pop() ?? '{}');
|
||||
expect(result).toMatchObject({ mode: 'dry-run', agentCount: 2 });
|
||||
});
|
||||
|
||||
it('--write rebuilds each generated projection from the roster SSOT', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const lines = capture();
|
||||
|
||||
await program(home, recordingRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
const result = JSON.parse(lines.pop() ?? '{}');
|
||||
expect(result).toMatchObject({ mode: 'write', written: 2, agentCount: 2 });
|
||||
|
||||
const coder0 = await readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8');
|
||||
expect(coder0).toContain('MOSAIC_AGENT_NAME=coder0');
|
||||
expect(coder0).toContain('MOSAIC_AGENT_WORKDIR=/srv/mosaic');
|
||||
expect(coder0).toContain('MOSAIC_TMUX_SOCKET=mosaic-fleet');
|
||||
const coder1 = await readFile(join(home, 'fleet', 'agents', 'coder1.env.generated'), 'utf8');
|
||||
expect(coder1).toContain('MOSAIC_AGENT_NAME=coder1');
|
||||
expect(coder1).toContain('MOSAIC_AGENT_WORKDIR=/srv/other');
|
||||
// No lifecycle/restart call — ever.
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('is deterministic and idempotent across repeated --write runs', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const path = join(home, 'fleet', 'agents', 'coder0.env.generated');
|
||||
|
||||
const cli = program(home, recordingRunner(calls));
|
||||
capture();
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
|
||||
const first = await readFile(path, 'utf8');
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
|
||||
const second = await readFile(path, 'utf8');
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports rebuild disposition once the generated projection already exists', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const cli = program(home, recordingRunner(calls));
|
||||
const lines = capture();
|
||||
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
|
||||
lines.length = 0;
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']);
|
||||
|
||||
const result = JSON.parse(lines.pop() ?? '{}');
|
||||
expect(
|
||||
result.agents.every((agent: { disposition: string }) => agent.disposition === 'rebuild'),
|
||||
).toBe(true);
|
||||
expect(result.written).toBe(0);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('never issues a lifecycle/restart call in either mode', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const cli = program(home, recordingRunner(calls));
|
||||
capture();
|
||||
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']);
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
|
||||
|
||||
// Structural guarantee: regen has no path to systemctl/tmux at all.
|
||||
expect(calls).toEqual([]);
|
||||
const systemctlCalls = calls.filter(([command]) => command === 'systemctl');
|
||||
expect(systemctlCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('human-readable --write output prints the do-not-restart recovery runbook', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
|
||||
await program(home, recordingRunner([])).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
]);
|
||||
|
||||
const output = lines.join('\n');
|
||||
expect(output).toMatch(/do not restart|before.*restart/i);
|
||||
expect(output).toMatch(/env\.generated/);
|
||||
// The unit has no EnvironmentFile= directive, so the runbook must NOT tell the
|
||||
// operator to verify one — verify the launcher/generated file instead.
|
||||
expect(output).not.toContain('EnvironmentFile');
|
||||
expect(output).toMatch(/systemctl --user cat mosaic-agent@<name>/);
|
||||
expect(output).toMatch(/restart/i);
|
||||
});
|
||||
|
||||
it('emits paths and counts only — never the projected env body (secrev)', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
|
||||
await program(home, recordingRunner([])).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
]);
|
||||
|
||||
const output = lines.join('\n');
|
||||
// Relative paths + counts are fine; the rendered projection body (KEY=value
|
||||
// lines) must never be echoed to stdout.
|
||||
expect(output).toContain('coder0.env.generated');
|
||||
expect(output).not.toMatch(/^MOSAIC_AGENT_\w+=/m);
|
||||
expect(output).not.toContain('MOSAIC_TMUX_SOCKET=mosaic-fleet');
|
||||
});
|
||||
|
||||
it('is projection-only: leaves legacy .env untouched and writes no .env.local/.env.quarantine', async (): Promise<void> => {
|
||||
// Recovery contract: regen rebuilds ONLY <name>.env.generated. It must never
|
||||
// relocate, quarantine, or unlink the operator-owned legacy .env surface — the
|
||||
// full reconciler apply path does, so regen must NOT use it.
|
||||
const home = await fleetHome();
|
||||
const legacyPath = join(home, 'fleet', 'agents', 'coder0.env');
|
||||
await writeFile(legacyPath, 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', { mode: 0o600 });
|
||||
capture();
|
||||
|
||||
await program(home, recordingRunner([])).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
// Generated projection rebuilt...
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(true);
|
||||
// ...but the operator's legacy .env is preserved verbatim, and no local/quarantine
|
||||
// files were fabricated from it.
|
||||
expect(await readFile(legacyPath, 'utf8')).toBe('MOSAIC_RUNTIME_BIN=/usr/bin/pi\n');
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.local'))).toBe(false);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.quarantine'))).toBe(false);
|
||||
});
|
||||
|
||||
it('prepares every agent before writing any: a later prepare failure leaves nothing written', async (): Promise<void> => {
|
||||
// Fail-closed across agents. Pre-seed coder1's projection with world-readable
|
||||
// perms so its prepare rejects; coder0 (valid) must NOT be written because
|
||||
// preparation is fully completed before the first apply.
|
||||
const home = await fleetHome();
|
||||
const coder1Path = join(home, 'fleet', 'agents', 'coder1.env.generated');
|
||||
await writeFile(coder1Path, 'MOSAIC_AGENT_NAME=coder1\n', { mode: 0o644 });
|
||||
const calls: string[][] = [];
|
||||
capture();
|
||||
const errors: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||
errors.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
await program(home, recordingRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join('')).toMatch(/regen failed/i);
|
||||
// coder0 is valid but must remain unwritten — no partial rebuild.
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails closed on a semantically invalid roster (protected-class tool-policy mismatch)', async (): Promise<void> => {
|
||||
// A hand-edited roster giving a protected class a weaker tool_policy is rejected
|
||||
// by reconcile/plan/verify; regen must enforce the SAME gate, not silently
|
||||
// project the downgraded policy into <name>.env.generated.
|
||||
const home = await fleetHome(`
|
||||
version: 2
|
||||
generation: 3
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-fleet
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: /srv/mosaic
|
||||
runtime: pi
|
||||
runtimes:
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: gate0
|
||||
alias: Gate 0
|
||||
class: merge-gate
|
||||
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
|
||||
`);
|
||||
const calls: string[][] = [];
|
||||
capture();
|
||||
const errors: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||
errors.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
await program(home, recordingRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join('')).toMatch(/regen failed/i);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'gate0.env.generated'))).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses to write while a concurrent reconcile holds the mutation lock', async (): Promise<void> => {
|
||||
// regen --write mutates the same projections as reconcile; it must take the
|
||||
// shared reconcile lock so a concurrent reconcile cannot race a stale write.
|
||||
const home = await fleetHome();
|
||||
await writeFile(join(home, 'fleet', 'roster.yaml.reconcile.lock'), 'held\n', { mode: 0o600 });
|
||||
const calls: string[][] = [];
|
||||
capture();
|
||||
const errors: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||
errors.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
await program(home, recordingRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join('')).toMatch(/regen failed/i);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails closed (non-zero, no writes) on an invalid roster', async (): Promise<void> => {
|
||||
// roster-v2 requires >= 1 agent; a malformed roster must abort regen without
|
||||
// writing any projection and without touching lifecycle.
|
||||
const home = await fleetHome('version: 2\ngeneration: 1\n');
|
||||
const calls: string[][] = [];
|
||||
capture();
|
||||
const errors: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||
errors.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
await program(home, recordingRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join('')).toMatch(/regen failed/i);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a non-canonical --roster path (canonical roster only)', async (): Promise<void> => {
|
||||
// `fleet` exposes a global `--roster`; reconcile rejects a non-canonical value
|
||||
// rather than silently reading the canonical roster. regen must enforce the
|
||||
// SAME guard so `--roster /elsewhere regen --write` cannot mislead the operator.
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
capture();
|
||||
const errors: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||
errors.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
await program(home, recordingRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--roster',
|
||||
join(home, 'other', 'roster.yaml'),
|
||||
'regen',
|
||||
'--write',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join('')).toMatch(/regen failed/i);
|
||||
expect(errors.join('')).toMatch(/canonical roster/i);
|
||||
// No projection written against a misdirected roster path.
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports an incomplete rebuild when a later projection write fails mid-loop', async (): Promise<void> => {
|
||||
// Fault injected on the SECOND apply: coder0 is already replaced on disk, so a
|
||||
// bare throw would hide the partial state. regen must surface which agents were
|
||||
// rebuilt and which failed, with a verify-before-restart recovery instruction.
|
||||
const home = await fleetHome();
|
||||
const realApply = applyPreparedGeneratedAgentEnvironmentProjection;
|
||||
const result = await executeFleetRegen(
|
||||
{
|
||||
runner: recordingRunner([]),
|
||||
mosaicHome: home,
|
||||
applyProjection: async (prepared): Promise<string> => {
|
||||
if (prepared.generatedPath.endsWith('coder1.env.generated')) {
|
||||
throw new Error('simulated ENOSPC on coder1');
|
||||
}
|
||||
return realApply(prepared);
|
||||
},
|
||||
},
|
||||
{ write: true },
|
||||
);
|
||||
|
||||
// coder0 written before the fault; coder1 not.
|
||||
expect(result.written).toBe(1);
|
||||
expect(result.incomplete).toMatchObject({
|
||||
code: 'projection-apply-failed',
|
||||
failedAgent: 'coder1',
|
||||
writtenAgents: ['coder0'],
|
||||
});
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(true);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(false);
|
||||
|
||||
// The human report names the failed agent and directs a verify-before-restart
|
||||
// recovery — not a bare success runbook.
|
||||
const report = formatFleetRegenReport(result).join('\n');
|
||||
expect(report).toMatch(/incomplete/i);
|
||||
expect(report).toContain('coder1');
|
||||
expect(report).toMatch(/verify|re-run|retry/i);
|
||||
// Structural: still no lifecycle call anywhere on the failure path.
|
||||
expect(report).not.toMatch(/systemctl --user restart mosaic-agent@<name>\n.*\n/);
|
||||
});
|
||||
|
||||
it('refuses to write while agent CRUD holds the roster mutation lock', async (): Promise<void> => {
|
||||
// regen --write overwrites the SAME generated projections that `fleet agent
|
||||
// create/update/delete` writes under roster.yaml.mutation.lock. If regen only
|
||||
// took the reconcile lock it could read a stale roster and overwrite a just-
|
||||
// committed projection. It must also take the mutation lock and fail closed.
|
||||
const home = await fleetHome();
|
||||
await writeFile(join(home, 'fleet', 'roster.yaml.mutation.lock'), 'crud\n', { mode: 0o600 });
|
||||
const calls: string[][] = [];
|
||||
capture();
|
||||
const errors: string[] = [];
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||
errors.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
await program(home, recordingRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'regen',
|
||||
'--write',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errors.join('')).toMatch(/regen failed/i);
|
||||
// No projection written against a roster a concurrent CRUD is mutating.
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('surfaces BOTH the roster failure and a stale-lock warning on a double fault', async (): Promise<void> => {
|
||||
// Worst case with nothing written: run() fails (invalid roster, fail-closed) AND
|
||||
// a lock release faults. The operator must be told the lock may be stale, not
|
||||
// just the roster error — otherwise the next regen/reconcile is silently blocked.
|
||||
const home = await fleetHome('version: 2\ngeneration: 1\n');
|
||||
const throwingRelease =
|
||||
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
throw new Error('simulated lock unlink fault');
|
||||
};
|
||||
await expect(
|
||||
executeFleetRegen(
|
||||
{
|
||||
runner: recordingRunner([]),
|
||||
mosaicHome: home,
|
||||
acquireReconcileLock: throwingRelease,
|
||||
acquireRosterMutationLock: throwingRelease,
|
||||
},
|
||||
{ write: true },
|
||||
),
|
||||
).rejects.toThrow(/stale|inspect|lock/i);
|
||||
// Nothing written on the fail-closed path.
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||
});
|
||||
|
||||
it('propagates a roster-mutation-lock release failure instead of silently keeping a stale lock', async (): Promise<void> => {
|
||||
// Finding L: the real `acquirePrivateRosterMutationLock` release must SURFACE an
|
||||
// unlink failure, not swallow it. If it swallowed (like the CRUD-internal
|
||||
// acquireMutationLock does), a stale roster.yaml.mutation.lock left after a
|
||||
// successful regen would block later regen/agent CRUD while the command reported
|
||||
// success — and the finding-J stale-lock warning would never fire for this lock.
|
||||
const home = await fleetHome();
|
||||
const release = await acquirePrivateRosterMutationLock(home)();
|
||||
// Simulate the lock vanishing (or being unremovable) before release runs: the
|
||||
// underlying unlink then faults. A swallowing release would resolve and hide it.
|
||||
await rm(join(home, 'fleet', 'roster.yaml.mutation.lock'));
|
||||
await expect(release()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('preserves a complete rebuild result and flags lock-cleanup when release faults', async (): Promise<void> => {
|
||||
// A lock-release fault after a successful write must NOT discard the fact that
|
||||
// projections are now live — the operator needs to know the write happened and
|
||||
// that the shared lock may be stale, not just a bare "regen failed".
|
||||
const home = await fleetHome();
|
||||
const result = await executeFleetRegen(
|
||||
{
|
||||
runner: recordingRunner([]),
|
||||
mosaicHome: home,
|
||||
acquireReconcileLock:
|
||||
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
throw new Error('simulated lock unlink fault');
|
||||
},
|
||||
},
|
||||
{ write: true },
|
||||
);
|
||||
|
||||
expect(result.written).toBe(2);
|
||||
expect(result.incomplete).toBeUndefined();
|
||||
expect(result.cleanup).toMatchObject({
|
||||
code: 'lock-cleanup-failed',
|
||||
action: 'inspect-lock-before-retry',
|
||||
});
|
||||
// Projections are genuinely on disk despite the release fault.
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(true);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(true);
|
||||
|
||||
const report = formatFleetRegenReport(result).join('\n');
|
||||
expect(report).toMatch(/lock/i);
|
||||
expect(report).toMatch(/stale|inspect|clear/i);
|
||||
});
|
||||
|
||||
it('preserves a partial rebuild AND flags lock-cleanup when both apply and release fault', async (): Promise<void> => {
|
||||
// Worst case: a mid-loop apply fault leaves a partial rebuild, then the release
|
||||
// also faults. Both recovery states must survive so the operator sees exactly
|
||||
// which projections are live and that the lock may be stale.
|
||||
const home = await fleetHome();
|
||||
const realApply = applyPreparedGeneratedAgentEnvironmentProjection;
|
||||
const result = await executeFleetRegen(
|
||||
{
|
||||
runner: recordingRunner([]),
|
||||
mosaicHome: home,
|
||||
applyProjection: async (prepared): Promise<string> => {
|
||||
if (prepared.generatedPath.endsWith('coder1.env.generated')) {
|
||||
throw new Error('simulated ENOSPC on coder1');
|
||||
}
|
||||
return realApply(prepared);
|
||||
},
|
||||
acquireReconcileLock:
|
||||
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
throw new Error('simulated lock unlink fault');
|
||||
},
|
||||
},
|
||||
{ write: true },
|
||||
);
|
||||
|
||||
expect(result.written).toBe(1);
|
||||
expect(result.incomplete).toMatchObject({
|
||||
code: 'projection-apply-failed',
|
||||
failedAgent: 'coder1',
|
||||
writtenAgents: ['coder0'],
|
||||
});
|
||||
expect(result.cleanup).toMatchObject({ code: 'lock-cleanup-failed' });
|
||||
|
||||
const report = formatFleetRegenReport(result).join('\n');
|
||||
expect(report).toMatch(/incomplete/i);
|
||||
expect(report).toMatch(/lock/i);
|
||||
});
|
||||
|
||||
it('fails closed without deleting a REPLACEMENT roster-mutation lock it no longer owns', async (): Promise<void> => {
|
||||
// Finding M1 (replacement-lock race): after this invocation created its lock, the
|
||||
// lock is cleared and re-created by ANOTHER writer (new inode + foreign token)
|
||||
// BEFORE release runs. The release must PROVE ownership (device/inode + token) and
|
||||
// refuse to unlink the stranger's live lock — otherwise it deletes it, a third
|
||||
// writer enters concurrently, and mutual exclusion over the projections is broken.
|
||||
// This pins the REACHABLE window (replaced-before-release); the residual sub-window
|
||||
// between the final check and the path `unlink` is unreachable within the `wx`
|
||||
// writer protocol and matches the merged reconcile lock — see the acquirer doc.
|
||||
const home = await fleetHome();
|
||||
const lockPath = join(home, 'fleet', 'roster.yaml.mutation.lock');
|
||||
const release = await acquirePrivateRosterMutationLock(home)();
|
||||
// A different writer replaces the lock: same path, NEW inode, foreign content.
|
||||
await rm(lockPath);
|
||||
await writeFile(lockPath, 'a-different-writer\n', { mode: 0o600 });
|
||||
const replacement = await stat(lockPath);
|
||||
// Release must reject (it no longer owns this file) AND the diagnosis must name
|
||||
// the MUTATION lock specifically — a fault on this shared helper must not be
|
||||
// mislabeled as the reconcile lock (that would defeat the M3 accurate-diagnosis
|
||||
// goal, since regen serializes on both) ...
|
||||
await expect(release()).rejects.toThrow(/roster\.yaml\.mutation\.lock/);
|
||||
// ... and MUST NOT have unlinked the stranger's live lock.
|
||||
expect(await exists(lockPath)).toBe(true);
|
||||
expect((await stat(lockPath)).ino).toBe(replacement.ino);
|
||||
});
|
||||
|
||||
it('does not strand the lock file when initialization fails after creating it', async (): Promise<void> => {
|
||||
// Codex r4: if writeFile/stat/close/ownership-check throws AFTER the `wx` create
|
||||
// succeeds, the just-created lock must NOT be left behind — a stranded lock would
|
||||
// permanently block future regen AND agent CRUD (both contend on this path). The
|
||||
// cleanup is dev/ino-guarded (never deletes a replacement) and best-effort (never
|
||||
// masks the initialization error).
|
||||
const home = await fleetHome();
|
||||
const lockPath = join(home, 'fleet', 'roster.yaml.mutation.lock');
|
||||
// Inject an opener that performs the REAL `wx` create (so the lock file really
|
||||
// lands on disk) but whose handle faults on the token write — a post-create
|
||||
// initialization failure.
|
||||
const faultingOpen = (async (
|
||||
path: Parameters<typeof open>[0],
|
||||
flags: Parameters<typeof open>[1],
|
||||
mode: Parameters<typeof open>[2],
|
||||
) => {
|
||||
const handle = await open(path, flags, mode);
|
||||
return new Proxy(handle, {
|
||||
get(target, prop, receiver): unknown {
|
||||
if (prop === 'writeFile') {
|
||||
return async (): Promise<never> => {
|
||||
throw new Error('simulated ENOSPC on token write');
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, prop, receiver);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
}) as typeof open;
|
||||
|
||||
await expect(acquirePrivateRosterMutationLock(home, faultingOpen)()).rejects.toThrow(
|
||||
/mutation\.lock/,
|
||||
);
|
||||
// The lock we created must have been cleaned up — not stranded on disk.
|
||||
expect(await exists(lockPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not strand the lock file when the post-create stat itself fails', async (): Promise<void> => {
|
||||
// Codex r5: the narrower sub-path where `handle.stat()` ITSELF rejects right after
|
||||
// the `wx` create succeeds (transient EIO/EBADF). device/inode identity is then
|
||||
// unavailable, so the init-failure cleanup cannot prove ownership by dev/ino — yet
|
||||
// the lock must STILL not be stranded, because a stranded lock permanently blocks
|
||||
// future regen AND agent CRUD. The token we persisted before the failure uniquely
|
||||
// identifies OUR lock, so cleanup falls back to matching it.
|
||||
const home = await fleetHome();
|
||||
const lockPath = join(home, 'fleet', 'roster.yaml.mutation.lock');
|
||||
// Real `wx` create (lock really lands on disk); the token write SUCCEEDS so our
|
||||
// random token is persisted, then `stat()` faults — a post-create init failure.
|
||||
const faultingStatOpen = (async (
|
||||
path: Parameters<typeof open>[0],
|
||||
flags: Parameters<typeof open>[1],
|
||||
mode: Parameters<typeof open>[2],
|
||||
) => {
|
||||
const handle = await open(path, flags, mode);
|
||||
return new Proxy(handle, {
|
||||
get(target, prop, receiver): unknown {
|
||||
if (prop === 'stat') {
|
||||
return async (): Promise<never> => {
|
||||
throw new Error('simulated EIO on post-create stat');
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, prop, receiver);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
}) as typeof open;
|
||||
|
||||
await expect(acquirePrivateRosterMutationLock(home, faultingStatOpen)()).rejects.toThrow(
|
||||
/mutation\.lock/,
|
||||
);
|
||||
// Even without dev/ino, the token-based fallback must have removed our lock.
|
||||
expect(await exists(lockPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces a lock-cleanup fault when unwinding after a later lock acquire fails', async (): Promise<void> => {
|
||||
// Finding M2: the FIRST lock is taken, the SECOND lock's acquire throws, and
|
||||
// unwinding the first lock's release ALSO faults. The acquire-unwind path must
|
||||
// surface both — the already-taken lock may now be stale and silently block the
|
||||
// next regen/reconcile — not just re-throw the acquire error and drop the fault.
|
||||
const home = await fleetHome();
|
||||
await expect(
|
||||
executeFleetRegen(
|
||||
{
|
||||
runner: recordingRunner([]),
|
||||
mosaicHome: home,
|
||||
// Mutation lock (acquired first) succeeds, but its RELEASE faults on unwind.
|
||||
acquireRosterMutationLock:
|
||||
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
throw new Error('simulated mutation-lock unlink fault');
|
||||
},
|
||||
// Reconcile lock (acquired second) ACQUIRE fails, triggering the unwind.
|
||||
acquireReconcileLock: () => async (): Promise<() => Promise<void>> => {
|
||||
throw new Error('simulated reconcile acquire failure');
|
||||
},
|
||||
},
|
||||
{ write: true },
|
||||
),
|
||||
).rejects.toThrow(/stale|inspect|lock/i);
|
||||
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||
});
|
||||
|
||||
it('names BOTH fleet locks in the stale-lock warning (cleanup can come from either)', async (): Promise<void> => {
|
||||
// Finding M3: finding L made the roster MUTATION lock's release fault reachable, so
|
||||
// the cleanup marker can now originate from either lock. The human report must not
|
||||
// claim only the reconcile lock is stale — it must name both candidate lock files
|
||||
// so the operator inspects the right one.
|
||||
const home = await fleetHome();
|
||||
const result = await executeFleetRegen(
|
||||
{
|
||||
runner: recordingRunner([]),
|
||||
mosaicHome: home,
|
||||
// Fault the MUTATION lock's release specifically (reconcile lock is real).
|
||||
acquireRosterMutationLock:
|
||||
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
throw new Error('simulated mutation-lock unlink fault');
|
||||
},
|
||||
},
|
||||
{ write: true },
|
||||
);
|
||||
|
||||
expect(result.written).toBe(2);
|
||||
expect(result.cleanup).toMatchObject({ code: 'lock-cleanup-failed' });
|
||||
const report = formatFleetRegenReport(result).join('\n');
|
||||
expect(report).toContain('roster.yaml.mutation.lock');
|
||||
expect(report).toContain('roster.yaml.reconcile.lock');
|
||||
});
|
||||
});
|
||||
441
packages/mosaic/src/commands/fleet-regen-command.ts
Normal file
441
packages/mosaic/src/commands/fleet-regen-command.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import type { CommandRunner } from './fleet.js';
|
||||
import {
|
||||
applyPreparedGeneratedAgentEnvironmentProjection,
|
||||
prepareGeneratedAgentEnvironmentProjection,
|
||||
type PreparedGeneratedAgentEnvironmentProjection,
|
||||
} from '../fleet/generated-env-boundary.js';
|
||||
import {
|
||||
acquirePrivateReconcileLock,
|
||||
acquirePrivateRosterMutationLock,
|
||||
projectRosterV2AgentGeneratedEnv,
|
||||
} from '../fleet/fleet-reconciler.js';
|
||||
import {
|
||||
parseRosterV2,
|
||||
validateRosterV2Semantics,
|
||||
type FleetRosterV2,
|
||||
} from '../fleet/roster-v2.js';
|
||||
|
||||
/**
|
||||
* `mosaic fleet regen` — recovery-framed regeneration of the roster-derived
|
||||
* agent env projections (`fleet/agents/<name>.env.generated`) from the roster
|
||||
* SSOT. It is the recovery layer of #791: when a wiped/diverged operator surface
|
||||
* has cost an agent its generated projection, regen rebuilds it deterministically
|
||||
* from `roster.yaml` so the launcher can source the intended identity again.
|
||||
*
|
||||
* It is intentionally a THIN projection-only wrapper over the same mapping the
|
||||
* reconciler apply path uses ({@link projectRosterV2AgentGeneratedEnv}), and it
|
||||
* has NO code path to systemd lifecycle: regen never starts, stops, or restarts
|
||||
* an agent. Recovery order forbids restart-before-verify, so the operator must
|
||||
* verify each rebuilt projection resolves before restarting anything.
|
||||
*/
|
||||
export interface FleetRegenAgentPlan {
|
||||
readonly name: string;
|
||||
/** Generated-projection path, relative to the Mosaic home (never absolute in output). */
|
||||
readonly path: string;
|
||||
/** `create` when the projection is currently absent, `rebuild` when it already exists. */
|
||||
readonly disposition: 'create' | 'rebuild';
|
||||
}
|
||||
|
||||
/**
|
||||
* Present ONLY when a `--write` projection apply failed partway through the
|
||||
* fleet. Every prior agent was already validated and prepared, so `writtenAgents`
|
||||
* are byte-complete on disk; `failedAgent` is where the write faulted. The
|
||||
* command surfaces this instead of a bare throw so the operator can finish the
|
||||
* rebuild and verify every projection before restarting any unit.
|
||||
*/
|
||||
export interface FleetRegenIncomplete {
|
||||
readonly code: 'projection-apply-failed';
|
||||
/** The agent whose generated-projection write faulted. */
|
||||
readonly failedAgent: string;
|
||||
/** Agents whose projections were fully written before the fault (in apply order). */
|
||||
readonly writtenAgents: readonly string[];
|
||||
}
|
||||
|
||||
export interface FleetRegenResult {
|
||||
readonly mode: 'dry-run' | 'write';
|
||||
/** Roster path, relative to the Mosaic home. */
|
||||
readonly rosterPath: string;
|
||||
readonly generation: number;
|
||||
readonly agentCount: number;
|
||||
/** Count of projections written to disk (always 0 in dry-run). */
|
||||
readonly written: number;
|
||||
readonly agents: readonly FleetRegenAgentPlan[];
|
||||
/** Set ONLY when a `--write` apply faulted mid-fleet — a partial rebuild. */
|
||||
readonly incomplete?: FleetRegenIncomplete;
|
||||
/**
|
||||
* Set ONLY when a shared fleet lock could not be released after a write completed
|
||||
* (or partially completed) — EITHER `roster.yaml.mutation.lock` or
|
||||
* `roster.yaml.reconcile.lock`, since regen holds both. The projections in this
|
||||
* result are real; the lock file may be stale and must be inspected before the
|
||||
* next mutation. Independent of {@link incomplete} — both can be present at once.
|
||||
*/
|
||||
readonly cleanup?: {
|
||||
readonly code: 'lock-cleanup-failed';
|
||||
readonly action: 'inspect-lock-before-retry';
|
||||
};
|
||||
}
|
||||
|
||||
export interface FleetRegenDeps {
|
||||
/**
|
||||
* Present ONLY so an accidental future lifecycle wiring is caught by the
|
||||
* "never restarts" test — regen is contractually forbidden to invoke it.
|
||||
*/
|
||||
readonly runner: CommandRunner;
|
||||
readonly mosaicHome?: string;
|
||||
/** Persona roots for semantic roster validation (defaults mirror the reconciler). */
|
||||
readonly rolesDir?: string;
|
||||
readonly overrideDir?: string;
|
||||
/** Test seam: canonical roster reader. Defaults to parse + semantic validation. */
|
||||
readonly readRoster?: (rosterPath: string) => Promise<FleetRosterV2>;
|
||||
/**
|
||||
* Test seam: generated-only projection validator. Defaults to the audited
|
||||
* boundary helper that validates ONLY `<name>.env.generated`.
|
||||
*/
|
||||
readonly prepareProjection?: typeof prepareGeneratedAgentEnvironmentProjection;
|
||||
/** Test seam: generated-only projection writer. Defaults to the audited boundary helper. */
|
||||
readonly applyProjection?: typeof applyPreparedGeneratedAgentEnvironmentProjection;
|
||||
/**
|
||||
* Test seam: acquire the shared reconcile lock before a `--write`. Defaults to
|
||||
* the reconciler's private lock so regen and reconcile are mutually exclusive
|
||||
* and a concurrent reconcile cannot race a stale projection write.
|
||||
*/
|
||||
readonly acquireReconcileLock?: (mosaicHome: string) => () => Promise<() => Promise<void>>;
|
||||
/**
|
||||
* Test seam: acquire the shared roster mutation lock before a `--write`.
|
||||
* Defaults to the reconciler's hardened, ownership-proving acquirer for the
|
||||
* same `fleet/roster.yaml.mutation.lock` path that CRUD contends on, so regen
|
||||
* serializes against agent create/update/delete — both rewrite the same
|
||||
* generated projections, so without this a concurrent CRUD could race a stale
|
||||
* projection write.
|
||||
*/
|
||||
readonly acquireRosterMutationLock?: (mosaicHome: string) => () => Promise<() => Promise<void>>;
|
||||
/** Test seam: existence probe for create/rebuild disposition. */
|
||||
readonly fileExists?: (path: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface FleetRegenOptions {
|
||||
readonly write?: boolean;
|
||||
}
|
||||
|
||||
function defaultMosaicHome(deps: FleetRegenDeps): string {
|
||||
return deps.mosaicHome ?? process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
}
|
||||
|
||||
async function defaultFileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection-only regeneration core. Deterministic and side-effect-free in
|
||||
* dry-run; in `--write` it rebuilds each generated projection through the
|
||||
* audited generated-only boundary writer and NOTHING else — it never writes
|
||||
* `.env.local`/`.env.quarantine`, never unlinks the legacy `.env`, and never
|
||||
* touches `deps.runner`.
|
||||
*
|
||||
* Fail-closed by construction: the roster is validated (structural + semantic)
|
||||
* and EVERY agent's projection is prepared before ANY is written, so a single
|
||||
* invalid agent (or a semantically rejected roster) leaves the whole surface
|
||||
* untouched. In `--write` mode the read-prepare-apply sequence runs under the
|
||||
* shared reconcile lock so a concurrent reconcile cannot race a stale write.
|
||||
*/
|
||||
export async function executeFleetRegen(
|
||||
deps: FleetRegenDeps,
|
||||
options: FleetRegenOptions,
|
||||
): Promise<FleetRegenResult> {
|
||||
const mosaicHome = defaultMosaicHome(deps);
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
const readRoster = deps.readRoster ?? defaultReadRoster(deps, mosaicHome);
|
||||
const prepare = deps.prepareProjection ?? prepareGeneratedAgentEnvironmentProjection;
|
||||
const apply = deps.applyProjection ?? applyPreparedGeneratedAgentEnvironmentProjection;
|
||||
const fileExists = deps.fileExists ?? defaultFileExists;
|
||||
const acquireReconcileLock = deps.acquireReconcileLock ?? acquirePrivateReconcileLock;
|
||||
const acquireRosterMutationLock =
|
||||
deps.acquireRosterMutationLock ?? acquirePrivateRosterMutationLock;
|
||||
const write = options.write === true;
|
||||
|
||||
const run = async (): Promise<FleetRegenResult> => {
|
||||
const roster = await readRoster(rosterPath);
|
||||
|
||||
// Prepare (validate) every agent BEFORE any write, so a later failure never
|
||||
// leaves an earlier projection partially rebuilt.
|
||||
const prepared: (PreparedGeneratedAgentEnvironmentProjection & {
|
||||
readonly name: string;
|
||||
readonly disposition: 'create' | 'rebuild';
|
||||
})[] = [];
|
||||
for (const agent of roster.agents) {
|
||||
const projection = await prepare({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: agent.name,
|
||||
generated: projectRosterV2AgentGeneratedEnv(roster, agent),
|
||||
});
|
||||
const disposition = (await fileExists(projection.generatedPath)) ? 'rebuild' : 'create';
|
||||
prepared.push({ ...projection, name: agent.name, disposition });
|
||||
}
|
||||
|
||||
// Every projection is already validated (prepared); an apply here can only
|
||||
// fault on the underlying I/O (ENOSPC, EIO, a race outside the lock). If a
|
||||
// later write faults, earlier agents are already replaced on disk, so we
|
||||
// stop and report the partial state rather than throwing a bare error that
|
||||
// hides which projections are now live.
|
||||
let written = 0;
|
||||
let incomplete: FleetRegenIncomplete | undefined;
|
||||
if (write) {
|
||||
for (const projection of prepared) {
|
||||
try {
|
||||
await apply(projection);
|
||||
} catch {
|
||||
incomplete = {
|
||||
code: 'projection-apply-failed',
|
||||
failedAgent: projection.name,
|
||||
writtenAgents: prepared.slice(0, written).map((entry) => entry.name),
|
||||
};
|
||||
break;
|
||||
}
|
||||
written += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode: write ? 'write' : 'dry-run',
|
||||
rosterPath: relative(mosaicHome, rosterPath),
|
||||
generation: roster.generation,
|
||||
agentCount: roster.agents.length,
|
||||
written,
|
||||
agents: prepared.map((projection) => ({
|
||||
name: projection.name,
|
||||
path: relative(mosaicHome, projection.generatedPath),
|
||||
disposition: projection.disposition,
|
||||
})),
|
||||
...(incomplete ? { incomplete } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
// Dry-run is preview-only and never mutates, so it stays lock-free (usable even
|
||||
// while a reconcile or CRUD holds a lock). A `--write` must serialize against
|
||||
// BOTH roster writers of the same generated projections: agent CRUD (roster
|
||||
// mutation lock) and reconcile (reconcile lock). Acquire in a fixed order — the
|
||||
// locks are non-blocking (they throw on contention rather than wait), so no
|
||||
// deadlock is possible — and fail closed if either is already held.
|
||||
if (!write) return run();
|
||||
|
||||
const releases: (() => Promise<void>)[] = [];
|
||||
try {
|
||||
releases.push(await acquireRosterMutationLock(mosaicHome)());
|
||||
releases.push(await acquireReconcileLock(mosaicHome)());
|
||||
} catch (error: unknown) {
|
||||
// A later acquire failed; unwind any lock already taken (reverse order). If
|
||||
// that unwind ALSO faults, surface both — the already-taken lock may now be
|
||||
// stale and silently block the next regen/reconcile — rather than dropping it.
|
||||
const releaseFault = await releaseFleetLocks(releases);
|
||||
throw augmentWithLockCleanupFault(error, releaseFault);
|
||||
}
|
||||
|
||||
let result: FleetRegenResult | undefined;
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
result = await run();
|
||||
} catch (error: unknown) {
|
||||
primaryError = error;
|
||||
}
|
||||
|
||||
// Release both locks (reverse acquire order), continuing past a fault so neither
|
||||
// leaks. A release fault must never discard a completed (or partial) rebuild —
|
||||
// the projections are already on disk — and must not be silently hidden even
|
||||
// when the run itself failed with nothing written.
|
||||
const releaseFault = await releaseFleetLocks(releases);
|
||||
if (result) {
|
||||
return releaseFault === undefined
|
||||
? result
|
||||
: {
|
||||
...result,
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
};
|
||||
}
|
||||
// result is undefined ⇒ run() threw ⇒ primaryError is set.
|
||||
throw augmentWithLockCleanupFault(primaryError, releaseFault);
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases held locks in reverse acquire order, continuing past a fault so a
|
||||
* single failed release never leaks the others. Returns the first fault seen (or
|
||||
* undefined when every release succeeded).
|
||||
*/
|
||||
async function releaseFleetLocks(releases: readonly (() => Promise<void>)[]): Promise<unknown> {
|
||||
let firstFault: unknown;
|
||||
for (const release of [...releases].reverse()) {
|
||||
try {
|
||||
await release();
|
||||
} catch (error: unknown) {
|
||||
if (firstFault === undefined) firstFault = error;
|
||||
}
|
||||
}
|
||||
return firstFault;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a run failed with nothing written AND lock cleanup also faulted, surface
|
||||
* both: the operator needs to know the lock may be stale (else the next
|
||||
* regen/reconcile is silently blocked), not just the original run error.
|
||||
*/
|
||||
function augmentWithLockCleanupFault(primaryError: unknown, releaseFault: unknown): unknown {
|
||||
if (releaseFault === undefined) return primaryError;
|
||||
const base = primaryError instanceof Error ? primaryError.message : String(primaryError);
|
||||
return new Error(
|
||||
`${base} (additionally, a fleet lock could not be released — ` +
|
||||
'fleet/roster.yaml.mutation.lock or roster.yaml.reconcile.lock may be stale; ' +
|
||||
'inspect and clear it before retry)',
|
||||
);
|
||||
}
|
||||
|
||||
function defaultReadRoster(
|
||||
deps: FleetRegenDeps,
|
||||
mosaicHome: string,
|
||||
): (rosterPath: string) => Promise<FleetRosterV2> {
|
||||
return async (rosterPath: string): Promise<FleetRosterV2> => {
|
||||
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
||||
// Enforce the SAME semantic gate as reconcile/plan/verify (persona resolution
|
||||
// + protected-class tool-policy match) so regen cannot project a roster the
|
||||
// rest of the fleet surface would reject.
|
||||
await validateRosterV2Semantics(roster, {
|
||||
rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
|
||||
overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
|
||||
});
|
||||
return roster;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable report — paths and counts ONLY. The rendered projection body
|
||||
* (KEY=value lines) is never echoed (secrev). In `--write` mode it prints the
|
||||
* recovery runbook: verify each projection resolves BEFORE any restart.
|
||||
*/
|
||||
export function formatFleetRegenReport(result: FleetRegenResult): string[] {
|
||||
const lines: string[] = [];
|
||||
const header =
|
||||
result.mode === 'dry-run'
|
||||
? 'mosaic fleet regen — dry run (no changes written)'
|
||||
: 'mosaic fleet regen — wrote roster-derived projections';
|
||||
lines.push(header);
|
||||
lines.push(
|
||||
`roster: ${result.rosterPath} (generation ${result.generation}, ${result.agentCount} agent(s))`,
|
||||
);
|
||||
for (const agent of result.agents) {
|
||||
lines.push(` ${agent.name} ${agent.path} [${agent.disposition}]`);
|
||||
}
|
||||
|
||||
if (result.mode === 'dry-run') {
|
||||
lines.push(
|
||||
`Plan: would write ${result.agentCount} generated projection(s). ` +
|
||||
`Re-run with --write to apply. regen never restarts agents.`,
|
||||
);
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (result.incomplete) {
|
||||
const { failedAgent, writtenAgents } = result.incomplete;
|
||||
lines.push(
|
||||
`INCOMPLETE: rebuild stopped at agent ${failedAgent} — its projection write faulted.`,
|
||||
);
|
||||
lines.push(
|
||||
`Rebuilt before the fault: ${writtenAgents.length} projection(s)` +
|
||||
(writtenAgents.length > 0 ? ` (${writtenAgents.join(', ')})` : '') +
|
||||
`; ${result.agentCount - writtenAgents.length} not written.`,
|
||||
);
|
||||
lines.push(
|
||||
'Recover: re-run `mosaic fleet regen --write` to finish, then VERIFY every ' +
|
||||
"agent's fleet/agents/<name>.env.generated resolves BEFORE restarting any " +
|
||||
'unit. regen never restarts agents.',
|
||||
);
|
||||
} else {
|
||||
lines.push(`Wrote ${result.written} generated projection(s).`);
|
||||
lines.push('Next steps — DO NOT restart agents before verifying:');
|
||||
lines.push(
|
||||
" 1. Confirm each agent's fleet/agents/<name>.env.generated exists and carries " +
|
||||
'the intended MOSAIC_AGENT_* values.',
|
||||
);
|
||||
// The unit sets NO EnvironmentFile= — it launches from a minimal env and the
|
||||
// launcher (start-agent-session.sh) sources .env.generated itself. Verify the
|
||||
// launcher path, not a nonexistent EnvironmentFile property.
|
||||
lines.push(
|
||||
' 2. Confirm the unit launches from it: `systemctl --user cat mosaic-agent@<name>` ' +
|
||||
'shows ExecStart running start-agent-session.sh, which reads .env.generated.',
|
||||
);
|
||||
lines.push(
|
||||
' 3. Only then restart one unit at a time: systemctl --user restart mosaic-agent@<name>.',
|
||||
);
|
||||
}
|
||||
|
||||
if (result.cleanup) {
|
||||
lines.push(
|
||||
'WARNING: a fleet lock could not be released — ' +
|
||||
'fleet/roster.yaml.mutation.lock or fleet/roster.yaml.reconcile.lock may be ' +
|
||||
'stale; inspect and clear it before the next reconcile or regen.',
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function resolveRegenMosaicHome(fleetCommand: Command, deps: FleetRegenDeps): string {
|
||||
const options = fleetCommand.optsWithGlobals<{ mosaicHome?: string }>();
|
||||
return options.mosaicHome ?? defaultMosaicHome(deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* regen reads the roster SSOT at `<mosaicHome>/fleet/roster.yaml` and ignores the
|
||||
* `fleet --roster <path>` global. If an operator passes a non-canonical `--roster`
|
||||
* they must NOT be silently served the canonical file — mirror reconcile's guard
|
||||
* and reject, so `mosaic fleet --roster /elsewhere regen --write` fails closed.
|
||||
*/
|
||||
function assertRegenCanonicalRoster(fleetCommand: Command, mosaicHome: string): void {
|
||||
const options = fleetCommand.optsWithGlobals<{ roster?: string }>();
|
||||
const canonical = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
if (options.roster !== undefined && resolve(options.roster) !== resolve(canonical)) {
|
||||
throw new Error(
|
||||
'Roster-v2 regeneration requires the canonical roster path (fleet/roster.yaml).',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers the recovery-framed `mosaic fleet regen` command. */
|
||||
export function registerFleetRegenCommand(fleetCommand: Command, deps: FleetRegenDeps): void {
|
||||
fleetCommand
|
||||
.command('regen')
|
||||
.description(
|
||||
'Regenerate roster-derived agent env projections (dry-run default, --write to apply; never restarts)',
|
||||
)
|
||||
.option('--write', 'Write the regenerated projections to disk (default: dry-run)')
|
||||
.option('--json', 'Emit the plan/result as JSON')
|
||||
.action(async (opts: { write?: boolean; json?: boolean }): Promise<void> => {
|
||||
const mosaicHome = resolveRegenMosaicHome(fleetCommand, deps);
|
||||
try {
|
||||
assertRegenCanonicalRoster(fleetCommand, mosaicHome);
|
||||
const result = await executeFleetRegen(
|
||||
{ ...deps, mosaicHome },
|
||||
{ write: opts.write === true },
|
||||
);
|
||||
if (opts.json === true) {
|
||||
console.log(JSON.stringify(result));
|
||||
} else {
|
||||
for (const line of formatFleetRegenReport(result)) console.log(line);
|
||||
}
|
||||
// A partial rebuild or a stale-lock cleanup state is a non-zero outcome:
|
||||
// the operator must finish/verify (and clear the lock) before restarting.
|
||||
if (result.incomplete || result.cleanup) process.exitCode = 1;
|
||||
} catch (error: unknown) {
|
||||
process.exitCode = 1;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`mosaic fleet regen failed: ${message}\n`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -97,6 +97,7 @@ describe('registerFleetCommand', () => {
|
||||
'provision',
|
||||
'ps',
|
||||
'reconcile',
|
||||
'regen',
|
||||
'remove',
|
||||
'restart',
|
||||
'start',
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
registerFleetReconcilerCommands,
|
||||
type FleetReconcilerCommandDeps,
|
||||
} from './fleet-reconciler-command.js';
|
||||
import { registerFleetRegenCommand } from './fleet-regen-command.js';
|
||||
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
|
||||
import {
|
||||
applyPreparedAgentEnvironmentProjection,
|
||||
@@ -2063,6 +2064,18 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
reconcileDeps: deps.reconcileDeps,
|
||||
});
|
||||
|
||||
// Recovery (#791 PR3): rebuild roster-derived env projections from the roster
|
||||
// SSOT. Projection-only and preview-first — never issues a lifecycle restart.
|
||||
registerFleetRegenCommand(cmd, {
|
||||
runner,
|
||||
mosaicHome: deps.mosaicHome,
|
||||
// Resolve personas the SAME way reconcile does: forward any configured roots
|
||||
// so a custom-persona-root deployment cannot have reconcile accept a roster
|
||||
// that regen then rejects against the default `<mosaicHome>/fleet/roles`.
|
||||
rolesDir: deps.reconcileDeps?.rolesDir,
|
||||
overrideDir: deps.reconcileDeps?.overrideDir,
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
piForceSkillNames,
|
||||
registerRuntimeLaunchers,
|
||||
type RuntimeLaunchHandler,
|
||||
type ClaudexLaunchHandler,
|
||||
} from './launch.js';
|
||||
|
||||
/**
|
||||
@@ -31,6 +32,16 @@ function buildProgram(handler: RuntimeLaunchHandler): Command {
|
||||
return program;
|
||||
}
|
||||
|
||||
function buildProgramWithClaudex(
|
||||
handler: RuntimeLaunchHandler,
|
||||
claudexHandler: ClaudexLaunchHandler,
|
||||
): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerRuntimeLaunchers(program, handler, claudexHandler);
|
||||
return program;
|
||||
}
|
||||
|
||||
const fakeSkills = ['--skill', '/skills/test-driven-development', '--skill', '/skills/pdf'];
|
||||
const fakeForced = ['--skill', '/skills/mosaic-tools'];
|
||||
|
||||
@@ -280,3 +291,61 @@ describe('registerRuntimeLaunchers — yolo <runtime>', () => {
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => {
|
||||
let mockExit: MockInstance<typeof process.exit>;
|
||||
let mockError: MockInstance<typeof console.error>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
|
||||
mockError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockExit.mockRestore();
|
||||
mockError.mockRestore();
|
||||
});
|
||||
|
||||
it('dispatches `claudex` to the claudex handler (yolo=false), not the runtime handler', () => {
|
||||
const handler = vi.fn();
|
||||
const claudex = vi.fn();
|
||||
const program = buildProgramWithClaudex(handler, claudex);
|
||||
program.parse(['node', 'mosaic', 'claudex']);
|
||||
|
||||
expect(claudex).toHaveBeenCalledTimes(1);
|
||||
expect(claudex).toHaveBeenCalledWith([], false);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards excess args after `claudex`', () => {
|
||||
const handler = vi.fn();
|
||||
const claudex = vi.fn();
|
||||
const program = buildProgramWithClaudex(handler, claudex);
|
||||
program.parse(['node', 'mosaic', 'claudex', '--print', 'hi']);
|
||||
|
||||
expect(claudex).toHaveBeenCalledWith(['--print', 'hi'], false);
|
||||
});
|
||||
|
||||
it('dispatches `yolo claudex` with yolo=true and slices off the runtime name (#454)', () => {
|
||||
const handler = vi.fn();
|
||||
const claudex = vi.fn();
|
||||
const program = buildProgramWithClaudex(handler, claudex);
|
||||
program.parse(['node', 'mosaic', 'yolo', 'claudex']);
|
||||
|
||||
expect(claudex).toHaveBeenCalledTimes(1);
|
||||
// extraArgs must be empty — the positional 'claudex' must not leak through.
|
||||
expect(claudex).toHaveBeenCalledWith([], true);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(mockExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards true excess args after `yolo claudex`', () => {
|
||||
const handler = vi.fn();
|
||||
const claudex = vi.fn();
|
||||
const program = buildProgramWithClaudex(handler, claudex);
|
||||
program.parse(['node', 'mosaic', 'yolo', 'claudex', '--model', 'gpt-5.6-sol']);
|
||||
|
||||
expect(claudex).toHaveBeenCalledWith(['--model', 'gpt-5.6-sol'], true);
|
||||
expect(mockExit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
||||
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
||||
import { canonicalizeRoleClass } from './fleet-personas.js';
|
||||
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
||||
|
||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
||||
@@ -806,12 +807,12 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
||||
}
|
||||
|
||||
/** exec into the runtime, replacing the current process. */
|
||||
function execRuntime(cmd: string, args: string[]): void {
|
||||
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
||||
try {
|
||||
// Use execFileSync with inherited stdio to replace the process
|
||||
const result = spawnSync(cmd, args, {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
env,
|
||||
});
|
||||
process.exit(result.status ?? 0);
|
||||
} catch (err) {
|
||||
@@ -820,6 +821,29 @@ function execRuntime(cmd: string, args: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Production glue for `mosaic [yolo] claudex` (EXPERIMENTAL — GPT models inside
|
||||
* the Claude Code harness via claude-code-proxy). Assembles the real harness
|
||||
* adapter and delegates the security-critical composition + fail-closed
|
||||
* orchestration to `launchClaudex` in `claudex.ts`. Kept thin so the tested
|
||||
* logic lives in the DI module, not here.
|
||||
*/
|
||||
function launchClaudexProduction(args: string[], yolo: boolean): void {
|
||||
writeSessionLock('claude');
|
||||
const adapter: ClaudexHarnessAdapter = {
|
||||
harnessPreflight: () => {
|
||||
checkMosaicHome();
|
||||
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
|
||||
checkSoul();
|
||||
checkRuntime('claude');
|
||||
checkSequentialThinking('claude');
|
||||
},
|
||||
composePrompt: () => buildRuntimePrompt('claude'),
|
||||
exec: (cmd, cmdArgs, env) => execRuntime(cmd, cmdArgs, env),
|
||||
};
|
||||
void launchClaudex(args, yolo, adapter);
|
||||
}
|
||||
|
||||
// ─── Framework script/tool delegation ───────────────────────────────────────
|
||||
|
||||
function delegateToScript(scriptPath: string, args: string[], env?: Record<string, string>): never {
|
||||
@@ -1034,12 +1058,25 @@ export type RuntimeLaunchHandler = (
|
||||
yolo: boolean,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Handler invoked for `claudex` / `yolo claudex`. Kept separate from
|
||||
* `RuntimeLaunchHandler` because claudex is an EXPERIMENTAL harness overlay
|
||||
* (GPT-via-proxy), not one of the first-class runtimes. Exposed + injectable so
|
||||
* the commander wiring can be exercised without composing a real launch.
|
||||
*/
|
||||
export type ClaudexLaunchHandler = (extraArgs: string[], yolo: boolean) => void;
|
||||
|
||||
/**
|
||||
* Wire `<runtime>` and `yolo <runtime>` subcommands onto `program` using a
|
||||
* pluggable launch handler. Separated from `registerLaunchCommands` so tests
|
||||
* can inject a spy and verify argument forwarding.
|
||||
*/
|
||||
export function registerRuntimeLaunchers(program: Command, handler: RuntimeLaunchHandler): void {
|
||||
export function registerRuntimeLaunchers(
|
||||
program: Command,
|
||||
handler: RuntimeLaunchHandler,
|
||||
claudexHandler: ClaudexLaunchHandler = (extraArgs, yolo) =>
|
||||
launchClaudexProduction(extraArgs, yolo),
|
||||
): void {
|
||||
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
|
||||
program
|
||||
.command(runtime)
|
||||
@@ -1051,16 +1088,37 @@ export function registerRuntimeLaunchers(program: Command, handler: RuntimeLaunc
|
||||
});
|
||||
}
|
||||
|
||||
// claudex — EXPERIMENTAL: GPT models inside the Claude Code harness via
|
||||
// claude-code-proxy (ChatGPT-subscription OAuth). Isolated CLAUDE_CONFIG_DIR
|
||||
// + zero-token-leak env injection live in claudex.ts.
|
||||
program
|
||||
.command('claudex')
|
||||
.description('EXPERIMENTAL: launch Claude Code harness against GPT via claude-code-proxy')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((_opts: unknown, cmd: Command) => {
|
||||
claudexHandler(cmd.args, false);
|
||||
});
|
||||
|
||||
program
|
||||
.command('yolo <runtime>')
|
||||
.description('Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi)')
|
||||
.description(
|
||||
'Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi|claudex)',
|
||||
)
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action((runtime: string, _opts: unknown, cmd: Command) => {
|
||||
// claudex is an EXPERIMENTAL overlay, not a RuntimeName — dispatch it
|
||||
// before the runtime allowlist check. Slice off the positional runtime
|
||||
// name for the same reason as below (#454).
|
||||
if (runtime === 'claudex') {
|
||||
claudexHandler(cmd.args.slice(1), true);
|
||||
return;
|
||||
}
|
||||
const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
|
||||
if (!valid.includes(runtime as RuntimeName)) {
|
||||
console.error(
|
||||
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}`,
|
||||
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}|claudex`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
466
packages/mosaic/src/commands/restore.spec.ts
Normal file
466
packages/mosaic/src/commands/restore.spec.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* Tests for `mosaic restore` (#791 PR2 Task 12).
|
||||
*
|
||||
* The durable pre-update snapshot (install.sh: make_durable_snapshot) writes the
|
||||
* operator-owned surface to $XDG_STATE_HOME/mosaic/backups/pre-update-<ts>/ with
|
||||
* 0700 dirs / 0600 files. `mosaic restore` is the recovery counterpart:
|
||||
* • --list (default) enumerate snapshots by timestamp — dry-run, never mutates.
|
||||
* • --from <ts> restore that snapshot over MOSAIC_HOME, confirmation-gated.
|
||||
* It reports counts and relative paths ONLY — a snapshot may contain secrets
|
||||
* (credentials.json), so no file content is ever printed (secrev invariant).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
lstatSync,
|
||||
symlinkSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { Command } from 'commander';
|
||||
import {
|
||||
resolveBackupRoot,
|
||||
listSnapshots,
|
||||
resolveSnapshotDir,
|
||||
planRestore,
|
||||
applyRestore,
|
||||
runRestore,
|
||||
registerRestoreCommand,
|
||||
} from './restore.js';
|
||||
|
||||
const SECRET = 'SUPER-SECRET-TOKEN-do-not-log-restore';
|
||||
|
||||
function seedSnapshot(root: string, ts: string, files: Record<string, string>): string {
|
||||
const dir = join(root, `pre-update-${ts}`);
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const abs = join(dir, rel);
|
||||
mkdirSync(join(abs, '..'), { recursive: true });
|
||||
writeFileSync(abs, content);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('mosaic restore (#791 PR2)', () => {
|
||||
let tmp: string;
|
||||
let backups: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'mosaic-restore-'));
|
||||
backups = join(tmp, 'state', 'mosaic', 'backups');
|
||||
mkdirSync(backups, { recursive: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('resolveBackupRoot', () => {
|
||||
it('honors XDG_STATE_HOME', () => {
|
||||
expect(resolveBackupRoot({ XDG_STATE_HOME: '/x/state' })).toBe('/x/state/mosaic/backups');
|
||||
});
|
||||
it('falls back to ~/.local/state', () => {
|
||||
expect(resolveBackupRoot({})).toBe(join(homedir(), '.local', 'state', 'mosaic', 'backups'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSnapshots', () => {
|
||||
it('returns [] when the backup root does not exist', () => {
|
||||
expect(listSnapshots(join(tmp, 'nope'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists pre-update snapshots newest-first with file counts, ignoring other dirs', () => {
|
||||
seedSnapshot(backups, '20240101T000000Z', { 'SOUL.md': 'a' });
|
||||
seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'b', 'agents/x.conf': 'c' });
|
||||
mkdirSync(join(backups, 'unrelated-dir'), { recursive: true });
|
||||
|
||||
const snaps = listSnapshots(backups);
|
||||
expect(snaps.map((s) => s.timestamp)).toEqual(['20260101T000000Z', '20240101T000000Z']);
|
||||
expect(snaps[0]!.fileCount).toBe(2);
|
||||
expect(snaps[1]!.fileCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSnapshotDir', () => {
|
||||
it('resolves by bare timestamp and by full pre-update-<ts> name', () => {
|
||||
const dir = seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'a' });
|
||||
expect(resolveSnapshotDir(backups, '20260101T000000Z')).toBe(dir);
|
||||
expect(resolveSnapshotDir(backups, 'pre-update-20260101T000000Z')).toBe(dir);
|
||||
});
|
||||
it('returns undefined for an unknown timestamp', () => {
|
||||
expect(resolveSnapshotDir(backups, '19990101T000000Z')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('planRestore', () => {
|
||||
it('walks nested dirs and returns every relative file path', () => {
|
||||
const dir = seedSnapshot(backups, '20260101T000000Z', {
|
||||
'SOUL.md': 'a',
|
||||
'agents/x.conf': 'b',
|
||||
'tools/_lib/credentials.json': 'c',
|
||||
});
|
||||
expect(planRestore(dir).sort()).toEqual(
|
||||
['SOUL.md', 'agents/x.conf', 'tools/_lib/credentials.json'].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyRestore', () => {
|
||||
it('restores byte-exact content, creates parent dirs, and sets 0600', () => {
|
||||
const dir = seedSnapshot(backups, '20260101T000000Z', {
|
||||
'SOUL.md': 'original-soul',
|
||||
'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
|
||||
});
|
||||
const home = join(tmp, 'home');
|
||||
mkdirSync(home, { recursive: true });
|
||||
// A diverged operator file that restore must overwrite.
|
||||
writeFileSync(join(home, 'SOUL.md'), 'CORRUPTED');
|
||||
|
||||
const n = applyRestore(dir, home, planRestore(dir));
|
||||
expect(n).toBe(2);
|
||||
expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('original-soul');
|
||||
expect(readFileSync(join(home, 'tools/_lib/credentials.json'), 'utf8')).toBe(
|
||||
`TOKEN=${SECRET}\n`,
|
||||
);
|
||||
expect(statSync(join(home, 'SOUL.md')).mode & 0o777).toBe(0o600);
|
||||
expect(statSync(join(home, 'tools/_lib/credentials.json')).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runRestore', () => {
|
||||
it('--list prints timestamps and counts, mutating nothing', async () => {
|
||||
seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'a', 'agents/x.conf': 'b' });
|
||||
const home = join(tmp, 'home');
|
||||
mkdirSync(home, { recursive: true });
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
const code = await runRestore({
|
||||
list: true,
|
||||
mosaicHome: home,
|
||||
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
const out = log.mock.calls.flat().join('\n');
|
||||
expect(out).toContain('20260101T000000Z');
|
||||
expect(out).toMatch(/2\b/); // the file count is surfaced
|
||||
log.mockRestore();
|
||||
});
|
||||
|
||||
it('--from restores the snapshot over MOSAIC_HOME byte-exact (yes bypasses prompt)', async () => {
|
||||
seedSnapshot(backups, '20260101T000000Z', {
|
||||
'SOUL.md': 'restored-soul',
|
||||
'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
|
||||
});
|
||||
const home = join(tmp, 'home');
|
||||
mkdirSync(home, { recursive: true });
|
||||
writeFileSync(join(home, 'SOUL.md'), 'STALE');
|
||||
|
||||
const code = await runRestore({
|
||||
from: '20260101T000000Z',
|
||||
yes: true,
|
||||
mosaicHome: home,
|
||||
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('restored-soul');
|
||||
expect(readFileSync(join(home, 'tools/_lib/credentials.json'), 'utf8')).toBe(
|
||||
`TOKEN=${SECRET}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it('--from with an unknown timestamp fails without mutating', async () => {
|
||||
const home = join(tmp, 'home');
|
||||
mkdirSync(home, { recursive: true });
|
||||
writeFileSync(join(home, 'SOUL.md'), 'KEEP');
|
||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const code = await runRestore({
|
||||
from: '19990101T000000Z',
|
||||
yes: true,
|
||||
mosaicHome: home,
|
||||
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('KEEP');
|
||||
err.mockRestore();
|
||||
});
|
||||
|
||||
it('--dry-run with --from reports the plan but mutates nothing', async () => {
|
||||
seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'snap' });
|
||||
const home = join(tmp, 'home');
|
||||
mkdirSync(home, { recursive: true });
|
||||
writeFileSync(join(home, 'SOUL.md'), 'UNCHANGED');
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
const code = await runRestore({
|
||||
from: '20260101T000000Z',
|
||||
dryRun: true,
|
||||
mosaicHome: home,
|
||||
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('UNCHANGED');
|
||||
log.mockRestore();
|
||||
});
|
||||
|
||||
it('--from prompts and applies the restore when the operator confirms', async () => {
|
||||
seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'confirmed-soul' });
|
||||
const home = join(tmp, 'home');
|
||||
mkdirSync(home, { recursive: true });
|
||||
writeFileSync(join(home, 'SOUL.md'), 'STALE');
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const confirm = vi.fn().mockResolvedValue(true);
|
||||
|
||||
const code = await runRestore({
|
||||
from: '20260101T000000Z',
|
||||
mosaicHome: home,
|
||||
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||
confirm,
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(confirm).toHaveBeenCalledOnce();
|
||||
expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('confirmed-soul');
|
||||
});
|
||||
|
||||
it('--from aborts without mutating when the operator declines', async () => {
|
||||
seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'snap' });
|
||||
const home = join(tmp, 'home');
|
||||
mkdirSync(home, { recursive: true });
|
||||
writeFileSync(join(home, 'SOUL.md'), 'KEEP');
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const confirm = vi.fn().mockResolvedValue(false);
|
||||
|
||||
const code = await runRestore({
|
||||
from: '20260101T000000Z',
|
||||
mosaicHome: home,
|
||||
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||
confirm,
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(confirm).toHaveBeenCalledOnce();
|
||||
expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('KEEP');
|
||||
});
|
||||
|
||||
it('MOSAIC_ASSUME_YES=1 bypasses the confirmation prompt', async () => {
|
||||
seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'env-yes' });
|
||||
const home = join(tmp, 'home');
|
||||
mkdirSync(home, { recursive: true });
|
||||
writeFileSync(join(home, 'SOUL.md'), 'STALE');
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const confirm = vi.fn().mockResolvedValue(false);
|
||||
|
||||
const code = await runRestore({
|
||||
from: '20260101T000000Z',
|
||||
mosaicHome: home,
|
||||
env: { XDG_STATE_HOME: join(tmp, 'state'), MOSAIC_ASSUME_YES: '1' },
|
||||
confirm,
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('env-yes');
|
||||
});
|
||||
|
||||
it('--list reports gracefully when no snapshots exist', async () => {
|
||||
const home = join(tmp, 'home');
|
||||
mkdirSync(home, { recursive: true });
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
const code = await runRestore({
|
||||
list: true,
|
||||
mosaicHome: home,
|
||||
env: { XDG_STATE_HOME: join(tmp, 'empty-state') },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(log.mock.calls.flat().join('\n')).toMatch(/No pre-update snapshots/);
|
||||
});
|
||||
|
||||
it('never prints a secret value found inside a backed-up file', async () => {
|
||||
seedSnapshot(backups, '20260101T000000Z', {
|
||||
'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
|
||||
});
|
||||
const home = join(tmp, 'home');
|
||||
mkdirSync(home, { recursive: true });
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await runRestore({
|
||||
list: true,
|
||||
mosaicHome: home,
|
||||
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||
});
|
||||
await runRestore({
|
||||
from: '20260101T000000Z',
|
||||
yes: true,
|
||||
mosaicHome: home,
|
||||
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||
});
|
||||
|
||||
const all = [...log.mock.calls, ...err.mock.calls].flat().join('\n');
|
||||
expect(all).not.toContain(SECRET);
|
||||
log.mockRestore();
|
||||
err.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// Regression coverage for the codex code+security review of PR2 (#791):
|
||||
// CWE-22 traversal via --from, and CWE-59 symlink write-through in applyRestore.
|
||||
describe('security hardening', () => {
|
||||
it.each([
|
||||
'../../etc',
|
||||
'pre-update-/../../tmp/poison',
|
||||
'pre-update-../evil',
|
||||
'20260101T000000Z/../../../tmp',
|
||||
'not-a-timestamp',
|
||||
'2026-01-01',
|
||||
])('resolveSnapshotDir rejects traversal / malformed selector %j', (bad) => {
|
||||
// Even if a matching directory exists on disk, a non-timestamp selector
|
||||
// must not resolve — the only accepted shape is <8>T<6>Z[-n].
|
||||
expect(resolveSnapshotDir(backups, bad)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('runRestore --from a traversal selector fails closed without copying', async () => {
|
||||
// Plant a real dir one level ABOVE the backup root. The naive resolver
|
||||
// `join(root, from)` with `from='../poison'` would reach it (backups is
|
||||
// .../mosaic/backups, so `../poison` == .../mosaic/poison) and import it.
|
||||
const outside = join(tmp, 'state', 'mosaic', 'poison');
|
||||
mkdirSync(outside, { recursive: true });
|
||||
writeFileSync(join(outside, 'x'), 'attacker');
|
||||
const home = join(tmp, 'home');
|
||||
mkdirSync(home, { recursive: true });
|
||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const code = await runRestore({
|
||||
from: '../poison',
|
||||
yes: true,
|
||||
mosaicHome: home,
|
||||
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(statSync(home).isDirectory()).toBe(true);
|
||||
// Nothing from `outside` was imported.
|
||||
expect(() => statSync(join(home, 'x'))).toThrow();
|
||||
err.mockRestore();
|
||||
});
|
||||
|
||||
it('applyRestore refuses to write a secret through a symlinked leaf (CWE-59)', () => {
|
||||
const dir = seedSnapshot(backups, '20260101T000000Z', {
|
||||
'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
|
||||
});
|
||||
const home = join(tmp, 'home');
|
||||
mkdirSync(join(home, 'tools', '_lib'), { recursive: true });
|
||||
// Attacker points the operator credentials file at a file they can read.
|
||||
const exfil = join(tmp, 'exfil-target');
|
||||
writeFileSync(exfil, 'original-attacker-content');
|
||||
symlinkSync(exfil, join(home, 'tools', '_lib', 'credentials.json'));
|
||||
|
||||
expect(() => applyRestore(dir, home, planRestore(dir))).toThrow();
|
||||
// The secret was NOT written through the link into the attacker's file.
|
||||
expect(readFileSync(exfil, 'utf8')).toBe('original-attacker-content');
|
||||
});
|
||||
|
||||
it('applyRestore refuses to write through a symlinked ancestor (CWE-59)', () => {
|
||||
const dir = seedSnapshot(backups, '20260101T000000Z', {
|
||||
'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
|
||||
});
|
||||
const home = join(tmp, 'home');
|
||||
mkdirSync(join(home, 'tools'), { recursive: true });
|
||||
// Attacker replaces the `tools/_lib` ancestor with a symlink out of the root.
|
||||
const exfilDir = join(tmp, 'exfil-dir');
|
||||
mkdirSync(exfilDir, { recursive: true });
|
||||
symlinkSync(exfilDir, join(home, 'tools', '_lib'));
|
||||
|
||||
expect(() => applyRestore(dir, home, planRestore(dir))).toThrow();
|
||||
// Nothing was written into the attacker-controlled directory.
|
||||
expect(() => statSync(join(exfilDir, 'credentials.json'))).toThrow();
|
||||
});
|
||||
|
||||
it('runRestore surfaces a symlink violation as exit 1 without leaking the secret', async () => {
|
||||
seedSnapshot(backups, '20260101T000000Z', {
|
||||
'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
|
||||
});
|
||||
const home = join(tmp, 'home');
|
||||
mkdirSync(join(home, 'tools', '_lib'), { recursive: true });
|
||||
const exfil = join(tmp, 'exfil-target');
|
||||
writeFileSync(exfil, 'attacker');
|
||||
symlinkSync(exfil, join(home, 'tools', '_lib', 'credentials.json'));
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const code = await runRestore({
|
||||
from: '20260101T000000Z',
|
||||
yes: true,
|
||||
mosaicHome: home,
|
||||
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(readFileSync(exfil, 'utf8')).toBe('attacker');
|
||||
const all = [...log.mock.calls, ...err.mock.calls].flat().join('\n');
|
||||
expect(all).not.toContain(SECRET);
|
||||
log.mockRestore();
|
||||
err.mockRestore();
|
||||
});
|
||||
|
||||
it('applyRestore replaces a diverged regular file in place with 0600 (not a symlink)', () => {
|
||||
const dir = seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'restored' });
|
||||
const home = join(tmp, 'home');
|
||||
mkdirSync(home, { recursive: true });
|
||||
writeFileSync(join(home, 'SOUL.md'), 'stale');
|
||||
|
||||
const n = applyRestore(dir, home, planRestore(dir));
|
||||
expect(n).toBe(1);
|
||||
expect(lstatSync(join(home, 'SOUL.md')).isSymbolicLink()).toBe(false);
|
||||
expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('restored');
|
||||
expect(statSync(join(home, 'SOUL.md')).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerRestoreCommand', () => {
|
||||
it('registers `restore` with the expected flags', () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerRestoreCommand(program);
|
||||
const cmd = program.commands.find((c) => c.name() === 'restore');
|
||||
expect(cmd).toBeDefined();
|
||||
const longs = cmd!.options.map((o) => o.long);
|
||||
expect(longs).toEqual(
|
||||
expect.arrayContaining(['--list', '--from', '--dry-run', '--yes', '--mosaic-home']),
|
||||
);
|
||||
});
|
||||
|
||||
it('runs the list action end-to-end via the parsed command', async () => {
|
||||
seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'a' });
|
||||
const prevXdg = process.env['XDG_STATE_HOME'];
|
||||
process.env['XDG_STATE_HOME'] = join(tmp, 'state');
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerRestoreCommand(program);
|
||||
await program.parseAsync(['restore', '--list', '--mosaic-home', join(tmp, 'home')], {
|
||||
from: 'user',
|
||||
});
|
||||
expect(log.mock.calls.flat().join('\n')).toContain('20260101T000000Z');
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
if (prevXdg === undefined) delete process.env['XDG_STATE_HOME'];
|
||||
else process.env['XDG_STATE_HOME'] = prevXdg;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
313
packages/mosaic/src/commands/restore.ts
Normal file
313
packages/mosaic/src/commands/restore.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* restore.ts — top-level `mosaic restore` command (#791 PR2 Task 12)
|
||||
*
|
||||
* Recovery counterpart to the durable pre-update snapshot taken by install.sh
|
||||
* (make_durable_snapshot). Before a keep-mode upgrade mutates anything, the
|
||||
* installer copies the operator-owned surface to
|
||||
* $XDG_STATE_HOME/mosaic/backups/pre-update-<UTC-ts>/ (0700 dirs / 0600 files)
|
||||
* This command lets the operator inspect and roll back to those snapshots:
|
||||
*
|
||||
* mosaic restore # == --list: enumerate snapshots (dry-run)
|
||||
* mosaic restore --list
|
||||
* mosaic restore --from <ts> # restore that snapshot over MOSAIC_HOME
|
||||
* mosaic restore --from <ts> --dry-run
|
||||
*
|
||||
* SECREV INVARIANT: a snapshot may contain secrets (e.g. tools/_lib/credentials.json).
|
||||
* This command reports counts and RELATIVE PATHS only — it never reads a backed-up
|
||||
* file into any logged string. Restored files are written back 0600 (owner-only),
|
||||
* matching the snapshot's own private posture. The path convention here mirrors
|
||||
* install.sh `backup_root()`; keep the two in sync (no shared code across the boundary).
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
lstatSync,
|
||||
readFileSync,
|
||||
openSync,
|
||||
writeSync,
|
||||
fchmodSync,
|
||||
closeSync,
|
||||
constants,
|
||||
} from 'node:fs';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname, relative } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||
import { assertCanonicalContainment, ensureManagedDirectory } from '../fleet/secure-file.js';
|
||||
|
||||
// ─── types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SnapshotInfo {
|
||||
/** The UTC stamp after the `pre-update-` prefix, e.g. "20260716T232225Z". */
|
||||
readonly timestamp: string;
|
||||
/** Absolute path to the snapshot directory. */
|
||||
readonly dir: string;
|
||||
/** Number of files captured in the snapshot. */
|
||||
readonly fileCount: number;
|
||||
}
|
||||
|
||||
export interface RestoreOptions {
|
||||
list?: boolean;
|
||||
from?: string;
|
||||
dryRun?: boolean;
|
||||
yes?: boolean;
|
||||
mosaicHome: string;
|
||||
/** Environment source (injectable for tests); defaults to process.env. */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/**
|
||||
* Confirmation gate (injectable for tests); defaults to an interactive
|
||||
* readline prompt. Returns true to proceed with the overwrite.
|
||||
*/
|
||||
confirm?: (question: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const SNAPSHOT_PREFIX = 'pre-update-';
|
||||
|
||||
/**
|
||||
* The exact shape install.sh `make_durable_snapshot()` stamps: `<8>T<6>Z` UTC,
|
||||
* with an optional `-<n>` same-second collision suffix. `--from` is matched
|
||||
* against this — nothing containing a path separator or `..` can pass, so a
|
||||
* selector can never escape the backup root (CWE-22).
|
||||
*/
|
||||
const SNAPSHOT_TS_RE = /^\d{8}T\d{6}Z(?:-\d+)?$/;
|
||||
|
||||
// ─── pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve the durable-snapshot root, mirroring install.sh `backup_root()`. */
|
||||
export function resolveBackupRoot(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const stateHome = env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state');
|
||||
return join(stateHome, 'mosaic', 'backups');
|
||||
}
|
||||
|
||||
/** Recursively collect every file under `dir` as a path relative to `dir`. */
|
||||
export function planRestore(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
const walk = (cur: string): void => {
|
||||
for (const entry of readdirSync(cur, { withFileTypes: true })) {
|
||||
const abs = join(cur, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(abs);
|
||||
} else if (entry.isFile()) {
|
||||
out.push(relative(dir, abs));
|
||||
}
|
||||
}
|
||||
};
|
||||
if (existsSync(dir)) walk(dir);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Enumerate snapshots newest-first (the `pre-update-<ts>` names sort chronologically). */
|
||||
export function listSnapshots(root: string): SnapshotInfo[] {
|
||||
if (!existsSync(root)) return [];
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(root);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return entries
|
||||
.filter((name) => name.startsWith(SNAPSHOT_PREFIX))
|
||||
.map((name) => join(root, name))
|
||||
.filter((dir) => {
|
||||
try {
|
||||
return statSync(dir).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.sort()
|
||||
.reverse()
|
||||
.map((dir) => ({
|
||||
timestamp: dir.split('/').at(-1)!.slice(SNAPSHOT_PREFIX.length),
|
||||
dir,
|
||||
fileCount: planRestore(dir).length,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a snapshot dir from a `--from` selector. Accepts ONLY a strict
|
||||
* generated identifier — a bare `<ts>` or the full `pre-update-<ts>` name — and
|
||||
* builds exactly `join(root, 'pre-update-' + ts)`. A selector containing `/`,
|
||||
* `..`, or anything but the timestamp shape is rejected (returns undefined), so
|
||||
* `--from` can never traverse outside the backup root (CWE-22). The resolved dir
|
||||
* must be a real, non-symlink directory (lstat, not stat), so a symlinked
|
||||
* snapshot entry can't redirect the restore either.
|
||||
*/
|
||||
export function resolveSnapshotDir(root: string, from: string): string | undefined {
|
||||
const ts = from.startsWith(SNAPSHOT_PREFIX) ? from.slice(SNAPSHOT_PREFIX.length) : from;
|
||||
if (!SNAPSHOT_TS_RE.test(ts)) return undefined;
|
||||
const dir = join(root, `${SNAPSHOT_PREFIX}${ts}`);
|
||||
try {
|
||||
if (lstatSync(dir).isDirectory()) return dir;
|
||||
} catch {
|
||||
/* absent or inaccessible */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy each `relPaths` entry from the snapshot back into `mosaicHome`, forcing
|
||||
* 0600 on the restored file (owner-only — the operator surface may hold secrets).
|
||||
* Returns the number of files restored. Never reads a file's content into a
|
||||
* logged string.
|
||||
*
|
||||
* SYMLINK-SAFE (CWE-59): a snapshot may hold secrets, so we must never let a
|
||||
* tampered destination redirect the write. Every destination path is contained
|
||||
* within `mosaicHome` (assertCanonicalContainment) and every ancestor is proven
|
||||
* to be a real, non-symlink directory (ensureManagedDirectory) before we write.
|
||||
* The leaf itself is opened O_NOFOLLOW, so if it was swapped for a symlink the
|
||||
* open fails closed (ELOOP) rather than writing the secret through the link.
|
||||
*/
|
||||
export function applyRestore(
|
||||
snapDir: string,
|
||||
mosaicHome: string,
|
||||
relPaths: readonly string[],
|
||||
): number {
|
||||
let restored = 0;
|
||||
for (const rel of relPaths) {
|
||||
const src = join(snapDir, rel);
|
||||
const dst = join(mosaicHome, rel);
|
||||
// Fail closed if the target path escapes the managed root or any ancestor is
|
||||
// a symlink; create missing ancestors as private (0700) real directories.
|
||||
assertCanonicalContainment(mosaicHome, dst);
|
||||
ensureManagedDirectory(mosaicHome, dirname(dst));
|
||||
// O_NOFOLLOW: refuse to follow a symlink at the leaf (secret exfil guard).
|
||||
// O_CREAT|O_TRUNC: create a fresh 0600 file, or overwrite a diverged real one.
|
||||
const fd = openSync(
|
||||
dst,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
fchmodSync(fd, 0o600); // enforce 0600 even when the file pre-existed
|
||||
writeSync(fd, readFileSync(src));
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
restored += 1;
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
// ─── orchestration ────────────────────────────────────────────────────────────
|
||||
|
||||
async function promptConfirm(question: string): Promise<boolean> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
rl.question(`${question} [y/N] `, (ans) => resolve(ans.trim().toLowerCase() === 'y'));
|
||||
});
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `mosaic restore`. Returns a process exit code (0 ok, 1 error) rather than
|
||||
* calling process.exit, so it stays unit-testable.
|
||||
*/
|
||||
export async function runRestore(opts: RestoreOptions): Promise<number> {
|
||||
const env = opts.env ?? process.env;
|
||||
const root = resolveBackupRoot(env);
|
||||
|
||||
// Default action (and explicit --list): enumerate, never mutate.
|
||||
if (opts.list || !opts.from) {
|
||||
const snaps = listSnapshots(root);
|
||||
if (snaps.length === 0) {
|
||||
console.log(`No pre-update snapshots found under ${root}.`);
|
||||
return 0;
|
||||
}
|
||||
console.log(`Pre-update snapshots under ${root} (newest first):\n`);
|
||||
for (const s of snaps) {
|
||||
console.log(` ${s.timestamp} — ${s.fileCount} file(s)`);
|
||||
}
|
||||
console.log(`\nRestore one with: mosaic restore --from <timestamp>`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --from <ts>: restore over the operator surface.
|
||||
const snapDir = resolveSnapshotDir(root, opts.from);
|
||||
if (!snapDir) {
|
||||
console.error(`No snapshot matching '${opts.from}' under ${root}.`);
|
||||
console.error(`Run 'mosaic restore --list' to see available timestamps.`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const relPaths = planRestore(snapDir);
|
||||
const ts = snapDir.split('/').at(-1)!.slice(SNAPSHOT_PREFIX.length);
|
||||
|
||||
if (opts.dryRun) {
|
||||
console.log(
|
||||
`[dry-run] Would restore ${relPaths.length} file(s) from snapshot ${ts} into ${opts.mosaicHome}:`,
|
||||
);
|
||||
for (const rel of relPaths) console.log(` ${rel}`);
|
||||
console.log('[dry-run] No changes made.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const assumeYes = opts.yes || env['MOSAIC_ASSUME_YES'] === '1';
|
||||
if (!assumeYes) {
|
||||
console.log(
|
||||
`About to restore ${relPaths.length} operator file(s) from snapshot ${ts} into ${opts.mosaicHome}.`,
|
||||
);
|
||||
console.log('This OVERWRITES those files with their pre-update contents.');
|
||||
const ok = await (opts.confirm ?? promptConfirm)('Proceed?');
|
||||
if (!ok) {
|
||||
console.log('Restore cancelled. No changes made.');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
let n: number;
|
||||
try {
|
||||
n = applyRestore(snapDir, opts.mosaicHome, relPaths);
|
||||
} catch (err) {
|
||||
// A containment/symlink violation is a fail-closed security stop, not a
|
||||
// routine error — surface it without leaking file contents and abort.
|
||||
console.error(
|
||||
`Restore aborted: a destination path under ${opts.mosaicHome} is unsafe to write ` +
|
||||
`(symlink or escapes the managed root). No files were restored. (${(err as Error).message})`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
console.log(`Restored ${n} operator file(s) from snapshot ${ts} into ${opts.mosaicHome}.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ─── commander registration ───────────────────────────────────────────────────
|
||||
|
||||
export function registerRestoreCommand(program: Command): void {
|
||||
program
|
||||
.command('restore')
|
||||
.description('List or restore durable pre-update snapshots of your operator config (#791)')
|
||||
.option('--list', 'List available snapshots by timestamp (default action)')
|
||||
.option('--from <timestamp>', 'Restore the snapshot with this timestamp over MOSAIC_HOME')
|
||||
.option('--dry-run', 'With --from: show what would be restored without changing anything')
|
||||
.option('--yes, -y', 'Skip the confirmation prompt (also: MOSAIC_ASSUME_YES=1)')
|
||||
.option(
|
||||
'--mosaic-home <path>',
|
||||
'Override MOSAIC_HOME directory',
|
||||
process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME,
|
||||
)
|
||||
.action(
|
||||
async (opts: {
|
||||
list?: boolean;
|
||||
from?: string;
|
||||
dryRun?: boolean;
|
||||
yes?: boolean;
|
||||
mosaicHome: string;
|
||||
}) => {
|
||||
const code = await runRestore({
|
||||
list: opts.list,
|
||||
from: opts.from,
|
||||
dryRun: opts.dryRun,
|
||||
yes: opts.yes,
|
||||
mosaicHome: opts.mosaicHome,
|
||||
});
|
||||
if (code !== 0) process.exit(code);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,22 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
copyFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { FileConfigAdapter, DEFAULT_SEED_FILES } from './file-adapter.js';
|
||||
|
||||
// The real shipping manifest — the fixture uses it verbatim so these tests
|
||||
// exercise production ownership resolution, not a synthetic copy (#791).
|
||||
const REAL_FRAMEWORK_ROOT = fileURLToPath(new URL('../../framework', import.meta.url));
|
||||
|
||||
/**
|
||||
* Regression tests for the `FileConfigAdapter.syncFramework` seed behavior.
|
||||
*
|
||||
@@ -34,6 +47,13 @@ function makeFixture(): { sourceDir: string; mosaicHome: string; defaultsDir: st
|
||||
mkdirSync(defaultsDir, { recursive: true });
|
||||
mkdirSync(mosaicHome, { recursive: true });
|
||||
|
||||
// #791: syncFramework resolves ownership from the shared manifest under the
|
||||
// source dir. Seed the real one so keep-mode syncs behave as in production.
|
||||
copyFileSync(
|
||||
join(REAL_FRAMEWORK_ROOT, 'framework-manifest.txt'),
|
||||
join(sourceDir, 'framework-manifest.txt'),
|
||||
);
|
||||
|
||||
// Framework-contract defaults we expect the wizard to seed.
|
||||
writeFileSync(join(defaultsDir, 'CONSTITUTION.md'), '# CONSTITUTION default\n');
|
||||
writeFileSync(join(defaultsDir, 'AGENTS.md'), '# AGENTS default\n');
|
||||
@@ -102,9 +122,10 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
});
|
||||
|
||||
it('overwrites framework-owned files (backup-once) but preserves user-seeded files', async () => {
|
||||
// Plant a root-level AGENTS.md in sourceDir so syncDirectory's preserve is exercised.
|
||||
writeFileSync(join(fixture.sourceDir, 'AGENTS.md'), '# shipped AGENTS from source root\n');
|
||||
|
||||
// Contract files (CONSTITUTION/AGENTS/STANDARDS) ship only under defaults/ —
|
||||
// reconcile_framework_files is their sole writer (backup-once). The bulk
|
||||
// sync never sees a root-level copy, so a user's edited root file is backed
|
||||
// up, not silently clobbered, on upgrade.
|
||||
writeFileSync(join(fixture.mosaicHome, 'TOOLS.md'), '# user-customized TOOLS\n');
|
||||
writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n');
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
buildToolsTemplateVars,
|
||||
} from '../template/builders.js';
|
||||
import { atomicWrite, backupFile, syncDirectory } from '../platform/file-ops.js';
|
||||
import { loadManifest, resolveOwnership } from '../framework/manifest.js';
|
||||
|
||||
/**
|
||||
* Parse a SoulConfig from an existing SOUL.md file.
|
||||
@@ -155,38 +156,22 @@ export class FileConfigAdapter implements ConfigService {
|
||||
}
|
||||
|
||||
async syncFramework(action: InstallAction): Promise<void> {
|
||||
// Must match PRESERVE_PATHS in packages/mosaic/framework/install.sh so
|
||||
// the bash and TS install paths have the same upgrade-preservation
|
||||
// semantics. Contract files (AGENTS.md, STANDARDS.md, TOOLS.md) are
|
||||
// seeded from defaults/ on first install and preserved thereafter;
|
||||
// identity files (SOUL.md, USER.md) are generated by wizard stages and
|
||||
// must never be touched by the framework sync.
|
||||
const preservePaths =
|
||||
action === 'keep' || action === 'reconfigure'
|
||||
? [
|
||||
'CONSTITUTION.md',
|
||||
'AGENTS.md',
|
||||
'SOUL.md',
|
||||
'USER.md',
|
||||
'TOOLS.md',
|
||||
'STANDARDS.md',
|
||||
'memory',
|
||||
'sources',
|
||||
'credentials',
|
||||
// User-authored fleet data MUST survive `mosaic update`'s re-seed.
|
||||
// The framework seeds only fleet/examples + fleet/roles +
|
||||
// fleet/roster.schema.json; the operator's roster, per-agent env, and
|
||||
// heartbeat run dir stay user-owned. (Mirror of install.sh PRESERVE_PATHS.)
|
||||
'fleet/roster.yaml',
|
||||
'fleet/roster.json',
|
||||
'fleet/agents',
|
||||
'fleet/run',
|
||||
]
|
||||
: [];
|
||||
// #791: ownership is derived from the shared framework manifest
|
||||
// (packages/mosaic/framework/framework-manifest.txt) — the SAME file the
|
||||
// bash installer reads — so the TS and bash paths can never drift. On an
|
||||
// upgrade (keep/reconfigure) the sync must NEVER write an operator-owned
|
||||
// path: every operator file, and any path the manifest never anticipated
|
||||
// (which resolves to operator by the fail-safe default), is left untouched.
|
||||
// A fresh install ('overwrite'/'reconfigure' onto an empty home) seeds the
|
||||
// full tree, so the guard applies only when preserving an existing home.
|
||||
const guardOwnership = action === 'keep' || action === 'reconfigure';
|
||||
const manifest = guardOwnership ? loadManifest(this.sourceDir) : undefined;
|
||||
|
||||
syncDirectory(this.sourceDir, this.mosaicHome, {
|
||||
preserve: preservePaths,
|
||||
excludeGit: true,
|
||||
isOperatorOwned: manifest
|
||||
? (relPath) => resolveOwnership(manifest, relPath) === 'operator'
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Reconcile framework-contract files from framework/defaults/ into the mosaic
|
||||
|
||||
@@ -381,6 +381,22 @@ function isMissingFile(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-blocking acquire of the roster mutation lock: an exclusive `wx` create that
|
||||
* throws `concurrent-mutation` (never waits) if the lock is already held. The
|
||||
* on-disk format is an empty private file. CRUD releases in a plain `finally` with
|
||||
* no result-preservation logic, so a release fault here must not override the
|
||||
* mutation result — the release swallows unlink failures by design.
|
||||
*
|
||||
* The recovery-framed `fleet regen` contends on this exact same lock file, but it
|
||||
* does so through the hardened, ownership-proving acquirer in the reconciler
|
||||
* (`acquirePrivateRosterMutationLock`), not this one: whoever wins the `wx` create
|
||||
* owns the file (the loser always gets `concurrent-mutation`), so the reconciler's
|
||||
* ownership token is only ever written and read back by the same regen invocation,
|
||||
* never by this empty-file writer. The two acquirers stay mutually compatible at
|
||||
* the `wx`-contention layer while regen additionally proves ownership before it
|
||||
* unlinks — a guarantee CRUD does not need because it releases unconditionally.
|
||||
*/
|
||||
async function acquireMutationLock(lockPath: string): Promise<() => Promise<void>> {
|
||||
try {
|
||||
const handle = await open(lockPath, 'wx', 0o600);
|
||||
|
||||
@@ -1,889 +0,0 @@
|
||||
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 commandBasename(token: string): string {
|
||||
return token.slice(token.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
function shellCommandSegments(source: string): string[] {
|
||||
const segments: string[] = [];
|
||||
let segment = '';
|
||||
let quote = '';
|
||||
let wordStarted = false;
|
||||
|
||||
const flush = (): void => {
|
||||
if (segment.trim() !== '') segments.push(segment);
|
||||
segment = '';
|
||||
wordStarted = false;
|
||||
};
|
||||
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const character = source[index] ?? '';
|
||||
const next = source[index + 1] ?? '';
|
||||
if (character === '\\' && quote !== "'") {
|
||||
if (next === '\n') {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
segment += character + next;
|
||||
wordStarted = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (quote !== '') {
|
||||
segment += character;
|
||||
if (character === quote) quote = '';
|
||||
continue;
|
||||
}
|
||||
if (character === "'" || character === '"') {
|
||||
segment += character;
|
||||
quote = character;
|
||||
wordStarted = true;
|
||||
continue;
|
||||
}
|
||||
if (character === '#' && !wordStarted && segment.trim() !== '') {
|
||||
while (index + 1 < source.length && source[index + 1] !== '\n') index += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === '\n' || character === ';' || character === '&' || character === '|') {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
segment += character;
|
||||
if (/\s/.test(character)) wordStarted = false;
|
||||
else wordStarted = true;
|
||||
}
|
||||
flush();
|
||||
return segments;
|
||||
}
|
||||
|
||||
function shellCommandTokens(source: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let token = '';
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
|
||||
for (const character of source) {
|
||||
if (escaped) {
|
||||
token += character;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (character === '\\' && quote !== "'") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (quote !== '') {
|
||||
if (character === quote) quote = '';
|
||||
else token += character;
|
||||
continue;
|
||||
}
|
||||
if (character === "'" || character === '"') {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(character)) {
|
||||
if (token !== '') {
|
||||
tokens.push(token);
|
||||
token = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
token += character;
|
||||
}
|
||||
if (escaped) token += '\\';
|
||||
if (token !== '') tokens.push(token);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function envSplitStringTokens(source: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let token = '';
|
||||
let quote = '';
|
||||
let tokenStarted = false;
|
||||
|
||||
const flush = (): void => {
|
||||
if (tokenStarted) tokens.push(token);
|
||||
token = '';
|
||||
tokenStarted = false;
|
||||
};
|
||||
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const character = source[index] ?? '';
|
||||
if (character === "'" || character === '"') {
|
||||
if (quote === '') {
|
||||
quote = character;
|
||||
tokenStarted = true;
|
||||
continue;
|
||||
}
|
||||
if (quote === character) {
|
||||
quote = '';
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (character === '\\') {
|
||||
const escaped = source[index + 1];
|
||||
if (escaped === undefined) {
|
||||
token += '\\';
|
||||
tokenStarted = true;
|
||||
continue;
|
||||
}
|
||||
if (quote === "'" && escaped !== "'" && escaped !== '\\') {
|
||||
token += `\\${escaped}`;
|
||||
tokenStarted = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (escaped === 'c' && quote === '') break;
|
||||
if (escaped === '_' && quote !== '"') {
|
||||
flush();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const escapes: Readonly<Record<string, string>> = {
|
||||
f: '\f',
|
||||
n: '\n',
|
||||
r: '\r',
|
||||
t: '\t',
|
||||
v: '\v',
|
||||
'#': '#',
|
||||
$: '$',
|
||||
_: ' ',
|
||||
'"': '"',
|
||||
"'": "'",
|
||||
'\\': '\\',
|
||||
};
|
||||
const value = escapes[escaped];
|
||||
if (value !== undefined) {
|
||||
if (/\s/.test(value) && quote === '') flush();
|
||||
else {
|
||||
token += value;
|
||||
tokenStarted = true;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
token += `\\${escaped}`;
|
||||
tokenStarted = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === '#' && quote === '' && !tokenStarted) break;
|
||||
if (/\s/.test(character) && quote === '') {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
token += character;
|
||||
tokenStarted = true;
|
||||
}
|
||||
flush();
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function normalizedCommandTokens(segment: string): string[] {
|
||||
const tokens = shellCommandTokens(segment.trim().replace(/^(?:[$#>]\s*)/, ''));
|
||||
let index = 0;
|
||||
|
||||
while (index < tokens.length) {
|
||||
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index] ?? '')) index += 1;
|
||||
const wrapper = commandBasename(tokens[index] ?? '');
|
||||
if (wrapper === 'env') {
|
||||
index += 1;
|
||||
while (index < tokens.length) {
|
||||
const token = tokens[index] ?? '';
|
||||
if (!token.startsWith('-') && /^[^=]+=/.test(token)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === '--') {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if (token === '--help' || token === '--version') return [];
|
||||
if (/^(?:--unset|--chdir|--argv0)$/.test(token)) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (/^(?:--unset=|--chdir=|--argv0=)/.test(token)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === '--split-string') {
|
||||
tokens.splice(index, 2, ...envSplitStringTokens(tokens[index + 1] ?? ''));
|
||||
continue;
|
||||
}
|
||||
const longSplitString = token.match(/^--split-string=(.*)$/)?.[1];
|
||||
if (longSplitString !== undefined) {
|
||||
tokens.splice(index, 1, ...envSplitStringTokens(longSplitString));
|
||||
continue;
|
||||
}
|
||||
if (/^-[^-]/.test(token)) {
|
||||
const options = token.slice(1);
|
||||
let consumed = false;
|
||||
for (let optionIndex = 0; optionIndex < options.length; optionIndex += 1) {
|
||||
const option = options[optionIndex] ?? '';
|
||||
if (option === 'u' || option === 'C' || option === 'a') {
|
||||
index += options.slice(optionIndex + 1) === '' ? 2 : 1;
|
||||
consumed = true;
|
||||
break;
|
||||
}
|
||||
if (option === 'S') {
|
||||
const attached = options.slice(optionIndex + 1);
|
||||
const operand = attached === '' ? (tokens[index + 1] ?? '') : attached;
|
||||
tokens.splice(index, attached === '' ? 2 : 1, ...envSplitStringTokens(operand));
|
||||
consumed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!consumed) index += 1;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('-')) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (wrapper === 'command') {
|
||||
index += 1;
|
||||
let availabilityQuery = false;
|
||||
while ((tokens[index] ?? '').startsWith('-')) {
|
||||
const option = tokens[index] ?? '';
|
||||
index += 1;
|
||||
if (option === '--') break;
|
||||
if (/^-[^-]*[vV]/.test(option)) availabilityQuery = true;
|
||||
}
|
||||
if (availabilityQuery) return [];
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return tokens.slice(index);
|
||||
}
|
||||
|
||||
function containsPrivilegedCommand(source: string, depth = 0): boolean {
|
||||
const privilegedCommands = new Set([
|
||||
'sudo',
|
||||
'doas',
|
||||
'pkexec',
|
||||
'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',
|
||||
]);
|
||||
|
||||
return shellCommandSegments(source).some((segment): boolean => {
|
||||
const tokens = normalizedCommandTokens(segment);
|
||||
const command = commandBasename(tokens[0] ?? '');
|
||||
if (privilegedCommands.has(command)) return true;
|
||||
if ((command === 'sh' || command === 'bash') && depth < 3) {
|
||||
let index = 1;
|
||||
while (index < tokens.length) {
|
||||
const token = tokens[index] ?? '';
|
||||
if (token === '--') {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === '-c' || token === '--command') {
|
||||
return containsPrivilegedCommand(tokens[index + 1] ?? '', depth + 1);
|
||||
}
|
||||
if (token === '-o' || token === '+o') {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (/^-[^-]*c/.test(token)) {
|
||||
return containsPrivilegedCommand(tokens[index + 1] ?? '', depth + 1);
|
||||
}
|
||||
if (token.startsWith('-') || token.startsWith('+')) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (command !== 'su') return false;
|
||||
|
||||
let index = 1;
|
||||
let user: string | undefined;
|
||||
let rootGroup = false;
|
||||
while (index < tokens.length) {
|
||||
const token = tokens[index] ?? '';
|
||||
if (token === '--') {
|
||||
user ??= tokens[index + 1];
|
||||
break;
|
||||
}
|
||||
const longOperand = token.match(
|
||||
/^--(command|session-command|group|supp-group|shell|whitelist-environment)=(.*)$/,
|
||||
);
|
||||
if (longOperand !== null) {
|
||||
if (
|
||||
(longOperand[1] === 'group' || longOperand[1] === 'supp-group') &&
|
||||
longOperand[2] === 'root'
|
||||
) {
|
||||
rootGroup = true;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
/^(?:--command|--session-command|--group|--supp-group|--shell|--whitelist-environment)$/.test(
|
||||
token,
|
||||
)
|
||||
) {
|
||||
if ((token === '--group' || token === '--supp-group') && tokens[index + 1] === 'root') {
|
||||
rootGroup = true;
|
||||
}
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (/^-[^-]/.test(token)) {
|
||||
const options = token.slice(1);
|
||||
let consumed = false;
|
||||
for (let optionIndex = 0; optionIndex < options.length; optionIndex += 1) {
|
||||
const option = options[optionIndex] ?? '';
|
||||
if ('cgGsw'.includes(option)) {
|
||||
const attached = options.slice(optionIndex + 1);
|
||||
const operand = attached === '' ? tokens[index + 1] : attached;
|
||||
if ((option === 'g' || option === 'G') && operand === 'root') rootGroup = true;
|
||||
index += attached === '' ? 2 : 1;
|
||||
consumed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!consumed) index += 1;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('-')) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
user ??= token;
|
||||
index += 1;
|
||||
}
|
||||
return rootGroup || user === undefined || user === 'root';
|
||||
});
|
||||
}
|
||||
|
||||
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/@]+@)/;
|
||||
|
||||
if (sensitiveKey.test(source)) kinds.add('sensitive-key');
|
||||
if (credentialFormat.test(source)) kinds.add('credential-format');
|
||||
if (containsPrivilegedCommand(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: '/usr/bin/env -u FOO /usr/bin/apt-get install example',
|
||||
},
|
||||
{
|
||||
expected: 'privileged-command',
|
||||
source: 'env -iu FOO /usr/bin/apt-get --version',
|
||||
},
|
||||
{
|
||||
expected: 'privileged-command',
|
||||
source: 'env -ivS/usr/bin/apt-get --version',
|
||||
},
|
||||
{
|
||||
expected: 'privileged-command',
|
||||
source: 'env --unset FOO /usr/bin/apt-get install example',
|
||||
},
|
||||
{
|
||||
expected: 'privileged-command',
|
||||
source: 'env -a alternate /usr/bin/apt-get --version',
|
||||
},
|
||||
{
|
||||
expected: 'privileged-command',
|
||||
source: 'env --argv0 alternate /usr/bin/apt-get --version',
|
||||
},
|
||||
{
|
||||
expected: 'privileged-command',
|
||||
source: 'env --argv0=alternate /usr/bin/apt-get --version',
|
||||
},
|
||||
{
|
||||
expected: 'privileged-command',
|
||||
source: 'env -ia alternate /usr/bin/apt-get --version',
|
||||
},
|
||||
{
|
||||
expected: 'privileged-command',
|
||||
source: 'env -iaalternate /usr/bin/apt-get --version',
|
||||
},
|
||||
{
|
||||
expected: 'privileged-command',
|
||||
source: "env -S '-a alternate /usr/bin/apt-get --version'",
|
||||
},
|
||||
{
|
||||
expected: 'privileged-command',
|
||||
source: "env -S '--argv0=alternate /usr/bin/apt-get --version'",
|
||||
},
|
||||
{ expected: 'privileged-command', source: 'env -S apt-get install example' },
|
||||
{ expected: 'privileged-command', source: "env -S 'apt-get update'" },
|
||||
{ expected: 'privileged-command', source: "env -S'/usr/bin/apt-get --version'" },
|
||||
{
|
||||
expected: 'privileged-command',
|
||||
source: "env --split-string='apt-get update'",
|
||||
},
|
||||
{
|
||||
expected: 'privileged-command',
|
||||
source: "env --split-string='-i /usr/bin/apt-get update'",
|
||||
},
|
||||
{
|
||||
expected: 'privileged-command',
|
||||
source: "env --split-string='FOO=x /usr/bin/apt-get update'",
|
||||
},
|
||||
{
|
||||
expected: 'privileged-command',
|
||||
source: "env --split-string='-- /usr/bin/apt-get update'",
|
||||
},
|
||||
{ expected: 'privileged-command', source: "env -S '\\_apt-get --version'" },
|
||||
{ expected: 'privileged-command', source: "env -S '\\tapt-get --version'" },
|
||||
{
|
||||
expected: 'privileged-command',
|
||||
source: String.raw`env -S 'env -S "\_apt-get --version"'`,
|
||||
},
|
||||
{ expected: 'privileged-command', source: 'LC_ALL=C apt-get --version' },
|
||||
{ expected: 'privileged-command', source: "env 'x-y=1' apt-get --version" },
|
||||
{ expected: 'privileged-command', source: 'env 1X=value apt-get --version' },
|
||||
{ expected: 'privileged-command', source: "sh -c 'apt-get --version'" },
|
||||
{ expected: 'privileged-command', source: "/bin/bash -lc 'apt-get --version'" },
|
||||
{
|
||||
expected: 'privileged-command',
|
||||
source: "bash --noprofile -o pipefail -c 'apt-get --version'",
|
||||
},
|
||||
{ expected: 'privileged-command', source: 'su operator --group root' },
|
||||
{ expected: 'privileged-command', source: 'su --group=root operator' },
|
||||
{ expected: 'privileged-command', source: 'su operator -g root' },
|
||||
{ expected: 'privileged-command', source: 'su -groot operator' },
|
||||
{ expected: 'privileged-command', source: 'su operator --supp-group root' },
|
||||
{ expected: 'privileged-command', source: 'su --supp-group=root operator' },
|
||||
{ expected: 'privileged-command', source: 'su operator -G root' },
|
||||
{ expected: 'privileged-command', source: 'su -lGroot operator' },
|
||||
{ expected: 'privileged-command', source: 'apt\\\n-get --version' },
|
||||
{ expected: 'privileged-command', source: 'reboot' },
|
||||
{ expected: 'privileged-command', source: 'su --command=/bin/sh' },
|
||||
{ expected: 'privileged-command', source: 'su -lc /usr/bin/id' },
|
||||
{ expected: 'privileged-command', source: 'su -l root' },
|
||||
{ expected: 'privileged-command', source: 'su -l -s /bin/sh root' },
|
||||
{ expected: 'privileged-command', source: '/usr/bin/su --login root' },
|
||||
{ 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('allows command availability queries without classifying the queried command', (): void => {
|
||||
expect(exampleSafetyViolationKinds('command -v apt-get')).not.toContain('privileged-command');
|
||||
});
|
||||
|
||||
it('keeps quoted command separators as inert argument data', (): void => {
|
||||
expect(
|
||||
exampleSafetyViolationKinds("printf '%s\\n' 'safe; /usr/bin/apt-get update'"),
|
||||
).not.toContain('privileged-command');
|
||||
});
|
||||
|
||||
it('still detects commands after unquoted command separators', (): void => {
|
||||
expect(exampleSafetyViolationKinds('printf safe; /usr/bin/apt-get update')).toContain(
|
||||
'privileged-command',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'printf safe # ; apt-get --version',
|
||||
'env --help apt-get',
|
||||
'env --version sudo',
|
||||
"env -S '\\\\_printf safe'",
|
||||
"env -S 'printf\\_safe'",
|
||||
"env -S 'printf \\#safe'",
|
||||
"env -S 'printf safe \\c apt-get --version'",
|
||||
'LC_ALL=C printf safe',
|
||||
"env 'x-y=1' printf safe",
|
||||
'env 1X=value printf safe',
|
||||
"sh -c 'printf safe'",
|
||||
"/bin/bash -lc 'command -v apt-get'",
|
||||
"printf '%s\\n' 'sh -c apt-get --version'",
|
||||
'su operator --group operators',
|
||||
'su --group=operators operator',
|
||||
'su operator -g operators',
|
||||
'su -goperators operator',
|
||||
'su operator --supp-group operators',
|
||||
'su --supp-group=operators operator',
|
||||
'su operator -G operators',
|
||||
'su -lGoperators operator',
|
||||
"printf '%s\\n' 'apt\\\\\\n-get --version'",
|
||||
])('keeps new benign command-parser controls safe', (source): void => {
|
||||
expect(exampleSafetyViolationKinds(source)).not.toContain('privileged-command');
|
||||
});
|
||||
|
||||
it.each([
|
||||
"env --split-string='-i -- /usr/bin/printf safe'",
|
||||
"env --split-string='FOO=x /usr/bin/printf safe'",
|
||||
'env -iu FOO /usr/bin/printf safe',
|
||||
'env -ivS/usr/bin/printf safe',
|
||||
'env -a alternate /usr/bin/printf safe',
|
||||
'env --argv0 alternate /usr/bin/printf safe',
|
||||
'env --argv0=alternate /usr/bin/printf safe',
|
||||
'env -ia alternate /usr/bin/printf safe',
|
||||
'env -iaalternate /usr/bin/printf safe',
|
||||
"env -S '-a alternate /usr/bin/printf safe'",
|
||||
"env -S '--argv0 alternate /usr/bin/printf safe'",
|
||||
'env -a',
|
||||
'env --argv0',
|
||||
'env -ia alternate',
|
||||
"printf '%s\\n' 'env -a alternate /usr/bin/apt-get --version'",
|
||||
'command -v env -a alternate /usr/bin/apt-get',
|
||||
'su -lc /usr/bin/id operator',
|
||||
'su -l -s /bin/sh operator',
|
||||
'su -s /bin/sh -- operator',
|
||||
'su -c /usr/bin/id operator',
|
||||
'su --command=/usr/bin/id operator',
|
||||
'su --session-command=/usr/bin/id operator',
|
||||
])('keeps valid non-privileged env and su forms safe', (source): void => {
|
||||
expect(exampleSafetyViolationKinds(source)).not.toContain('privileged-command');
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -584,6 +584,29 @@ function defaultValidateRoster(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The single roster-v2 → generated-projection mapping. Both the reconciler's
|
||||
* apply path ({@link defaultPrepareProjections}) and the recovery-framed
|
||||
* `mosaic fleet regen` command derive their generated env from THIS one
|
||||
* function, so the two paths can never drift (the #791 single-SSOT invariant).
|
||||
* Pure: no IO, no clock — a deterministic function of the roster alone.
|
||||
*/
|
||||
export function projectRosterV2AgentGeneratedEnv(
|
||||
roster: FleetRosterV2,
|
||||
agent: FleetRosterV2Agent,
|
||||
): Readonly<Record<string, string>> {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultPrepareProjections(
|
||||
request: FleetReconcileRequest,
|
||||
): (roster: FleetRosterV2) => Promise<readonly PreparedAgentEnvironmentProjection[]> {
|
||||
@@ -596,16 +619,7 @@ function defaultPrepareProjections(
|
||||
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,
|
||||
},
|
||||
generated: projectRosterV2AgentGeneratedEnv(roster, agent),
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -620,62 +634,193 @@ function mosaicHomeFor(deps: FleetReconcileDeps): string {
|
||||
return deps.mosaicHome ?? join(homedir(), '.config', 'mosaic');
|
||||
}
|
||||
|
||||
/** Acquires a private lock only after proving the canonical managed path. */
|
||||
/** Acquires the private reconcile lock only after proving the canonical managed path. */
|
||||
export function acquirePrivateReconcileLock(
|
||||
mosaicHome: string,
|
||||
openLock: typeof open = open,
|
||||
): () => Promise<() => Promise<void>> {
|
||||
return acquirePrivateManagedRosterLock(
|
||||
mosaicHome,
|
||||
'roster.yaml.reconcile.lock',
|
||||
'Another roster reconciliation is in progress.',
|
||||
openLock,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquires the private roster MUTATION lock (`roster.yaml.mutation.lock`) with the
|
||||
* exact same ownership-proof discipline as the reconcile lock. Exposed so the
|
||||
* recovery-framed `mosaic fleet regen` can serialize its projection rewrites
|
||||
* against agent CRUD — which holds this very lock while it rewrites the roster and
|
||||
* the same derived projections — reusing this hardened acquirer rather than
|
||||
* reimplementing it.
|
||||
*
|
||||
* The release PROVES ownership (device/inode + token) before unlinking, so a lock
|
||||
* that was already cleared and replaced by the time release runs is never deleted
|
||||
* out from under the new owner (it fails closed as `lock-cleanup-failed` instead),
|
||||
* and that cleanup fault is propagated to the caller rather than silently dropped.
|
||||
* CRUD contends on the identical path with a plain empty-file `wx` create; the two
|
||||
* never co-own it (whoever wins `wx` owns it; the loser gets `concurrent-mutation`),
|
||||
* so the token this writes is only ever read back by the same regen invocation that
|
||||
* wrote it.
|
||||
*
|
||||
* SCOPE of the guarantee (matches the merged reconcile lock exactly — see
|
||||
* {@link acquirePrivateManagedRosterLock}): the ownership proof closes the
|
||||
* *reachable* window — a stale-lock reaper or operator cleared our lock and another
|
||||
* writer took it BEFORE our release began — not the residual sub-instruction window
|
||||
* between the final check and the path-based `unlink`. Closing that residual fully
|
||||
* requires an fd-held advisory lock adopted by every fleet writer (CRUD, reconcile,
|
||||
* regen), which is a cross-cutting mechanism change out of scope for this
|
||||
* projection-only recovery command; it is unreachable within the `wx` writer
|
||||
* protocol regardless (no Mosaic writer removes a lock it does not own, so only
|
||||
* external interference can vacate our inode mid-release).
|
||||
*/
|
||||
export function acquirePrivateRosterMutationLock(
|
||||
mosaicHome: string,
|
||||
openLock: typeof open = open,
|
||||
): () => Promise<() => Promise<void>> {
|
||||
return acquirePrivateManagedRosterLock(
|
||||
mosaicHome,
|
||||
'roster.yaml.mutation.lock',
|
||||
'Another roster mutation is in progress.',
|
||||
openLock,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared body for the ownership-proving managed roster locks. Acquires `lockLeaf`
|
||||
* under `<mosaicHome>/fleet` with a private `wx` create, writes an ownership token,
|
||||
* and returns a release that re-proves device/inode + token before unlinking so it
|
||||
* can never remove a lock it no longer owns. Non-blocking: throws `concurrent-mutation`
|
||||
* (with `busyMessage`) on contention rather than waiting.
|
||||
*/
|
||||
function acquirePrivateManagedRosterLock(
|
||||
mosaicHome: string,
|
||||
lockLeaf: string,
|
||||
busyMessage: string,
|
||||
openLock: typeof open,
|
||||
): () => Promise<() => Promise<void>> {
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const lockPath = join(fleetDir, 'roster.yaml.reconcile.lock');
|
||||
const lockPath = join(fleetDir, lockLeaf);
|
||||
// The message-producing helpers below serve BOTH managed locks, so every fault
|
||||
// names the actual lock file (`fleet/<leaf>`) — an operator needs to know WHICH
|
||||
// lock is stale, not a hardcoded "reconciliation lock".
|
||||
const lockLabel = `fleet/${lockLeaf}`;
|
||||
return async (): Promise<() => Promise<void>> => {
|
||||
await assertPrivateManagedDirectory(mosaicHome);
|
||||
await assertPrivateManagedDirectory(fleetDir);
|
||||
await assertSafeLockLeafIfPresent(lockPath);
|
||||
await assertSafeLockLeafIfPresent(lockPath, lockLabel);
|
||||
|
||||
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.',
|
||||
);
|
||||
await assertSafeLockLeafIfPresent(lockPath, lockLabel);
|
||||
throw new FleetReconcileError('concurrent-mutation', busyMessage);
|
||||
}
|
||||
throw new FleetReconcileError('lock-io-failed', 'The reconciliation lock cannot be created.');
|
||||
throw new FleetReconcileError('lock-io-failed', `The ${lockLabel} lock cannot be created.`);
|
||||
}
|
||||
|
||||
// Identity of the lock WE exclusively created (the `wx` create guaranteed it is
|
||||
// ours). Captured up front so both the release closure and the init-failure
|
||||
// cleanup below can prove ownership by device/inode before touching the file.
|
||||
const created = await handle.stat().catch((): undefined => undefined);
|
||||
|
||||
const token = randomUUID();
|
||||
let tokenPersisted = false;
|
||||
try {
|
||||
await handle.writeFile(`${token}\n`, 'utf8');
|
||||
const opened = await handle.stat();
|
||||
tokenPersisted = true;
|
||||
await handle.close();
|
||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'unsafe-lock');
|
||||
if (!created)
|
||||
throw new FleetReconcileError(
|
||||
'lock-io-failed',
|
||||
`The ${lockLabel} lock cannot be initialized.`,
|
||||
);
|
||||
await assertLockOwnership(
|
||||
lockPath,
|
||||
created.dev,
|
||||
created.ino,
|
||||
token,
|
||||
'unsafe-lock',
|
||||
lockLabel,
|
||||
);
|
||||
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 assertLockOwnership(
|
||||
lockPath,
|
||||
created.dev,
|
||||
created.ino,
|
||||
token,
|
||||
'lock-cleanup-failed',
|
||||
lockLabel,
|
||||
);
|
||||
await unlink(lockPath);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError(
|
||||
'lock-cleanup-failed',
|
||||
'The reconciliation lock cleanup failed.',
|
||||
);
|
||||
throw new FleetReconcileError('lock-cleanup-failed', `The ${lockLabel} cleanup failed.`);
|
||||
}
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
await handle.close().catch((): void => {});
|
||||
// Best-effort: remove the lock THIS invocation created so a transient init
|
||||
// fault does not strand a lock that would block future regen and agent CRUD.
|
||||
// dev/ino-guarded so it can never delete a replacement; swallowed so cleanup
|
||||
// failure never masks the initialization error being surfaced. The token is
|
||||
// passed only once persisted, so the fallback path (when the post-create stat
|
||||
// failed and dev/ino is unavailable) can still prove ownership by content.
|
||||
await removeOwnedLockLeafBestEffort(lockPath, created, tokenPersisted ? token : undefined);
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError(
|
||||
'lock-io-failed',
|
||||
'The reconciliation lock cannot be initialized.',
|
||||
`The ${lockLabel} lock cannot be initialized.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort removal of a managed lock leaf THIS process created, used only on the
|
||||
* initialization-failure path. Two independent ownership proofs, so a lock that was
|
||||
* already replaced is never deleted:
|
||||
*
|
||||
* - primary: the captured device/inode of the file we exclusively `wx`-created; or
|
||||
* - fallback: when the post-create stat itself failed (dev/ino unavailable), the
|
||||
* random `ownershipToken` we persisted — only OUR lock carries it, so a CRUD
|
||||
* (empty) or a differently-tokened replacement is never removed.
|
||||
*
|
||||
* Every fault is swallowed because the caller is already surfacing the initialization
|
||||
* error and a leftover lock is recoverable via the documented runbook. If BOTH proofs
|
||||
* are unavailable (stat failed AND the token write never landed) the lock is left in
|
||||
* place rather than risk deleting another writer's file — a doubly-degenerate case
|
||||
* requiring two independent fs faults on a just-created fd.
|
||||
*/
|
||||
async function removeOwnedLockLeafBestEffort(
|
||||
lockPath: string,
|
||||
created: { dev: number; ino: number } | undefined,
|
||||
ownershipToken: string | undefined,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const current = await lstat(lockPath);
|
||||
if (!current.isFile() || current.isSymbolicLink()) return;
|
||||
if (created) {
|
||||
if (current.dev === created.dev && current.ino === created.ino) {
|
||||
await unlink(lockPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ownershipToken !== undefined) {
|
||||
const contents = await readFile(lockPath, 'utf8');
|
||||
if (contents.trim() === ownershipToken) {
|
||||
await unlink(lockPath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Swallowed: leftover lock is recoverable; do not mask the init error.
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPrivateManagedDirectory(path: string): Promise<void> {
|
||||
try {
|
||||
const metadata = await lstat(path);
|
||||
@@ -691,16 +836,16 @@ async function assertPrivateManagedDirectory(path: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertSafeLockLeafIfPresent(lockPath: string): Promise<void> {
|
||||
async function assertSafeLockLeafIfPresent(lockPath: string, lockLabel: 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.');
|
||||
throw new FleetReconcileError('unsafe-lock', `The ${lockLabel} 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.');
|
||||
throw new FleetReconcileError('unsafe-lock', `The ${lockLabel} lock path is unavailable.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,6 +855,7 @@ async function assertLockOwnership(
|
||||
inode: number,
|
||||
token: string,
|
||||
failureCode: 'unsafe-lock' | 'lock-cleanup-failed',
|
||||
lockLabel: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const metadata = await lstat(lockPath);
|
||||
@@ -720,24 +866,21 @@ async function assertLockOwnership(
|
||||
metadata.dev !== device ||
|
||||
metadata.ino !== inode
|
||||
) {
|
||||
throw new FleetReconcileError(failureCode, 'The reconciliation lock ownership changed.');
|
||||
throw new FleetReconcileError(failureCode, `The ${lockLabel} 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.');
|
||||
throw new FleetReconcileError(failureCode, `The ${lockLabel} 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.',
|
||||
);
|
||||
throw new FleetReconcileError(failureCode, `The ${lockLabel} ownership cannot be proven.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -204,6 +204,53 @@ export async function prepareAgentEnvironmentProjection(
|
||||
};
|
||||
}
|
||||
|
||||
/** A generated-only projection validated without touching any legacy/local/quarantine file. */
|
||||
export interface PreparedGeneratedAgentEnvironmentProjection {
|
||||
readonly mosaicHome: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly generatedPath: string;
|
||||
readonly generated: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates ONLY the roster-derived generated projection for a recovery rebuild.
|
||||
* Unlike {@link prepareAgentEnvironmentProjection}, this never reads, classifies,
|
||||
* relocates, or quarantines the legacy `.env` / `.env.local` operator surface — it
|
||||
* exists so `mosaic fleet regen` has no code path that can mutate anything except
|
||||
* `<name>.env.generated`. The existing generated file, if present, must already be
|
||||
* a private regular file.
|
||||
*/
|
||||
export async function prepareGeneratedAgentEnvironmentProjection(
|
||||
options: AgentEnvironmentProjectionOptions,
|
||||
): Promise<PreparedGeneratedAgentEnvironmentProjection> {
|
||||
if (!AGENT_NAME.test(options.agentName)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
||||
}
|
||||
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
||||
const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`);
|
||||
const generated = renderGeneratedAgentEnvironment(options.generated);
|
||||
await assertPrivateRegularFileIfPresent(generatedPath);
|
||||
return {
|
||||
mosaicHome: options.mosaicHome,
|
||||
agentEnvDir: options.agentEnvDir,
|
||||
generatedPath,
|
||||
generated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a generated-only projection: writes ONLY `<name>.env.generated` atomically
|
||||
* and touches nothing else. It never writes `.env.local`/`.env.quarantine` and never
|
||||
* unlinks the legacy `.env` — the projection-only recovery guarantee is structural.
|
||||
*/
|
||||
export async function applyPreparedGeneratedAgentEnvironmentProjection(
|
||||
prepared: PreparedGeneratedAgentEnvironmentProjection,
|
||||
): Promise<string> {
|
||||
await ensurePrivateProjectionDirectory(prepared.mosaicHome, prepared.agentEnvDir);
|
||||
await writePrivateAtomically(prepared.generatedPath, prepared.generated);
|
||||
return prepared.generatedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates only the exact generated projection eligible for a roster delete.
|
||||
* Local overrides, legacy input, and quarantine records are operator-retained and
|
||||
|
||||
343
packages/mosaic/src/framework/manifest-parity.spec.ts
Normal file
343
packages/mosaic/src/framework/manifest-parity.spec.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import { afterAll, describe, it, expect } from 'vitest';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
loadManifest,
|
||||
parseManifest,
|
||||
resolveOwnership,
|
||||
frameworkSubtreeRoots,
|
||||
} from './manifest.js';
|
||||
|
||||
/**
|
||||
* Bash ↔ TS parity (#791, §6.1).
|
||||
*
|
||||
* The installer (bash) and the config adapter (TS) each resolve path ownership
|
||||
* from framework-manifest.txt. If the two resolvers disagreed on a single path,
|
||||
* an upgrade could protect a file on one code path and wipe it on the other —
|
||||
* exactly the two-copies drift that #631 patched by hand. This test drives the
|
||||
* bash resolver (`tools/_lib/manifest.sh`) as a subprocess and asserts it agrees
|
||||
* with the TS resolver for a broad set of paths spanning every ownership class.
|
||||
*/
|
||||
|
||||
const FRAMEWORK_ROOT = fileURLToPath(new URL('../../framework', import.meta.url));
|
||||
const MANIFEST_SH = join(FRAMEWORK_ROOT, 'tools', '_lib', 'manifest.sh');
|
||||
|
||||
const hasBash = (() => {
|
||||
try {
|
||||
execFileSync('bash', ['-c', 'true'], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
function bashResolve(relPath: string): string {
|
||||
return execFileSync('bash', [MANIFEST_SH, 'resolve', relPath], {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
}
|
||||
|
||||
/** Drive the bash resolver against an arbitrary manifest file (MANIFEST_FILE override). */
|
||||
function bashResolveWith(manifestFile: string, relPath: string): string {
|
||||
return execFileSync('bash', [MANIFEST_SH, 'resolve', relPath], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, MANIFEST_FILE: manifestFile },
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function bashSubtreeRoots(): string[] {
|
||||
return execFileSync('bash', [MANIFEST_SH, 'subtree-roots'], { encoding: 'utf-8' })
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the bash resolver CLI against a manifest file and report how it exited.
|
||||
* A fail-closed manifest must make the CLI exit non-zero with a message on
|
||||
* stderr — never exit 0 having silently resolved everything to operator.
|
||||
*/
|
||||
function bashCli(manifestFile: string): { status: number; stderr: string } {
|
||||
const res = spawnSync('bash', [MANIFEST_SH, 'resolve', 'CONSTITUTION.md'], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, MANIFEST_FILE: manifestFile },
|
||||
});
|
||||
return { status: res.status ?? -1, stderr: res.stderr ?? '' };
|
||||
}
|
||||
|
||||
// Paths spanning every ownership class: framework single-files, framework
|
||||
// subtrees, operator declared trees, operator carve-out inside a framework
|
||||
// subtree, local overlays, and deliberately UNANTICIPATED paths (fail-safe).
|
||||
const PROBE_PATHS = [
|
||||
'CONSTITUTION.md',
|
||||
'AGENTS.md',
|
||||
'STANDARDS.md',
|
||||
'install.sh',
|
||||
'framework-manifest.txt',
|
||||
'guides/E2E-DELIVERY.md',
|
||||
'tools/git/pr-create.sh',
|
||||
'tools/_lib/manifest.sh',
|
||||
'defaults/SOUL.md',
|
||||
'fleet/README.md',
|
||||
'fleet/roles/coder.md',
|
||||
'fleet/roster.schema.json',
|
||||
'fleet/examples/general.yaml',
|
||||
// operator
|
||||
'SOUL.md',
|
||||
'USER.md',
|
||||
'TOOLS.md',
|
||||
'SOUL.local.md',
|
||||
'USER.local.md',
|
||||
'STANDARDS.local.md',
|
||||
'agents/coder0.conf',
|
||||
'policy/custom.md',
|
||||
'memory/note.md',
|
||||
'sources/skills/x.md',
|
||||
'credentials/c.json',
|
||||
'tools/_lib/credentials.json',
|
||||
'fleet/roster.yaml',
|
||||
'fleet/roster.json',
|
||||
'fleet/agents/coder0.env',
|
||||
'fleet/run/coder0.hb',
|
||||
// #797 Runtime Session Ledger — must resolve operator on both paths.
|
||||
'fleet/run/sessions/events.ndjson',
|
||||
'fleet/run/sessions/ledger.json',
|
||||
'fleet/backlog/data.db',
|
||||
'fleet/roles.local/custom.md',
|
||||
// unanticipated → operator (fail-safe)
|
||||
'harvester/sop.md',
|
||||
'unknown-operator-dir/x',
|
||||
'fleet/my-fleet.yaml',
|
||||
'random-root-file.md',
|
||||
'tools/some-new-framework-tool.sh',
|
||||
];
|
||||
|
||||
describe.skipIf(!hasBash)('bash ↔ TS manifest parity (§6.1)', () => {
|
||||
it('the bash resolver CLI exists and is executable', () => {
|
||||
expect(existsSync(MANIFEST_SH)).toBe(true);
|
||||
});
|
||||
|
||||
it('bash and TS resolve identical ownership for every probe path', () => {
|
||||
const manifest = loadManifest(FRAMEWORK_ROOT);
|
||||
const disagreements: Array<{ path: string; ts: string; bash: string }> = [];
|
||||
for (const p of PROBE_PATHS) {
|
||||
const ts = resolveOwnership(manifest, p);
|
||||
const bash = bashResolve(p);
|
||||
if (ts !== bash) disagreements.push({ path: p, ts, bash });
|
||||
}
|
||||
expect(disagreements).toEqual([]);
|
||||
});
|
||||
|
||||
it('bash and TS agree on the framework subtree roots', () => {
|
||||
const manifest = loadManifest(FRAMEWORK_ROOT);
|
||||
expect(bashSubtreeRoots().sort()).toEqual(frameworkSubtreeRoots(manifest).sort());
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Format-safety parity (#791, Decision 1 — the `.txt` line-oriented format is
|
||||
* accepted only because both resolvers agree on the format edge cases a hand-
|
||||
* edited text file invites: comments, blank lines, stray whitespace, duplicate
|
||||
* and overlapping globs (where deny-wins must resolve), and section ordering.
|
||||
* Each fixture is driven through BOTH resolvers (bash via MANIFEST_FILE, TS via
|
||||
* parseManifest) and must agree AND land on the expected ownership. Any
|
||||
* divergence here means the format itself is unsafe and must be fixed/converted.
|
||||
*/
|
||||
describe.skipIf(!hasBash)('bash ↔ TS manifest format-edge parity (§6.1, Decision 1)', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'mf-parity-'));
|
||||
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
let fixtureSeq = 0;
|
||||
function writeFixture(text: string): string {
|
||||
const file = join(tmp, `manifest-${fixtureSeq++}.txt`);
|
||||
writeFileSync(file, text);
|
||||
return file;
|
||||
}
|
||||
|
||||
// Both resolvers must agree, and on the expected value, for every probe.
|
||||
function expectParity(text: string, cases: ReadonlyArray<readonly [string, string]>): void {
|
||||
const file = writeFixture(text);
|
||||
const manifest = parseManifest(text);
|
||||
for (const [path, expected] of cases) {
|
||||
const ts = resolveOwnership(manifest, path);
|
||||
const bash = bashResolveWith(file, path);
|
||||
expect(bash, `bash disagrees with TS on ${path}`).toBe(ts);
|
||||
expect(ts, `ownership of ${path}`).toBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
it('tolerates comments, blank lines, and leading/trailing whitespace identically', () => {
|
||||
// Entries and headers are padded with spaces/tabs; comments and blanks are
|
||||
// interleaved. Both resolvers must trim and ignore them the same way.
|
||||
const text = [
|
||||
'# leading comment',
|
||||
' ',
|
||||
'\t[framework] ',
|
||||
' tools/** ',
|
||||
'# mid-section comment',
|
||||
'',
|
||||
'\tguides/**\t',
|
||||
' [operator] ',
|
||||
'\ttools/_lib/credentials.json ',
|
||||
'*.local.md',
|
||||
'',
|
||||
].join('\n');
|
||||
expectParity(text, [
|
||||
['tools/git/pr-create.sh', 'framework'],
|
||||
['guides/E2E-DELIVERY.md', 'framework'],
|
||||
['tools/_lib/credentials.json', 'operator'], // deny-wins carve-out inside tools/**
|
||||
['SOUL.local.md', 'operator'],
|
||||
['nowhere/unknown.md', 'operator'], // negative probe: matches NO rule → operator
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves deny-wins for overlapping and duplicate globs identically', () => {
|
||||
// Framework claims tools/** (twice) and the overlapping tools/git/**;
|
||||
// operator carves out tools/_lib/**. Operator must win the overlap on both.
|
||||
const text = [
|
||||
'[framework]',
|
||||
'tools/**',
|
||||
'tools/**', // duplicate — must not change resolution
|
||||
'tools/git/**', // overlaps tools/**
|
||||
'[operator]',
|
||||
'tools/_lib/**',
|
||||
].join('\n');
|
||||
expectParity(text, [
|
||||
['tools/git/pr-create.sh', 'framework'],
|
||||
['tools/other.sh', 'framework'],
|
||||
['tools/_lib/credentials.json', 'operator'], // deny-wins over both framework globs
|
||||
['tools/_lib/nested/deep.json', 'operator'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('is independent of section and glob ordering', () => {
|
||||
// Same rule set, operator section first and entries reordered. Resolution
|
||||
// must be identical because deny-wins checks all operator globs before any
|
||||
// framework glob — order within or between sections cannot matter.
|
||||
const forward = [
|
||||
'[framework]',
|
||||
'guides/**',
|
||||
'tools/**',
|
||||
'[operator]',
|
||||
'tools/_lib/credentials.json',
|
||||
'*.local.md',
|
||||
].join('\n');
|
||||
const reversed = [
|
||||
'[operator]',
|
||||
'*.local.md',
|
||||
'tools/_lib/credentials.json',
|
||||
'[framework]',
|
||||
'tools/**',
|
||||
'guides/**',
|
||||
].join('\n');
|
||||
const probes: ReadonlyArray<readonly [string, string]> = [
|
||||
['tools/git/pr-create.sh', 'framework'],
|
||||
['guides/E2E-DELIVERY.md', 'framework'],
|
||||
['tools/_lib/credentials.json', 'operator'],
|
||||
['SOUL.local.md', 'operator'],
|
||||
['unanticipated/path.md', 'operator'],
|
||||
];
|
||||
expectParity(forward, probes);
|
||||
expectParity(reversed, probes);
|
||||
// And the two orderings agree path-for-path on both resolvers.
|
||||
const fFile = writeFixture(forward);
|
||||
const rFile = writeFixture(reversed);
|
||||
for (const [path] of probes) {
|
||||
expect(bashResolveWith(fFile, path)).toBe(bashResolveWith(rFile, path));
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults an unmatched path to operator on both resolvers (UNKNOWN → operator)', () => {
|
||||
// A manifest that names only a narrow framework slice. Everything else —
|
||||
// including paths under no rule at all — must fail safe to operator.
|
||||
const text = ['[framework]', 'guides/**', '[operator]', 'agents/**'].join('\n');
|
||||
expectParity(text, [
|
||||
['guides/x.md', 'framework'],
|
||||
['agents/coder0.conf', 'operator'],
|
||||
['totally/unlisted/file.txt', 'operator'], // negative probe
|
||||
['fleet/run/sessions/ledger.json', 'operator'], // unlisted → operator
|
||||
['README.md', 'operator'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Failure-mode parity (#791 B2/B3). A bad manifest is the dangerous case: if the
|
||||
* two resolvers DISAGREED on rejection — one throwing while the other quietly
|
||||
* resolved everything to operator — an upgrade could fail loud on one code path
|
||||
* and no-op on the other. So for every malformed/empty/missing manifest, BOTH
|
||||
* must reject: TS throws, and the bash CLI exits non-zero with a stderr message.
|
||||
*/
|
||||
describe.skipIf(!hasBash)('bash ↔ TS manifest failure-mode parity (§6.1, B2/B3)', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'mf-failmode-'));
|
||||
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
let seq = 0;
|
||||
function writeFixture(text: string): string {
|
||||
const file = join(tmp, `bad-manifest-${seq++}.txt`);
|
||||
writeFileSync(file, text);
|
||||
return file;
|
||||
}
|
||||
|
||||
// TS throws AND bash CLI exits non-zero with a non-empty stderr — identical rejection.
|
||||
function expectBothReject(label: string, manifestFile: string): void {
|
||||
expect(() => parseManifestFile(manifestFile), `TS accepted ${label}`).toThrow();
|
||||
const cli = bashCli(manifestFile);
|
||||
expect(cli.status, `bash did not exit non-zero for ${label}`).not.toBe(0);
|
||||
expect(cli.stderr.trim().length, `bash was silent for ${label}`).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Read the file for the TS side the same way loadManifest does, so both halves
|
||||
// see identical bytes (loadManifest keys off a directory, not an arbitrary file).
|
||||
function parseManifestFile(file: string): void {
|
||||
parseManifest(readFileSync(file, 'utf-8'));
|
||||
}
|
||||
|
||||
it('both reject a completely empty manifest', () => {
|
||||
expectBothReject('empty', writeFixture(''));
|
||||
});
|
||||
|
||||
it('both reject a comment/blank-only manifest', () => {
|
||||
expectBothReject('comment-only', writeFixture('# header only\n\n \n'));
|
||||
});
|
||||
|
||||
it('both reject an operator-only manifest (zero framework paths)', () => {
|
||||
expectBothReject('operator-only', writeFixture('[operator]\nSOUL.md\n*.local.md\n'));
|
||||
});
|
||||
|
||||
it('both reject a [framework] section with no entries', () => {
|
||||
expectBothReject('empty-framework-section', writeFixture('[framework]\n[operator]\nSOUL.md\n'));
|
||||
});
|
||||
|
||||
it('both reject a [framework] entry that normalizes to an empty glob (/)', () => {
|
||||
expectBothReject('root-slash-framework', writeFixture('[framework]\n/\n'));
|
||||
});
|
||||
|
||||
it('both reject a [framework] entry that normalizes to nothing (./)', () => {
|
||||
expectBothReject('dot-slash-framework', writeFixture('[framework]\n./\n[operator]\nSOUL.md\n'));
|
||||
});
|
||||
|
||||
it('both reject [framework] entries that are only bare dot segments', () => {
|
||||
expectBothReject('bare-dot-framework', writeFixture('[framework]\n.\n..\n'));
|
||||
});
|
||||
|
||||
it('both reject an entry that appears before any section header', () => {
|
||||
expectBothReject('entry-before-header', writeFixture('stray.md\n[framework]\nguides/**\n'));
|
||||
});
|
||||
|
||||
it('both reject an unknown section header', () => {
|
||||
expectBothReject('unknown-header', writeFixture('[bogus]\nx\n'));
|
||||
});
|
||||
|
||||
it('both reject a missing manifest file (fail-closed, not empty result)', () => {
|
||||
const missing = join(tmp, 'does-not-exist.txt');
|
||||
// TS: loadManifest would throw a read error; here read-then-parse throws on read.
|
||||
expect(() => parseManifestFile(missing)).toThrow();
|
||||
const cli = bashCli(missing);
|
||||
expect(cli.status).not.toBe(0);
|
||||
expect(cli.stderr.trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
327
packages/mosaic/src/framework/manifest.spec.ts
Normal file
327
packages/mosaic/src/framework/manifest.spec.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
parseManifest,
|
||||
loadManifest,
|
||||
matchGlob,
|
||||
resolveOwnership,
|
||||
frameworkSubtreeRoots,
|
||||
planPrune,
|
||||
ManifestError,
|
||||
type FrameworkManifest,
|
||||
} from './manifest.js';
|
||||
|
||||
const FRAMEWORK_ROOT = fileURLToPath(new URL('../../framework', import.meta.url));
|
||||
|
||||
const SAMPLE = `
|
||||
# comment
|
||||
[framework]
|
||||
CONSTITUTION.md
|
||||
guides/**
|
||||
tools/**
|
||||
|
||||
[operator]
|
||||
SOUL.md
|
||||
*.local.md
|
||||
agents/**
|
||||
tools/_lib/credentials.json
|
||||
`;
|
||||
|
||||
describe('parseManifest', () => {
|
||||
it('splits entries into framework and operator sections, ignoring comments/blanks', () => {
|
||||
const m = parseManifest(SAMPLE);
|
||||
expect(m.framework).toEqual(['CONSTITUTION.md', 'guides/**', 'tools/**']);
|
||||
expect(m.operator).toEqual([
|
||||
'SOUL.md',
|
||||
'*.local.md',
|
||||
'agents/**',
|
||||
'tools/_lib/credentials.json',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an entry that appears before any section header', () => {
|
||||
expect(() => parseManifest('stray.md\n[framework]\n')).toThrow(/before any \[section\]/);
|
||||
});
|
||||
|
||||
it('rejects an unknown section header', () => {
|
||||
expect(() => parseManifest('[bogus]\nx\n')).toThrow(/Unknown manifest section/);
|
||||
});
|
||||
});
|
||||
|
||||
// Fail-closed parsing/loading (#791 B2/B3). An empty, comment-only, operator-only,
|
||||
// or unreadable manifest must NOT resolve to "framework owns nothing" (which would
|
||||
// make an upgrade a silent no-op). Both must throw so finalizeStage surfaces the
|
||||
// abort instead of reporting "Installation complete". The bash reader rejects the
|
||||
// same inputs — asserted for parity in manifest-parity.spec.ts.
|
||||
describe('parseManifest / loadManifest fail closed on empty or unreadable input', () => {
|
||||
it('throws on a completely empty manifest', () => {
|
||||
expect(() => parseManifest('')).toThrow(/no \[framework\] paths/);
|
||||
});
|
||||
|
||||
it('throws on a comment- and blank-only manifest (no entries at all)', () => {
|
||||
expect(() => parseManifest('# just a header comment\n\n \n')).toThrow(
|
||||
/no \[framework\] paths/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when only an [operator] section is present (zero framework paths)', () => {
|
||||
expect(() => parseManifest('[operator]\nSOUL.md\n*.local.md\n')).toThrow(
|
||||
/no \[framework\] paths/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on a [framework] header with no entries beneath it', () => {
|
||||
expect(() => parseManifest('[framework]\n\n[operator]\nSOUL.md\n')).toThrow(
|
||||
/no \[framework\] paths/,
|
||||
);
|
||||
});
|
||||
|
||||
// Degenerate framework entries that pass the length check but normalize to a
|
||||
// glob matching nothing — the manifest would silently protect the whole tree
|
||||
// as operator (#791 blocker-B). Both `/` and `./` normalize to '' ; `.`/`..`
|
||||
// are bare-dot segments.
|
||||
it.each([['/'], ['./'], ['.'], ['..'], ['/\n./']])(
|
||||
'throws when the only [framework] entry (%j) normalizes to nothing usable',
|
||||
(entry) => {
|
||||
expect(() => parseManifest(`[framework]\n${entry}\n`)).toThrow(
|
||||
/no usable \[framework\] paths/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts a wildcard-only framework glob (** is usable)', () => {
|
||||
expect(() => parseManifest('[framework]\n**\n')).not.toThrow();
|
||||
});
|
||||
|
||||
it('loadManifest throws a clear fail-closed error when the manifest file is missing', () => {
|
||||
const missingRoot = fileURLToPath(new URL('./__no_such_framework_root__', import.meta.url));
|
||||
expect(() => loadManifest(missingRoot)).toThrow(/Cannot read framework manifest/);
|
||||
});
|
||||
|
||||
// The distinct error type is what lets finalizeStage tell a pre-sync validation
|
||||
// abort (nothing written) from a mid-sync filesystem failure (#791 blocker-C).
|
||||
it('every fail-closed rejection is a ManifestError', () => {
|
||||
expect(() => parseManifest('')).toThrow(ManifestError);
|
||||
expect(() => parseManifest('[operator]\nSOUL.md\n')).toThrow(ManifestError);
|
||||
expect(() => parseManifest('[framework]\n/\n')).toThrow(ManifestError);
|
||||
expect(() => parseManifest('[bogus]\nx\n')).toThrow(ManifestError);
|
||||
expect(() => parseManifest('stray.md\n[framework]\n')).toThrow(ManifestError);
|
||||
const missingRoot = fileURLToPath(new URL('./__no_such_framework_root__', import.meta.url));
|
||||
expect(() => loadManifest(missingRoot)).toThrow(ManifestError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchGlob', () => {
|
||||
it('matches an exact file', () => {
|
||||
expect(matchGlob('CONSTITUTION.md', 'CONSTITUTION.md')).toBe(true);
|
||||
expect(matchGlob('CONSTITUTION.md', 'AGENTS.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a bare directory entry as covering its descendants', () => {
|
||||
expect(matchGlob('memory', 'memory')).toBe(true);
|
||||
expect(matchGlob('memory', 'memory/notes.md')).toBe(true);
|
||||
expect(matchGlob('memory', 'memoryfoo')).toBe(false);
|
||||
});
|
||||
|
||||
it('** matches any depth including the root itself', () => {
|
||||
expect(matchGlob('agents/**', 'agents')).toBe(true);
|
||||
expect(matchGlob('agents/**', 'agents/a.conf')).toBe(true);
|
||||
expect(matchGlob('agents/**', 'agents/nested/deep.conf')).toBe(true);
|
||||
expect(matchGlob('agents/**', 'agentsX')).toBe(false);
|
||||
});
|
||||
|
||||
it('* stays within a single segment', () => {
|
||||
expect(matchGlob('*.local.md', 'SOUL.local.md')).toBe(true);
|
||||
expect(matchGlob('*.local.md', 'a/SOUL.local.md')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOwnership (deny-wins + fail-safe)', () => {
|
||||
const m = parseManifest(SAMPLE);
|
||||
|
||||
it('operator globs win over framework globs (carve-out inside a framework subtree)', () => {
|
||||
expect(resolveOwnership(m, 'tools/_lib/credentials.json')).toBe('operator');
|
||||
expect(resolveOwnership(m, 'tools/git/pr-create.sh')).toBe('framework');
|
||||
});
|
||||
|
||||
it('framework-declared paths resolve to framework', () => {
|
||||
expect(resolveOwnership(m, 'guides/E2E-DELIVERY.md')).toBe('framework');
|
||||
expect(resolveOwnership(m, 'CONSTITUTION.md')).toBe('framework');
|
||||
});
|
||||
|
||||
it('UNKNOWN paths default to operator (the #791 root-cause guarantee)', () => {
|
||||
expect(resolveOwnership(m, 'agents/coder0.conf')).toBe('operator'); // declared
|
||||
expect(resolveOwnership(m, 'harvester/sop.md')).toBe('operator'); // undeclared → fail-safe
|
||||
expect(resolveOwnership(m, 'totally-unknown-dir/x')).toBe('operator');
|
||||
expect(resolveOwnership(m, 'random-root-file.md')).toBe('operator');
|
||||
});
|
||||
});
|
||||
|
||||
describe('planPrune (pure prune planner)', () => {
|
||||
const m = parseManifest(SAMPLE);
|
||||
|
||||
it('prunes a retired framework file inside a shipped subtree', () => {
|
||||
const del = planPrune({
|
||||
manifest: m,
|
||||
targetPaths: ['guides/OLD.md', 'guides/KEEP.md'],
|
||||
sourcePaths: ['guides/KEEP.md'],
|
||||
});
|
||||
expect(del).toEqual(['guides/OLD.md']);
|
||||
});
|
||||
|
||||
it('never prunes operator-reserved paths even when absent from source', () => {
|
||||
const del = planPrune({
|
||||
manifest: m,
|
||||
targetPaths: ['agents/coder0.conf', 'tools/_lib/credentials.json', 'SOUL.local.md'],
|
||||
sourcePaths: [],
|
||||
});
|
||||
expect(del).toEqual([]);
|
||||
});
|
||||
|
||||
it('never prunes UNKNOWN paths outside every framework subtree (fail-safe)', () => {
|
||||
const del = planPrune({
|
||||
manifest: m,
|
||||
targetPaths: ['harvester/sop.md', 'my-fleet.yaml', 'unknown-dir/deep/x'],
|
||||
sourcePaths: [],
|
||||
});
|
||||
expect(del).toEqual([]);
|
||||
});
|
||||
|
||||
it('never prunes single-file framework entries (reconcile-managed, not in subtree)', () => {
|
||||
const del = planPrune({ manifest: m, targetPaths: ['CONSTITUTION.md'], sourcePaths: [] });
|
||||
expect(del).toEqual([]);
|
||||
});
|
||||
|
||||
it('property: delete-set ⊆ {framework-owned ∧ in-target ∧ not-in-source} and ∩ operator = ∅', () => {
|
||||
const operatorish = [
|
||||
'agents/a.conf',
|
||||
'policy/p.md',
|
||||
'SOUL.local.md',
|
||||
'memory/m.md',
|
||||
'tools/_lib/credentials.json',
|
||||
'harvester/sop.md',
|
||||
'unknown-top/x',
|
||||
'another-unknown/deep/y.txt',
|
||||
];
|
||||
const frameworkish = ['guides/A.md', 'guides/sub/B.md', 'tools/git/x.sh'];
|
||||
const targetPaths = [...operatorish, ...frameworkish];
|
||||
const del = planPrune({ manifest: m, targetPaths, sourcePaths: [] });
|
||||
|
||||
for (const p of del) {
|
||||
expect(resolveOwnership(m, p)).toBe('framework');
|
||||
expect(targetPaths).toContain(p);
|
||||
}
|
||||
// No operator/unknown path ever appears in the delete-set.
|
||||
for (const p of operatorish) expect(del).not.toContain(p);
|
||||
});
|
||||
});
|
||||
|
||||
// The #797 Runtime Session Ledger lives at fleet/run/sessions/. Today it is safe
|
||||
// twice over: it matches the explicit `fleet/run/**` operator carve-out AND, even
|
||||
// without it, the UNKNOWN→operator fail-safe. This test isolates the CARVE-OUT's
|
||||
// load-bearing value by simulating a future framework author who broadens fleet
|
||||
// ownership to `fleet/**`: without the operator carve-out the ledger would resolve
|
||||
// framework and be pruned; deny-wins is what keeps it protected. If deleting the
|
||||
// `fleet/run/**` line ever stops turning this test red, the carve-out has silently
|
||||
// stopped mattering — which is exactly the #797 regression we are gating against.
|
||||
describe('fleet/run/** carve-out is load-bearing for the #797 ledger (deny-wins)', () => {
|
||||
const LEDGER = ['fleet/run/sessions/events.ndjson', 'fleet/run/sessions/ledger.json'];
|
||||
// A framework that (hypothetically) ships all of fleet/** as a subtree.
|
||||
const withoutCarveOut: FrameworkManifest = {
|
||||
framework: ['fleet/**'],
|
||||
operator: [],
|
||||
};
|
||||
const withCarveOut: FrameworkManifest = {
|
||||
framework: ['fleet/**'],
|
||||
operator: ['fleet/run/**'],
|
||||
};
|
||||
|
||||
it('RED without the carve-out: the ledger resolves framework and is pruned', () => {
|
||||
for (const p of LEDGER) expect(resolveOwnership(withoutCarveOut, p)).toBe('framework');
|
||||
const del = planPrune({ manifest: withoutCarveOut, targetPaths: LEDGER, sourcePaths: [] });
|
||||
expect(del.sort()).toEqual([...LEDGER].sort());
|
||||
});
|
||||
|
||||
it('GREEN with the carve-out: deny-wins makes the ledger operator and unprunable', () => {
|
||||
for (const p of LEDGER) expect(resolveOwnership(withCarveOut, p)).toBe('operator');
|
||||
const del = planPrune({ manifest: withCarveOut, targetPaths: LEDGER, sourcePaths: [] });
|
||||
expect(del).toEqual([]);
|
||||
});
|
||||
|
||||
it('the SHIPPED manifest reserves fleet/run/** so the ledger is operator-owned', () => {
|
||||
const shipped = loadManifest(FRAMEWORK_ROOT);
|
||||
for (const p of LEDGER) expect(resolveOwnership(shipped, p)).toBe('operator');
|
||||
// And it is structurally unreachable by pruning even if it were in a subtree.
|
||||
expect(planPrune({ manifest: shipped, targetPaths: LEDGER, sourcePaths: [] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('frameworkSubtreeRoots', () => {
|
||||
it('returns only the /** subtree roots, not single-file entries', () => {
|
||||
const m = parseManifest(SAMPLE);
|
||||
expect(frameworkSubtreeRoots(m)).toEqual(['guides', 'tools']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── SSOT manifest: shipped-file completeness (§6.2) ──────────────────────────
|
||||
// A newly-shipped framework file must not silently fall outside the manifest —
|
||||
// if it did, the updater could neither guarantee it as framework-owned nor
|
||||
// prune it when retired. Every file the framework actually ships must resolve
|
||||
// to `framework` (except the defaults/{SOUL,USER}.md identity seeds, which are
|
||||
// operator-owned by design).
|
||||
describe('manifest completeness against shipped framework tree', () => {
|
||||
const manifest = loadManifest(FRAMEWORK_ROOT);
|
||||
|
||||
const IGNORED_TOP = new Set(['.git', 'node_modules']);
|
||||
// Framework-shipped files that are operator-owned by design: the identity
|
||||
// seeds under defaults/, and the `.gitkeep` placeholder that lets the empty
|
||||
// operator-owned memory/ directory exist in git.
|
||||
function isOperatorShipped(rel: string): boolean {
|
||||
if (rel === 'defaults/SOUL.md' || rel === 'defaults/USER.md') return true;
|
||||
if (rel.startsWith('memory/')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const abs = join(dir, entry);
|
||||
const rel = relative(FRAMEWORK_ROOT, abs);
|
||||
if (IGNORED_TOP.has(rel)) continue;
|
||||
if (statSync(abs).isDirectory()) out.push(...walk(abs));
|
||||
else out.push(rel);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it('every shipped framework file resolves to framework ownership', () => {
|
||||
const shipped = walk(FRAMEWORK_ROOT);
|
||||
const misclassified = shipped.filter(
|
||||
(p) => !isOperatorShipped(p) && resolveOwnership(manifest, p) !== 'framework',
|
||||
);
|
||||
expect(misclassified).toEqual([]);
|
||||
});
|
||||
|
||||
it('the operator-owned surface from #791 resolves to operator', () => {
|
||||
const operatorPaths = [
|
||||
'agents/coder0.conf',
|
||||
'fleet/agents/coder0.env',
|
||||
'memory/note.md',
|
||||
'policy/custom.md',
|
||||
'SOUL.local.md',
|
||||
'USER.local.md',
|
||||
'STANDARDS.local.md',
|
||||
'tools/_lib/credentials.json',
|
||||
'fleet/roster.yaml',
|
||||
'fleet/roster.json',
|
||||
'fleet/run/coder0.hb',
|
||||
'fleet/backlog/data.db',
|
||||
'fleet/roles.local/custom.md',
|
||||
];
|
||||
for (const p of operatorPaths) {
|
||||
expect(resolveOwnership(manifest, p), p).toBe('operator');
|
||||
}
|
||||
});
|
||||
});
|
||||
248
packages/mosaic/src/framework/manifest.ts
Normal file
248
packages/mosaic/src/framework/manifest.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Framework path-ownership manifest (#791).
|
||||
*
|
||||
* The updater must operate from an explicit framework-owned path manifest and
|
||||
* NEVER write outside it. This module is the TypeScript reader for the shared
|
||||
* SSOT manifest (`packages/mosaic/framework/framework-manifest.txt`) that the
|
||||
* bash installer also consumes. Keeping both paths on one data file is what
|
||||
* closes the two-copies-drift failure class (see #631 → #791).
|
||||
*
|
||||
* Everything here is pure (parse + resolve + plan) so the ownership guarantee
|
||||
* is unit- and property-testable without touching the filesystem.
|
||||
*/
|
||||
|
||||
export type Ownership = 'framework' | 'operator';
|
||||
|
||||
/**
|
||||
* Thrown when the manifest is missing, empty, or malformed. A distinct type lets
|
||||
* callers (e.g. finalizeStage) tell a pre-sync validation abort — where NO files
|
||||
* were touched — apart from a generic mid-sync filesystem failure, and message
|
||||
* the user accurately (#791 blocker-C).
|
||||
*/
|
||||
export class ManifestError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ManifestError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface FrameworkManifest {
|
||||
/** Globs the updater MAY create/overwrite, and prune only when retired. */
|
||||
readonly framework: readonly string[];
|
||||
/** Globs the updater must NEVER write over or prune. Win over `framework`. */
|
||||
readonly operator: readonly string[];
|
||||
}
|
||||
|
||||
type Section = 'framework' | 'operator' | null;
|
||||
|
||||
/**
|
||||
* Parse the line-oriented manifest text. `#` comments and blank lines are
|
||||
* ignored; `[framework]` / `[operator]` headers switch the active section.
|
||||
* Lines before any header are rejected — the format must be explicit.
|
||||
*/
|
||||
export function parseManifest(text: string): FrameworkManifest {
|
||||
const framework: string[] = [];
|
||||
const operator: string[] = [];
|
||||
let section: Section = null;
|
||||
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i] ?? '';
|
||||
const line = raw.trim();
|
||||
if (line === '' || line.startsWith('#')) continue;
|
||||
|
||||
if (line === '[framework]') {
|
||||
section = 'framework';
|
||||
continue;
|
||||
}
|
||||
if (line === '[operator]') {
|
||||
section = 'operator';
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('[')) {
|
||||
throw new ManifestError(`Unknown manifest section header on line ${i + 1}: ${line}`);
|
||||
}
|
||||
|
||||
if (section === null) {
|
||||
throw new ManifestError(
|
||||
`Manifest entry before any [section] header on line ${i + 1}: ${line}`,
|
||||
);
|
||||
}
|
||||
(section === 'framework' ? framework : operator).push(line);
|
||||
}
|
||||
|
||||
// Fail CLOSED on an empty or comment-only manifest. A manifest with zero
|
||||
// framework-owned globs would make resolveOwnership() return `operator` for
|
||||
// every path: an upgrade would prune nothing and refresh nothing — a silent
|
||||
// no-op indistinguishable from success. Refuse loudly instead, mirroring the
|
||||
// bash reader's `manifest_load` guard so both halves reject it identically (#791 B2).
|
||||
if (framework.length === 0) {
|
||||
throw new ManifestError(
|
||||
'Framework manifest defines no [framework] paths — refusing to proceed (empty or malformed manifest).',
|
||||
);
|
||||
}
|
||||
|
||||
// Fail CLOSED on framework entries that normalize to nothing usable. A manifest
|
||||
// like `[framework]\n/` or `[framework]\n./` passes the length check above but
|
||||
// every entry normalizes to an empty (or bare-dot) glob that matches no real
|
||||
// path — so the compiled framework matcher is empty and every path resolves
|
||||
// `operator`: the same silent no-op as an empty manifest. Require at least one
|
||||
// entry with a real, non-dot character (the bash reader applies the identical
|
||||
// `[^/.]` test, so both halves reject these inputs together — #791 blocker-B).
|
||||
if (!framework.some(isUsableFrameworkGlob)) {
|
||||
throw new ManifestError(
|
||||
'Framework manifest defines no usable [framework] paths (every entry is empty or a bare dot segment) — refusing to proceed (malformed manifest).',
|
||||
);
|
||||
}
|
||||
|
||||
return { framework, operator };
|
||||
}
|
||||
|
||||
/**
|
||||
* A framework glob is usable only if, once normalized, it still contains a
|
||||
* character other than `/` or `.` — i.e. it names a real path segment or a
|
||||
* wildcard. `''`, `/`, `./`, `.`, `..` are all unusable (they compile to a glob
|
||||
* that matches nothing). Kept byte-compatible with the bash `[[ =~ [^/.] ]]`
|
||||
* test so TS and bash accept/reject exactly the same manifests.
|
||||
*/
|
||||
function isUsableFrameworkGlob(glob: string): boolean {
|
||||
return /[^/.]/.test(normalizeRel(glob));
|
||||
}
|
||||
|
||||
/** Read and parse the manifest from a framework root directory. */
|
||||
export function loadManifest(frameworkRoot: string): FrameworkManifest {
|
||||
const file = `${frameworkRoot}/framework-manifest.txt`;
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(file, 'utf-8');
|
||||
} catch (err) {
|
||||
// A missing/unreadable manifest must fail closed with a clear message, not a
|
||||
// raw ENOENT that a caller might mistake for an empty result set (#791 B2/B3).
|
||||
throw new ManifestError(
|
||||
`Cannot read framework manifest at ${file}: ${(err as Error).message} — refusing to sync (fail-closed).`,
|
||||
);
|
||||
}
|
||||
return parseManifest(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a mosaic-home-relative POSIX path against one glob.
|
||||
*
|
||||
* Supported: `**` (any depth, including zero segments) and `*` (any run of
|
||||
* characters within a single segment, not crossing `/`). A glob with no
|
||||
* wildcard matches either the exact path OR any path beneath it (so a bare
|
||||
* directory entry like `memory` covers `memory/notes.md`).
|
||||
*/
|
||||
export function matchGlob(glob: string, relPath: string): boolean {
|
||||
const path = normalizeRel(relPath);
|
||||
const pattern = normalizeRel(glob);
|
||||
if (pattern === '') return false;
|
||||
|
||||
if (!pattern.includes('*')) {
|
||||
// Exact file, or any descendant of a bare directory prefix.
|
||||
return path === pattern || path.startsWith(`${pattern}/`);
|
||||
}
|
||||
|
||||
const re = new RegExp(`^${globToRegExpBody(pattern)}$`);
|
||||
return re.test(path);
|
||||
}
|
||||
|
||||
/** True if the path matches any glob in the list. */
|
||||
export function matchesAny(globs: readonly string[], relPath: string): boolean {
|
||||
return globs.some((g) => matchGlob(g, relPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ownership of a mosaic-home-relative path (deny-wins / fail-safe):
|
||||
* operator globs win, then framework globs, else operator by default.
|
||||
*/
|
||||
export function resolveOwnership(manifest: FrameworkManifest, relPath: string): Ownership {
|
||||
if (matchesAny(manifest.operator, relPath)) return 'operator';
|
||||
if (matchesAny(manifest.framework, relPath)) return 'framework';
|
||||
return 'operator';
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of `[framework]` subtree roots that pruning is allowed to descend
|
||||
* into (glob entries of the form `dir/**`). Single-file framework entries
|
||||
* (e.g. `CONSTITUTION.md`) are reconcile-managed and never pruned.
|
||||
*/
|
||||
export function frameworkSubtreeRoots(manifest: FrameworkManifest): string[] {
|
||||
const roots: string[] = [];
|
||||
for (const g of manifest.framework) {
|
||||
if (g.endsWith('/**')) roots.push(g.slice(0, -3));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
export interface PrunePlanInput {
|
||||
readonly manifest: FrameworkManifest;
|
||||
/** Mosaic-home-relative paths currently present in the target. */
|
||||
readonly targetPaths: readonly string[];
|
||||
/** Mosaic-home-relative paths the framework currently ships (source). */
|
||||
readonly sourcePaths: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure prune planner — the testable seam of the #791 fix.
|
||||
*
|
||||
* Returns the delete-set: target paths that are framework-owned, live inside a
|
||||
* shipped framework subtree, and are absent from the current source (retired
|
||||
* framework files). By construction the result never contains an operator-owned
|
||||
* or unknown path: those either resolve to `operator` or fall outside every
|
||||
* framework subtree root, so they are structurally unreachable by pruning.
|
||||
*/
|
||||
export function planPrune(input: PrunePlanInput): string[] {
|
||||
const { manifest, targetPaths, sourcePaths } = input;
|
||||
const source = new Set(sourcePaths.map(normalizeRel));
|
||||
const roots = frameworkSubtreeRoots(manifest);
|
||||
|
||||
const deleteSet: string[] = [];
|
||||
for (const raw of targetPaths) {
|
||||
const path = normalizeRel(raw);
|
||||
if (source.has(path)) continue; // still shipped — keep
|
||||
if (resolveOwnership(manifest, path) !== 'framework') continue; // operator/unknown — never prune
|
||||
if (!roots.some((root) => path === root || path.startsWith(`${root}/`))) continue; // outside shipped subtrees
|
||||
deleteSet.push(path);
|
||||
}
|
||||
return deleteSet;
|
||||
}
|
||||
|
||||
function normalizeRel(p: string): string {
|
||||
return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** Translate a glob body (already normalized) into a RegExp source fragment. */
|
||||
function globToRegExpBody(pattern: string): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < pattern.length; i++) {
|
||||
const c = pattern[i];
|
||||
if (c === undefined) continue;
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// `**` — any depth. `a/**` must also match the bare root `a`, so when a
|
||||
// literal `/` was just emitted, make it optional along with the rest.
|
||||
i++;
|
||||
let trailingSlash = false;
|
||||
if (pattern[i + 1] === '/') {
|
||||
i++;
|
||||
trailingSlash = true;
|
||||
}
|
||||
if (out.endsWith('/')) {
|
||||
out = `${out.slice(0, -1)}(?:/.*)?`;
|
||||
} else if (trailingSlash) {
|
||||
out += '(?:.*/)?';
|
||||
} else {
|
||||
out += '.*';
|
||||
}
|
||||
} else {
|
||||
out += '[^/]*';
|
||||
}
|
||||
} else {
|
||||
out += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -62,16 +62,28 @@ function rotateBackups(filePath: string): void {
|
||||
/**
|
||||
* Sync a source directory to a target, with optional preserve paths.
|
||||
* Replaces the rsync/cp logic from install.sh.
|
||||
*
|
||||
* `isOperatorOwned` is the #791 ownership guard: when supplied, any source path
|
||||
* it flags as operator-owned is never copied (the framework must never write an
|
||||
* operator path). Callers derive it from the shared framework manifest so the TS
|
||||
* and bash sync paths obey one source of truth. This copy is non-destructive —
|
||||
* it never deletes a target file — so honoring the guard is sufficient to leave
|
||||
* operator config untouched.
|
||||
*/
|
||||
export function syncDirectory(
|
||||
source: string,
|
||||
target: string,
|
||||
options: { preserve?: string[]; excludeGit?: boolean } = {},
|
||||
options: {
|
||||
preserve?: string[];
|
||||
excludeGit?: boolean;
|
||||
isOperatorOwned?: (relPath: string) => boolean;
|
||||
} = {},
|
||||
): void {
|
||||
// Guard: source and target are the same directory — nothing to sync
|
||||
if (resolve(source) === resolve(target)) return;
|
||||
|
||||
const preserveSet = new Set(options.preserve ?? []);
|
||||
const isOperatorOwned = options.isOperatorOwned ?? (() => false);
|
||||
|
||||
// Collect files from source
|
||||
function copyRecursive(src: string, dest: string, relBase: string): void {
|
||||
@@ -86,7 +98,7 @@ export function syncDirectory(
|
||||
if (options.excludeGit && (dirName === '.git' || relPath.includes('/.git'))) return;
|
||||
|
||||
// Skip preserved paths at top level
|
||||
if (preserveSet.has(relPath) && existsSync(dest)) return;
|
||||
if (relPath !== '' && preserveSet.has(relPath) && existsSync(dest)) return;
|
||||
|
||||
mkdirSync(dest, { recursive: true });
|
||||
for (const entry of readdirSync(src)) {
|
||||
@@ -101,6 +113,10 @@ export function syncDirectory(
|
||||
// Skip preserved files at top level
|
||||
if (preserveSet.has(relPath) && existsSync(dest)) return;
|
||||
|
||||
// #791: never write an operator-owned path (the framework owns only its
|
||||
// own files; unknown paths resolve to operator and are skipped too).
|
||||
if (isOperatorOwned(relPath)) return;
|
||||
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
copyFileSync(src, dest);
|
||||
}
|
||||
|
||||
@@ -488,9 +488,13 @@ export function getInstallAllCommand(outdated: PackageUpdateResult[]): string {
|
||||
// `mosaic update` installs the new npm CLI but, on its own, leaves the framework
|
||||
// files in ~/.config/mosaic/ stale — so shipped launcher/runtime changes (e.g.
|
||||
// the agent-name export + native heartbeat) never ACTIVATE until a re-seed.
|
||||
// These helpers run the package's own install.sh in sync-only mode (the P4
|
||||
// data-safe reconcile: framework-owned overwrite + backup-once; SOUL/USER/
|
||||
// *.local/credentials preserved) and, opt-in, relaunch durable agents.
|
||||
// These helpers run the package's own install.sh in sync-only mode. The re-seed
|
||||
// is manifest-driven (#791): keep mode writes ONLY framework-owned paths from the
|
||||
// shared framework-manifest.txt and prunes only retired framework files inside
|
||||
// shipped subtrees — every operator path (SOUL/USER/*.local/credentials, fleet
|
||||
// roster + agents + backlog, and anything the manifest never anticipated) is
|
||||
// left byte-identical. Contract files are still reconciled (overwrite +
|
||||
// backup-once). Opt-in, this also relaunches durable agents.
|
||||
|
||||
/** Resolve the framework/ directory bundled in the installed package. */
|
||||
export function resolveBundledFrameworkRoot(): string {
|
||||
|
||||
136
packages/mosaic/src/stages/finalize-sync-abort.spec.ts
Normal file
136
packages/mosaic/src/stages/finalize-sync-abort.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Tests for the framework-sync abort messaging (#791 B2 + blocker-C).
|
||||
*
|
||||
* finalizeStage runs `config.syncFramework()` first, inside a try/catch. If the
|
||||
* sync throws, the wizard must:
|
||||
* 1. NEVER fall through to "Installation complete" — the error is re-raised so
|
||||
* the process exits non-zero (#791 B2).
|
||||
* 2. Classify the failure so recovery advice is accurate (#791 blocker-C):
|
||||
* - ManifestError → a PRE-sync validation abort; nothing was written, so
|
||||
* the message states "no files were changed".
|
||||
* - any other error → may surface mid-copy, so the message must NOT claim
|
||||
* nothing changed; it warns the state "may be partially applied".
|
||||
*
|
||||
* We assert on the spinner's stop() message (the user-visible line) and that the
|
||||
* original error is re-thrown unchanged in both cases.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import type { WizardState } from '../types.js';
|
||||
import type { ConfigService } from '../config/config-service.js';
|
||||
import { ManifestError } from '../framework/manifest.js';
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
spawnSync: vi.fn<any>().mockReturnValue({ status: 0, stdout: '', stderr: '' }),
|
||||
}));
|
||||
|
||||
vi.mock('../platform/detect.js', () => ({
|
||||
getShellProfilePath: () => null,
|
||||
}));
|
||||
|
||||
import { finalizeStage } from './finalize.js';
|
||||
|
||||
function makeState(mosaicHome: string): WizardState {
|
||||
return {
|
||||
mosaicHome,
|
||||
sourceDir: mosaicHome,
|
||||
mode: 'quick',
|
||||
installAction: 'keep',
|
||||
soul: { agentName: 'TestBot', communicationStyle: 'direct' },
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrompter() {
|
||||
const stop = vi.fn();
|
||||
const update = vi.fn();
|
||||
const prompter = {
|
||||
intro: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
note: vi.fn(),
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
text: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
select: vi.fn(),
|
||||
multiselect: vi.fn(),
|
||||
groupMultiselect: vi.fn(),
|
||||
spinner: vi.fn().mockReturnValue({ update, stop }),
|
||||
separator: vi.fn(),
|
||||
};
|
||||
return { prompter, stop };
|
||||
}
|
||||
|
||||
function makeConfigService(syncFramework: ConfigService['syncFramework']): ConfigService {
|
||||
return {
|
||||
readSoul: vi.fn().mockResolvedValue({}),
|
||||
readUser: vi.fn().mockResolvedValue({}),
|
||||
readTools: vi.fn().mockResolvedValue({}),
|
||||
writeSoul: vi.fn().mockResolvedValue(undefined),
|
||||
writeUser: vi.fn().mockResolvedValue(undefined),
|
||||
writeTools: vi.fn().mockResolvedValue(undefined),
|
||||
syncFramework,
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
getSection: vi.fn(),
|
||||
} as unknown as ConfigService;
|
||||
}
|
||||
|
||||
describe('finalizeStage — framework sync abort (#791 B2 + blocker-C)', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'mosaic-sync-abort-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('re-throws a ManifestError and reports that no files were changed', async () => {
|
||||
const err = new ManifestError('Framework manifest defines no usable [framework] paths');
|
||||
const { prompter, stop } = buildPrompter();
|
||||
const config = makeConfigService(vi.fn().mockRejectedValue(err));
|
||||
|
||||
await expect(finalizeStage(prompter, makeState(tmp), config)).rejects.toBe(err);
|
||||
|
||||
// The abort message must state nothing was written (pre-sync validation).
|
||||
expect(stop).toHaveBeenCalledWith(expect.stringContaining('no files were changed'));
|
||||
// It must NOT fall through to a success line.
|
||||
expect(stop).not.toHaveBeenCalledWith(expect.stringContaining('Installation complete'));
|
||||
});
|
||||
|
||||
it('re-throws a non-ManifestError and warns the state may be partially applied', async () => {
|
||||
const err = new Error('cp: write error mid-sync (disk full)');
|
||||
const { prompter, stop } = buildPrompter();
|
||||
const config = makeConfigService(vi.fn().mockRejectedValue(err));
|
||||
|
||||
await expect(finalizeStage(prompter, makeState(tmp), config)).rejects.toBe(err);
|
||||
|
||||
// A generic mid-sync failure must NOT claim nothing changed…
|
||||
expect(stop).toHaveBeenCalledWith(expect.stringContaining('may be partially applied'));
|
||||
expect(stop).not.toHaveBeenCalledWith(expect.stringContaining('no files were changed'));
|
||||
expect(stop).not.toHaveBeenCalledWith(expect.stringContaining('Installation complete'));
|
||||
});
|
||||
|
||||
it('does not proceed to config writes when the sync aborts', async () => {
|
||||
const err = new ManifestError('malformed manifest');
|
||||
const { prompter } = buildPrompter();
|
||||
const config = makeConfigService(vi.fn().mockRejectedValue(err));
|
||||
|
||||
await expect(finalizeStage(prompter, makeState(tmp), config)).rejects.toBe(err);
|
||||
|
||||
// writeSoul/writeUser/writeTools are only reached after a successful sync.
|
||||
expect(config.writeSoul).not.toHaveBeenCalled();
|
||||
expect(config.writeUser).not.toHaveBeenCalled();
|
||||
expect(config.writeTools).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { ConfigService } from '../config/config-service.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { getShellProfilePath } from '../platform/detect.js';
|
||||
import { ManifestError } from '../framework/manifest.js';
|
||||
|
||||
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
||||
@@ -160,7 +161,26 @@ export async function finalizeStage(
|
||||
|
||||
// 1. Sync framework files (before config writes so identity files aren't overwritten)
|
||||
spin.update('Syncing framework files...');
|
||||
await config.syncFramework(state.installAction);
|
||||
try {
|
||||
await config.syncFramework(state.installAction);
|
||||
} catch (err) {
|
||||
// Stop the spinner loudly and re-raise so the process exits non-zero — never
|
||||
// fall through to "Installation complete" on an aborted sync (#791 B2).
|
||||
// A ManifestError is a PRE-sync validation abort: the manifest is loaded and
|
||||
// validated before any file is written, so nothing was touched. Any other
|
||||
// error can surface AFTER files were partially copied, so we must NOT claim
|
||||
// "no files were changed" for it — that would misdirect recovery (#791 blocker-C).
|
||||
if (err instanceof ManifestError) {
|
||||
spin.stop(
|
||||
'Framework sync aborted — the framework manifest is missing, empty, or malformed; no files were changed.',
|
||||
);
|
||||
} else {
|
||||
spin.stop(
|
||||
'Framework sync aborted — the update did not complete and may be partially applied; see the error below.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 2. Write config files (after sync so they aren't overwritten by source templates)
|
||||
if (state.installAction !== 'keep') {
|
||||
|
||||
Reference in New Issue
Block a user