Compare commits
2 Commits
feat/791-p
...
feat/790-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79e2fa35d5 | ||
|
|
ab8c9a2d4b |
@@ -42,27 +42,6 @@ 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:
|
||||
@@ -71,7 +50,6 @@ steps:
|
||||
depends_on:
|
||||
- install
|
||||
- sanitization
|
||||
- upgrade-guard
|
||||
|
||||
# lint, format, and test are independent — run in parallel after typecheck
|
||||
lint:
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
# 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.
|
||||
@@ -98,39 +98,6 @@ 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:
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
# 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,529 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,64 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,37 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,85 +0,0 @@
|
||||
# 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,10 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# -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
|
||||
set -euo pipefail
|
||||
|
||||
# ─── Mosaic Framework Installer ──────────────────────────────────────────────
|
||||
#
|
||||
@@ -24,19 +19,32 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||
|
||||
# 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.
|
||||
# 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")
|
||||
|
||||
# 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).
|
||||
@@ -67,267 +75,17 @@ 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")"
|
||||
# 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
|
||||
cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/" 2>/dev/null || true
|
||||
}
|
||||
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"
|
||||
# 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
|
||||
rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"
|
||||
cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/" 2>/dev/null || true
|
||||
}
|
||||
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() {
|
||||
@@ -426,105 +184,63 @@ sync_framework() {
|
||||
return
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
# 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/"
|
||||
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/"
|
||||
return
|
||||
fi
|
||||
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 \
|
||||
! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" \
|
||||
-exec rm -rf {} +
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -545,10 +261,6 @@ 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"
|
||||
@@ -599,26 +311,9 @@ 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; exit 1' ERR INT TERM
|
||||
trap 'restore_snapshot' ERR INT TERM
|
||||
|
||||
sync_framework
|
||||
|
||||
@@ -647,10 +342,6 @@ 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
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
#!/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
|
||||
@@ -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" && "$http_code" != "206" ]]; then
|
||||
if [[ "$http_code" != "200" ]]; then
|
||||
echo "Error: Failed to list computers (HTTP $http_code)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/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" && "$http_code" != "206" ]]; then
|
||||
if [[ "$http_code" != "200" ]]; 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" && "$http_code" != "206" ]]; then
|
||||
if [[ "$http_code" != "200" ]]; then
|
||||
echo "Error: Failed to list users (HTTP $http_code)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -61,10 +61,8 @@ 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 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.
|
||||
# F6 — keep-mode re-seed (the `mosaic update` path) MUST preserve only the
|
||||
# exact user-owned roster paths while refreshing framework-owned schema/examples.
|
||||
T6=$(mktemp -d); mkdir -p "$T6/fleet/examples" "$T6/fleet/run" "$T6/fleet/agents"
|
||||
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"
|
||||
@@ -77,14 +75,13 @@ 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: unanticipated operator fleet file survives (fail-safe, #791)" "cmp -s '$T6/fleet/my-fleet.yaml' '$E6/my-fleet.expected'"
|
||||
chk "F6 reseed: unrelated fleet YAML is not preserved" "[ ! -f '$T6/fleet/my-fleet.yaml' ]"
|
||||
chk "F6 reseed: per-agent env bytes survive" "cmp -s '$T6/fleet/agents/coder0.env' '$E6/agent.expected'"
|
||||
chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'"
|
||||
chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
#!/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 ]
|
||||
@@ -1,231 +0,0 @@
|
||||
#!/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 ]
|
||||
@@ -1,319 +0,0 @@
|
||||
#!/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,11 +122,12 @@ fi
|
||||
|
||||
# Source label: this agent's host:session (auto-detected, overridable).
|
||||
if [ -z "$SRC_LABEL" ]; then
|
||||
src_host=$(hostname -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 "?")
|
||||
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_LABEL="${src_host}:${src_sess}"
|
||||
fi
|
||||
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
# 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)
|
||||
@@ -24,45 +21,22 @@ TOOL="$HERE/agent-send.sh"
|
||||
|
||||
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
|
||||
STUB=$(mktemp)
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN"' EXIT
|
||||
trap 'rm -f "$STUB"' EXIT
|
||||
cat >"$STUB" <<'STUB_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
b64=""
|
||||
while getopts "L:t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
|
||||
while getopts "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)\] (.*)$'
|
||||
@@ -118,30 +92,6 @@ 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,7 +17,6 @@ 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';
|
||||
@@ -407,10 +406,6 @@ registerStorageCommand(program);
|
||||
|
||||
registerUninstallCommand(program);
|
||||
|
||||
// ─── restore ─────────────────────────────────────────────────────────────────
|
||||
|
||||
registerRestoreCommand(program);
|
||||
|
||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||
|
||||
registerTelemetryCommand(program);
|
||||
|
||||
@@ -1,885 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,441 +0,0 @@
|
||||
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,7 +97,6 @@ describe('registerFleetCommand', () => {
|
||||
'provision',
|
||||
'ps',
|
||||
'reconcile',
|
||||
'regen',
|
||||
'remove',
|
||||
'restart',
|
||||
'start',
|
||||
|
||||
@@ -44,7 +44,6 @@ 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,
|
||||
@@ -2064,18 +2063,6 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,466 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,313 +0,0 @@
|
||||
/**
|
||||
* 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,22 +1,9 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
copyFileSync,
|
||||
} from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } 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.
|
||||
*
|
||||
@@ -47,13 +34,6 @@ 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');
|
||||
@@ -122,10 +102,9 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
});
|
||||
|
||||
it('overwrites framework-owned files (backup-once) but preserves user-seeded files', async () => {
|
||||
// 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.
|
||||
// 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');
|
||||
|
||||
writeFileSync(join(fixture.mosaicHome, 'TOOLS.md'), '# user-customized TOOLS\n');
|
||||
writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n');
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ 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.
|
||||
@@ -156,22 +155,38 @@ export class FileConfigAdapter implements ConfigService {
|
||||
}
|
||||
|
||||
async syncFramework(action: InstallAction): Promise<void> {
|
||||
// #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;
|
||||
// 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',
|
||||
]
|
||||
: [];
|
||||
|
||||
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,22 +381,6 @@ 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);
|
||||
|
||||
@@ -584,29 +584,6 @@ 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[]> {
|
||||
@@ -619,7 +596,16 @@ function defaultPrepareProjections(
|
||||
mosaicHome,
|
||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||
agentName: agent.name,
|
||||
generated: projectRosterV2AgentGeneratedEnv(roster, agent),
|
||||
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,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -634,193 +620,62 @@ function mosaicHomeFor(deps: FleetReconcileDeps): string {
|
||||
return deps.mosaicHome ?? join(homedir(), '.config', 'mosaic');
|
||||
}
|
||||
|
||||
/** Acquires the private reconcile lock only after proving the canonical managed path. */
|
||||
/** Acquires a private 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, 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}`;
|
||||
const lockPath = join(fleetDir, 'roster.yaml.reconcile.lock');
|
||||
return async (): Promise<() => Promise<void>> => {
|
||||
await assertPrivateManagedDirectory(mosaicHome);
|
||||
await assertPrivateManagedDirectory(fleetDir);
|
||||
await assertSafeLockLeafIfPresent(lockPath, lockLabel);
|
||||
await assertSafeLockLeafIfPresent(lockPath);
|
||||
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await openLock(lockPath, 'wx', 0o600);
|
||||
} catch (error: unknown) {
|
||||
if (isCode(error, 'EEXIST')) {
|
||||
await assertSafeLockLeafIfPresent(lockPath, lockLabel);
|
||||
throw new FleetReconcileError('concurrent-mutation', busyMessage);
|
||||
await assertSafeLockLeafIfPresent(lockPath);
|
||||
throw new FleetReconcileError(
|
||||
'concurrent-mutation',
|
||||
'Another roster reconciliation is in progress.',
|
||||
);
|
||||
}
|
||||
throw new FleetReconcileError('lock-io-failed', `The ${lockLabel} lock cannot be created.`);
|
||||
throw new FleetReconcileError('lock-io-failed', 'The reconciliation 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');
|
||||
tokenPersisted = true;
|
||||
const opened = await handle.stat();
|
||||
await handle.close();
|
||||
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,
|
||||
);
|
||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'unsafe-lock');
|
||||
return async (): Promise<void> => {
|
||||
try {
|
||||
await assertLockOwnership(
|
||||
lockPath,
|
||||
created.dev,
|
||||
created.ino,
|
||||
token,
|
||||
'lock-cleanup-failed',
|
||||
lockLabel,
|
||||
);
|
||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
|
||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
|
||||
await unlink(lockPath);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError('lock-cleanup-failed', `The ${lockLabel} cleanup failed.`);
|
||||
throw new FleetReconcileError(
|
||||
'lock-cleanup-failed',
|
||||
'The reconciliation lock 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 ${lockLabel} lock cannot be initialized.`,
|
||||
'The reconciliation 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);
|
||||
@@ -836,16 +691,16 @@ async function assertPrivateManagedDirectory(path: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertSafeLockLeafIfPresent(lockPath: string, lockLabel: string): Promise<void> {
|
||||
async function assertSafeLockLeafIfPresent(lockPath: string): Promise<void> {
|
||||
try {
|
||||
const metadata = await lstat(lockPath);
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||
throw new FleetReconcileError('unsafe-lock', `The ${lockLabel} lock path is unsafe.`);
|
||||
throw new FleetReconcileError('unsafe-lock', 'The reconciliation lock path is unsafe.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isCode(error, 'ENOENT')) return;
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError('unsafe-lock', `The ${lockLabel} lock path is unavailable.`);
|
||||
throw new FleetReconcileError('unsafe-lock', 'The reconciliation lock path is unavailable.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,7 +710,6 @@ async function assertLockOwnership(
|
||||
inode: number,
|
||||
token: string,
|
||||
failureCode: 'unsafe-lock' | 'lock-cleanup-failed',
|
||||
lockLabel: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const metadata = await lstat(lockPath);
|
||||
@@ -866,21 +720,24 @@ async function assertLockOwnership(
|
||||
metadata.dev !== device ||
|
||||
metadata.ino !== inode
|
||||
) {
|
||||
throw new FleetReconcileError(failureCode, `The ${lockLabel} ownership changed.`);
|
||||
throw new FleetReconcileError(failureCode, 'The reconciliation lock ownership changed.');
|
||||
}
|
||||
const handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const opened = await handle.stat();
|
||||
const contents = await handle.readFile({ encoding: 'utf8' });
|
||||
if (opened.dev !== device || opened.ino !== inode || contents !== `${token}\n`) {
|
||||
throw new FleetReconcileError(failureCode, `The ${lockLabel} ownership changed.`);
|
||||
throw new FleetReconcileError(failureCode, 'The reconciliation lock ownership changed.');
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError(failureCode, `The ${lockLabel} ownership cannot be proven.`);
|
||||
throw new FleetReconcileError(
|
||||
failureCode,
|
||||
'The reconciliation lock ownership cannot be proven.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -204,53 +204,6 @@ 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
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,327 +0,0 @@
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
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,28 +62,16 @@ 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;
|
||||
isOperatorOwned?: (relPath: string) => boolean;
|
||||
} = {},
|
||||
options: { preserve?: string[]; excludeGit?: 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 {
|
||||
@@ -98,7 +86,7 @@ export function syncDirectory(
|
||||
if (options.excludeGit && (dirName === '.git' || relPath.includes('/.git'))) return;
|
||||
|
||||
// Skip preserved paths at top level
|
||||
if (relPath !== '' && preserveSet.has(relPath) && existsSync(dest)) return;
|
||||
if (preserveSet.has(relPath) && existsSync(dest)) return;
|
||||
|
||||
mkdirSync(dest, { recursive: true });
|
||||
for (const entry of readdirSync(src)) {
|
||||
@@ -113,10 +101,6 @@ 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,13 +488,9 @@ 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 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.
|
||||
// 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.
|
||||
|
||||
/** Resolve the framework/ directory bundled in the installed package. */
|
||||
export function resolveBundledFrameworkRoot(): string {
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* 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,7 +6,6 @@ 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');
|
||||
@@ -161,26 +160,7 @@ export async function finalizeStage(
|
||||
|
||||
// 1. Sync framework files (before config writes so identity files aren't overwritten)
|
||||
spin.update('Syncing framework files...');
|
||||
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;
|
||||
}
|
||||
await config.syncFramework(state.installAction);
|
||||
|
||||
// 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