Compare commits
2 Commits
fix/792-fl
...
fix/766-ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd7f8a77a0 | ||
|
|
0dc47cac92 |
@@ -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 --self-test
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
- 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:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
@@ -71,7 +50,6 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
- sanitization
|
- sanitization
|
||||||
- upgrade-guard
|
|
||||||
|
|
||||||
# lint, format, and test are independent — run in parallel after typecheck
|
# lint, format, and test are independent — run in parallel after typecheck
|
||||||
lint:
|
lint:
|
||||||
|
|||||||
@@ -52,20 +52,20 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
> the repository quality gates, independent code and security review, terminal-green CI, and
|
> the repository quality gates, independent code and security review, terminal-green CI, and
|
||||||
> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
|
> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
|
||||||
|
|
||||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||||
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
|
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 |
|
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 |
|
||||||
| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation |
|
| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation |
|
||||||
| FCM-M1-002 | done | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | #768 squash `a5e8e55`; shared resolver and canonical authority/alias validation delivered |
|
| FCM-M1-002 | in-progress | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | Started 2026-07-14 from `aa5b43b`; one shared resolver only; validator certificate-only; merge-gate sole merge authority |
|
||||||
| FCM-M1-003 | done | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | #770 squash `e9c4aa3`; shipped artifact disposition validation delivered |
|
| FCM-M1-003 | not-started | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | Every shipped artifact must validate, be versioned v1, or be retired with replacement |
|
||||||
| FCM-M2-001 | done | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | #772 squash `191efae`; generated/local boundary and private quarantine delivered |
|
| FCM-M2-001 | not-started | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | No arbitrary command compatibility path; diagnostics expose key names/hashes only |
|
||||||
| FCM-M2-002 | done | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | #773 squash `bc5e736`; generation-guarded atomic CRUD and recovery contracts delivered |
|
| FCM-M2-002 | not-started | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | Fresh create persists stopped unless explicit persisted start |
|
||||||
| FCM-M3-001 | done | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | #785 squash `4990905`; exact roster-owned systemd/tmux reconcile and lifecycle contracts delivered |
|
| FCM-M3-001 | not-started | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | Exact systemd/tmux ownership; remote/schema-only entries are inventory only |
|
||||||
| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only |
|
| FCM-M3-002 | not-started | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Proves stopped-state preservation and zero fuzzy destructive targeting |
|
||||||
| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference |
|
| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference |
|
||||||
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session |
|
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session |
|
||||||
| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral |
|
| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral |
|
||||||
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI |
|
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI |
|
||||||
|
|
||||||
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -41,27 +41,10 @@ artifact can be removed.
|
|||||||
| `profiles/software-delivery.yaml` | Canonical profile | shared profile/persona resolver | Retains the governance profile; authority validation remains FCM-M1-002 evidence. |
|
| `profiles/software-delivery.yaml` | Canonical profile | shared profile/persona resolver | Retains the governance profile; authority validation remains FCM-M1-002 evidence. |
|
||||||
| `services/operator-interaction.yaml` | Canonical service policy | service-policy reader/provisioner | Generic provisioning supplies the instance name; the policy itself never names Tess. |
|
| `services/operator-interaction.yaml` | Canonical service policy | service-policy reader/provisioner | Generic provisioning supplies the instance name; the policy itself never names Tess. |
|
||||||
|
|
||||||
## M4 migration-preview evidence
|
|
||||||
|
|
||||||
FCM-M4-001 layers an executable migration posture over the same 13-entry M1 inventory without
|
|
||||||
changing the retained artifact classification:
|
|
||||||
|
|
||||||
- every `v1-fixture` is previewed only with explicit class and lifecycle evidence;
|
|
||||||
- every `canonical-profile` remains validated by the shared baseline-plus-`roles.local` resolver;
|
|
||||||
- the canonical service policy remains generic and uses only the approved tool-policy alias.
|
|
||||||
|
|
||||||
`validateShippedFleetMigrationDispositions` first runs the existing executable M1 guard, then requires
|
|
||||||
explicit decisions and lifecycle observations and executes `previewV1ToV2Migration` for every shipped
|
|
||||||
v1 fixture. `collectShippedFleetMigrationDispositions` derives the 13-entry posture directly from
|
|
||||||
`SHIPPED_FLEET_ARTIFACT_DISPOSITIONS`, so additions or removals continue to fail the M1 guard rather
|
|
||||||
than creating a second artifact list. None of these dispositions claims a cutover, canary, or
|
|
||||||
rollback; those gates belong to FCM-M4-002. See [v1-to-v2 preview](./v1-to-v2.md).
|
|
||||||
|
|
||||||
## Running the guard
|
## Running the guard
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter @mosaicstack/mosaic test -- v1-v2-migration.spec.ts \
|
pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts
|
||||||
-t "validates all 13 shipped artifacts and executes ready previews for every v1 fixture"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The guard is intentionally limited to shipped assets and validation. It does not generate
|
The guard is intentionally limited to shipped assets and validation. It does not generate
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
# Previewing a Fleet Roster v1-to-v2 Migration
|
|
||||||
|
|
||||||
**Issue:** #758 · **Card:** FCM-M4-001 · **Effect boundary:** preview only
|
|
||||||
|
|
||||||
`mosaic fleet migrate-v1 preview` inventories a v1 roster and emits a canonical v2 candidate plus
|
|
||||||
recovery evidence. It does not write a roster, apply environment projections, invoke systemd or
|
|
||||||
`tmux`, contact connectors or remote hosts, launch an agent, run a canary, or execute rollback.
|
|
||||||
FCM-M4-002 owns reversible cutover and rollback.
|
|
||||||
|
|
||||||
## Inputs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic fleet migrate-v1 preview \
|
|
||||||
--source roster-v1.yaml \
|
|
||||||
--decisions migration-decisions.json \
|
|
||||||
--observations reviewed-observations.json
|
|
||||||
```
|
|
||||||
|
|
||||||
The command emits one JSON object and exits nonzero when the preview is blocked, including when any of
|
|
||||||
`--source`, `--decisions`, or `--observations` is omitted, passed without a path value, or passed an empty
|
|
||||||
path value. These request-shape failures are reported before any input file is read. Decision and
|
|
||||||
observation JSON is validated fail-closed: unknown fields, malformed values, and records for non-local
|
|
||||||
agents are rejected. Decisions must supply a positive v2 `generation`, a reviewed `fleetHost` whenever
|
|
||||||
v1 agents include `host` or `ssh`, explicit `defaultRuntime`, and per-local-agent provider, model,
|
|
||||||
reasoning, enabled state, and launch policy. The v1 source remains authoritative for socket semantics:
|
|
||||||
a supported declared socket field, including an explicit empty value for the default tmux server, is
|
|
||||||
preserved; if both supported root aliases are absent, the production v1 default is the literal empty socket.
|
|
||||||
A matching `socketName` decision is accepted and an incompatible decision blocks, but a decision never
|
|
||||||
supplies or repairs a missing source socket. If v1 omitted `tool_policy`, decisions must supply an
|
|
||||||
explicit replacement; it is never derived from `class`. `model_hint` is never split or treated as
|
|
||||||
authority.
|
|
||||||
|
|
||||||
Observations are separate reviewed evidence keyed by local agent name:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"coder0": { "systemd": "inactive", "tmux": "missing" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Only `active` plus `present` maps to `running`; only `inactive` plus `missing` maps to `stopped`.
|
|
||||||
Missing, extra, unknown, or contradictory evidence blocks output. An observed-running agent cannot
|
|
||||||
be marked disabled. Observed-stopped agents always remain stopped.
|
|
||||||
|
|
||||||
## Field disposition
|
|
||||||
|
|
||||||
| v1 field | v2 disposition |
|
|
||||||
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `version`, `transport`, `tmux`, `defaults`, `runtimes` | Inventoried and structurally compiled; omitted runtimes retain v1 built-in defaults, while each explicitly declared runtime without a reset field follows the production v1 `/clear` fallback; present-empty holder/work-directory/reset values block |
|
|
||||||
| agent `name`, `alias`, `runtime`, working directory, persona/reset flags | Copied or explicitly defaulted only when absent; present-empty alias/work-directory values block for explicit disposition. Canonical `~`/`~/...` values stay unchanged in roster evidence and traversal-free forms expand only at the shared production environment-projection boundary before unchanged absolute-path validation |
|
|
||||||
| `provider`, `model_hint`, `reasoning_level` | Explicit provider/model/reasoning decisions; no model-hint inference |
|
|
||||||
| `class`, `tool_policy` | Only approved aliases canonicalize automatically; other classes require explicit preserve/replace disposition and shared-resolver validation |
|
|
||||||
| `kickstart_template` | No v2 field; explicit inventory-only disposition required |
|
|
||||||
| agent `host`, `ssh` | `host != fleetHost` is demonstrably remote and inventory-only; `host == fleetHost` stays local; SSH targets with or without an explicit user must agree with `host`; ssh-only, missing fleet-host evidence, or contradictory targets block |
|
|
||||||
| agent `socket` | Same-host candidate only when it matches the canonical fleet socket; conflicts block for explicit future disposition |
|
|
||||||
| root `connector` | Inventory-only; never contacted or reconciled |
|
|
||||||
| unknown fields or snake/camel synonym collisions | Inventoried and block readiness |
|
|
||||||
| `.env.generated` | Rebuild from canonical roster data |
|
|
||||||
| no legacy `.env` | `absent`; no legacy action required |
|
|
||||||
| legacy `.env` containing generated keys only | `regenerate-only`; replace later from canonical roster data |
|
|
||||||
| legacy `.env` containing strict local keys | `relocate-local`; preserve those keys in `.env.local` during a later reviewed cutover |
|
|
||||||
| legacy `.env` containing forbidden/unsafe/sensitive/malformed keys | `quarantine`; private input only, with diagnostics limited to code, key, and SHA-256 |
|
|
||||||
|
|
||||||
The only automatic aliases are `implementer → code`, `reviewer → review`, and
|
|
||||||
`operator-interaction → interaction`. Similar or domain-specific names are never inferred. Automatic
|
|
||||||
classes do not accept competing disposition records. Semantic validation delegates to the existing
|
|
||||||
baseline-plus-`roles.local` resolver after the candidate is compiled by the existing v2 compiler.
|
|
||||||
|
|
||||||
## Evidence and recovery boundary
|
|
||||||
|
|
||||||
Ready output includes source and candidate SHA-256 identities, value-free field inventory, excluded
|
|
||||||
remote/connector entries, explicit environment dispositions with sanitized diagnostics, and the lifecycle
|
|
||||||
evidence used for each local candidate. Canonical lifecycle and remote-exclusion evidence ordering compares
|
|
||||||
Unicode code points directly and does not depend on source-agent order or process locale. Source field
|
|
||||||
inventory remains position-addressed evidence of the exact input. Recovery is marked non-executable and
|
|
||||||
assigns the executable gate to FCM-M4-002.
|
|
||||||
|
|
||||||
Before any later cutover, preserve these artifacts:
|
|
||||||
|
|
||||||
1. authoritative v1 roster backup;
|
|
||||||
2. agent environment backup, including `.env.local` and private quarantine inputs;
|
|
||||||
3. reviewed lifecycle observations;
|
|
||||||
4. canonical candidate v2 roster and its SHA-256.
|
|
||||||
|
|
||||||
See [backup and restore](../operations/backup-restore.md). Preview output is migration-readiness
|
|
||||||
evidence, not proof that migration, canary, or rollback occurred.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Fleet Configuration Backup and Restore Boundary
|
|
||||||
|
|
||||||
**Issue:** #758 · **Card:** FCM-M4-001
|
|
||||||
|
|
||||||
This page defines evidence that must exist before a roster v1-to-v2 cutover. FCM-M4-001 lists these
|
|
||||||
prerequisites in non-executable recovery evidence but does not validate that backups exist and performs
|
|
||||||
no backup, migration, canary, or restore. FCM-M4-002 owns the executable reversible canary and rollback
|
|
||||||
gates.
|
|
||||||
|
|
||||||
## Preserve before cutover
|
|
||||||
|
|
||||||
- The authoritative v1 roster, byte-for-byte, with a SHA-256 identity.
|
|
||||||
- Existing per-agent legacy `.env`, strict `.env.local`, and quarantine files under private
|
|
||||||
permissions.
|
|
||||||
- Reviewed per-local-agent systemd and exact-socket tmux observations.
|
|
||||||
- The canonical v2 candidate and its SHA-256 identity.
|
|
||||||
- Inventory-only remote agents and connector configuration as evidence, not local control-plane input.
|
|
||||||
|
|
||||||
`.env.generated` is a rebuildable projection and is not restored as authority. It must be regenerated
|
|
||||||
from the selected authoritative roster. `.env.local` is operator-owned strict data and must not be
|
|
||||||
overwritten or absorbed into generated output. Quarantined source remains private evidence; public
|
|
||||||
diagnostics expose only rule code, key name, and SHA-256.
|
|
||||||
|
|
||||||
## Restore requirements
|
|
||||||
|
|
||||||
A later rollback implementation must restore the authoritative roster and operator-owned environment
|
|
||||||
files, regenerate managed projections, and preserve each reviewed pre-cutover stopped/running state.
|
|
||||||
It must never start an agent observed stopped and must never reconcile an inventory-only remote or
|
|
||||||
connector entry.
|
|
||||||
|
|
||||||
The preview evidence deliberately records:
|
|
||||||
|
|
||||||
- `executable: false`;
|
|
||||||
- required backup artifacts;
|
|
||||||
- source and candidate identities;
|
|
||||||
- lifecycle observations and resulting desired states;
|
|
||||||
- environment relocation/quarantine dispositions;
|
|
||||||
- FCM-M4-002 as the executable rollback gate owner.
|
|
||||||
|
|
||||||
Do not interpret a ready preview as a completed backup, migration, canary, or rollback.
|
|
||||||
@@ -11,15 +11,8 @@ mosaic fleet restart [name] --expected-generation <n> [--dry-run]
|
|||||||
mosaic fleet status [name]
|
mosaic fleet status [name]
|
||||||
mosaic fleet verify
|
mosaic fleet verify
|
||||||
mosaic fleet doctor
|
mosaic fleet doctor
|
||||||
mosaic fleet migrate-v1 preview --source <path> --decisions <path> --observations <path>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`migrate-v1 preview` is non-mutating: it emits value-free v1 inventory, a canonical semantically
|
|
||||||
validated v2 candidate when ready, sanitized environment dispositions, and non-executable recovery
|
|
||||||
evidence. It has no write, apply, canary, or rollback option. Missing preview inputs also return one stable
|
|
||||||
blocked JSON object and a non-zero exit, rather than Commander text. See
|
|
||||||
[the migration preview contract](../migration/v1-to-v2.md).
|
|
||||||
|
|
||||||
`apply` and `reconcile` use roster desired state. `start`, `stop`, and `restart` are exact local one-shot lifecycle effects and never persist a desired-state edit. `status`, `verify`, and `doctor` are observational.
|
`apply` and `reconcile` use roster desired state. `start`, `stop`, and `restart` are exact local one-shot lifecycle effects and never persist a desired-state edit. `status`, `verify`, and `doctor` are observational.
|
||||||
|
|
||||||
Commands emit one JSON object. Handled precondition errors emit `{ "error": { "code": "..." } }` and exit non-zero. Partial derived/lifecycle effects use explicit `authoritativeRoster`, `projections`, `lifecycle`, and bounded `recovery` fields; they never claim rollback. Any additive `cleanup` diagnostic also exits non-zero, even where known effects are complete: it is not a clean completion and the lock requires inspection before retry.
|
Commands emit one JSON object. Handled precondition errors emit `{ "error": { "code": "..." } }` and exit non-zero. Partial derived/lifecycle effects use explicit `authoritativeRoster`, `projections`, `lifecycle`, and bounded `recovery` fields; they never claim rollback. Any additive `cleanup` diagnostic also exits non-zero, even where known effects are complete: it is not a clean completion and the lock requires inspection before retry.
|
||||||
|
|||||||
@@ -62,24 +62,24 @@ agents:
|
|||||||
|
|
||||||
## Nested fields
|
## Nested fields
|
||||||
|
|
||||||
| Path | Required | Constraint |
|
| Path | Required | Constraint |
|
||||||
| ---------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
|
||||||
| `tmux.socket_name` | yes | `[A-Za-z0-9_.-]*`; empty string means the literal default tmux server, while a non-empty value names a socket |
|
| `tmux.socket_name` | yes | non-empty `[A-Za-z0-9_.-]+`; an explicit named socket prevents default-versus-named socket ambiguity |
|
||||||
| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` |
|
| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` |
|
||||||
| `defaults.working_directory` | yes | non-empty string |
|
| `defaults.working_directory` | yes | non-empty string |
|
||||||
| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
|
| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
|
||||||
| `runtimes.<runtime>.reset_command` | yes | non-empty string; runtime key must be a supported local runtime |
|
| `runtimes.<runtime>.reset_command` | yes | non-empty string; runtime key must be a supported local runtime |
|
||||||
| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity |
|
| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity |
|
||||||
| `agents[].alias` | yes | non-empty display string |
|
| `agents[].alias` | yes | non-empty display string |
|
||||||
| `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 |
|
| `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 |
|
||||||
| `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
|
| `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
|
||||||
| `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card |
|
| `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card |
|
||||||
| `agents[].reasoning` | yes | `low`, `medium`, or `high` |
|
| `agents[].reasoning` | yes | `low`, `medium`, or `high` |
|
||||||
| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 |
|
| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 |
|
||||||
| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans |
|
| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans |
|
||||||
| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 |
|
| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 |
|
||||||
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
|
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
|
||||||
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
|
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
|
||||||
|
|
||||||
## Semantic handoff
|
## Semantic handoff
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"socket_name": {
|
"socket_name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[A-Za-z0-9_.-]*$"
|
"pattern": "^[A-Za-z0-9_.-]+$"
|
||||||
},
|
},
|
||||||
"holder_session": {
|
"holder_session": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -98,39 +98,6 @@ Expected results:
|
|||||||
that means the unit ran, not that an agent pane is live. Treat tmux
|
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.
|
`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
|
## Release Preflight
|
||||||
|
|
||||||
Run this checklist before cutting or dogfooding a fleet release:
|
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,84 +0,0 @@
|
|||||||
# FCM-M3-002 — Reconciler lifecycle acceptance gates
|
|
||||||
|
|
||||||
- **Task / issue:** FCM-M3-002 / mosaicstack/stack#758
|
|
||||||
- **Branch:** `test/758-reconciler-lifecycle-gates`
|
|
||||||
- **Required starting head:** `499090508ef1d768660e4d54e7934cbcf13cb1cd`
|
|
||||||
- **Required starting tree:** `2f1bb7fed48291f3f7ba8b21c2b52491aa14fe2b`
|
|
||||||
- **Scope:** isolated acceptance coverage and card-required evidence/tracking only; no live fleet, systemd, tmux, session, site, migration, canary, deployment, runtime, connector, or remote action.
|
|
||||||
- **Budget:** use the task estimate of 25K as the working cap; keep the delta to one coherent acceptance suite plus required task/scratchpad evidence. No production change unless a failing reproducer proves an in-scope defect.
|
|
||||||
|
|
||||||
## Intake evidence
|
|
||||||
|
|
||||||
- Clean exact local branch/head/tree verified before editing.
|
|
||||||
- `origin/test/758-reconciler-lifecycle-gates` fetched and verified at the same required head.
|
|
||||||
- The Mosaic PR wrapper reported no open pull requests, so no open-PR branch collision exists.
|
|
||||||
- Parent issue #758 is open and remains intentionally open through M5.
|
|
||||||
- Requirements loaded from `docs/PRD.md` FCM requirements and `AC-FCM-05`, `docs/TASKS.md` FCM DAG, the M3 rows in `docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md`, and the FCM-M3-001 implementation scratchpad.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Add broad, behavior-oriented acceptance evidence around the shipped local reconciler contracts. Exercise only injected fake systemd/tmux adapters and temporary filesystem fixtures. Prove exact ownership/targeting, persisted stopped-state safety, truthful partial-failure recovery, rollback behavior, and stable command JSON/exit outcomes without touching live services or sessions.
|
|
||||||
|
|
||||||
## Acceptance mapping and evidence
|
|
||||||
|
|
||||||
| FCM-M3-002 acceptance concern | Delivered isolated evidence |
|
|
||||||
| --- | --- |
|
|
||||||
| Systemd/tmux lifecycle | `fleet-reconciler.acceptance.spec.ts` drives apply, reconcile, stop, restart, status, and recovery reconcile through one stateful injected fake host. The fake models exact systemd effects and tmux session observations; no host commands run. |
|
|
||||||
| Drift | Canonical roster-v2 YAML drives the Commander `fleet status` boundary and classifies `missing-session`, `unexpected-session`, and `disabled-running`, including combined drift, while asserting observation emits no lifecycle mutation. |
|
|
||||||
| Exact default/named socket targeting | Canonical v2 requires an explicit non-empty named socket; the parser rejects missing/empty values and the Commander acceptance path asserts exact `-L mosaic-fleet` targeting. A separate canonical legacy-v1 roster loader plus runtime-transport path proves a socket-less compatibility roster targets the literal tmux default server with no `-L`. No unreachable empty-socket v2 fixture is used. |
|
|
||||||
| Unmanaged-session classification | Stateful fixtures report sorted `coder0-shadow`/`unmanaged` sessions, then prove an exact roster stop leaves both sessions and the near-collision service intact. |
|
|
||||||
| Crash/partial failure | Injected restart failure is applied after the fake effect to model crash/partial truth: result is `lifecycle: incomplete`, the roster is unchanged, and observed runtime may be active. |
|
|
||||||
| Rollback/recovery semantics | M3 has no rollback command and explicitly does not claim automatic rollback. The acceptance workflow proves the bounded recovery contract: inspect, then exact reconcile restores the persisted stopped target without a start or fuzzy effect. M4 migration/canary rollback remains outside this card. |
|
|
||||||
| Stopped-state preservation | Stateful apply and reconcile both stop an initially running observed agent whose persisted target is stopped; failed explicit restart leaves desired state stopped; recovery reconcile restores stopped state. No start call is emitted. |
|
|
||||||
| Zero fuzzy destructive targeting | Near-collision `coder0-shadow` service/session plus `unmanaged` session remain untouched. The recorded destructive calls contain only exact `mosaic-agent@coder0.service`; no tmux kill action is emitted. |
|
|
||||||
| Stable JSON/exit behavior | Temporary canonical roster fixture invokes the CLI boundary and asserts exactly one JSON line, exact partial-result shape, and exit code 1. Existing focused command specs continue to cover clean zero-exit and stable error JSON. |
|
|
||||||
| Redacted truthful recovery | Fake stderr includes `PASSWORD=acceptance-secret`; exact CLI JSON contains only bounded recovery metadata and excludes the key, value, and raw diagnostic. |
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Inventory existing reconciler and command specs against the table above; avoid duplicating narrow assertions already present.
|
|
||||||
2. Add one acceptance-level spec using only fake/injected adapters and temporary files.
|
|
||||||
3. If a real defect is exposed, preserve the failing reproducer and make only the smallest FCM-M3-002-required fix; otherwise leave production unchanged.
|
|
||||||
4. Reconcile `docs/TASKS.md` only for delivered M1/M2/M3-001 truth and mark FCM-M3-002 in progress.
|
|
||||||
5. Run focused tests, full `@mosaicstack/mosaic` tests, package/root typecheck and lint, Prettier/format and diff checks, plus adversarial fake-runner cases.
|
|
||||||
6. Record exact evidence and leave the tree uncommitted for independent synthetic-tree review.
|
|
||||||
|
|
||||||
## TDD decision
|
|
||||||
|
|
||||||
This card adds acceptance coverage to already-delivered behavior. Test-first applies to any product defect discovered: retain a failing reproducer before an in-scope fix. If the shipped behavior already satisfies the acceptance contract, no production code will be changed and the acceptance suite itself is the deliverable.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- Intake and immutable baseline verification complete.
|
|
||||||
- Existing coverage inventory confirmed strong unit coverage but no stateful cross-command lifecycle acceptance harness.
|
|
||||||
- Added `packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts`: one injected stateful fake systemd/tmux host, temporary canonical v2 and legacy-v1 roster fixtures, and seven acceptance tests.
|
|
||||||
- Production source is unchanged; no product defect requiring an FCM-M3-002 fix was found.
|
|
||||||
- `docs/TASKS.md` reconciles only merged M1/M2/M3-001 truth and marks FCM-M3-002 in progress.
|
|
||||||
|
|
||||||
## Verification evidence
|
|
||||||
|
|
||||||
All commands ran from `/home/jarvis/src/mosaic-stack-local-reconciler` and passed unless explicitly noted.
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/fleet/fleet-reconciler.acceptance.spec.ts` — final remediation run: 1 file, 7 tests passed; canonical v2 named-socket parsing/Commander status, missing/empty v2 rejection, and canonical legacy-v1 default-server runtime targeting are distinct reachable cases.
|
|
||||||
- Focused reconciler/roster/transport command covering acceptance, reconciler, command, CRUD, v2 parser, and runtime transport specs — 8 files, 304 tests passed.
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic test` — final remediation run: 57 files, 827 tests passed.
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic typecheck` — passed.
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic lint` — passed.
|
|
||||||
- `pnpm typecheck` — 42/42 Turbo tasks successful.
|
|
||||||
- `pnpm lint` — 23/23 Turbo tasks successful.
|
|
||||||
- `pnpm exec prettier --check docs/TASKS.md docs/scratchpads/758-fcm-m3-002-reconciler-lifecycle-gates.md packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts` — passed.
|
|
||||||
- `pnpm format:check` — all matched files use Prettier style.
|
|
||||||
- `git diff --check` — passed with no output.
|
|
||||||
- Initial scoped Prettier check found style drift in the new spec and tracking table; `pnpm exec prettier --write ...` remediated it before all final gates above.
|
|
||||||
- No live fleet, systemctl, tmux, process, site, migration, canary, deploy, runtime, connector, or remote command was invoked.
|
|
||||||
|
|
||||||
## Review boundary
|
|
||||||
|
|
||||||
This is an author handoff. No self-review is represented as reviewer-of-record. The uncommitted synthetic tree is intended for independent review.
|
|
||||||
|
|
||||||
## Risks / blockers
|
|
||||||
|
|
||||||
- M3 truthfully reports incomplete lifecycle effects and bounded recovery; it does not implement or claim an automatic rollback command. This suite proves stopped-state restoration by the documented exact recovery reconcile. M4 retains migration/canary rollback ownership.
|
|
||||||
- The fake host models only the public systemd/tmux runner contract and temporary roster filesystem boundary. This is intentional under the no-live-effects hold.
|
|
||||||
- Parent issue closure, commit, push, PR, merge, deployment, and branch cleanup remain explicit holds.
|
|
||||||
- No residual implementation blocker.
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# FCM-M4-001 — v1-to-v2 inventory, preview, and migrator
|
|
||||||
|
|
||||||
- **Task / issue:** FCM-M4-001 / mosaicstack/stack#758
|
|
||||||
- **Branch / base:** `feat/758-v1-v2-migrator` from `origin/main` `c1aecfabe97a5dc81a72f44910cd4e626f41863f`
|
|
||||||
- **Base tree:** `46cdfbcdc1d1ff9c7b8b2b9cf3841086590bf774`
|
|
||||||
- **Scope:** field-complete inventory, non-mutating preview, canonical v2 migration output, and migration/recovery disposition evidence. All effects use injected fakes or temporary fixtures.
|
|
||||||
- **Budget:** 35K task estimate is the hard working cap. Keep one card/one PR and prefer focused reuse of the v2 compiler, shared role resolver, generated-env boundary, M1 executable disposition inventory, and reconciler observations.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement preview-first v1 migration that never infers unresolved classes or lifecycle, preserves observed running/stopped state, quarantines forbidden legacy environment inputs with key-name/SHA-256-only diagnostics, inventories remote/connector/schema-only entries without reconciling them, covers every M1-classified shipped artifact, and emits deterministic recovery disposition evidence for the later M4-002 canary/rollback gate.
|
|
||||||
|
|
||||||
## Acceptance mapping
|
|
||||||
|
|
||||||
1. Field-by-field v1 inventory and no-mutation preview.
|
|
||||||
2. Canonical output compiled by `roster-v2.ts` and semantically validated by the existing baseline-plus-`roles.local` resolver.
|
|
||||||
3. Only approved deterministic aliases; every other noncanonical class requires an explicit disposition.
|
|
||||||
4. Observed stopped/running maps explicitly to persisted lifecycle; stopped observations never produce running targets.
|
|
||||||
5. Generated env is regenerated; strict local data is relocated; forbidden keys are quarantine inputs reported only by key name and SHA-256.
|
|
||||||
6. Remote/connector/schema-only entries are inventory-only and excluded from local reconciliation output.
|
|
||||||
7. Every shipped M1 example/profile/service preset has executable migration disposition evidence.
|
|
||||||
8. Deterministic migration/recovery evidence records source, output, exclusions, quarantine, and restore prerequisites without executing a canary or rollback.
|
|
||||||
|
|
||||||
## Boundaries
|
|
||||||
|
|
||||||
Out of scope: FCM-M4-002 executable canary/rollback and host fixture, #766 communications, #636 commands/channels, live fleet/systemd/tmux/session/migration/deploy/connector/remote/gateway effects, `docs/TASKS.md`, parent issue mutation, commit, push, and PR operations.
|
|
||||||
|
|
||||||
## TDD plan
|
|
||||||
|
|
||||||
Migration rules and redaction are critical data-mutation/security logic, so tests are written red-first for inventory completeness, explicit class disposition, observed-state preservation, quarantine redaction, inventory-only remote/schema entries, compiler/resolver reuse, artifact coverage, and recovery evidence. Production code follows only after the focused tests fail for the missing behavior.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Map existing v1 loader, v2 compiler/resolver, env quarantine, reconciler observation, and M1 disposition guard.
|
|
||||||
2. Add behavior-oriented failing migration tests with temporary fixtures and injected observation/filesystem adapters only.
|
|
||||||
3. Implement the narrow migration module and CLI boundary without a second resolver or live command runner.
|
|
||||||
4. Add scoped M4 migration/recovery documentation and executable shipped-artifact evidence.
|
|
||||||
5. Run focused tests, full package tests, package/root typecheck and lint, formatting, diff checks, and adversarial redaction/no-effect verification.
|
|
||||||
6. Run independent code/security review, remediate findings, reconstruct the synthetic tree using a temporary index, and stop uncommitted.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- Collision checks passed: no local/remote branch, worktree, target path, or open PR owned `feat/758-v1-v2-migrator`.
|
|
||||||
- Dedicated worktree created at the exact green `origin/main` base.
|
|
||||||
- Required global/repository guides and FCM requirements/evidence loaded.
|
|
||||||
- No matching migration skill exists under the configured skill directories; no unrelated skill loaded.
|
|
||||||
- Added a preview-only CLI and migration module that compile with the existing v2 parser/renderer and validate through the shared persona resolver.
|
|
||||||
- Added value-free raw-v1 inventory, strict unknown-field/synonym/duplicate detection, inventory-only remote and connector handling, and explicit class/tool-policy decisions.
|
|
||||||
- Added separate reviewed lifecycle observations with only unambiguous running/stopped mappings.
|
|
||||||
- Added sanitized, non-mutating environment preflight and recovery evidence explicitly marked non-executable.
|
|
||||||
- Added executable disposition evidence derived from the exact 13-entry M1 inventory and operator documentation.
|
|
||||||
- Tightened untrusted decisions/observations to reject unknown keys, invalid types/enums, extra local records, and competing automatic-alias dispositions.
|
|
||||||
- Remediated independent review findings: v1 runtime/reset defaults are preserved, `~` workdirs expand only at env preflight, malformed/required agent fields fail closed before remote exclusion, and all seven shipped v1 fixtures now execute real previews with explicit evidence.
|
|
||||||
- Remediated socket and locality authority blockers: socket-only agents stay local; `host == fleetHost` stays local; only `host != fleetHost` is inventory-only; ssh-only, missing reviewed fleet-host identity, and contradictory host/ssh targets block explicitly without lifecycle omission.
|
|
||||||
- Remediated final exact-tree blockers: a declared v1 root socket cannot be overridden; matching/conflicting socket decisions retain reviewed running evidence; canonical ordering uses a shared locale-independent Unicode code-point comparator; migration evidence preserves all four legacy environment dispositions; backup documentation no longer claims validation that M4-001 does not perform.
|
|
||||||
- Remediated immutable-review socket-presence blocker: both `socket_name` and `socketName` are field-presence-aware, so explicit empty/default-server declarations remain authoritative and incompatible named decisions block rather than replacing them.
|
|
||||||
- Remediated the replacement-tree blockers: the shared v2 compiler and reconciler accept an explicit empty socket as literal default-server identity; present-empty holder session, default/agent work directory, runtime reset command, and alias values block rather than defaulting; missing preview inputs emit one stable blocked JSON object with non-zero status. Snake/camel aliases and whitespace-only input have adversarial coverage.
|
|
||||||
- Remediated the subsequent authority blockers: each present-empty CLI path emits exactly one stable blocked JSON object with exit 1 before file reads, and reconciler `start`/`restart` or desired-state `apply`/`reconcile` fail closed before fixed `mosaic-fleet` systemd services can act on a default-server roster.
|
|
||||||
- Remediated committed-head review blockers: explicitly declared empty runtime objects use the production v1 `/clear` reset fallback while omitted `pi` retains `/new`; lifecycle observations are sorted by canonical agent name; and bare CLI path flags reach preview validation, emit one stable blocked JSON object with exit 1, and perform zero reads. Built production-CLI subprocess tests cover all three bare flags.
|
|
||||||
- Remediated late-audit blockers: the documented M4 guard invokes the 13-artifact validator and all seven v1 previews; canonical `~`/`~/...` workdirs remain unchanged in migration evidence and traversal-free forms expand at the shared production projection boundary while ordinary relative and home-relative traversal paths remain rejected; remote inventory and exclusion evidence sort canonically; and every default-server lifecycle-mutating reconciler path fails before observation, projection preparation/application, or fixed-unit effects. Explicit regressions preserve `plan`, `status`, `doctor`, and `verify` as observational default-server commands.
|
|
||||||
- Remediated exact-tree traversal review: `~/../escape` and `~/src/../../escape` remain unexpanded and fail the unchanged shared `unsafe-path` validation. Both the shared generated-environment boundary and the production v1 environment caller have red-first regressions, preventing earlier caller normalization from bypassing the boundary.
|
|
||||||
|
|
||||||
## Verification evidence
|
|
||||||
|
|
||||||
- Focused migration/compiler/environment/reconciler/CLI: 8 files, 372 tests passed.
|
|
||||||
- Documented 13-artifact guard: 1 matching test passed and executed all seven v1 previews.
|
|
||||||
- Full `@mosaicstack/mosaic`: 59 files, 902 tests passed.
|
|
||||||
- Workspace build: 23 tasks passed.
|
|
||||||
- Root typecheck: 42 tasks passed.
|
|
||||||
- Root lint: 23 tasks passed.
|
|
||||||
- Root format check and `git diff --check`: passed.
|
|
||||||
- Built production CLI: canonical `~/src` remains in ready roster/YAML evidence; generated projection preflight succeeds with no blockers; a bare path flag emits one blocked JSON object, exit 1, and no stderr.
|
|
||||||
- Independent high-effort late-audit review: no blocker remained in the four assigned repair surfaces; separate exact-tree security audits found no qualifying newly introduced vulnerability.
|
|
||||||
- Exact temporary-index synthetic tree includes every tracked changed path; immutable SHA and duplicate reconstruction are recorded in the final handoff.
|
|
||||||
|
|
||||||
## Risks / blockers
|
|
||||||
|
|
||||||
- M4-001 emits rollback prerequisites/evidence only; executable rollback/canary and the managed/unmanaged host fixture remain owned by M4-002.
|
|
||||||
- Remote/connector entries remain inventory-only; later federation or connector reconciliation requires separately reviewed work.
|
|
||||||
- Existing environment data is only preflighted. Cutover backup, quarantine write, legacy removal, and generated projection application remain later reviewed effects.
|
|
||||||
@@ -1,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,36 +0,0 @@
|
|||||||
# ms-792 — Fleet roster error handling and installer heading
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Make expected missing or malformed fleet roster configuration fail with an actionable message and nonzero exit instead of a raw Node stack trace. Ensure the installer preserves the `@mosaicstack/mosaic` heading.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Add failing coverage for missing and malformed roster input.
|
|
||||||
2. Centralize roster-file read and parse error translation; add the CLI async error boundary.
|
|
||||||
3. Sweep fleet command read paths that bypass the roster loader.
|
|
||||||
4. Replace the installer heading output with format-safe rendering and test it.
|
|
||||||
5. Run focused and repository quality checks; request independent review.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- 2026-07-16: Confirmed issue #792 and branch base `9745bc3f`.
|
|
||||||
- 2026-07-16: Installed locked workspace dependencies using a worktree-local pnpm store; no `.mosaic/` files were changed intentionally.
|
|
||||||
- 2026-07-16: Added a shared roster read/parse guard and routed v1 fleet commands plus v1/v2 selection through Commander’s actionable nonzero error path. V2 command modules already return structured nonzero JSON errors for their guarded reads.
|
|
||||||
- 2026-07-16: Replaced installer heading `echo` with format-safe `printf`; added a regression check for the scoped package heading.
|
|
||||||
- 2026-07-16: Rebuilt CLI and manually verified `fleet ps` with no roster prints the initialization hint, exits 1, and has no stack trace.
|
|
||||||
- 2026-07-17: Rebased #818 onto `origin/main` at `9ddc6fbd` (#791 PR3). The added `fleet regen` command had a canonical roster read in its sibling module; it now uses the same missing-roster guard and Commander exit path. Internal NORTH_STAR, preset, and post-write invariant reads remain intentionally unguarded.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic test` — PASS (61 files, 1,046 tests; executed outside sandbox because CLI smoke tests spawn Node)
|
|
||||||
- `pnpm typecheck` — PASS
|
|
||||||
- `pnpm lint` — PASS
|
|
||||||
- `pnpm format:check` — PASS
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts src/commands/install-heading.spec.ts` — PASS (209 tests)
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-regen-command.spec.ts` — PASS (27 tests, including missing canonical roster)
|
|
||||||
- Instrumented Vitest coverage is unavailable because `@vitest/coverage-v8` is not declared in this repository. Each branch added in the roster guard has direct unit coverage.
|
|
||||||
|
|
||||||
## Risks / blockers
|
|
||||||
|
|
||||||
- Dependency installation is required before executing Vitest, TypeScript, lint, and formatting gates.
|
|
||||||
@@ -47,61 +47,6 @@ export MOSAIC_ADMIN_PASSWORD="securepass123"
|
|||||||
mosaic gateway install
|
mosaic gateway install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Runtime launchers
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic claude # Launch Claude Code with Mosaic injection
|
|
||||||
mosaic yolo claude # …with --dangerously-skip-permissions
|
|
||||||
mosaic codex | opencode | pi
|
|
||||||
```
|
|
||||||
|
|
||||||
### `mosaic claudex` (EXPERIMENTAL)
|
|
||||||
|
|
||||||
Runs GPT models **inside the Claude Code harness** by pointing Claude Code at a
|
|
||||||
local [`claude-code-proxy`](https://github.com/raine/claude-code-proxy) that
|
|
||||||
translates the Anthropic Messages API to a ChatGPT-subscription (Codex OAuth)
|
|
||||||
backend. This is **not Anthropic Claude** — model behavior, tool use, and output
|
|
||||||
quality may differ. Intended for evaluation, not production delivery.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic claudex # launch (prompts through the proxy readiness gate)
|
|
||||||
mosaic yolo claudex # …with --dangerously-skip-permissions
|
|
||||||
mosaic claudex --print "hello" # trailing args are forwarded to Claude Code
|
|
||||||
```
|
|
||||||
|
|
||||||
**Prerequisite:** the `claude-code-proxy` binary must be installed and
|
|
||||||
authenticated (`claude-code-proxy codex auth …`). `mosaic claudex` runs a
|
|
||||||
preflight that verifies the binary, the OAuth state (triggering a device re-auth
|
|
||||||
if needed), and a trusted local listener before launching; it **fails closed**
|
|
||||||
if the proxy cannot be brought up with a verified identity.
|
|
||||||
|
|
||||||
**Isolation (never touches your real Claude state).** claudex always launches
|
|
||||||
against an isolated `CLAUDE_CONFIG_DIR` (default `~/.config/mosaic/claudex/home`).
|
|
||||||
The ambient `CLAUDE_CONFIG_DIR` is deliberately ignored, and a guard proves the
|
|
||||||
resolved dir can never be — or live under — the real `~/.claude`. A claudex
|
|
||||||
session therefore cannot mutate your normal Claude Code config.
|
|
||||||
|
|
||||||
**No token leakage.** claudex never reads the proxy's credential file. Claude
|
|
||||||
Code is handed only `ANTHROPIC_AUTH_TOKEN=unused` pointed at the loopback proxy;
|
|
||||||
the entire credential-bearing env family (`ANTHROPIC_*`, `AWS_*`, `GOOGLE_CLOUD_*`,
|
|
||||||
`GOOGLE_APPLICATION_CREDENTIALS`, `*_TOKEN`, `*_KEY`, `*_SECRET`, …) is stripped
|
|
||||||
from the composed environment. The Bedrock/Vertex routing switches
|
|
||||||
(`CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_USE_VERTEX`, and the `_SKIP_*_AUTH`
|
|
||||||
pair) are force-removed regardless of value — otherwise their mere presence
|
|
||||||
would route Claude Code to the real Anthropic API via AWS/GCP and bypass the
|
|
||||||
proxy. The proxy holds the real OAuth credential.
|
|
||||||
|
|
||||||
**Model tiers (override via env).**
|
|
||||||
|
|
||||||
| Tier | Env var | Default |
|
|
||||||
| --------------------- | ---------------------------- | -------------- |
|
|
||||||
| primary (opus/sonnet) | `ANTHROPIC_MODEL` | `gpt-5.6-sol` |
|
|
||||||
| small/fast (haiku) | `ANTHROPIC_SMALL_FAST_MODEL` | `gpt-5.6-luna` |
|
|
||||||
|
|
||||||
Operator-provided values win over the defaults. Additional overrides:
|
|
||||||
`MOSAIC_CLAUDEX_CONFIG_DIR` (isolated config dir), `ANTHROPIC_BASE_URL` (proxy
|
|
||||||
endpoint).
|
|
||||||
|
|
||||||
## Hooks management
|
## Hooks management
|
||||||
|
|
||||||
After running `mosaic wizard`, Claude hooks are installed in `~/.claude/hooks-config.json`.
|
After running `mosaic wizard`, Claude hooks are installed in `~/.claude/hooks-config.json`.
|
||||||
|
|||||||
@@ -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
|
#!/usr/bin/env bash
|
||||||
# -E (errtrace): the ERR trap must propagate INTO functions and command
|
set -euo pipefail
|
||||||
# substitutions. Without it the `trap restore_snapshot ERR` set below is dead
|
|
||||||
# code for any failure inside sync_framework_keep() (its whole body runs in a
|
|
||||||
# function) — a mid-sync failure would abort with a half-written target and NO
|
|
||||||
# rollback (#791 B1). Keep -E first so every later function inherits the trap.
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
# ─── Mosaic Framework Installer ──────────────────────────────────────────────
|
# ─── Mosaic Framework Installer ──────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
@@ -24,19 +19,32 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||||
|
|
||||||
# Shared framework path-ownership manifest reader (#791). Parity with
|
# Files/dirs protected from rsync --delete during sync. NOTE: framework-owned
|
||||||
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
# entries (CONSTITUTION/AGENTS/STANDARDS) ARE re-applied afterward by
|
||||||
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
# reconcile_framework_files (overwrite + backup-once); the rest stay user-owned.
|
||||||
# shellcheck source=tools/_lib/manifest.sh
|
# User-created content in these paths survives rsync --delete.
|
||||||
source "$SOURCE_DIR/tools/_lib/manifest.sh"
|
#
|
||||||
|
# fleet/* — the framework SEEDS fleet/examples, fleet/roles, fleet/profiles, and
|
||||||
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
|
# fleet/roster.schema.json (synced normally — every fleet/roles/*.md role contract
|
||||||
# denylist. It is derived from the shared framework-manifest.txt (#791): the
|
# and fleet/profiles/*.yaml system-type profile lands automatically via this sync,
|
||||||
# updater only ever creates/overwrites framework-owned paths and only prunes a
|
# so no per-file entry is needed; exact preserved roster paths are anchored to
|
||||||
# retired framework file inside a shipped framework subtree. Everything else —
|
# the top level only and do NOT shadow fleet/profiles/*.yaml). The user's
|
||||||
# every operator file, and every path the manifest never anticipated — is
|
# own fleet files MUST
|
||||||
# operator-owned by default (fail-safe) and is never written or deleted. See
|
# survive `mosaic update` (which runs this sync automatically): the active
|
||||||
# sync_framework_keep() below and packages/mosaic/src/framework/manifest.ts.
|
# 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
|
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
|
||||||
# user must not edit them; a divergent copy is backed up once before overwrite).
|
# user must not edit them; a divergent copy is backed up once before overwrite).
|
||||||
@@ -67,267 +75,17 @@ step() { echo -e "\n${BOLD}$1${RESET}"; }
|
|||||||
SNAPSHOT_DIR=""
|
SNAPSHOT_DIR=""
|
||||||
make_snapshot() {
|
make_snapshot() {
|
||||||
is_existing_install || return 0
|
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")"
|
SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-snapshot-XXXXXX")"
|
||||||
# The snapshot MUST be complete: restore rebuilds the target from it, so a
|
cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/" 2>/dev/null || true
|
||||||
# partial capture (unreadable file, disk-full, I/O error) would silently
|
|
||||||
# discard whatever it missed. If cp -a cannot copy the whole tree, abort NOW —
|
|
||||||
# before the restore trap is armed and before anything is mutated. Fail closed
|
|
||||||
# rather than proceed with a snapshot we cannot trust (#791 blocker-2).
|
|
||||||
if ! cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/"; then
|
|
||||||
fail "Could not capture a complete pre-upgrade snapshot of $TARGET_DIR — aborting before any changes were made (fail-closed)."
|
|
||||||
rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
restore_snapshot() {
|
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
|
[[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] || return 0
|
||||||
fail "Install interrupted/failed — restoring previous state from snapshot"
|
fail "Install interrupted/failed — restoring previous state from snapshot"
|
||||||
# Reset the target before rebuilding from the snapshot — but CHECK it. Under
|
rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"
|
||||||
# `set -e` (trap already disarmed) a bare `rm -rf; mkdir -p` that fails would
|
cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/" 2>/dev/null || true
|
||||||
# exit the whole script immediately, after `rm` may have deleted part of the
|
|
||||||
# target, WITHOUT ever printing the recovery pointer below — the operator would
|
|
||||||
# be left with a half-removed target and no idea the snapshot survives in /tmp.
|
|
||||||
# Test the reset explicitly (like the cp -a below), and on failure keep the
|
|
||||||
# snapshot and tell the operator where it is (#791 blocker-D2).
|
|
||||||
if ! rm -rf "$TARGET_DIR" || ! mkdir -p "$TARGET_DIR"; then
|
|
||||||
fail "Snapshot restore could not reset $TARGET_DIR. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
# Surface an incomplete restore instead of swallowing it: the snapshot is the
|
|
||||||
# last good copy, so if cp cannot fully rebuild the target we must NOT delete
|
|
||||||
# the snapshot — point the operator at it for manual recovery (#791 blocker-2).
|
|
||||||
if ! cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/"; then
|
|
||||||
fail "Snapshot restore did not complete cleanly. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
cleanup_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] && rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""; }
|
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),
|
# Reconcile contract files after sync: framework-owned overwrite (backup-once),
|
||||||
# user-seeded seed-if-absent.
|
# user-seeded seed-if-absent.
|
||||||
reconcile_framework_files() {
|
reconcile_framework_files() {
|
||||||
@@ -426,105 +184,63 @@ sync_framework() {
|
|||||||
return
|
return
|
||||||
fi
|
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
|
if command -v rsync >/dev/null 2>&1; then
|
||||||
rsync -a --delete \
|
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak")
|
||||||
--exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak" \
|
|
||||||
"$SOURCE_DIR/" "$TARGET_DIR/"
|
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
|
return
|
||||||
fi
|
fi
|
||||||
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 \
|
|
||||||
! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" \
|
# Fallback: cp-based sync. Exact top-level preserved paths mirror the
|
||||||
-exec rm -rf {} +
|
# 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"/
|
cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/
|
||||||
rm -rf "$TARGET_DIR/.git"
|
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.
|
# Remove bin/ directory — all executables now live in the npm CLI.
|
||||||
# Scripts that were in bin/ are now in tools/_scripts/.
|
# Scripts that were in bin/ are now in tools/_scripts/.
|
||||||
if [[ "$from_version" -lt 2 ]]; then
|
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
|
if [[ -d "$TARGET_DIR/bin" ]]; then
|
||||||
ok "Removing legacy bin/ directory (executables now in npm CLI)"
|
ok "Removing legacy bin/ directory (executables now in npm CLI)"
|
||||||
rm -rf "$TARGET_DIR/bin"
|
rm -rf "$TARGET_DIR/bin"
|
||||||
@@ -599,26 +311,9 @@ else
|
|||||||
ok "Install mode: overwrite"
|
ok "Install mode: overwrite"
|
||||||
fi
|
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.
|
# 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
|
make_snapshot
|
||||||
trap 'restore_snapshot; exit 1' ERR INT TERM
|
trap 'restore_snapshot' ERR INT TERM
|
||||||
|
|
||||||
sync_framework
|
sync_framework
|
||||||
|
|
||||||
@@ -647,10 +342,6 @@ run_migrations
|
|||||||
|
|
||||||
# File-system phase complete and consistent — clear the restore trap.
|
# File-system phase complete and consistent — clear the restore trap.
|
||||||
trap - ERR INT TERM
|
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
|
cleanup_snapshot
|
||||||
|
|
||||||
# Testability / minimal-install hook: stop after the file-system phase, before any
|
# 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)
|
http_code=$(echo "$response" | tail -n1)
|
||||||
body=$(echo "$response" | sed '$d')
|
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
|
echo "Error: Failed to list computers (HTTP $http_code)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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)
|
http_code=$(echo "$response" | tail -n1)
|
||||||
body=$(echo "$response" | sed '$d')
|
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
|
echo "Error: Failed to list tickets (HTTP $http_code)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ response=$(curl -sk -w "\n%{http_code}" \
|
|||||||
http_code=$(echo "$response" | tail -n1)
|
http_code=$(echo "$response" | tail -n1)
|
||||||
body=$(echo "$response" | sed '$d')
|
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
|
echo "Error: Failed to list users (HTTP $http_code)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]"
|
||||||
chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
|
chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
|
||||||
|
|
||||||
# F6 — keep-mode re-seed (the `mosaic update` path) MUST preserve ALL user-owned
|
# F6 — keep-mode re-seed (the `mosaic update` path) MUST preserve only the
|
||||||
# fleet state — including an unanticipated file the manifest never names, which
|
# exact user-owned roster paths while refreshing framework-owned schema/examples.
|
||||||
# resolves to operator-owned by the #791 fail-safe — while refreshing the
|
|
||||||
# framework-owned schema/examples.
|
|
||||||
T6=$(mktemp -d); mkdir -p "$T6/fleet/examples" "$T6/fleet/run" "$T6/fleet/agents"
|
T6=$(mktemp -d); mkdir -p "$T6/fleet/examples" "$T6/fleet/run" "$T6/fleet/agents"
|
||||||
printf '# persona\n' > "$T6/SOUL.md" # makes it a recognized existing install (→ keep mode)
|
printf '# persona\n' > "$T6/SOUL.md" # makes it a recognized existing install (→ keep mode)
|
||||||
printf 'version: 1\nagents:\n - name: coder0\n' > "$T6/fleet/roster.yaml"
|
printf 'version: 1\nagents:\n - name: coder0\n' > "$T6/fleet/roster.yaml"
|
||||||
@@ -77,14 +75,13 @@ printf '{"stale":true}\n' > "$T6/fleet/roster.schema.json"
|
|||||||
E6=$(mktemp -d)
|
E6=$(mktemp -d)
|
||||||
cp "$T6/fleet/roster.yaml" "$E6/roster-yaml.expected"
|
cp "$T6/fleet/roster.yaml" "$E6/roster-yaml.expected"
|
||||||
cp "$T6/fleet/roster.json" "$E6/roster-json.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/run/coder0.hb" "$E6/run.expected"
|
||||||
cp "$T6/fleet/agents/coder0.env" "$E6/agent.expected"
|
cp "$T6/fleet/agents/coder0.env" "$E6/agent.expected"
|
||||||
echo 3 > "$T6/.framework-version"
|
echo 3 > "$T6/.framework-version"
|
||||||
run "$T6" keep
|
run "$T6" keep
|
||||||
chk "F6 reseed: exact roster.yaml bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.yaml' '$E6/roster-yaml.expected'"
|
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: 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: 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: 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'"
|
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).
|
# Source label: this agent's host:session (auto-detected, overridable).
|
||||||
if [ -z "$SRC_LABEL" ]; then
|
if [ -z "$SRC_LABEL" ]; then
|
||||||
src_host=$(hostname -s 2>/dev/null || echo "?")
|
tmux_cmd=(tmux)
|
||||||
src_sess=${MOSAIC_AGENT_NAME:-}
|
if [ -n "$SOCKET_NAME" ]; then
|
||||||
if [ -z "$src_sess" ]; then
|
tmux_cmd+=(-L "$SOCKET_NAME")
|
||||||
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
|
|
||||||
fi
|
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}"
|
SRC_LABEL="${src_host}:${src_sess}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,6 @@
|
|||||||
# 5. invalid class => exit 3, nothing sent.
|
# 5. invalid class => exit 3, nothing sent.
|
||||||
# 6. --class with no value => exit 3.
|
# 6. --class with no value => exit 3.
|
||||||
# 7. the documented consumer regex parses producer output for every class.
|
# 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
|
set -uo pipefail
|
||||||
|
|
||||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
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.
|
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
|
||||||
STUB=$(mktemp)
|
STUB=$(mktemp)
|
||||||
FAKE_BIN=$(mktemp -d)
|
trap 'rm -f "$STUB"' EXIT
|
||||||
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN"' EXIT
|
|
||||||
cat >"$STUB" <<'STUB_EOF'
|
cat >"$STUB" <<'STUB_EOF'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
b64=""
|
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
|
printf '%s' "$b64" | base64 -d
|
||||||
STUB_EOF
|
STUB_EOF
|
||||||
chmod +x "$STUB"
|
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
|
PASS=0; FAIL=0
|
||||||
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
|
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
|
||||||
no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
|
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 the tool with the stub injected; echoes captured payload on stdout.
|
||||||
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
|
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.
|
# Documented consumer grammar — the daemon will mirror exactly this.
|
||||||
GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction)\] (.*)$'
|
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" ] \
|
[[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \
|
||||||
&& ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]"
|
&& 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 "---"
|
||||||
echo "PASS=$PASS FAIL=$FAIL"
|
echo "PASS=$PASS FAIL=$FAIL"
|
||||||
[ "$FAIL" -eq 0 ]
|
[ "$FAIL" -eq 0 ]
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { registerBrainCommand } from '@mosaicstack/brain';
|
import { registerBrainCommand } from '@mosaicstack/brain';
|
||||||
@@ -18,8 +16,6 @@ import { registerConfigCommand } from './commands/config.js';
|
|||||||
// without throwing. This is the "mosaic <cmd> --help exits 0" gate that
|
// without throwing. This is the "mosaic <cmd> --help exits 0" gate that
|
||||||
// guards the sub-package CLI surface (CU-05-01..08) from silent breakage.
|
// guards the sub-package CLI surface (CU-05-01..08) from silent breakage.
|
||||||
|
|
||||||
const CLI_PATH = fileURLToPath(new URL('../dist/cli.js', import.meta.url));
|
|
||||||
|
|
||||||
const REGISTRARS: Array<[string, (program: Command) => void]> = [
|
const REGISTRARS: Array<[string, (program: Command) => void]> = [
|
||||||
['auth', registerAuthCommand],
|
['auth', registerAuthCommand],
|
||||||
['brain', registerBrainCommand],
|
['brain', registerBrainCommand],
|
||||||
@@ -50,40 +46,6 @@ describe('sub-package CLI smoke (CU-05-10)', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
it.each(['source', 'decisions', 'observations'] as const)(
|
|
||||||
'production CLI emits one blocked JSON object for bare --%s',
|
|
||||||
(bareOption) => {
|
|
||||||
const args = [
|
|
||||||
CLI_PATH,
|
|
||||||
'fleet',
|
|
||||||
'migrate-v1',
|
|
||||||
'preview',
|
|
||||||
'--source=source',
|
|
||||||
'--decisions=decisions',
|
|
||||||
'--observations=observations',
|
|
||||||
];
|
|
||||||
args[args.findIndex((argument) => argument.startsWith(`--${bareOption}=`))] =
|
|
||||||
`--${bareOption}`;
|
|
||||||
|
|
||||||
const result = spawnSync(process.execPath, args, { encoding: 'utf8' });
|
|
||||||
const outputLines = result.stdout.trim().split('\n');
|
|
||||||
|
|
||||||
expect(result.status).toBe(1);
|
|
||||||
expect(result.stderr).toBe('');
|
|
||||||
expect(outputLines).toHaveLength(1);
|
|
||||||
expect(JSON.parse(outputLines[0]!)).toEqual({
|
|
||||||
status: 'blocked',
|
|
||||||
blockers: [
|
|
||||||
{
|
|
||||||
code: 'missing-migration-preview-option-value',
|
|
||||||
path: `request.${bareOption}`,
|
|
||||||
detail: 'Required migration preview option value is missing.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('all nine sub-package commands coexist on a single program', () => {
|
it('all nine sub-package commands coexist on a single program', () => {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
for (const [, register] of REGISTRARS) register(program);
|
for (const [, register] of REGISTRARS) register(program);
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { registerConfigCommand } from './commands/config.js';
|
|||||||
import { registerFleetCommand } from './commands/fleet.js';
|
import { registerFleetCommand } from './commands/fleet.js';
|
||||||
import { registerMissionCommand } from './commands/mission.js';
|
import { registerMissionCommand } from './commands/mission.js';
|
||||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||||
import { registerRestoreCommand } from './commands/restore.js';
|
|
||||||
// prdy is registered via launch.ts
|
// prdy is registered via launch.ts
|
||||||
import { registerLaunchCommands } from './commands/launch.js';
|
import { registerLaunchCommands } from './commands/launch.js';
|
||||||
import { registerAuthCommand } from './commands/auth.js';
|
import { registerAuthCommand } from './commands/auth.js';
|
||||||
@@ -407,10 +406,6 @@ registerStorageCommand(program);
|
|||||||
|
|
||||||
registerUninstallCommand(program);
|
registerUninstallCommand(program);
|
||||||
|
|
||||||
// ─── restore ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
registerRestoreCommand(program);
|
|
||||||
|
|
||||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerTelemetryCommand(program);
|
registerTelemetryCommand(program);
|
||||||
|
|||||||
@@ -1,862 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import {
|
|
||||||
CLAUDEX_PROXY_HOST,
|
|
||||||
CLAUDEX_PROXY_PORT,
|
|
||||||
CLAUDEX_PROXY_URL,
|
|
||||||
CLAUDEX_PROXY_BINARY,
|
|
||||||
CLAUDEX_HEALTH_PATH,
|
|
||||||
CLAUDEX_HEALTH_URL,
|
|
||||||
buildAuthStatusArgs,
|
|
||||||
buildDeviceAuthArgs,
|
|
||||||
buildServeArgs,
|
|
||||||
parseAuthStatus,
|
|
||||||
checkProxyBinary,
|
|
||||||
checkAuthStatus,
|
|
||||||
runDeviceReauth,
|
|
||||||
probeLiveness,
|
|
||||||
buildSystemdUnitContent,
|
|
||||||
systemdUnitPath,
|
|
||||||
installSystemdUnit,
|
|
||||||
startNohupProxy,
|
|
||||||
verifyListenerIdentity,
|
|
||||||
runProxyPreflight,
|
|
||||||
ensureProxyRunning,
|
|
||||||
type AuthStatus,
|
|
||||||
type ProxyRunResult,
|
|
||||||
type SpawnedChild,
|
|
||||||
type ListenerIdentity,
|
|
||||||
} from './claudex-proxy.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* P1 — Proxy preflight + lifecycle helpers for `mosaic yolo claudex`.
|
|
||||||
*
|
|
||||||
* Security-relevant invariants exercised here:
|
|
||||||
* - Liveness probe hits the proxy's dedicated `GET /healthz` and treats only a
|
|
||||||
* 2xx as "alive" — a *proxy-specific* health contract, not arbitrary HTTP on
|
|
||||||
* the port (CWE-345: a local port-squatter must not be trusted as the proxy).
|
|
||||||
* This also honors spec gotcha #1 (never `curl -f` the root, which returns
|
|
||||||
* non-2xx): `/healthz` returns 2xx when the proxy is up, so a healthy proxy is
|
|
||||||
* never mistaken for dead and no duplicate proxy is spawned.
|
|
||||||
* - Auth-status parsing NEVER surfaces OAuth token material — only a coarse
|
|
||||||
* state + optional expiry — even if a token-shaped string appears in output.
|
|
||||||
* - The systemd unit's ExecStart never interpolates an unvalidated path
|
|
||||||
* (CWE-74: a CR/LF in the path could inject arbitrary systemd directives).
|
|
||||||
* - The nohup fallback captures spawn's *async* error event instead of crashing.
|
|
||||||
*/
|
|
||||||
|
|
||||||
describe('claudex-proxy constants', () => {
|
|
||||||
it('pins the proxy endpoint to loopback :18765 (spec table)', () => {
|
|
||||||
expect(CLAUDEX_PROXY_HOST).toBe('127.0.0.1');
|
|
||||||
expect(CLAUDEX_PROXY_PORT).toBe(18765);
|
|
||||||
expect(CLAUDEX_PROXY_URL).toBe('http://127.0.0.1:18765');
|
|
||||||
expect(CLAUDEX_PROXY_BINARY).toBe('claude-code-proxy');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes the dedicated /healthz liveness endpoint (not the root path)', () => {
|
|
||||||
expect(CLAUDEX_HEALTH_PATH).toBe('/healthz');
|
|
||||||
expect(CLAUDEX_HEALTH_URL).toBe('http://127.0.0.1:18765/healthz');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds the documented codex subcommand argv', () => {
|
|
||||||
expect(buildAuthStatusArgs()).toEqual(['codex', 'auth', 'status']);
|
|
||||||
expect(buildDeviceAuthArgs()).toEqual(['codex', 'auth', 'device']);
|
|
||||||
expect(buildServeArgs()).toEqual(['serve', '--no-monitor']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseAuthStatus', () => {
|
|
||||||
it('reports valid on exit 0 with an authenticated marker', () => {
|
|
||||||
const s = parseAuthStatus({
|
|
||||||
status: 0,
|
|
||||||
stdout: 'Authenticated as user; token valid',
|
|
||||||
stderr: '',
|
|
||||||
});
|
|
||||||
expect(s.state).toBe('valid');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports expired when output mentions expiry', () => {
|
|
||||||
const s = parseAuthStatus({ status: 0, stdout: 'Token expired 2 days ago', stderr: '' });
|
|
||||||
expect(s.state).toBe('expired');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports unauthenticated when output says not logged in', () => {
|
|
||||||
const s = parseAuthStatus({
|
|
||||||
status: 1,
|
|
||||||
stdout: '',
|
|
||||||
stderr: 'not authenticated: run codex auth device',
|
|
||||||
});
|
|
||||||
expect(s.state).toBe('unauthenticated');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports unknown on an unrecognized non-zero exit', () => {
|
|
||||||
const s = parseAuthStatus({ status: 2, stdout: 'weird', stderr: '' });
|
|
||||||
expect(s.state).toBe('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT trust a signal-terminated check (status null) even with an auth-looking line', () => {
|
|
||||||
// status: null means the process was killed by a signal — an INCOMPLETE
|
|
||||||
// check. An auth-looking line that happened to be flushed must not be read
|
|
||||||
// as valid, or preflight passes on a check that never finished.
|
|
||||||
const s = parseAuthStatus({ status: null, stdout: 'Authenticated', stderr: '' });
|
|
||||||
expect(s.state).toBe('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts a best-effort expiry in days when present', () => {
|
|
||||||
const s = parseAuthStatus({
|
|
||||||
status: 0,
|
|
||||||
stdout: 'Authenticated; expires in 9 days',
|
|
||||||
stderr: '',
|
|
||||||
});
|
|
||||||
expect(s.state).toBe('valid');
|
|
||||||
expect(s.expiresInDays).toBe(9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a clean exit 0 with no explicit markers as valid', () => {
|
|
||||||
const s = parseAuthStatus({ status: 0, stdout: 'Session active for account foo', stderr: '' });
|
|
||||||
expect(s.state).toBe('valid');
|
|
||||||
expect(s.expiresInDays).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NEVER retains token-shaped material from output', () => {
|
|
||||||
const leaky = 'Authenticated. access_token=sk-abc123SECRETdeadbeef refresh_token=rt-9999';
|
|
||||||
const s: AuthStatus = parseAuthStatus({ status: 0, stdout: leaky, stderr: '' });
|
|
||||||
const serialized = JSON.stringify(s);
|
|
||||||
expect(serialized).not.toContain('sk-abc123SECRETdeadbeef');
|
|
||||||
expect(serialized).not.toContain('rt-9999');
|
|
||||||
expect(serialized).not.toContain('access_token');
|
|
||||||
expect(serialized).not.toContain('refresh_token');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('checkAuthStatus', () => {
|
|
||||||
it('runs the status subcommand and parses the result', () => {
|
|
||||||
const run = vi.fn(
|
|
||||||
(_cmd: string, _args: string[]): ProxyRunResult => ({
|
|
||||||
status: 0,
|
|
||||||
stdout: 'Authenticated; expires in 7 days',
|
|
||||||
stderr: '',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const s = checkAuthStatus(run);
|
|
||||||
expect(run).toHaveBeenCalledWith(CLAUDEX_PROXY_BINARY, ['codex', 'auth', 'status']);
|
|
||||||
expect(s.state).toBe('valid');
|
|
||||||
expect(s.expiresInDays).toBe(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces unknown when the default runner cannot find the binary', () => {
|
|
||||||
// Exercises the default spawnSync path against an absent binary: no throw,
|
|
||||||
// status is non-zero/null → unknown. Deterministic on a box without the proxy.
|
|
||||||
const s = checkAuthStatus();
|
|
||||||
expect(['unknown', 'unauthenticated', 'valid', 'expired']).toContain(s.state);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('runDeviceReauth', () => {
|
|
||||||
it('spawns the device flow with inherited stdio (never captures the code/token)', () => {
|
|
||||||
const calls: Array<{ cmd: string; args: string[]; opts: { stdio: string } }> = [];
|
|
||||||
const status = runDeviceReauth((cmd, args, opts) => {
|
|
||||||
calls.push({ cmd, args, opts });
|
|
||||||
return { status: 0 };
|
|
||||||
});
|
|
||||||
expect(status).toBe(0);
|
|
||||||
expect(calls).toHaveLength(1);
|
|
||||||
expect(calls[0]!.cmd).toBe(CLAUDEX_PROXY_BINARY);
|
|
||||||
expect(calls[0]!.args).toEqual(['codex', 'auth', 'device']);
|
|
||||||
// stdio 'inherit' is the security-critical bit: the device code streams to
|
|
||||||
// the user's TTY; the launcher never pipes/captures it.
|
|
||||||
expect(calls[0]!.opts.stdio).toBe('inherit');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 1 when the child yields no status (absent binary)', () => {
|
|
||||||
const status = runDeviceReauth(() => ({ status: null }));
|
|
||||||
expect(status).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('checkProxyBinary', () => {
|
|
||||||
it('resolves via the default `which` path (proxy absent → null)', () => {
|
|
||||||
// Covers the default resolver; on CI/dev the proxy is not installed.
|
|
||||||
const r = checkProxyBinary();
|
|
||||||
expect(typeof r.present).toBe('boolean');
|
|
||||||
if (!r.present) expect(r.path).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports present with the resolved path', () => {
|
|
||||||
const r = checkProxyBinary(() => '/home/u/.local/bin/claude-code-proxy');
|
|
||||||
expect(r.present).toBe(true);
|
|
||||||
expect(r.path).toBe('/home/u/.local/bin/claude-code-proxy');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports absent when the resolver finds nothing', () => {
|
|
||||||
const r = checkProxyBinary(() => null);
|
|
||||||
expect(r.present).toBe(false);
|
|
||||||
expect(r.path).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('probeLiveness (proxy-specific /healthz, not arbitrary HTTP)', () => {
|
|
||||||
it('defaults to probing the /healthz endpoint, never the root path', async () => {
|
|
||||||
const seen: string[] = [];
|
|
||||||
await probeLiveness(undefined, async (u) => {
|
|
||||||
seen.push(u);
|
|
||||||
return { status: 200 };
|
|
||||||
});
|
|
||||||
expect(seen[0]).toBe(CLAUDEX_HEALTH_URL);
|
|
||||||
expect(seen[0]).toContain('/healthz');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a 200 on /healthz as alive', async () => {
|
|
||||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 200 }));
|
|
||||||
expect(live).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a 204 on /healthz as alive', async () => {
|
|
||||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 204 }));
|
|
||||||
expect(live).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a 404 as DEAD — does not trust an arbitrary responder on the port (CWE-345)', async () => {
|
|
||||||
// The whole point: a random local process squatting :18765 will not honor the
|
|
||||||
// proxy's /healthz contract, so a non-2xx there must not be mistaken for the proxy.
|
|
||||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 404 }));
|
|
||||||
expect(live).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a 500 as DEAD (unhealthy / not the proxy health contract)', async () => {
|
|
||||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 500 }));
|
|
||||||
expect(live).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a missing status as dead', async () => {
|
|
||||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({}));
|
|
||||||
expect(live).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a connection failure (reject) as dead', async () => {
|
|
||||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => {
|
|
||||||
throw new Error('ECONNREFUSED');
|
|
||||||
});
|
|
||||||
expect(live).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a timeout as dead', async () => {
|
|
||||||
const never = () => new Promise<{ status?: number }>(() => {});
|
|
||||||
const live = await probeLiveness(CLAUDEX_HEALTH_URL, never, 20);
|
|
||||||
expect(live).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('buildSystemdUnitContent', () => {
|
|
||||||
it('emits a user unit that execs the given binary with serve args', () => {
|
|
||||||
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
|
|
||||||
expect(unit).toContain('[Unit]');
|
|
||||||
expect(unit).toContain('[Service]');
|
|
||||||
expect(unit).toContain('[Install]');
|
|
||||||
expect(unit).toContain('/home/u/.local/bin/claude-code-proxy serve --no-monitor');
|
|
||||||
expect(unit).toContain('WantedBy=default.target');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never embeds credential material', () => {
|
|
||||||
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
|
|
||||||
expect(unit).not.toMatch(/token/i);
|
|
||||||
expect(unit).not.toMatch(/auth\.json/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a path containing a newline (CWE-74 systemd directive injection)', () => {
|
|
||||||
// A raw newline in ExecStart would let an attacker append arbitrary unit
|
|
||||||
// directives — e.g. `ExecStartPost=curl evil`. Must be rejected outright.
|
|
||||||
expect(() =>
|
|
||||||
buildSystemdUnitContent('/bin/claude-code-proxy\nExecStartPost=/bin/rm -rf /'),
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a path containing a carriage return', () => {
|
|
||||||
expect(() => buildSystemdUnitContent('/bin/claude-code-proxy\rmalicious')).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a path with other control characters', () => {
|
|
||||||
expect(() => buildSystemdUnitContent('/bin/claude-code-proxy\x00nul')).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a non-absolute path', () => {
|
|
||||||
expect(() => buildSystemdUnitContent('claude-code-proxy')).toThrow();
|
|
||||||
expect(() => buildSystemdUnitContent('')).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('systemd-quotes a path that contains spaces', () => {
|
|
||||||
const unit = buildSystemdUnitContent('/home/u/my apps/claude-code-proxy');
|
|
||||||
expect(unit).toContain('ExecStart="/home/u/my apps/claude-code-proxy" serve --no-monitor');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('escapes embedded quotes and backslashes when quoting', () => {
|
|
||||||
const unit = buildSystemdUnitContent('/home/u/we"ird\\dir/claude-code-proxy');
|
|
||||||
// No unescaped closing quote can terminate the token early.
|
|
||||||
expect(unit).toContain('ExecStart="/home/u/we\\"ird\\\\dir/claude-code-proxy" serve');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves a clean absolute path unquoted (no needless churn)', () => {
|
|
||||||
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
|
|
||||||
expect(unit).toContain('ExecStart=/home/u/.local/bin/claude-code-proxy serve --no-monitor');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('systemdUnitPath', () => {
|
|
||||||
it('targets the systemd --user unit dir', () => {
|
|
||||||
expect(systemdUnitPath('/home/u')).toBe(
|
|
||||||
'/home/u/.config/systemd/user/claude-code-proxy.service',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('installSystemdUnit', () => {
|
|
||||||
it('writes the unit and returns true when daemon-reload succeeds', () => {
|
|
||||||
let written: { path: string; content: string } | null = null;
|
|
||||||
const ok = installSystemdUnit('/bin/claude-code-proxy', {
|
|
||||||
home: '/home/u',
|
|
||||||
writeUnit: (path, content) => {
|
|
||||||
written = { path, content };
|
|
||||||
},
|
|
||||||
run: () => ({ status: 0, stdout: '', stderr: '' }),
|
|
||||||
});
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
expect(written).not.toBeNull();
|
|
||||||
expect(written!.path).toBe('/home/u/.config/systemd/user/claude-code-proxy.service');
|
|
||||||
expect(written!.content).toContain('ExecStart=/bin/claude-code-proxy serve --no-monitor');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false when daemon-reload fails (systemd --user unavailable)', () => {
|
|
||||||
const ok = installSystemdUnit('/bin/claude-code-proxy', {
|
|
||||||
home: '/home/u',
|
|
||||||
writeUnit: () => {},
|
|
||||||
run: () => ({ status: 1, stdout: '', stderr: 'Failed to connect to bus' }),
|
|
||||||
});
|
|
||||||
expect(ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false when writing the unit throws', () => {
|
|
||||||
const ok = installSystemdUnit('/bin/claude-code-proxy', {
|
|
||||||
home: '/home/u',
|
|
||||||
writeUnit: () => {
|
|
||||||
throw new Error('EACCES');
|
|
||||||
},
|
|
||||||
run: () => ({ status: 0, stdout: '', stderr: '' }),
|
|
||||||
});
|
|
||||||
expect(ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses to write a unit for an injection-bearing path (never writes a poisoned unit)', () => {
|
|
||||||
const writeUnit = vi.fn();
|
|
||||||
const ok = installSystemdUnit('/bin/claude-code-proxy\nExecStartPost=/bin/rm -rf /', {
|
|
||||||
home: '/home/u',
|
|
||||||
writeUnit,
|
|
||||||
run: () => ({ status: 0, stdout: '', stderr: '' }),
|
|
||||||
});
|
|
||||||
expect(ok).toBe(false);
|
|
||||||
// The poisoned unit content is never even produced, so nothing is written.
|
|
||||||
expect(writeUnit).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('writes to a real temp dir via the default writer', () => {
|
|
||||||
const home = mkdtempSync(join(tmpdir(), 'claudex-unit-'));
|
|
||||||
try {
|
|
||||||
const ok = installSystemdUnit('/bin/claude-code-proxy', {
|
|
||||||
home,
|
|
||||||
run: () => ({ status: 0, stdout: '', stderr: '' }),
|
|
||||||
});
|
|
||||||
expect(ok).toBe(true);
|
|
||||||
const written = readFileSync(systemdUnitPath(home), 'utf8');
|
|
||||||
expect(written).toContain('[Service]');
|
|
||||||
} finally {
|
|
||||||
rmSync(home, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('runProxyPreflight', () => {
|
|
||||||
const trustedListener = () => 'ok' as const;
|
|
||||||
|
|
||||||
it('is ok when binary present, auth valid, proxy live, and listener identity-verified', async () => {
|
|
||||||
const report = await runProxyPreflight({
|
|
||||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
|
||||||
checkAuth: () => ({ state: 'valid' }),
|
|
||||||
probe: async () => true,
|
|
||||||
verifyListener: trustedListener,
|
|
||||||
});
|
|
||||||
expect(report.ok).toBe(true);
|
|
||||||
expect(report.listenerVerdict).toBe('ok');
|
|
||||||
expect(report.problems).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flags a missing binary', async () => {
|
|
||||||
const report = await runProxyPreflight({
|
|
||||||
checkBinary: () => ({ present: false, path: null }),
|
|
||||||
checkAuth: () => ({ state: 'valid' }),
|
|
||||||
probe: async () => true,
|
|
||||||
verifyListener: trustedListener,
|
|
||||||
});
|
|
||||||
expect(report.ok).toBe(false);
|
|
||||||
expect(report.problems.some((p) => /binary/i.test(p))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flags expired auth (re-auth needed)', async () => {
|
|
||||||
const report = await runProxyPreflight({
|
|
||||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
|
||||||
checkAuth: () => ({ state: 'expired' }),
|
|
||||||
probe: async () => true,
|
|
||||||
verifyListener: trustedListener,
|
|
||||||
});
|
|
||||||
expect(report.ok).toBe(false);
|
|
||||||
expect(report.needsReauth).toBe(true);
|
|
||||||
expect(report.problems.some((p) => /auth/i.test(p))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flags a dead proxy', async () => {
|
|
||||||
const report = await runProxyPreflight({
|
|
||||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
|
||||||
checkAuth: () => ({ state: 'valid' }),
|
|
||||||
probe: async () => false,
|
|
||||||
verifyListener: trustedListener,
|
|
||||||
});
|
|
||||||
expect(report.ok).toBe(false);
|
|
||||||
expect(report.live).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flags an unknown auth state without marking it for re-auth', async () => {
|
|
||||||
const report = await runProxyPreflight({
|
|
||||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
|
||||||
checkAuth: () => ({ state: 'unknown' }),
|
|
||||||
probe: async () => true,
|
|
||||||
verifyListener: trustedListener,
|
|
||||||
});
|
|
||||||
expect(report.ok).toBe(false);
|
|
||||||
expect(report.needsReauth).toBe(false);
|
|
||||||
expect(report.problems.some((p) => /could not determine/i.test(p))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT pass preflight when the live responder fails identity verification (F2b)', async () => {
|
|
||||||
// A squatter answering /healthz-2xx must not yield ok:true just because the
|
|
||||||
// binary is installed and OAuth is valid — the identity gate holds here too.
|
|
||||||
for (const verdict of ['foreign-user', 'wrong-exe', 'unknown'] as const) {
|
|
||||||
const report = await runProxyPreflight({
|
|
||||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
|
||||||
checkAuth: () => ({ state: 'valid' }),
|
|
||||||
probe: async () => true,
|
|
||||||
verifyListener: () => verdict,
|
|
||||||
});
|
|
||||||
expect(report.ok).toBe(false);
|
|
||||||
expect(report.live).toBe(true);
|
|
||||||
expect(report.listenerVerdict).toBe(verdict);
|
|
||||||
expect(report.problems.some((p) => /identity could not be verified/i.test(p))).toBe(true);
|
|
||||||
// The identity problem is non-sensitive: port + verdict only, no token.
|
|
||||||
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not verify listener identity when the proxy is dead (no listener to trust)', async () => {
|
|
||||||
const verifyListener = vi.fn(() => 'ok' as const);
|
|
||||||
const report = await runProxyPreflight({
|
|
||||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
|
||||||
checkAuth: () => ({ state: 'valid' }),
|
|
||||||
probe: async () => false,
|
|
||||||
verifyListener,
|
|
||||||
});
|
|
||||||
expect(verifyListener).not.toHaveBeenCalled();
|
|
||||||
expect(report.listenerVerdict).toBe('unknown');
|
|
||||||
expect(report.ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not leak token material for any auth state', async () => {
|
|
||||||
const report = await runProxyPreflight({
|
|
||||||
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
|
||||||
checkAuth: () => ({ state: 'expired' }),
|
|
||||||
probe: async () => false,
|
|
||||||
verifyListener: trustedListener,
|
|
||||||
});
|
|
||||||
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs end-to-end with all real defaults (no proxy installed → not ok)', async () => {
|
|
||||||
// Exercises the default checkBinary/checkAuth/probe closures against a box
|
|
||||||
// with no proxy: absent binary, spawnSync status, real loopback probe that
|
|
||||||
// fast-fails with ECONNREFUSED. Asserts shape only (never token material).
|
|
||||||
const report = await runProxyPreflight();
|
|
||||||
expect(typeof report.ok).toBe('boolean');
|
|
||||||
expect(Array.isArray(report.problems)).toBe(true);
|
|
||||||
expect(['valid', 'expired', 'unauthenticated', 'unknown']).toContain(report.auth.state);
|
|
||||||
expect(JSON.stringify(report)).not.toMatch(/access_token|refresh_token|sk-/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A minimal fake ChildProcess for the nohup-fallback tests: records once()
|
|
||||||
* handlers so a test can drive the async 'spawn'/'error' events, and tracks
|
|
||||||
* whether the 'error' listener was already attached at the moment unref() ran
|
|
||||||
* (the security-critical ordering from finding #1).
|
|
||||||
*/
|
|
||||||
function fakeChild() {
|
|
||||||
const handlers: Record<string, (arg?: unknown) => void> = {};
|
|
||||||
const state = { unreffed: false, errorHandlerAtUnref: false };
|
|
||||||
const child = {
|
|
||||||
once(event: string, listener: (arg?: unknown) => void) {
|
|
||||||
handlers[event] = listener;
|
|
||||||
return child;
|
|
||||||
},
|
|
||||||
unref() {
|
|
||||||
state.unreffed = true;
|
|
||||||
state.errorHandlerAtUnref = typeof handlers.error === 'function';
|
|
||||||
},
|
|
||||||
emit(event: string, arg?: unknown) {
|
|
||||||
handlers[event]?.(arg);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
child: child as unknown as SpawnedChild & { emit(e: string, a?: unknown): void },
|
|
||||||
state,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('startNohupProxy (finding #1 — async spawn error must not crash)', () => {
|
|
||||||
it('resolves status 0 only after a confirmed spawn, and unrefs the child', async () => {
|
|
||||||
const { child, state } = fakeChild();
|
|
||||||
const spawnImpl = vi.fn((_cmd: string, _args: string[]) => {
|
|
||||||
queueMicrotask(() => child.emit('spawn'));
|
|
||||||
return child;
|
|
||||||
});
|
|
||||||
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
|
|
||||||
expect(r.status).toBe(0);
|
|
||||||
expect(state.unreffed).toBe(true);
|
|
||||||
// The error listener MUST be registered before unref(), so an ENOENT that
|
|
||||||
// arrives asynchronously can never become an unhandled 'error' crash.
|
|
||||||
expect(state.errorHandlerAtUnref).toBe(true);
|
|
||||||
expect(spawnImpl).toHaveBeenCalledWith('/bin/claude-code-proxy', ['serve', '--no-monitor'], {
|
|
||||||
detached: true,
|
|
||||||
stdio: 'ignore',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('captures an async spawn error (ENOENT) as a failed start instead of crashing', async () => {
|
|
||||||
const { child, state } = fakeChild();
|
|
||||||
const spawnImpl = () => {
|
|
||||||
queueMicrotask(() => child.emit('error', new Error('spawn claude-code-proxy ENOENT')));
|
|
||||||
return child;
|
|
||||||
};
|
|
||||||
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
|
|
||||||
expect(r.status).toBe(1);
|
|
||||||
expect(r.stderr).toContain('ENOENT');
|
|
||||||
expect(state.unreffed).toBe(false); // never unref a child that failed to start
|
|
||||||
});
|
|
||||||
|
|
||||||
it('captures a synchronous spawn throw as a failed start', async () => {
|
|
||||||
const spawnImpl = () => {
|
|
||||||
throw new Error('EACCES');
|
|
||||||
};
|
|
||||||
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
|
|
||||||
expect(r.status).toBe(1);
|
|
||||||
expect(r.stderr).toContain('EACCES');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores a late error after a successful spawn (settles once)', async () => {
|
|
||||||
const { child } = fakeChild();
|
|
||||||
const spawnImpl = () => {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
child.emit('spawn');
|
|
||||||
child.emit('error', new Error('late boom'));
|
|
||||||
});
|
|
||||||
return child;
|
|
||||||
};
|
|
||||||
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
|
|
||||||
expect(r.status).toBe(0); // first settle wins; the late error cannot flip it
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE-345)', () => {
|
|
||||||
const me: ListenerIdentity = {
|
|
||||||
pid: 4242,
|
|
||||||
uid: 1000,
|
|
||||||
exePath: '/home/me/.local/bin/claude-code-proxy',
|
|
||||||
};
|
|
||||||
// Identity canonicalize for tests: fake paths don't exist on disk, so we map
|
|
||||||
// each path to itself and exercise symlink resolution explicitly where needed.
|
|
||||||
const idc = (p: string) => p;
|
|
||||||
|
|
||||||
it('accepts a listener owned by the current uid whose exe is the expected proxy path', () => {
|
|
||||||
const verdict = verifyListenerIdentity({
|
|
||||||
identify: () => me,
|
|
||||||
currentUid: () => 1000,
|
|
||||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
|
||||||
canonicalize: idc,
|
|
||||||
});
|
|
||||||
expect(verdict).toBe('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves symlinks on BOTH sides before comparing (canonical match → ok)', () => {
|
|
||||||
// The listener exe and our resolved binary reach the same real file via
|
|
||||||
// different symlink paths — a canonical comparison must accept it.
|
|
||||||
const canon: Record<string, string> = {
|
|
||||||
'/var/run/proxy.link': '/opt/proxy/bin/claude-code-proxy',
|
|
||||||
'/home/me/.local/bin/claude-code-proxy': '/opt/proxy/bin/claude-code-proxy',
|
|
||||||
};
|
|
||||||
const verdict = verifyListenerIdentity({
|
|
||||||
identify: () => ({ ...me, exePath: '/var/run/proxy.link' }),
|
|
||||||
currentUid: () => 1000,
|
|
||||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
|
||||||
canonicalize: (p) => canon[p] ?? null,
|
|
||||||
});
|
|
||||||
expect(verdict).toBe('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT trust a same-uid process at the WRONG path with the right basename (F2a)', () => {
|
|
||||||
// The squatter vector on a shared-uid host: right basename, wrong path. The
|
|
||||||
// basename must NEVER be a trust signal when an expected exact path resolved.
|
|
||||||
const verdict = verifyListenerIdentity({
|
|
||||||
identify: () => ({ ...me, exePath: '/tmp/claude-code-proxy' }),
|
|
||||||
currentUid: () => 1000,
|
|
||||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
|
||||||
canonicalize: idc,
|
|
||||||
});
|
|
||||||
expect(verdict).toBe('wrong-exe');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed (unknown) when our own proxy binary path cannot be resolved (F2a)', () => {
|
|
||||||
// No expected path → we cannot assert identity → refuse to trust (no basename
|
|
||||||
// acceptance). Previously this returned `ok` by basename; that was a bypass.
|
|
||||||
const verdict = verifyListenerIdentity({
|
|
||||||
identify: () => me,
|
|
||||||
currentUid: () => 1000,
|
|
||||||
expectedExe: () => null,
|
|
||||||
canonicalize: idc,
|
|
||||||
});
|
|
||||||
expect(verdict).toBe('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed (unknown) when a path cannot be canonicalized (F2a)', () => {
|
|
||||||
const verdict = verifyListenerIdentity({
|
|
||||||
identify: () => me,
|
|
||||||
currentUid: () => 1000,
|
|
||||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
|
||||||
canonicalize: () => null, // e.g. binary deleted out from under the listener
|
|
||||||
});
|
|
||||||
expect(verdict).toBe('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a listener owned by a DIFFERENT uid (foreign-user) — fail closed', () => {
|
|
||||||
const verdict = verifyListenerIdentity({
|
|
||||||
identify: () => ({ ...me, uid: 0 }),
|
|
||||||
currentUid: () => 1000,
|
|
||||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
|
||||||
canonicalize: idc,
|
|
||||||
});
|
|
||||||
expect(verdict).toBe('foreign-user');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a same-user listener whose exe is NOT the proxy (wrong-exe)', () => {
|
|
||||||
const verdict = verifyListenerIdentity({
|
|
||||||
identify: () => ({ ...me, exePath: '/usr/bin/nc' }),
|
|
||||||
currentUid: () => 1000,
|
|
||||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
|
||||||
canonicalize: idc,
|
|
||||||
});
|
|
||||||
expect(verdict).toBe('wrong-exe');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns unknown (fail closed) when the listener cannot be identified', () => {
|
|
||||||
const verdict = verifyListenerIdentity({
|
|
||||||
identify: () => null,
|
|
||||||
currentUid: () => 1000,
|
|
||||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
|
||||||
canonicalize: idc,
|
|
||||||
});
|
|
||||||
expect(verdict).toBe('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns unknown when the current uid is unavailable (non-posix)', () => {
|
|
||||||
const verdict = verifyListenerIdentity({
|
|
||||||
identify: () => me,
|
|
||||||
currentUid: () => -1,
|
|
||||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
|
||||||
canonicalize: idc,
|
|
||||||
});
|
|
||||||
expect(verdict).toBe('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns unknown when the listener exe path cannot be read', () => {
|
|
||||||
const verdict = verifyListenerIdentity({
|
|
||||||
identify: () => ({ ...me, exePath: null }),
|
|
||||||
currentUid: () => 1000,
|
|
||||||
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
|
||||||
canonicalize: idc,
|
|
||||||
});
|
|
||||||
expect(verdict).toBe('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs with real defaults without throwing (identity may be unresolved → verdict)', () => {
|
|
||||||
const verdict = verifyListenerIdentity();
|
|
||||||
expect(['ok', 'foreign-user', 'wrong-exe', 'unknown']).toContain(verdict);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ensureProxyRunning', () => {
|
|
||||||
const ok: ProxyRunResult = { status: 0, stdout: '', stderr: '' };
|
|
||||||
const nohupOk = async (): Promise<ProxyRunResult> => ok;
|
|
||||||
const trusted = () => 'ok' as const;
|
|
||||||
|
|
||||||
it('is a no-op when the proxy is already live AND identity-verified', async () => {
|
|
||||||
const startSystemd = vi.fn(() => ok);
|
|
||||||
const startNohup = vi.fn(nohupOk);
|
|
||||||
const r = await ensureProxyRunning({
|
|
||||||
probe: async () => true,
|
|
||||||
verifyListener: trusted,
|
|
||||||
startSystemd,
|
|
||||||
startNohup,
|
|
||||||
waitMs: async () => {},
|
|
||||||
});
|
|
||||||
expect(r.method).toBe('already');
|
|
||||||
expect(r.live).toBe(true);
|
|
||||||
expect(startSystemd).not.toHaveBeenCalled();
|
|
||||||
expect(startNohup).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed as untrusted when a responder holds :18765 but identity is NOT ours', async () => {
|
|
||||||
// A foreign process answers /healthz but the listener is not our proxy
|
|
||||||
// (foreign uid / wrong exe / unidentifiable). We must NOT trust it and must
|
|
||||||
// NOT start a second proxy (the port is already taken) — fail closed.
|
|
||||||
const startSystemd = vi.fn(() => ok);
|
|
||||||
const startNohup = vi.fn(nohupOk);
|
|
||||||
const r = await ensureProxyRunning({
|
|
||||||
probe: async () => true,
|
|
||||||
verifyListener: () => 'foreign-user',
|
|
||||||
startSystemd,
|
|
||||||
startNohup,
|
|
||||||
waitMs: async () => {},
|
|
||||||
});
|
|
||||||
expect(r.method).toBe('untrusted');
|
|
||||||
expect(r.live).toBe(false);
|
|
||||||
expect(startSystemd).not.toHaveBeenCalled();
|
|
||||||
expect(startNohup).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('starts via systemd when available and then becomes trusted-live', async () => {
|
|
||||||
let calls = 0;
|
|
||||||
const r = await ensureProxyRunning({
|
|
||||||
probe: async () => calls++ > 0, // dead first, live after start
|
|
||||||
verifyListener: trusted,
|
|
||||||
startSystemd: () => ok,
|
|
||||||
startNohup: async () => {
|
|
||||||
throw new Error('should not fall back');
|
|
||||||
},
|
|
||||||
waitMs: async () => {},
|
|
||||||
});
|
|
||||||
expect(r.method).toBe('systemd');
|
|
||||||
expect(r.live).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('waits past a slow systemd bind before giving up (finding #2 — no duplicate proxy)', async () => {
|
|
||||||
// systemd `start` returns 0 (job accepted) but the socket only binds on the
|
|
||||||
// 4th probe — still well within the startup deadline. nohup must NOT run,
|
|
||||||
// or two proxies would contend for :18765.
|
|
||||||
let probes = 0;
|
|
||||||
const startNohup = vi.fn(nohupOk);
|
|
||||||
const r = await ensureProxyRunning({
|
|
||||||
probe: async () => probes++ >= 3,
|
|
||||||
verifyListener: trusted,
|
|
||||||
startSystemd: () => ok,
|
|
||||||
startNohup,
|
|
||||||
waitMs: async () => {},
|
|
||||||
settleMs: 10,
|
|
||||||
startupDeadlineMs: 200,
|
|
||||||
});
|
|
||||||
expect(r.method).toBe('systemd');
|
|
||||||
expect(r.live).toBe(true);
|
|
||||||
expect(startNohup).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT fall back to nohup after systemd accepts but never binds (finding #1 — dup-proxy race)', async () => {
|
|
||||||
// systemctl start exit 0 means the job was ACCEPTED, not bound. If it binds
|
|
||||||
// just after our deadline (or systemd restarts it), a nohup fallback would
|
|
||||||
// create a SECOND proxy contending for :18765. Once systemd has accepted the
|
|
||||||
// job we never spawn nohup — we report a managed-service startup failure.
|
|
||||||
const startNohup = vi.fn(nohupOk);
|
|
||||||
const r = await ensureProxyRunning({
|
|
||||||
probe: async () => false, // never becomes live within the deadline
|
|
||||||
verifyListener: trusted,
|
|
||||||
startSystemd: () => ok,
|
|
||||||
startNohup,
|
|
||||||
waitMs: async () => {},
|
|
||||||
settleMs: 10,
|
|
||||||
startupDeadlineMs: 30,
|
|
||||||
});
|
|
||||||
expect(startNohup).not.toHaveBeenCalled();
|
|
||||||
expect(r.method).toBe('failed');
|
|
||||||
expect(r.live).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT trust a systemd-started responder whose identity cannot be verified', async () => {
|
|
||||||
// Dead at first (so we reach the systemd start), then the socket binds — but
|
|
||||||
// identity never verifies (e.g. a squatter beat systemd to the port). A live
|
|
||||||
// responder that fails identity must never be reported as a successful start.
|
|
||||||
let calls = 0;
|
|
||||||
const startNohup = vi.fn(nohupOk);
|
|
||||||
const r = await ensureProxyRunning({
|
|
||||||
probe: async () => calls++ > 0,
|
|
||||||
verifyListener: () => 'wrong-exe',
|
|
||||||
startSystemd: () => ok,
|
|
||||||
startNohup,
|
|
||||||
waitMs: async () => {},
|
|
||||||
settleMs: 10,
|
|
||||||
startupDeadlineMs: 30,
|
|
||||||
});
|
|
||||||
expect(startNohup).not.toHaveBeenCalled();
|
|
||||||
expect(r.method).toBe('failed');
|
|
||||||
expect(r.live).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to nohup only when systemd start FAILS outright (not accepted)', async () => {
|
|
||||||
let calls = 0;
|
|
||||||
const r = await ensureProxyRunning({
|
|
||||||
// A failed systemd start skips its post-start poll, so probes are:
|
|
||||||
// #0 initial (dead), #1 after nohup (live). nohup fallback is reachable
|
|
||||||
// ONLY because systemd never accepted the job (status 1).
|
|
||||||
probe: async () => calls++ > 0,
|
|
||||||
verifyListener: trusted,
|
|
||||||
startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }),
|
|
||||||
startNohup: nohupOk,
|
|
||||||
waitMs: async () => {},
|
|
||||||
settleMs: 10,
|
|
||||||
startupDeadlineMs: 30,
|
|
||||||
});
|
|
||||||
expect(r.method).toBe('nohup');
|
|
||||||
expect(r.live).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT trust a nohup-started responder whose identity cannot be verified', async () => {
|
|
||||||
let calls = 0;
|
|
||||||
const r = await ensureProxyRunning({
|
|
||||||
probe: async () => calls++ > 0,
|
|
||||||
verifyListener: () => 'unknown',
|
|
||||||
startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }),
|
|
||||||
startNohup: nohupOk,
|
|
||||||
waitMs: async () => {},
|
|
||||||
settleMs: 10,
|
|
||||||
startupDeadlineMs: 30,
|
|
||||||
});
|
|
||||||
expect(r.method).toBe('failed');
|
|
||||||
expect(r.live).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports failed when nothing brings the proxy up', async () => {
|
|
||||||
const r = await ensureProxyRunning({
|
|
||||||
probe: async () => false,
|
|
||||||
verifyListener: trusted,
|
|
||||||
startSystemd: () => ({ status: 1, stdout: '', stderr: '' }),
|
|
||||||
startNohup: async () => ({ status: 1, stdout: '', stderr: '' }),
|
|
||||||
waitMs: async () => {},
|
|
||||||
settleMs: 10,
|
|
||||||
startupDeadlineMs: 30,
|
|
||||||
});
|
|
||||||
expect(r.method).toBe('failed');
|
|
||||||
expect(r.live).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,700 +0,0 @@
|
|||||||
/**
|
|
||||||
* Claudex proxy preflight + lifecycle (P1 of `mosaic yolo claudex`).
|
|
||||||
*
|
|
||||||
* `raine/claude-code-proxy` runs a local server on 127.0.0.1:18765 that speaks
|
|
||||||
* the Anthropic Messages API and translates to the ChatGPT/Codex backend using
|
|
||||||
* ChatGPT-subscription OAuth. This module owns the *preflight* and *lifecycle*
|
|
||||||
* concerns for the launcher: is the binary present, is OAuth valid, is the proxy
|
|
||||||
* listening, and — if not — bring it up (systemd user unit preferred, nohup
|
|
||||||
* fallback).
|
|
||||||
*
|
|
||||||
* Design: every function is pure or dependency-injected so the launch path is
|
|
||||||
* fully unit-testable without touching a real process, socket, or the OAuth
|
|
||||||
* token. Nothing here reads `~/.config/claude-code-proxy/codex/auth.json`; the
|
|
||||||
* proxy holds the real credential and Claude Code only ever sees
|
|
||||||
* `ANTHROPIC_AUTH_TOKEN=unused`. Parsed auth status is deliberately coarse
|
|
||||||
* (state + optional expiry) so no token material can be retained or surfaced.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
||||||
import { mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync } from 'node:fs';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
|
|
||||||
// ─── Endpoint / command constants (spec table) ──────────────────────────────
|
|
||||||
|
|
||||||
export const CLAUDEX_PROXY_HOST = '127.0.0.1';
|
|
||||||
export const CLAUDEX_PROXY_PORT = 18765;
|
|
||||||
export const CLAUDEX_PROXY_URL = `http://${CLAUDEX_PROXY_HOST}:${CLAUDEX_PROXY_PORT}`;
|
|
||||||
export const CLAUDEX_PROXY_BINARY = 'claude-code-proxy';
|
|
||||||
export const CLAUDEX_SYSTEMD_UNIT = 'claude-code-proxy.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The proxy's dedicated liveness endpoint. We probe this — NOT the root path —
|
|
||||||
* for two reasons: (1) the root returns non-2xx (spec gotcha #1), which is why
|
|
||||||
* the original `curl -f` check spawned duplicate proxies; `/healthz` returns 2xx
|
|
||||||
* when the proxy is healthy. (2) It is a *proxy-specific* contract, so a 2xx here
|
|
||||||
* is a much stronger signal that the responder on :18765 is actually our proxy
|
|
||||||
* and not some other local process squatting the port (CWE-345).
|
|
||||||
*/
|
|
||||||
export const CLAUDEX_HEALTH_PATH = '/healthz';
|
|
||||||
export const CLAUDEX_HEALTH_URL = `${CLAUDEX_PROXY_URL}${CLAUDEX_HEALTH_PATH}`;
|
|
||||||
|
|
||||||
/** argv for `claude-code-proxy codex auth status`. */
|
|
||||||
export function buildAuthStatusArgs(): string[] {
|
|
||||||
return ['codex', 'auth', 'status'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** argv for `claude-code-proxy codex auth device` (device-code re-auth flow). */
|
|
||||||
export function buildDeviceAuthArgs(): string[] {
|
|
||||||
return ['codex', 'auth', 'device'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** argv for `claude-code-proxy serve --no-monitor`. */
|
|
||||||
export function buildServeArgs(): string[] {
|
|
||||||
return ['serve', '--no-monitor'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type AuthState = 'valid' | 'expired' | 'unauthenticated' | 'unknown';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Coarse OAuth status. Intentionally carries NO token material — only a state
|
|
||||||
* and an optional best-effort expiry-in-days for user-facing messaging.
|
|
||||||
*/
|
|
||||||
export interface AuthStatus {
|
|
||||||
state: AuthState;
|
|
||||||
expiresInDays?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProxyRunResult {
|
|
||||||
status: number | null;
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Runs a command synchronously and returns its captured result. */
|
|
||||||
export type CommandRunner = (cmd: string, args: string[]) => ProxyRunResult;
|
|
||||||
|
|
||||||
/** Minimal fetch shape used for the liveness probe (any HTTP response = alive). */
|
|
||||||
export type FetchLike = (
|
|
||||||
url: string,
|
|
||||||
init?: { signal?: AbortSignal },
|
|
||||||
) => Promise<{ status?: number }>;
|
|
||||||
|
|
||||||
// ─── Binary presence ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function defaultWhich(cmd: string): string | null {
|
|
||||||
try {
|
|
||||||
return execFileSync('which', [cmd], { encoding: 'utf8' }).trim() || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkProxyBinary(resolve: (cmd: string) => string | null = defaultWhich): {
|
|
||||||
present: boolean;
|
|
||||||
path: string | null;
|
|
||||||
} {
|
|
||||||
const path = resolve(CLAUDEX_PROXY_BINARY);
|
|
||||||
return { present: path !== null && path !== '', path: path || null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Auth status ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse `claude-code-proxy codex auth status` output into a coarse state.
|
|
||||||
*
|
|
||||||
* The proxy's exact wording is not contractually pinned, so this matches
|
|
||||||
* tolerantly on well-known markers and falls back on the exit code. It never
|
|
||||||
* copies the raw output onto the result — only a state and an optional expiry —
|
|
||||||
* so token-shaped strings in the output cannot leak downstream.
|
|
||||||
*/
|
|
||||||
export function parseAuthStatus(result: ProxyRunResult): AuthStatus {
|
|
||||||
const text = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
|
||||||
|
|
||||||
const expired = /\bexpired\b|token has expired|expires?d? \d+ days? ago/.test(text);
|
|
||||||
const unauth =
|
|
||||||
/not authenticated|not logged in|no (?:auth|credentials|token)|please (?:log ?in|authenticate)|run .*auth device/.test(
|
|
||||||
text,
|
|
||||||
);
|
|
||||||
const authed = /\bauthenticated\b|logged in|token valid|valid until|expires? in/.test(text);
|
|
||||||
|
|
||||||
let state: AuthState;
|
|
||||||
if (expired) {
|
|
||||||
state = 'expired';
|
|
||||||
} else if (unauth) {
|
|
||||||
state = 'unauthenticated';
|
|
||||||
} else if (authed && result.status === 0) {
|
|
||||||
// A `null` status means the check was killed by a signal — an INCOMPLETE
|
|
||||||
// run. We require a clean exit 0 for `valid`; a partially-flushed auth line
|
|
||||||
// from a signal-terminated check must never be trusted (finding #3).
|
|
||||||
state = 'valid';
|
|
||||||
} else if (result.status === 0) {
|
|
||||||
state = 'valid';
|
|
||||||
} else {
|
|
||||||
state = 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
const status: AuthStatus = { state };
|
|
||||||
const days = /expires? in (\d+) days?/.exec(text);
|
|
||||||
if (state === 'valid' && days) {
|
|
||||||
status.expiresInDays = Number(days[1]);
|
|
||||||
}
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultRun(cmd: string, args: string[]): ProxyRunResult {
|
|
||||||
const r = spawnSync(cmd, args, { encoding: 'utf8' });
|
|
||||||
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkAuthStatus(run: CommandRunner = defaultRun): AuthStatus {
|
|
||||||
return parseAuthStatus(run(CLAUDEX_PROXY_BINARY, buildAuthStatusArgs()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Spawn shape for the interactive device re-auth flow. */
|
|
||||||
export type InheritSpawn = (
|
|
||||||
cmd: string,
|
|
||||||
args: string[],
|
|
||||||
opts: { stdio: 'inherit' },
|
|
||||||
) => { status: number | null };
|
|
||||||
|
|
||||||
function defaultInheritSpawn(cmd: string, args: string[], opts: { stdio: 'inherit' }) {
|
|
||||||
return spawnSync(cmd, args, opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the device-code re-auth flow (`claude-code-proxy codex auth device`).
|
|
||||||
*
|
|
||||||
* Deliberately `stdio: 'inherit'` so the device code the proxy prints goes
|
|
||||||
* straight to the user's terminal — the launcher NEVER captures, stores, or logs
|
|
||||||
* it, and never observes the resulting OAuth token (the proxy persists that to
|
|
||||||
* its own config). Returns the child's exit status; 1 on an absent binary.
|
|
||||||
*/
|
|
||||||
export function runDeviceReauth(spawnImpl: InheritSpawn = defaultInheritSpawn): number {
|
|
||||||
const r = spawnImpl(CLAUDEX_PROXY_BINARY, buildDeviceAuthArgs(), { stdio: 'inherit' });
|
|
||||||
return r.status ?? 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Liveness (probe the proxy-specific /healthz; require 2xx) ────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Probe the proxy for liveness by hitting its dedicated `GET /healthz` endpoint
|
|
||||||
* and requiring a 2xx response.
|
|
||||||
*
|
|
||||||
* This is a LIVENESS check only — it answers "is a healthy proxy responding?",
|
|
||||||
* not "is that responder actually ours?". Requiring a 2xx on the proxy's own
|
|
||||||
* `/healthz` contract (rather than "any HTTP response = alive") resolves spec
|
|
||||||
* gotcha #1: the root path returns non-2xx, but `/healthz` returns 2xx when
|
|
||||||
* healthy, so a live proxy is never mistaken for dead and no duplicate proxy is
|
|
||||||
* spawned.
|
|
||||||
*
|
|
||||||
* Residual risk (CWE-345): the proxy binds loopback with NO client
|
|
||||||
* authentication, so on a shared host a local process could occupy :18765 and
|
|
||||||
* serve a 2xx here. A 2xx therefore does NOT by itself establish that the
|
|
||||||
* listener is our proxy. Identity is verified SEPARATELY and at every trust
|
|
||||||
* point by {@link verifyListenerIdentity} (OS-level uid + executable check),
|
|
||||||
* which fails closed when identity can't be established. See
|
|
||||||
* {@link ensureProxyRunning}. (Broader multi-user hardening — a persistent
|
|
||||||
* warning when a foreign listener is seen — is tracked for a later phase.)
|
|
||||||
*/
|
|
||||||
export async function probeLiveness(
|
|
||||||
url: string = CLAUDEX_HEALTH_URL,
|
|
||||||
fetchImpl: FetchLike = fetch as unknown as FetchLike,
|
|
||||||
timeoutMs = 1500,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const controller = new AbortController();
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
|
|
||||||
// Bound the probe with our own timeout race rather than trusting the fetch
|
|
||||||
// implementation to honor the abort signal — a hung socket (or a fetch that
|
|
||||||
// ignores the signal) must never wedge the launcher. We still abort() so a
|
|
||||||
// signal-aware fetch tears the request down promptly.
|
|
||||||
const timeout = new Promise<boolean>((resolve) => {
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
controller.abort();
|
|
||||||
resolve(false);
|
|
||||||
}, timeoutMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
const probe = fetchImpl(url, { signal: controller.signal })
|
|
||||||
.then((res) => typeof res.status === 'number' && res.status >= 200 && res.status < 300)
|
|
||||||
.catch(() => false); // connection refused / aborted → dead
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await Promise.race([probe, timeout]);
|
|
||||||
} finally {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Listener identity (OS-level, CWE-345 mitigation) ─────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The result of verifying who actually owns the :18765 listener.
|
|
||||||
* - `ok` — same-user process running the expected proxy binary.
|
|
||||||
* - `foreign-user` — a process owned by a DIFFERENT uid holds the port.
|
|
||||||
* - `wrong-exe` — same-user, but the executable is not the proxy.
|
|
||||||
* - `unknown` — identity could not be established (fail closed).
|
|
||||||
*/
|
|
||||||
export type ListenerVerdict = 'ok' | 'foreign-user' | 'wrong-exe' | 'unknown';
|
|
||||||
|
|
||||||
/** OS-level identity of the process bound to the proxy port. */
|
|
||||||
export interface ListenerIdentity {
|
|
||||||
pid: number;
|
|
||||||
uid: number;
|
|
||||||
/** Absolute path of the process executable, or null if unreadable. */
|
|
||||||
exePath: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VerifyListenerDeps {
|
|
||||||
/** Resolve the process bound to the proxy port (null → unidentifiable). */
|
|
||||||
identify?: () => ListenerIdentity | null;
|
|
||||||
/** The current process uid (-1 when unavailable, e.g. non-posix). */
|
|
||||||
currentUid?: () => number;
|
|
||||||
/** The expected proxy executable path (null when it can't be resolved). */
|
|
||||||
expectedExe?: () => string | null;
|
|
||||||
/** Canonicalize a path (resolve symlinks); null when it can't be resolved. */
|
|
||||||
canonicalize?: (p: string) => string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve a path through symlinks to its canonical form; null on any failure. */
|
|
||||||
function defaultCanonicalize(p: string): string | null {
|
|
||||||
try {
|
|
||||||
return realpathSync(p);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Identify the process listening on the proxy port via `ss` + `/proc`. Every
|
|
||||||
* failure path returns null so the caller fails closed. Reads no credential
|
|
||||||
* material — only pid/uid/exe path of the listener.
|
|
||||||
*/
|
|
||||||
function defaultIdentifyListener(port: number = CLAUDEX_PROXY_PORT): ListenerIdentity | null {
|
|
||||||
try {
|
|
||||||
const out = execFileSync('ss', ['-H', '-ltnp', `sport = :${port}`], { encoding: 'utf8' });
|
|
||||||
const pidMatch = /pid=(\d+)/.exec(out);
|
|
||||||
if (!pidMatch) return null;
|
|
||||||
const pid = Number(pidMatch[1]);
|
|
||||||
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
||||||
|
|
||||||
const status = readFileSync(`/proc/${pid}/status`, 'utf8');
|
|
||||||
const uidLine = /^Uid:\s*(\d+)/m.exec(status);
|
|
||||||
if (!uidLine) return null;
|
|
||||||
const uid = Number(uidLine[1]);
|
|
||||||
|
|
||||||
let exePath: string | null = null;
|
|
||||||
try {
|
|
||||||
exePath = readlinkSync(`/proc/${pid}/exe`);
|
|
||||||
} catch {
|
|
||||||
exePath = null;
|
|
||||||
}
|
|
||||||
return { pid, uid, exePath };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify that the process owning :18765 is genuinely OUR proxy before trusting
|
|
||||||
* it. The proxy binds loopback with NO client authentication, so on a shared
|
|
||||||
* host any local process could squat the port and a liveness 2xx alone does not
|
|
||||||
* prove identity (CWE-345). We FAIL CLOSED (`unknown`) whenever identity cannot
|
|
||||||
* be established. This needs no upstream shared-secret/unix-socket support from
|
|
||||||
* `claude-code-proxy`.
|
|
||||||
*
|
|
||||||
* The executable path is the trust boundary that matters: on a shared-uid host
|
|
||||||
* (every agent session runs as the same operator) same-uid is NOT sufficient, so
|
|
||||||
* we require an EXACT canonical-path match against our resolved proxy binary and
|
|
||||||
* canonicalize both sides for symlinks. There is deliberately NO basename
|
|
||||||
* fallback — a same-uid process running `/tmp/claude-code-proxy` (right name,
|
|
||||||
* wrong path) must never be trusted. If our own binary path can't be resolved,
|
|
||||||
* or either path can't be canonicalized, we fail closed rather than downgrade to
|
|
||||||
* a weaker check.
|
|
||||||
*/
|
|
||||||
export function verifyListenerIdentity(deps: VerifyListenerDeps = {}): ListenerVerdict {
|
|
||||||
const identify = deps.identify ?? (() => defaultIdentifyListener());
|
|
||||||
const currentUid =
|
|
||||||
deps.currentUid ?? (() => (typeof process.getuid === 'function' ? process.getuid() : -1));
|
|
||||||
const expectedExe = deps.expectedExe ?? (() => checkProxyBinary().path);
|
|
||||||
const canonicalize = deps.canonicalize ?? defaultCanonicalize;
|
|
||||||
|
|
||||||
const id = identify();
|
|
||||||
if (!id) return 'unknown'; // can't see the listener → don't trust it
|
|
||||||
const uid = currentUid();
|
|
||||||
if (uid < 0) return 'unknown'; // can't establish our own identity → fail closed
|
|
||||||
if (id.uid !== uid) return 'foreign-user'; // someone else's process holds the port
|
|
||||||
if (!id.exePath) return 'unknown'; // can't confirm the executable → fail closed
|
|
||||||
|
|
||||||
const expected = expectedExe();
|
|
||||||
if (!expected) return 'unknown'; // can't resolve our own binary → fail closed
|
|
||||||
const expectedReal = canonicalize(expected);
|
|
||||||
const actualReal = canonicalize(id.exePath);
|
|
||||||
if (!expectedReal || !actualReal) return 'unknown'; // uncanonicalizable → fail closed
|
|
||||||
return actualReal === expectedReal ? 'ok' : 'wrong-exe';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── systemd user unit ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function systemdUnitPath(home: string = homedir()): string {
|
|
||||||
return join(home, '.config', 'systemd', 'user', CLAUDEX_SYSTEMD_UNIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a path destined for a systemd `ExecStart=` line. A raw newline (or
|
|
||||||
* other control character) in the path would let an attacker inject arbitrary
|
|
||||||
* unit directives (e.g. an extra `ExecStartPost=`), a CWE-74 command injection.
|
|
||||||
* We require a plain absolute path and reject any control character outright.
|
|
||||||
*/
|
|
||||||
function validateExecPath(binaryPath: string): string {
|
|
||||||
if (typeof binaryPath !== 'string' || binaryPath.length === 0) {
|
|
||||||
throw new Error('systemd ExecStart: binary path is empty');
|
|
||||||
}
|
|
||||||
if (!binaryPath.startsWith('/')) {
|
|
||||||
throw new Error(
|
|
||||||
`systemd ExecStart: binary path must be absolute: ${JSON.stringify(binaryPath)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/[\x00-\x1f\x7f]/.test(binaryPath)) {
|
|
||||||
throw new Error('systemd ExecStart: binary path contains control characters');
|
|
||||||
}
|
|
||||||
return binaryPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode a validated path for a systemd `ExecStart=` token. systemd only needs
|
|
||||||
* quoting when the token carries whitespace or quote/backslash characters; a
|
|
||||||
* clean path is emitted verbatim. When quoting, we escape backslashes and double
|
|
||||||
* quotes per systemd's C-style rules so the token cannot be terminated early.
|
|
||||||
*/
|
|
||||||
function systemdQuoteExec(path: string): string {
|
|
||||||
if (!/[\s"'\\]/.test(path)) {
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
const escaped = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
||||||
return `"${escaped}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the `claude-code-proxy.service` user unit. Contains no credential
|
|
||||||
* material — the proxy reads its own OAuth token from its config dir at runtime.
|
|
||||||
* The binary path is validated (absolute, no control characters) and systemd-
|
|
||||||
* quoted so it cannot inject unit directives.
|
|
||||||
*/
|
|
||||||
export function buildSystemdUnitContent(binaryPath: string): string {
|
|
||||||
const exec = `${systemdQuoteExec(validateExecPath(binaryPath))} ${buildServeArgs().join(' ')}`;
|
|
||||||
return [
|
|
||||||
'[Unit]',
|
|
||||||
'Description=claude-code-proxy (Anthropic->Codex translation proxy for mosaic claudex)',
|
|
||||||
'After=network-online.target',
|
|
||||||
'Wants=network-online.target',
|
|
||||||
'',
|
|
||||||
'[Service]',
|
|
||||||
'Type=simple',
|
|
||||||
`ExecStart=${exec}`,
|
|
||||||
'Restart=on-failure',
|
|
||||||
'RestartSec=2',
|
|
||||||
'',
|
|
||||||
'[Install]',
|
|
||||||
'WantedBy=default.target',
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write the user unit and reload the systemd --user daemon. Returns false when
|
|
||||||
* systemd --user is unavailable (the caller then falls back to nohup).
|
|
||||||
*/
|
|
||||||
export function installSystemdUnit(
|
|
||||||
binaryPath: string,
|
|
||||||
deps: {
|
|
||||||
home?: string;
|
|
||||||
writeUnit?: (path: string, content: string) => void;
|
|
||||||
run?: CommandRunner;
|
|
||||||
} = {},
|
|
||||||
): boolean {
|
|
||||||
const home = deps.home ?? homedir();
|
|
||||||
const write =
|
|
||||||
deps.writeUnit ??
|
|
||||||
((path: string, content: string) => {
|
|
||||||
mkdirSync(dirname(path), { recursive: true });
|
|
||||||
writeFileSync(path, content);
|
|
||||||
});
|
|
||||||
const run = deps.run ?? defaultRun;
|
|
||||||
|
|
||||||
try {
|
|
||||||
write(systemdUnitPath(home), buildSystemdUnitContent(binaryPath));
|
|
||||||
const reload = run('systemctl', ['--user', 'daemon-reload']);
|
|
||||||
return reload.status === 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Preflight report ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface PreflightReport {
|
|
||||||
binaryPresent: boolean;
|
|
||||||
binaryPath: string | null;
|
|
||||||
auth: AuthStatus;
|
|
||||||
live: boolean;
|
|
||||||
/** OS-level identity verdict for the :18765 listener (`unknown` when dead). */
|
|
||||||
listenerVerdict: ListenerVerdict;
|
|
||||||
needsReauth: boolean;
|
|
||||||
ok: boolean;
|
|
||||||
problems: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PreflightDeps {
|
|
||||||
checkBinary?: () => { present: boolean; path: string | null };
|
|
||||||
checkAuth?: () => AuthStatus;
|
|
||||||
probe?: () => Promise<boolean>;
|
|
||||||
verifyListener?: () => ListenerVerdict;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compose the preflight checks into a single structured report. `ok` is true
|
|
||||||
* only when the binary is present, OAuth is valid, the proxy responds, AND the
|
|
||||||
* responding listener's OS-level identity verifies as our proxy.
|
|
||||||
*
|
|
||||||
* The identity gate lives here too, not only in {@link ensureProxyRunning}: any
|
|
||||||
* consumer of this report (notably the phase-2 launch path) would otherwise
|
|
||||||
* treat a `/healthz`-2xx squatter as healthy and route Claude traffic to it
|
|
||||||
* (CWE-345). A liveness 2xx is necessary but not sufficient — a live responder
|
|
||||||
* that fails identity fails the preflight.
|
|
||||||
*/
|
|
||||||
export async function runProxyPreflight(deps: PreflightDeps = {}): Promise<PreflightReport> {
|
|
||||||
const checkBinary = deps.checkBinary ?? (() => checkProxyBinary());
|
|
||||||
const checkAuth = deps.checkAuth ?? (() => checkAuthStatus());
|
|
||||||
const probe = deps.probe ?? (() => probeLiveness());
|
|
||||||
const verifyListener = deps.verifyListener ?? (() => verifyListenerIdentity());
|
|
||||||
|
|
||||||
const bin = checkBinary();
|
|
||||||
const auth = checkAuth();
|
|
||||||
const live = await probe();
|
|
||||||
// Only meaningful when something is actually responding; a dead port has no
|
|
||||||
// listener identity to establish.
|
|
||||||
const listenerVerdict: ListenerVerdict = live ? verifyListener() : 'unknown';
|
|
||||||
|
|
||||||
const problems: string[] = [];
|
|
||||||
if (!bin.present) {
|
|
||||||
problems.push(
|
|
||||||
`claude-code-proxy binary not found in PATH. Install it before launching claudex.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const needsReauth = auth.state === 'expired' || auth.state === 'unauthenticated';
|
|
||||||
if (needsReauth) {
|
|
||||||
problems.push(
|
|
||||||
`claude-code-proxy OAuth is ${auth.state}. Re-auth with: ${CLAUDEX_PROXY_BINARY} ${buildDeviceAuthArgs().join(' ')}`,
|
|
||||||
);
|
|
||||||
} else if (auth.state === 'unknown') {
|
|
||||||
problems.push('Could not determine claude-code-proxy OAuth status.');
|
|
||||||
}
|
|
||||||
if (!live) {
|
|
||||||
problems.push(`No proxy responding on ${CLAUDEX_PROXY_URL}.`);
|
|
||||||
} else if (listenerVerdict !== 'ok') {
|
|
||||||
// Non-sensitive: names the port and the verdict only — never any listener
|
|
||||||
// command line, token, or other process detail.
|
|
||||||
problems.push(
|
|
||||||
`A process is listening on ${CLAUDEX_PROXY_URL} but its identity could not be verified as ${CLAUDEX_PROXY_BINARY} (${listenerVerdict}). Refusing to trust it.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = bin.present && auth.state === 'valid' && live && listenerVerdict === 'ok';
|
|
||||||
return {
|
|
||||||
binaryPresent: bin.present,
|
|
||||||
binaryPath: bin.path,
|
|
||||||
auth,
|
|
||||||
live,
|
|
||||||
listenerVerdict,
|
|
||||||
needsReauth,
|
|
||||||
ok,
|
|
||||||
problems,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Lifecycle: ensure the proxy is running ──────────────────────────────────
|
|
||||||
|
|
||||||
export type ProxyStartMethod = 'already' | 'systemd' | 'nohup' | 'untrusted' | 'failed';
|
|
||||||
|
|
||||||
export interface EnsureProxyResult {
|
|
||||||
live: boolean;
|
|
||||||
method: ProxyStartMethod;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimal spawned-child shape used by the nohup fallback (testable seam). */
|
|
||||||
export interface SpawnedChild {
|
|
||||||
once(event: string, listener: (arg?: unknown) => void): unknown;
|
|
||||||
unref(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Spawn shape for the detached fallback process. */
|
|
||||||
export type SpawnLike = (
|
|
||||||
cmd: string,
|
|
||||||
args: string[],
|
|
||||||
opts: { detached: boolean; stdio: 'ignore' },
|
|
||||||
) => SpawnedChild;
|
|
||||||
|
|
||||||
export interface StartNohupDeps {
|
|
||||||
resolveBin?: () => string;
|
|
||||||
spawnImpl?: SpawnLike;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the proxy as a detached background process (the fallback when no systemd
|
|
||||||
* user unit is available).
|
|
||||||
*
|
|
||||||
* `spawn()` reports launch failures (ENOENT/EACCES) ASYNCHRONOUSLY via the
|
|
||||||
* child's `error` event, which a `try/catch` cannot see. If left unhandled that
|
|
||||||
* event throws and crashes the launcher. So we: (1) attach the `error` listener
|
|
||||||
* BEFORE `unref()`, capturing a failed launch as a non-zero result instead of a
|
|
||||||
* crash; and (2) resolve success only after the child's `spawn` event fires —
|
|
||||||
* never optimistically before the process is known to have started.
|
|
||||||
*/
|
|
||||||
export function startNohupProxy(deps: StartNohupDeps = {}): Promise<ProxyRunResult> {
|
|
||||||
const resolveBin = deps.resolveBin ?? (() => checkProxyBinary().path ?? CLAUDEX_PROXY_BINARY);
|
|
||||||
const spawnImpl =
|
|
||||||
deps.spawnImpl ?? ((cmd, args, opts) => spawn(cmd, args, opts) as unknown as SpawnedChild);
|
|
||||||
|
|
||||||
return new Promise<ProxyRunResult>((resolve) => {
|
|
||||||
let settled = false;
|
|
||||||
const finish = (r: ProxyRunResult) => {
|
|
||||||
if (!settled) {
|
|
||||||
settled = true;
|
|
||||||
resolve(r);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let child: SpawnedChild;
|
|
||||||
try {
|
|
||||||
child = spawnImpl(resolveBin(), buildServeArgs(), { detached: true, stdio: 'ignore' });
|
|
||||||
} catch (err) {
|
|
||||||
finish({ status: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register error handling BEFORE unref so an async spawn failure is caught.
|
|
||||||
child.once('error', (err) => {
|
|
||||||
finish({
|
|
||||||
status: 1,
|
|
||||||
stdout: '',
|
|
||||||
stderr: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
child.once('spawn', () => {
|
|
||||||
child.unref();
|
|
||||||
finish({ status: 0, stdout: '', stderr: '' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnsureProxyDeps {
|
|
||||||
probe?: () => Promise<boolean>;
|
|
||||||
/** OS-level identity check for the process holding the proxy port. */
|
|
||||||
verifyListener?: () => ListenerVerdict;
|
|
||||||
startSystemd?: () => ProxyRunResult;
|
|
||||||
startNohup?: () => Promise<ProxyRunResult>;
|
|
||||||
waitMs?: (ms: number) => Promise<void>;
|
|
||||||
/** Interval between liveness polls while waiting for a start to bind. */
|
|
||||||
settleMs?: number;
|
|
||||||
/** Total budget to wait for a started proxy to bind its socket. */
|
|
||||||
startupDeadlineMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultWait(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultStartSystemd(): ProxyRunResult {
|
|
||||||
return defaultRun('systemctl', ['--user', 'start', CLAUDEX_SYSTEMD_UNIT]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Poll for a TRUSTED-live proxy up to a bounded startup deadline. A start command
|
|
||||||
* returning 0 only means the job was ACCEPTED, not that the socket is bound — so
|
|
||||||
* we keep probing at `intervalMs` until either the deadline elapses or the port
|
|
||||||
* both responds AND passes the OS-level identity check. Liveness alone is not
|
|
||||||
* enough: a responder that fails identity (a squatter) must never be trusted.
|
|
||||||
*/
|
|
||||||
async function waitForTrusted(
|
|
||||||
probe: () => Promise<boolean>,
|
|
||||||
verifyListener: () => ListenerVerdict,
|
|
||||||
waitMs: (ms: number) => Promise<void>,
|
|
||||||
intervalMs: number,
|
|
||||||
deadlineMs: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
let elapsed = 0;
|
|
||||||
while (elapsed < deadlineMs) {
|
|
||||||
await waitMs(intervalMs);
|
|
||||||
elapsed += intervalMs;
|
|
||||||
if ((await probe()) && verifyListener() === 'ok') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure a proxy is listening. No-op when already live. Otherwise prefer the
|
|
||||||
* systemd user unit, then fall back to a detached background process.
|
|
||||||
*
|
|
||||||
* Every trust point is gated on OS-level listener identity, not just liveness:
|
|
||||||
* the proxy has no client authentication, so on a shared host a local process
|
|
||||||
* could squat :18765 and a 2xx `/healthz` alone would not prove it is our proxy
|
|
||||||
* (CWE-345, finding #2). We only trust a responder whose owning process is the
|
|
||||||
* current uid running the expected proxy binary; otherwise we fail closed.
|
|
||||||
*
|
|
||||||
* If a responder is already present but its identity does NOT verify, we return
|
|
||||||
* `untrusted` WITHOUT starting anything — the port is taken, so spawning would
|
|
||||||
* only create contention, and we must never route Claude traffic through an
|
|
||||||
* unverified listener.
|
|
||||||
*
|
|
||||||
* After a start command is accepted we poll to a bounded startup deadline before
|
|
||||||
* giving up: `systemctl start` exit 0 means the job was accepted, not that the
|
|
||||||
* socket bound within one probe interval. Critically, once systemd ACCEPTS the
|
|
||||||
* job we do NOT fall back to nohup even if it never becomes trusted-live in the
|
|
||||||
* deadline (finding #1): the accepted unit may bind late or be restarted by
|
|
||||||
* systemd, and a second proxy would then contend for :18765 — the very
|
|
||||||
* duplicate-proxy outcome this function exists to prevent. nohup is reachable
|
|
||||||
* only when systemd never accepted the job at all.
|
|
||||||
*/
|
|
||||||
export async function ensureProxyRunning(deps: EnsureProxyDeps = {}): Promise<EnsureProxyResult> {
|
|
||||||
const probe = deps.probe ?? (() => probeLiveness());
|
|
||||||
const verifyListener = deps.verifyListener ?? (() => verifyListenerIdentity());
|
|
||||||
const startSystemd = deps.startSystemd ?? defaultStartSystemd;
|
|
||||||
const startNohup = deps.startNohup ?? (() => startNohupProxy());
|
|
||||||
const waitMs = deps.waitMs ?? defaultWait;
|
|
||||||
const settleMs = deps.settleMs ?? 500;
|
|
||||||
const startupDeadlineMs = deps.startupDeadlineMs ?? 5000;
|
|
||||||
|
|
||||||
if (await probe()) {
|
|
||||||
// Something answers on :18765 — trust it ONLY if it is provably our proxy.
|
|
||||||
return verifyListener() === 'ok'
|
|
||||||
? { live: true, method: 'already' }
|
|
||||||
: { live: false, method: 'untrusted' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemd = startSystemd();
|
|
||||||
if (systemd.status === 0) {
|
|
||||||
// systemd accepted the job. Wait for a trusted-live bind, but never fall
|
|
||||||
// back to nohup afterward — that would risk a duplicate proxy (finding #1).
|
|
||||||
if (await waitForTrusted(probe, verifyListener, waitMs, settleMs, startupDeadlineMs)) {
|
|
||||||
return { live: true, method: 'systemd' };
|
|
||||||
}
|
|
||||||
return { live: false, method: 'failed' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const nohup = await startNohup();
|
|
||||||
if (nohup.status === 0) {
|
|
||||||
if (await waitForTrusted(probe, verifyListener, waitMs, settleMs, startupDeadlineMs)) {
|
|
||||||
return { live: true, method: 'nohup' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { live: false, method: 'failed' };
|
|
||||||
}
|
|
||||||
@@ -1,732 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import { mkdtempSync, mkdirSync, symlinkSync, rmSync, lstatSync, writeFileSync } from 'node:fs';
|
|
||||||
import { tmpdir, homedir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import {
|
|
||||||
CLAUDEX_CONFIG_DIR_ENV,
|
|
||||||
CLAUDEX_DEFAULT_PRIMARY_MODEL,
|
|
||||||
CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
|
|
||||||
CLAUDEX_CREDENTIAL_ENV_RE,
|
|
||||||
defaultClaudexConfigDir,
|
|
||||||
assertIsolatedConfigDir,
|
|
||||||
resolveClaudexConfigDir,
|
|
||||||
resolveClaudexModels,
|
|
||||||
buildClaudexEnv,
|
|
||||||
buildClaudexBanner,
|
|
||||||
buildClaudexContractNote,
|
|
||||||
runClaudexProxyGate,
|
|
||||||
launchClaudex,
|
|
||||||
type ClaudexHarnessAdapter,
|
|
||||||
} from './claudex.js';
|
|
||||||
import { CLAUDEX_PROXY_URL, type PreflightReport } from './claudex-proxy.js';
|
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function makeReport(overrides: Partial<PreflightReport> = {}): PreflightReport {
|
|
||||||
return {
|
|
||||||
binaryPresent: true,
|
|
||||||
binaryPath: '/usr/bin/claude-code-proxy',
|
|
||||||
auth: { state: 'valid' },
|
|
||||||
live: true,
|
|
||||||
listenerVerdict: 'ok',
|
|
||||||
needsReauth: false,
|
|
||||||
ok: true,
|
|
||||||
problems: [],
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function okAdapter(overrides: Partial<ClaudexHarnessAdapter> = {}): ClaudexHarnessAdapter {
|
|
||||||
return {
|
|
||||||
harnessPreflight: () => {},
|
|
||||||
composePrompt: () => '# Composed Claude contract',
|
|
||||||
exec: () => {},
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Identity canonicalizer + no-op FS deps so config-dir logic is tested purely.
|
|
||||||
const idCanon = (p: string): string => p;
|
|
||||||
const noFsDeps = { canonicalize: idCanon, mkdir: () => {}, isSymlink: () => false };
|
|
||||||
|
|
||||||
// ─── isolated config dir (HARD SECURITY REQ 1 — provable isolation) ───────────
|
|
||||||
|
|
||||||
describe('defaultClaudexConfigDir', () => {
|
|
||||||
it('is namespaced under the mosaic home, never ~/.claude', () => {
|
|
||||||
const dir = defaultClaudexConfigDir('/home/agent/.config/mosaic');
|
|
||||||
expect(dir).toBe(join('/home/agent/.config/mosaic', 'claudex', 'home'));
|
|
||||||
expect(dir).not.toBe(join(homedir(), '.claude'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('assertIsolatedConfigDir — the isolation guard is provable', () => {
|
|
||||||
const realClaude = '/home/agent/.claude';
|
|
||||||
|
|
||||||
it('accepts a dir that does not resolve to ~/.claude', () => {
|
|
||||||
const safe = '/home/agent/.config/mosaic/claudex/home';
|
|
||||||
expect(
|
|
||||||
assertIsolatedConfigDir(safe, { realClaudeDir: realClaude, canonicalize: idCanon }),
|
|
||||||
).toBe(safe);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('REJECTS a candidate that is literally ~/.claude', () => {
|
|
||||||
expect(() =>
|
|
||||||
assertIsolatedConfigDir(realClaude, { realClaudeDir: realClaude, canonicalize: idCanon }),
|
|
||||||
).toThrow(/refusing/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('REJECTS a descendant of ~/.claude (would pollute the real tree)', () => {
|
|
||||||
expect(() =>
|
|
||||||
assertIsolatedConfigDir('/home/agent/.claude/projects/x', {
|
|
||||||
realClaudeDir: realClaude,
|
|
||||||
canonicalize: idCanon,
|
|
||||||
}),
|
|
||||||
).toThrow(/refusing/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('REJECTS a candidate that canonically resolves to ~/.claude (symlink, both sides canonicalized)', () => {
|
|
||||||
const canon = (p: string): string => (p === '/home/agent/link' ? realClaude : p);
|
|
||||||
expect(() =>
|
|
||||||
assertIsolatedConfigDir('/home/agent/link', {
|
|
||||||
realClaudeDir: realClaude,
|
|
||||||
canonicalize: canon,
|
|
||||||
}),
|
|
||||||
).toThrow(/refusing/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('canonicalizes the ~/.claude side too (real dir itself may be a symlink)', () => {
|
|
||||||
// realClaudeDir is a symlink whose canonical target equals the candidate's target.
|
|
||||||
const canon = (p: string): string =>
|
|
||||||
p === '/home/agent/.claude' || p === '/home/agent/link' ? '/canonical/claude' : p;
|
|
||||||
expect(() =>
|
|
||||||
assertIsolatedConfigDir('/home/agent/link', {
|
|
||||||
realClaudeDir: realClaude,
|
|
||||||
canonicalize: canon,
|
|
||||||
}),
|
|
||||||
).toThrow(/refusing/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('REJECTS an empty or whitespace candidate (fail closed)', () => {
|
|
||||||
expect(() =>
|
|
||||||
assertIsolatedConfigDir('', { realClaudeDir: realClaude, canonicalize: idCanon }),
|
|
||||||
).toThrow();
|
|
||||||
expect(() =>
|
|
||||||
assertIsolatedConfigDir(' ', { realClaudeDir: realClaude, canonicalize: idCanon }),
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('REJECTS a relative candidate (must be absolute)', () => {
|
|
||||||
expect(() =>
|
|
||||||
assertIsolatedConfigDir('relative/dir', { realClaudeDir: realClaude, canonicalize: idCanon }),
|
|
||||||
).toThrow(/absolute/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolveClaudexConfigDir', () => {
|
|
||||||
it('uses the namespaced default and never the ambient CLAUDE_CONFIG_DIR', () => {
|
|
||||||
// Ambient CLAUDE_CONFIG_DIR is deliberately ignored (it could be ~/.claude).
|
|
||||||
const env = { CLAUDE_CONFIG_DIR: join(homedir(), '.claude') };
|
|
||||||
const dir = resolveClaudexConfigDir(env, {
|
|
||||||
mosaicHome: '/home/agent/.config/mosaic',
|
|
||||||
realClaudeDir: '/home/agent/.claude',
|
|
||||||
...noFsDeps,
|
|
||||||
});
|
|
||||||
expect(dir).toBe(join('/home/agent/.config/mosaic', 'claudex', 'home'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('honors the dedicated override env when it is safe', () => {
|
|
||||||
const env = { [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/custom-claudex' };
|
|
||||||
const dir = resolveClaudexConfigDir(env, {
|
|
||||||
mosaicHome: '/home/agent/.config/mosaic',
|
|
||||||
realClaudeDir: '/home/agent/.claude',
|
|
||||||
...noFsDeps,
|
|
||||||
});
|
|
||||||
expect(dir).toBe('/home/agent/custom-claudex');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('REJECTS a dedicated override that points at ~/.claude (before creating anything)', () => {
|
|
||||||
const mkdir = vi.fn();
|
|
||||||
const env = { [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/.claude' };
|
|
||||||
expect(() =>
|
|
||||||
resolveClaudexConfigDir(env, {
|
|
||||||
mosaicHome: '/home/agent/.config/mosaic',
|
|
||||||
realClaudeDir: '/home/agent/.claude',
|
|
||||||
canonicalize: idCanon,
|
|
||||||
mkdir,
|
|
||||||
isSymlink: () => false,
|
|
||||||
}),
|
|
||||||
).toThrow(/refusing/i);
|
|
||||||
expect(mkdir).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('TOCTOU: REJECTS when the created target is itself a symlink (pre-created race)', () => {
|
|
||||||
const env = {};
|
|
||||||
expect(() =>
|
|
||||||
resolveClaudexConfigDir(env, {
|
|
||||||
mosaicHome: '/home/agent/.config/mosaic',
|
|
||||||
realClaudeDir: '/home/agent/.claude',
|
|
||||||
canonicalize: idCanon,
|
|
||||||
mkdir: () => {},
|
|
||||||
isSymlink: () => true, // the just-ensured dir is a symlink → fail closed
|
|
||||||
}),
|
|
||||||
).toThrow(/refusing|symlink/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('real-FS: creates the isolated dir 0700 and returns its canonical path', () => {
|
|
||||||
const root = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
|
|
||||||
try {
|
|
||||||
const mosaicHome = join(root, '.config', 'mosaic');
|
|
||||||
const dir = resolveClaudexConfigDir({}, { mosaicHome, realClaudeDir: join(root, '.claude') });
|
|
||||||
expect(dir).toBe(join(mosaicHome, 'claudex', 'home'));
|
|
||||||
const st = lstatSync(dir);
|
|
||||||
expect(st.isDirectory()).toBe(true);
|
|
||||||
// 0700 (owner-only) — mask off the type bits.
|
|
||||||
expect(st.mode & 0o777).toBe(0o700);
|
|
||||||
} finally {
|
|
||||||
rmSync(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('real-FS: catches an override whose ancestor symlinks into ~/.claude', () => {
|
|
||||||
const root = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
|
|
||||||
try {
|
|
||||||
const realClaudeDir = join(root, 'dot-claude');
|
|
||||||
mkdirSync(realClaudeDir, { recursive: true });
|
|
||||||
const link = join(root, 'link'); // link -> dot-claude
|
|
||||||
symlinkSync(realClaudeDir, link, 'dir');
|
|
||||||
const override = join(link, 'sub'); // resolves under ~/.claude
|
|
||||||
expect(() =>
|
|
||||||
resolveClaudexConfigDir(
|
|
||||||
{ [CLAUDEX_CONFIG_DIR_ENV]: override },
|
|
||||||
{ mosaicHome: join(root, '.config', 'mosaic'), realClaudeDir },
|
|
||||||
),
|
|
||||||
).toThrow(/refusing/i);
|
|
||||||
} finally {
|
|
||||||
rmSync(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FAIL CLOSED: default canonicalizer rethrows a non-ENOENT error (ELOOP) instead of a literal fallback', () => {
|
|
||||||
// A symlink loop makes realpathSync throw ELOOP. The guard must NOT swallow
|
|
||||||
// it as "does not exist yet, keep walking up" and return a literal path —
|
|
||||||
// it must fail closed. (REQ 1: fails CLOSED on any uncertainty.)
|
|
||||||
const root = mkdtempSync(join(tmpdir(), 'claudex-loop-'));
|
|
||||||
try {
|
|
||||||
const a = join(root, 'a');
|
|
||||||
const b = join(root, 'b');
|
|
||||||
symlinkSync(b, a, 'dir'); // a -> b
|
|
||||||
symlinkSync(a, b, 'dir'); // b -> a (loop)
|
|
||||||
const looped = join(a, 'home'); // canonicalizing this hits ELOOP
|
|
||||||
// No canonicalize dep → the real defaultCanonicalizeIntended runs.
|
|
||||||
expect(() =>
|
|
||||||
assertIsolatedConfigDir(looped, { realClaudeDir: join(root, '.claude') }),
|
|
||||||
).toThrow();
|
|
||||||
} finally {
|
|
||||||
rmSync(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FAIL CLOSED: default isSymlink rethrows a non-ENOENT error (ENOTDIR) rather than reporting "not a symlink"', () => {
|
|
||||||
// A candidate whose parent is a regular FILE makes lstat throw ENOTDIR.
|
|
||||||
// The post-create symlink check must fail closed, not treat it as safe.
|
|
||||||
const root = mkdtempSync(join(tmpdir(), 'claudex-notdir-'));
|
|
||||||
try {
|
|
||||||
const file = join(root, 'afile');
|
|
||||||
writeFileSync(file, 'x');
|
|
||||||
const candidate = join(file, 'child'); // parent is a file → ENOTDIR on lstat
|
|
||||||
expect(() =>
|
|
||||||
// Bypass the guard/mkdir side-effects; only the default isSymlink runs live.
|
|
||||||
resolveClaudexConfigDir(
|
|
||||||
{ [CLAUDEX_CONFIG_DIR_ENV]: candidate },
|
|
||||||
{
|
|
||||||
realClaudeDir: join(root, '.claude'),
|
|
||||||
canonicalize: idCanon,
|
|
||||||
mkdir: () => {},
|
|
||||||
// isSymlink omitted → real defaultIsSymlink runs on the ENOTDIR path.
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).toThrow();
|
|
||||||
} finally {
|
|
||||||
rmSync(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FAIL CLOSED: an injected canonicalize throwing EACCES is not swallowed', () => {
|
|
||||||
const eacces = Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
|
||||||
expect(() =>
|
|
||||||
resolveClaudexConfigDir(
|
|
||||||
{ [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/custom-claudex' },
|
|
||||||
{
|
|
||||||
realClaudeDir: '/home/agent/.claude',
|
|
||||||
canonicalize: () => {
|
|
||||||
throw eacces;
|
|
||||||
},
|
|
||||||
mkdir: () => {},
|
|
||||||
isSymlink: () => false,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).toThrow(/permission denied/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── model-tier map (P3) ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('resolveClaudexModels', () => {
|
|
||||||
it('defaults primary=sol / smallFast=luna', () => {
|
|
||||||
expect(resolveClaudexModels({})).toEqual({
|
|
||||||
primary: CLAUDEX_DEFAULT_PRIMARY_MODEL,
|
|
||||||
smallFast: CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
|
|
||||||
});
|
|
||||||
expect(CLAUDEX_DEFAULT_PRIMARY_MODEL).toBe('gpt-5.6-sol');
|
|
||||||
expect(CLAUDEX_DEFAULT_SMALL_FAST_MODEL).toBe('gpt-5.6-luna');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('env-provided values WIN over defaults', () => {
|
|
||||||
expect(
|
|
||||||
resolveClaudexModels({ ANTHROPIC_MODEL: 'gpt-x', ANTHROPIC_SMALL_FAST_MODEL: 'gpt-y' }),
|
|
||||||
).toEqual({ primary: 'gpt-x', smallFast: 'gpt-y' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores blank env values (falls back to defaults)', () => {
|
|
||||||
expect(
|
|
||||||
resolveClaudexModels({ ANTHROPIC_MODEL: ' ', ANTHROPIC_SMALL_FAST_MODEL: '' }),
|
|
||||||
).toEqual({
|
|
||||||
primary: CLAUDEX_DEFAULT_PRIMARY_MODEL,
|
|
||||||
smallFast: CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── env injection (HARD SECURITY REQ 2 — zero token leakage) ─────────────────
|
|
||||||
|
|
||||||
describe('buildClaudexEnv — zero token leakage', () => {
|
|
||||||
const models = { primary: 'gpt-5.6-sol', smallFast: 'gpt-5.6-luna' };
|
|
||||||
const configDir = '/home/agent/.config/mosaic/claudex/home';
|
|
||||||
|
|
||||||
it('sets only ANTHROPIC_AUTH_TOKEN=unused and points at the loopback proxy', () => {
|
|
||||||
const env = buildClaudexEnv({}, { configDir, models });
|
|
||||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
|
||||||
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
|
|
||||||
expect(env.CLAUDE_CONFIG_DIR).toBe(configDir);
|
|
||||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol');
|
|
||||||
expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('gpt-5.6-luna');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('OVERWRITES an inherited real auth token with the literal "unused"', () => {
|
|
||||||
const env = buildClaudexEnv(
|
|
||||||
{ ANTHROPIC_AUTH_TOKEN: 'sk-ant-realsecret-should-never-flow' },
|
|
||||||
{ configDir, models },
|
|
||||||
);
|
|
||||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DELETES ANTHROPIC_API_KEY so no real Anthropic key reaches the local proxy', () => {
|
|
||||||
const env = buildClaudexEnv(
|
|
||||||
{ ANTHROPIC_API_KEY: 'sk-ant-api03-realkey' },
|
|
||||||
{ configDir, models },
|
|
||||||
);
|
|
||||||
expect('ANTHROPIC_API_KEY' in env).toBe(false);
|
|
||||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sweeps the WHOLE credential-bearing env family (token/api-key/secret/oauth), not just two', () => {
|
|
||||||
const env = buildClaudexEnv(
|
|
||||||
{
|
|
||||||
ANTHROPIC_API_KEY: 'sk-ant-api03-leak',
|
|
||||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-leak',
|
|
||||||
SOME_SERVICE_TOKEN: 'tok-leak',
|
|
||||||
VENDOR_API_KEY: 'key-leak',
|
|
||||||
DB_SECRET: 'secret-leak',
|
|
||||||
HARMLESS: 'kept',
|
|
||||||
PATH: '/usr/bin',
|
|
||||||
},
|
|
||||||
{ configDir, models },
|
|
||||||
);
|
|
||||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
|
||||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
|
||||||
expect(env.SOME_SERVICE_TOKEN).toBeUndefined();
|
|
||||||
expect(env.VENDOR_API_KEY).toBeUndefined();
|
|
||||||
expect(env.DB_SECRET).toBeUndefined();
|
|
||||||
// Non-credential vars the harness needs are preserved.
|
|
||||||
expect(env.HARMLESS).toBe('kept');
|
|
||||||
expect(env.PATH).toBe('/usr/bin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('neutralizes Bedrock/Vertex provider switches so Claude cannot bypass the proxy (REQ 2)', () => {
|
|
||||||
// CLAUDE_CODE_USE_BEDROCK / _USE_VERTEX are ROUTING switches: their mere
|
|
||||||
// presence makes Claude Code route to AWS Bedrock / GCP Vertex against the
|
|
||||||
// ambient cloud credential chain — reaching the real Anthropic API and
|
|
||||||
// bypassing ANTHROPIC_BASE_URL (the loopback proxy) entirely. They MUST be
|
|
||||||
// gone from the composed env regardless of the launching env.
|
|
||||||
const env = buildClaudexEnv(
|
|
||||||
{
|
|
||||||
CLAUDE_CODE_USE_BEDROCK: '1',
|
|
||||||
CLAUDE_CODE_USE_VERTEX: '1',
|
|
||||||
CLAUDE_CODE_SKIP_BEDROCK_AUTH: '1',
|
|
||||||
CLAUDE_CODE_SKIP_VERTEX_AUTH: '1',
|
|
||||||
AWS_ACCESS_KEY_ID: 'AKIAREAL',
|
|
||||||
AWS_SECRET_ACCESS_KEY: 'realsecret',
|
|
||||||
AWS_SESSION_TOKEN: 'realsession',
|
|
||||||
AWS_BEARER_TOKEN_BEDROCK: 'bearer-bedrock-real',
|
|
||||||
AWS_REGION: 'us-east-1',
|
|
||||||
GOOGLE_APPLICATION_CREDENTIALS: '/home/agent/gcp.json',
|
|
||||||
GOOGLE_CLOUD_ACCESS_TOKEN: 'gcp-token-real',
|
|
||||||
PATH: '/usr/bin',
|
|
||||||
},
|
|
||||||
{ configDir, models },
|
|
||||||
);
|
|
||||||
// Routing switches gone by construction.
|
|
||||||
expect('CLAUDE_CODE_USE_BEDROCK' in env).toBe(false);
|
|
||||||
expect('CLAUDE_CODE_USE_VERTEX' in env).toBe(false);
|
|
||||||
expect('CLAUDE_CODE_SKIP_BEDROCK_AUTH' in env).toBe(false);
|
|
||||||
expect('CLAUDE_CODE_SKIP_VERTEX_AUTH' in env).toBe(false);
|
|
||||||
// Cloud credentials swept — none of the Claude-capable creds survive.
|
|
||||||
expect(env.AWS_ACCESS_KEY_ID).toBeUndefined();
|
|
||||||
expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined();
|
|
||||||
expect(env.AWS_SESSION_TOKEN).toBeUndefined();
|
|
||||||
expect(env.AWS_BEARER_TOKEN_BEDROCK).toBeUndefined();
|
|
||||||
expect(env.AWS_REGION).toBeUndefined();
|
|
||||||
expect(env.GOOGLE_APPLICATION_CREDENTIALS).toBeUndefined();
|
|
||||||
expect(env.GOOGLE_CLOUD_ACCESS_TOKEN).toBeUndefined();
|
|
||||||
// The proxy routing is still the only path.
|
|
||||||
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
|
|
||||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
|
||||||
expect(env.PATH).toBe('/usr/bin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes the mid-string _KEY / _SECRET gap (STRIPE_SECRET_KEY, SSH_PRIVATE_KEY)', () => {
|
|
||||||
const env = buildClaudexEnv(
|
|
||||||
{
|
|
||||||
STRIPE_SECRET_KEY: 'sk-live-real',
|
|
||||||
SSH_PRIVATE_KEY: '-----BEGIN OPENSSH PRIVATE KEY-----',
|
|
||||||
HARMLESS: 'kept',
|
|
||||||
},
|
|
||||||
{ configDir, models },
|
|
||||||
);
|
|
||||||
expect(env.STRIPE_SECRET_KEY).toBeUndefined();
|
|
||||||
expect(env.SSH_PRIVATE_KEY).toBeUndefined();
|
|
||||||
expect(env.HARMLESS).toBe('kept');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('no credential-NAMED key in the composed env carries a real-looking value', () => {
|
|
||||||
const env = buildClaudexEnv(
|
|
||||||
{
|
|
||||||
ANTHROPIC_API_KEY: 'sk-ant-api03-leak',
|
|
||||||
ANTHROPIC_AUTH_TOKEN: 'access_token_leak',
|
|
||||||
SOME_JWT_TOKEN: 'eyJhbGciOiJIUzI1NiJ9.payload.sig',
|
|
||||||
REFRESH_SECRET: 'refresh_token_value',
|
|
||||||
},
|
|
||||||
{ configDir, models },
|
|
||||||
);
|
|
||||||
for (const [name, value] of Object.entries(env)) {
|
|
||||||
if (CLAUDEX_CREDENTIAL_ENV_RE.test(name)) {
|
|
||||||
// Any surviving credential-named var must carry only a safe sentinel value.
|
|
||||||
expect(value).not.toMatch(/sk-(ant|proj)-/);
|
|
||||||
expect(value).not.toMatch(/access_token|refresh_token/);
|
|
||||||
expect(value).not.toMatch(/eyJ[A-Za-z0-9_-]+\./); // JWT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('honors a caller-provided baseUrl override (loopback default otherwise)', () => {
|
|
||||||
const env = buildClaudexEnv({}, { configDir, models, baseUrl: 'http://127.0.0.1:9999' });
|
|
||||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9999');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a fresh object without mutating the base env', () => {
|
|
||||||
const base = { EXISTING: 'kept' };
|
|
||||||
const env = buildClaudexEnv(base, { configDir, models });
|
|
||||||
expect(env.EXISTING).toBe('kept');
|
|
||||||
expect(base).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('buildClaudexBanner / buildClaudexContractNote', () => {
|
|
||||||
const models = { primary: 'gpt-5.6-sol', smallFast: 'gpt-5.6-luna' };
|
|
||||||
|
|
||||||
it('banner marks EXPERIMENTAL and names the models + proxy', () => {
|
|
||||||
const banner = buildClaudexBanner(models);
|
|
||||||
expect(banner).toMatch(/EXPERIMENTAL/);
|
|
||||||
expect(banner).toMatch(/gpt-5\.6-sol/);
|
|
||||||
expect(banner).toMatch(/gpt-5\.6-luna/);
|
|
||||||
expect(banner).toMatch(/claude-code-proxy/);
|
|
||||||
expect(banner).not.toMatch(/unused/); // no token material in the banner
|
|
||||||
});
|
|
||||||
|
|
||||||
it('contract note classifies the runtime as EXPERIMENTAL GPT-via-proxy', () => {
|
|
||||||
const note = buildClaudexContractNote(models);
|
|
||||||
expect(note).toMatch(/EXPERIMENTAL/);
|
|
||||||
expect(note).toMatch(/gpt-5\.6-sol/);
|
|
||||||
expect(note).toMatch(/not.*Anthropic/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── proxy gate ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('runClaudexProxyGate', () => {
|
|
||||||
it('is ok when the first preflight already passes', async () => {
|
|
||||||
const preflight = vi.fn().mockResolvedValue(makeReport());
|
|
||||||
const ensureProxy = vi.fn();
|
|
||||||
const reauth = vi.fn();
|
|
||||||
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth });
|
|
||||||
expect(gate.ok).toBe(true);
|
|
||||||
expect(ensureProxy).not.toHaveBeenCalled();
|
|
||||||
expect(reauth).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails fast when the binary is missing (no reauth, no start)', async () => {
|
|
||||||
const preflight = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(
|
|
||||||
makeReport({ binaryPresent: false, ok: false, problems: ['binary not found'] }),
|
|
||||||
);
|
|
||||||
const ensureProxy = vi.fn();
|
|
||||||
const reauth = vi.fn();
|
|
||||||
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth });
|
|
||||||
expect(gate.ok).toBe(false);
|
|
||||||
expect(reauth).not.toHaveBeenCalled();
|
|
||||||
expect(ensureProxy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs device reauth then re-preflights when OAuth needs it', async () => {
|
|
||||||
const preflight = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
makeReport({
|
|
||||||
auth: { state: 'expired' },
|
|
||||||
needsReauth: true,
|
|
||||||
ok: false,
|
|
||||||
problems: ['expired'],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(makeReport());
|
|
||||||
const reauth = vi.fn().mockReturnValue(0);
|
|
||||||
const gate = await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
|
|
||||||
expect(reauth).toHaveBeenCalledTimes(1);
|
|
||||||
expect(preflight).toHaveBeenCalledTimes(2);
|
|
||||||
expect(gate.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT reauth when auth is already valid', async () => {
|
|
||||||
const preflight = vi.fn().mockResolvedValue(makeReport());
|
|
||||||
const reauth = vi.fn();
|
|
||||||
await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
|
|
||||||
expect(reauth).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('aborts when device reauth fails', async () => {
|
|
||||||
const preflight = vi.fn().mockResolvedValue(
|
|
||||||
makeReport({
|
|
||||||
auth: { state: 'unauthenticated' },
|
|
||||||
needsReauth: true,
|
|
||||||
ok: false,
|
|
||||||
problems: ['unauth'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const reauth = vi.fn().mockReturnValue(1);
|
|
||||||
const gate = await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
|
|
||||||
expect(gate.ok).toBe(false);
|
|
||||||
expect(gate.problems.join(' ')).toMatch(/re-auth/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('starts the proxy then re-preflights when nothing is live', async () => {
|
|
||||||
const preflight = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
makeReport({ live: false, listenerVerdict: 'unknown', ok: false, problems: ['dead'] }),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(makeReport());
|
|
||||||
const ensureProxy = vi.fn().mockResolvedValue({ live: true, method: 'systemd' });
|
|
||||||
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth: vi.fn() });
|
|
||||||
expect(ensureProxy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(gate.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('aborts (non-sensitive) when the proxy cannot come up trusted', async () => {
|
|
||||||
const preflight = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(
|
|
||||||
makeReport({ live: false, listenerVerdict: 'unknown', ok: false, problems: ['dead'] }),
|
|
||||||
);
|
|
||||||
const ensureProxy = vi.fn().mockResolvedValue({ live: false, method: 'untrusted' });
|
|
||||||
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth: vi.fn() });
|
|
||||||
expect(gate.ok).toBe(false);
|
|
||||||
expect(gate.problems.join(' ')).toMatch(/untrusted/);
|
|
||||||
// Non-sensitive: no token material in surfaced problems.
|
|
||||||
expect(gate.problems.join(' ')).not.toMatch(/access_token|refresh_token|sk-/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── launch orchestration (fail-closed ordering) ──────────────────────────────
|
|
||||||
|
|
||||||
describe('launchClaudex', () => {
|
|
||||||
const baseDeps = {
|
|
||||||
baseEnv: {},
|
|
||||||
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
|
|
||||||
resolveConfigDir: () => '/home/agent/.config/mosaic/claudex/home',
|
|
||||||
log: () => {},
|
|
||||||
errorLog: () => {},
|
|
||||||
fail: (() => {
|
|
||||||
throw new Error('exit');
|
|
||||||
}) as (code: number) => never,
|
|
||||||
};
|
|
||||||
|
|
||||||
it('yolo=true passes --dangerously-skip-permissions + injected env to claude', async () => {
|
|
||||||
const exec = vi.fn();
|
|
||||||
await launchClaudex(['--print', 'hi'], true, okAdapter({ exec }), baseDeps);
|
|
||||||
expect(exec).toHaveBeenCalledTimes(1);
|
|
||||||
const [cmd, args, env] = exec.mock.calls[0]!;
|
|
||||||
expect(cmd).toBe('claude');
|
|
||||||
expect(args[0]).toBe('--dangerously-skip-permissions');
|
|
||||||
expect(args).toContain('--append-system-prompt');
|
|
||||||
expect(args).toContain('--print');
|
|
||||||
expect(args).toContain('hi');
|
|
||||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
|
||||||
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
|
|
||||||
expect(env.CLAUDE_CONFIG_DIR).toBe('/home/agent/.config/mosaic/claudex/home');
|
|
||||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('non-yolo omits --dangerously-skip-permissions but still injects the proxy env', async () => {
|
|
||||||
const exec = vi.fn();
|
|
||||||
await launchClaudex([], false, okAdapter({ exec }), baseDeps);
|
|
||||||
const [, args, env] = exec.mock.calls[0]!;
|
|
||||||
expect(args).not.toContain('--dangerously-skip-permissions');
|
|
||||||
expect(args[0]).toBe('--append-system-prompt');
|
|
||||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('appends the EXPERIMENTAL contract note to the composed prompt', async () => {
|
|
||||||
const exec = vi.fn();
|
|
||||||
await launchClaudex([], true, okAdapter({ exec, composePrompt: () => '# BASE' }), baseDeps);
|
|
||||||
const args = exec.mock.calls[0]![1] as string[];
|
|
||||||
const promptIdx = args.indexOf('--append-system-prompt') + 1;
|
|
||||||
expect(args[promptIdx]).toContain('# BASE');
|
|
||||||
expect(args[promptIdx]).toMatch(/EXPERIMENTAL/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs the harness preflight BEFORE the proxy gate and exec', async () => {
|
|
||||||
const order: string[] = [];
|
|
||||||
const adapter = okAdapter({
|
|
||||||
harnessPreflight: () => order.push('preflight'),
|
|
||||||
exec: () => order.push('exec'),
|
|
||||||
});
|
|
||||||
await launchClaudex([], true, adapter, {
|
|
||||||
...baseDeps,
|
|
||||||
proxyGate: () => {
|
|
||||||
order.push('gate');
|
|
||||||
return Promise.resolve({ ok: true, report: makeReport(), problems: [] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(order).toEqual(['preflight', 'gate', 'exec']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FAIL CLOSED: exits WITHOUT exec when the proxy gate fails', async () => {
|
|
||||||
const exec = vi.fn();
|
|
||||||
const errors: string[] = [];
|
|
||||||
await expect(
|
|
||||||
launchClaudex([], true, okAdapter({ exec }), {
|
|
||||||
...baseDeps,
|
|
||||||
proxyGate: () =>
|
|
||||||
Promise.resolve({ ok: false, report: makeReport({ ok: false }), problems: ['no proxy'] }),
|
|
||||||
errorLog: (m: string) => errors.push(m),
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('exit');
|
|
||||||
expect(exec).not.toHaveBeenCalled();
|
|
||||||
expect(errors.join('\n')).toMatch(/no proxy/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FAIL CLOSED: exits WITHOUT exec when the config-dir guard throws', async () => {
|
|
||||||
const exec = vi.fn();
|
|
||||||
await expect(
|
|
||||||
launchClaudex([], true, okAdapter({ exec }), {
|
|
||||||
...baseDeps,
|
|
||||||
resolveConfigDir: () => {
|
|
||||||
throw new Error('refusing to use ~/.claude');
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('exit');
|
|
||||||
expect(exec).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FAIL CLOSED: reports a non-Error throw via String() and still aborts', async () => {
|
|
||||||
const exec = vi.fn();
|
|
||||||
const errors: string[] = [];
|
|
||||||
await expect(
|
|
||||||
launchClaudex([], true, okAdapter({ exec }), {
|
|
||||||
...baseDeps,
|
|
||||||
resolveConfigDir: () => {
|
|
||||||
// A non-Error throw exercises the String(err) branch of the catch.
|
|
||||||
throw { toString: () => 'string-shaped failure' };
|
|
||||||
},
|
|
||||||
errorLog: (m: string) => errors.push(m),
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('exit');
|
|
||||||
expect(exec).not.toHaveBeenCalled();
|
|
||||||
expect(errors.join('\n')).toMatch(/string-shaped failure/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── production DI defaults (fallback-branch coverage; no real proxy touched) ──
|
|
||||||
|
|
||||||
describe('production dependency defaults', () => {
|
|
||||||
it('assertIsolatedConfigDir defaults realClaudeDir to ~/.claude', () => {
|
|
||||||
const safe = join(tmpdir(), 'mosaic-claudex-default-real', 'home');
|
|
||||||
// Only canonicalize injected; realClaudeDir falls back to ~/.claude.
|
|
||||||
expect(assertIsolatedConfigDir(safe, { canonicalize: idCanon })).toBe(safe);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('assertIsolatedConfigDir default canonicalizer resolves a non-existent path', () => {
|
|
||||||
// No canonicalize dep → exercises the real realpath-longest-ancestor walk
|
|
||||||
// (including the not-yet-existing tail), on a path safely outside ~/.claude.
|
|
||||||
const safe = join(tmpdir(), 'mosaic-claudex-canon', 'nested', 'home');
|
|
||||||
expect(assertIsolatedConfigDir(safe)).toContain('mosaic-claudex-canon');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolveClaudexConfigDir defaults mosaicHome when not injected', () => {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
|
|
||||||
try {
|
|
||||||
const target = join(dir, 'home');
|
|
||||||
// Override env points elsewhere; mosaicHome dep omitted → MOSAIC_HOME default path is exercised.
|
|
||||||
const out = resolveClaudexConfigDir(
|
|
||||||
{ [CLAUDEX_CONFIG_DIR_ENV]: target },
|
|
||||||
{ canonicalize: idCanon },
|
|
||||||
);
|
|
||||||
expect(out).toBe(target);
|
|
||||||
expect(lstatSync(target).isDirectory()).toBe(true);
|
|
||||||
} finally {
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runClaudexProxyGate defaults ensureProxy/reauth/log without invoking them on a missing binary', async () => {
|
|
||||||
// Only preflight injected; binary missing → returns before the default
|
|
||||||
// ensureProxy/reauth thunks could ever reach the real proxy.
|
|
||||||
const preflight = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(makeReport({ binaryPresent: false, ok: false, problems: ['missing'] }));
|
|
||||||
const gate = await runClaudexProxyGate({ preflight });
|
|
||||||
expect(gate.ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('launchClaudex defaults log/errorLog/fail/baseEnv/models/buildEnv on the success path', async () => {
|
|
||||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
try {
|
|
||||||
const exec = vi.fn();
|
|
||||||
// Inject only the boundaries that would touch the real proxy/FS; let the
|
|
||||||
// rest default. Success path never calls fail/errorLog.
|
|
||||||
await launchClaudex([], true, okAdapter({ exec }), {
|
|
||||||
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
|
|
||||||
resolveConfigDir: () => join(tmpdir(), 'claudex-default-launch'),
|
|
||||||
});
|
|
||||||
expect(exec).toHaveBeenCalledTimes(1);
|
|
||||||
const [, , env] = exec.mock.calls[0]!;
|
|
||||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
|
||||||
expect(env.ANTHROPIC_MODEL).toBe(CLAUDEX_DEFAULT_PRIMARY_MODEL);
|
|
||||||
} finally {
|
|
||||||
logSpy.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
/**
|
|
||||||
* Claudex launch composition (P2–P4 of `mosaic yolo claudex`).
|
|
||||||
*
|
|
||||||
* Builds the isolated launch environment for running GPT models inside the
|
|
||||||
* Claude Code harness via `raine/claude-code-proxy` (ChatGPT-subscription OAuth).
|
|
||||||
* PR-1 (`claudex-proxy.ts`) owns the proxy preflight/lifecycle; this module owns
|
|
||||||
* the *composition* the launcher hands to Claude Code:
|
|
||||||
*
|
|
||||||
* P2 isolated CLAUDE_CONFIG_DIR (provably never the real ~/.claude) + env
|
|
||||||
* injection that leaks ZERO token material;
|
|
||||||
* P3 the model-tier map (primary → gpt-5.6-sol, small/fast → gpt-5.6-luna,
|
|
||||||
* operator env values win);
|
|
||||||
* P4 the EXPERIMENTAL classification banner + composed-contract note.
|
|
||||||
*
|
|
||||||
* Two hard security invariants (secrev-enforced):
|
|
||||||
* REQ 1 — Provable isolation. {@link assertIsolatedConfigDir} makes the
|
|
||||||
* CLAUDE_CONFIG_DIR seam incapable of resolving to `~/.claude` (or any
|
|
||||||
* descendant of it); it canonicalizes both sides, rejects descendants,
|
|
||||||
* and — after ensuring the dir — re-checks and rejects a symlinked
|
|
||||||
* target (TOCTOU). Fails CLOSED on any uncertainty.
|
|
||||||
* REQ 2 — Zero token leakage. This module never reads the proxy's
|
|
||||||
* `auth.json`; {@link buildClaudexEnv} strips the ENTIRE
|
|
||||||
* credential-bearing env family and hands Claude Code only
|
|
||||||
* `ANTHROPIC_AUTH_TOKEN=unused`. The proxy holds the real credential.
|
|
||||||
*
|
|
||||||
* Every side-effecting boundary is dependency-injected so the launch path is
|
|
||||||
* unit-testable without spawning Claude Code or touching a real config dir.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { lstatSync, mkdirSync, realpathSync } from 'node:fs';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
||||||
import {
|
|
||||||
CLAUDEX_PROXY_URL,
|
|
||||||
ensureProxyRunning,
|
|
||||||
runDeviceReauth,
|
|
||||||
runProxyPreflight,
|
|
||||||
type EnsureProxyResult,
|
|
||||||
type PreflightReport,
|
|
||||||
} from './claudex-proxy.js';
|
|
||||||
|
|
||||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
|
||||||
|
|
||||||
// ─── Isolated CLAUDE_CONFIG_DIR (HARD SECURITY REQ 1) ─────────────────────────
|
|
||||||
|
|
||||||
/** Dedicated override env for the isolated config dir. The ambient
|
|
||||||
* `CLAUDE_CONFIG_DIR` is deliberately NOT honored — it may already point at the
|
|
||||||
* real `~/.claude` of the launching session. */
|
|
||||||
export const CLAUDEX_CONFIG_DIR_ENV = 'MOSAIC_CLAUDEX_CONFIG_DIR';
|
|
||||||
|
|
||||||
/** The default isolated config dir — structurally under the mosaic home, so it
|
|
||||||
* can never equal `~/.claude`. */
|
|
||||||
export function defaultClaudexConfigDir(mosaicHome: string = MOSAIC_HOME): string {
|
|
||||||
return join(mosaicHome, 'claudex', 'home');
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConfigDirDeps {
|
|
||||||
/** The real Claude state dir to protect (default `~/.claude`). */
|
|
||||||
realClaudeDir?: string;
|
|
||||||
/** Resolve a path to canonical form, resolving symlinks on the longest
|
|
||||||
* existing ancestor (so a not-yet-created dir still canonicalizes). */
|
|
||||||
canonicalize?: (p: string) => string;
|
|
||||||
/** Ensure the isolated dir exists (mkdir -p, owner-only 0700). */
|
|
||||||
mkdir?: (p: string) => void;
|
|
||||||
/** Whether a path is itself a symlink (lstat). */
|
|
||||||
isSymlink?: (p: string) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve a path to canonical form, resolving symlinks on the LONGEST EXISTING
|
|
||||||
* ancestor and re-appending the not-yet-existing tail. A symlinked ancestor that
|
|
||||||
* points into `~/.claude` is therefore caught even before the leaf exists. */
|
|
||||||
function defaultCanonicalizeIntended(p: string): string {
|
|
||||||
const abs = resolve(p);
|
|
||||||
let existing = abs;
|
|
||||||
const tail: string[] = [];
|
|
||||||
// Walk up until we hit an existing ancestor (or the filesystem root).
|
|
||||||
for (;;) {
|
|
||||||
try {
|
|
||||||
const real = realpathSync(existing);
|
|
||||||
return tail.length > 0 ? join(real, ...tail) : real;
|
|
||||||
} catch (err) {
|
|
||||||
// ONLY a genuine "does not exist yet" (ENOENT) justifies walking up to an
|
|
||||||
// existing ancestor. Any other errno (ELOOP, EACCES, ENOTDIR, …) means we
|
|
||||||
// cannot establish the canonical form — fail CLOSED rather than fall back
|
|
||||||
// to a possibly-wrong literal path.
|
|
||||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
||||||
const parent = dirname(existing);
|
|
||||||
if (parent === existing) return abs; // reached root without an existing prefix
|
|
||||||
tail.unshift(existing.slice(parent.length + 1));
|
|
||||||
existing = parent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultMkdir(p: string): void {
|
|
||||||
mkdirSync(p, { recursive: true, mode: 0o700 });
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultIsSymlink(p: string): boolean {
|
|
||||||
try {
|
|
||||||
return lstatSync(p).isSymbolicLink();
|
|
||||||
} catch (err) {
|
|
||||||
// A missing path is genuinely "not a symlink"; anything else (EACCES, ELOOP,
|
|
||||||
// ENOTDIR, …) is uncertainty the guard must not swallow — fail CLOSED.
|
|
||||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when `child` is `parent` itself or a descendant of it (path-wise). */
|
|
||||||
function isWithin(child: string, parent: string): boolean {
|
|
||||||
if (child === parent) return true;
|
|
||||||
const rel = relative(parent, child);
|
|
||||||
return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Guard: prove that `candidate` is a legitimate ISOLATED config dir and can
|
|
||||||
* never be, resolve to, or live under the real `~/.claude`. Canonicalizes BOTH
|
|
||||||
* sides (either may be a symlink), rejects `~/.claude` and any descendant, and
|
|
||||||
* fails CLOSED (throws) on an empty/relative candidate. Returns the canonical
|
|
||||||
* isolated path on success.
|
|
||||||
*/
|
|
||||||
export function assertIsolatedConfigDir(candidate: string, deps: ConfigDirDeps = {}): string {
|
|
||||||
const canonicalize = deps.canonicalize ?? defaultCanonicalizeIntended;
|
|
||||||
const realClaudeDir = deps.realClaudeDir ?? join(homedir(), '.claude');
|
|
||||||
|
|
||||||
if (typeof candidate !== 'string' || candidate.trim() === '') {
|
|
||||||
throw new Error('claudex: isolated CLAUDE_CONFIG_DIR must be a non-empty path (fail closed).');
|
|
||||||
}
|
|
||||||
if (!isAbsolute(candidate)) {
|
|
||||||
throw new Error(
|
|
||||||
`claudex: isolated CLAUDE_CONFIG_DIR must be an absolute path: ${JSON.stringify(candidate)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const canonCandidate = canonicalize(candidate);
|
|
||||||
const canonReal = canonicalize(realClaudeDir);
|
|
||||||
|
|
||||||
// Compare canonical forms AND raw resolved forms — belt and suspenders so a
|
|
||||||
// canonicalizer that no-ops on a nonexistent real dir still catches the literal.
|
|
||||||
if (isWithin(canonCandidate, canonReal) || isWithin(resolve(candidate), resolve(realClaudeDir))) {
|
|
||||||
throw new Error(
|
|
||||||
`claudex: refusing to use the real Claude config dir (or a descendant of it) as the ` +
|
|
||||||
`isolated CLAUDE_CONFIG_DIR. Resolved to ${JSON.stringify(canonCandidate)}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return canonCandidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the isolated CLAUDE_CONFIG_DIR: pick the dedicated override or the
|
|
||||||
* namespaced default, run the pre-create guard, ensure the dir (0700), then
|
|
||||||
* RE-CHECK after creation — reject a symlinked target and re-run the guard on
|
|
||||||
* the now-existing (fully canonicalizable) path. This closes the pre-created
|
|
||||||
* symlink race (TOCTOU). Every failure throws (fail closed).
|
|
||||||
*/
|
|
||||||
export function resolveClaudexConfigDir(
|
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
|
||||||
deps: ConfigDirDeps & { mosaicHome?: string } = {},
|
|
||||||
): string {
|
|
||||||
const mosaicHome = deps.mosaicHome ?? MOSAIC_HOME;
|
|
||||||
const mkdir = deps.mkdir ?? defaultMkdir;
|
|
||||||
const isSymlink = deps.isSymlink ?? defaultIsSymlink;
|
|
||||||
|
|
||||||
const override = env[CLAUDEX_CONFIG_DIR_ENV]?.trim();
|
|
||||||
const candidate =
|
|
||||||
override && override.length > 0 ? override : defaultClaudexConfigDir(mosaicHome);
|
|
||||||
|
|
||||||
// Pre-create guard (before touching the filesystem).
|
|
||||||
assertIsolatedConfigDir(candidate, deps);
|
|
||||||
|
|
||||||
// Ensure the dir, then re-verify against the post-create reality.
|
|
||||||
mkdir(candidate);
|
|
||||||
if (isSymlink(candidate)) {
|
|
||||||
throw new Error(
|
|
||||||
'claudex: refusing to use the isolated CLAUDE_CONFIG_DIR — the target is a symlink ' +
|
|
||||||
'(possible pre-created race). Fail closed.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Re-run the guard now that the leaf exists so canonicalization reflects any
|
|
||||||
// symlinked ancestor introduced between the pre-check and mkdir.
|
|
||||||
return assertIsolatedConfigDir(candidate, deps);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Model-tier map (P3) ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const CLAUDEX_DEFAULT_PRIMARY_MODEL = 'gpt-5.6-sol';
|
|
||||||
export const CLAUDEX_DEFAULT_SMALL_FAST_MODEL = 'gpt-5.6-luna';
|
|
||||||
|
|
||||||
export interface ClaudexModels {
|
|
||||||
/** Primary tier (opus/sonnet) → ANTHROPIC_MODEL. */
|
|
||||||
primary: string;
|
|
||||||
/** Small/fast tier (haiku) → ANTHROPIC_SMALL_FAST_MODEL. */
|
|
||||||
smallFast: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve the model-tier map. Operator-provided env values WIN over defaults;
|
|
||||||
* blank values fall back to the defaults. */
|
|
||||||
export function resolveClaudexModels(env: NodeJS.ProcessEnv = process.env): ClaudexModels {
|
|
||||||
const primary = env['ANTHROPIC_MODEL']?.trim() || CLAUDEX_DEFAULT_PRIMARY_MODEL;
|
|
||||||
const smallFast = env['ANTHROPIC_SMALL_FAST_MODEL']?.trim() || CLAUDEX_DEFAULT_SMALL_FAST_MODEL;
|
|
||||||
return { primary, smallFast };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Env injection (HARD SECURITY REQ 2 — zero token leakage) ─────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Names of env vars considered credential-bearing. The whole family is stripped
|
|
||||||
* from the composed env so no real Anthropic key, OAuth token, or third-party /
|
|
||||||
* cloud credential can reach the local proxy or be used by Claude Code to bypass
|
|
||||||
* it. We then re-add ONLY the safe claudex vars (`ANTHROPIC_MODEL`,
|
|
||||||
* `_SMALL_FAST_MODEL`, `_BASE_URL`, `_AUTH_TOKEN=unused`). A name-pattern sweep
|
|
||||||
* can't miss a specific var a short denylist forgot, while still preserving the
|
|
||||||
* arbitrary non-credential env the harness/MCP/hooks require (PATH, HOME, XDG,
|
|
||||||
* terminal, proxies, …), which a strict allowlist would fragilely drop.
|
|
||||||
*
|
|
||||||
* The cloud-provider families (`AWS_*`, `GOOGLE_APPLICATION_CREDENTIALS`,
|
|
||||||
* `GOOGLE_CLOUD_*`, `GCP_*`) are included because Claude Code can route to the
|
|
||||||
* real Anthropic API via AWS Bedrock / GCP Vertex using the ambient cloud
|
|
||||||
* credential chain — a Claude-capable credential that must never survive into a
|
|
||||||
* claudex launch. `_KEY$` / `_SECRET` (not just the `_API_KEY$` tail) close the
|
|
||||||
* mid-string gap (`STRIPE_SECRET_KEY`, `SSH_PRIVATE_KEY`, `AWS_SECRET_ACCESS_KEY`).
|
|
||||||
*/
|
|
||||||
export const CLAUDEX_CREDENTIAL_ENV_RE =
|
|
||||||
/^ANTHROPIC_|^CLAUDE_CODE_OAUTH|^AWS_|^GOOGLE_APPLICATION_CREDENTIALS$|^GOOGLE_CLOUD_|^GCP_|_API_?KEY$|_KEY$|_TOKEN$|_SECRET/i;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provider ROUTING switches whose mere PRESENCE (independent of any credential)
|
|
||||||
* makes Claude Code bypass `ANTHROPIC_BASE_URL` (the loopback proxy) and talk to
|
|
||||||
* the real Anthropic API via Bedrock/Vertex. A name-pattern is the wrong model
|
|
||||||
* for a boolean switch, so these are force-deleted by exact name — REGARDLESS of
|
|
||||||
* value — after the credential sweep. (REQ 2: isolation must hold for any
|
|
||||||
* launching env, including a Bedrock/Vertex-configured enterprise host.)
|
|
||||||
*/
|
|
||||||
export const CLAUDEX_FORCED_UNSET_ENV = [
|
|
||||||
'CLAUDE_CODE_USE_BEDROCK',
|
|
||||||
'CLAUDE_CODE_USE_VERTEX',
|
|
||||||
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
|
|
||||||
'CLAUDE_CODE_SKIP_VERTEX_AUTH',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export interface BuildClaudexEnvOptions {
|
|
||||||
configDir: string;
|
|
||||||
models: ClaudexModels;
|
|
||||||
/** Override the proxy base URL (defaults to the PR-1 loopback constant). */
|
|
||||||
baseUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compose the launch env for Claude Code. Returns a FRESH object (never mutates
|
|
||||||
* the base env). Strips the entire credential-bearing family AND force-deletes
|
|
||||||
* the Bedrock/Vertex routing switches (REQ 2), then sets the isolated config dir
|
|
||||||
* and the proxy routing. Claude Code sees only `ANTHROPIC_AUTH_TOKEN=unused`
|
|
||||||
* pointed at the loopback proxy; the proxy holds the real OAuth credential, which
|
|
||||||
* this module never reads.
|
|
||||||
*/
|
|
||||||
export function buildClaudexEnv(
|
|
||||||
baseEnv: NodeJS.ProcessEnv,
|
|
||||||
opts: BuildClaudexEnvOptions,
|
|
||||||
): NodeJS.ProcessEnv {
|
|
||||||
const env: NodeJS.ProcessEnv = {};
|
|
||||||
for (const [name, value] of Object.entries(baseEnv)) {
|
|
||||||
if (CLAUDEX_CREDENTIAL_ENV_RE.test(name)) continue; // drop the whole credential family
|
|
||||||
env[name] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force-delete routing switches by exact name — their presence (not their
|
|
||||||
// value) is what would route Claude Code off the proxy to the real API.
|
|
||||||
for (const name of CLAUDEX_FORCED_UNSET_ENV) delete env[name];
|
|
||||||
|
|
||||||
env['CLAUDE_CONFIG_DIR'] = opts.configDir;
|
|
||||||
env['ANTHROPIC_BASE_URL'] = opts.baseUrl ?? CLAUDEX_PROXY_URL;
|
|
||||||
env['ANTHROPIC_AUTH_TOKEN'] = 'unused';
|
|
||||||
env['ANTHROPIC_MODEL'] = opts.models.primary;
|
|
||||||
env['ANTHROPIC_SMALL_FAST_MODEL'] = opts.models.smallFast;
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Console banner shown at launch. Contains no token material by construction. */
|
|
||||||
export function buildClaudexBanner(models: ClaudexModels): string {
|
|
||||||
return [
|
|
||||||
'',
|
|
||||||
' ┌─────────────────────────────────────────────────────────────────────┐',
|
|
||||||
' │ ⚠ EXPERIMENTAL — mosaic claudex │',
|
|
||||||
' └─────────────────────────────────────────────────────────────────────┘',
|
|
||||||
` Running GPT models inside the Claude Code harness via claude-code-proxy`,
|
|
||||||
` (ChatGPT-subscription OAuth). This is NOT Anthropic Claude.`,
|
|
||||||
` primary : ${models.primary}`,
|
|
||||||
` small/fast : ${models.smallFast}`,
|
|
||||||
` Model behavior, tool use, and output quality may differ from Claude.`,
|
|
||||||
'',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Markdown note appended to the composed runtime contract so the model itself
|
|
||||||
* knows it is running the EXPERIMENTAL GPT-via-proxy configuration. */
|
|
||||||
export function buildClaudexContractNote(models: ClaudexModels): string {
|
|
||||||
return [
|
|
||||||
'# EXPERIMENTAL Runtime — claudex (GPT via claude-code-proxy)',
|
|
||||||
'',
|
|
||||||
'You are running in Mosaic **claudex** mode: the Claude Code harness is wired to',
|
|
||||||
'GPT models through a local `claude-code-proxy` (ChatGPT-subscription OAuth). This',
|
|
||||||
"runtime is NOT Anthropic's Claude API and is not Claude.",
|
|
||||||
'',
|
|
||||||
`- Primary model: \`${models.primary}\``,
|
|
||||||
`- Small/fast model: \`${models.smallFast}\``,
|
|
||||||
'',
|
|
||||||
'Some Claude-specific harness assumptions may not hold under GPT models — verify',
|
|
||||||
'tool output carefully. This classification is EXPERIMENTAL and is not intended for',
|
|
||||||
'production delivery without explicit operator sign-off.',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Proxy gate ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ProxyGateResult {
|
|
||||||
ok: boolean;
|
|
||||||
report: PreflightReport;
|
|
||||||
/** Non-sensitive problems suitable for surfacing to the operator. */
|
|
||||||
problems: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProxyGateDeps {
|
|
||||||
preflight?: () => Promise<PreflightReport>;
|
|
||||||
ensureProxy?: () => Promise<EnsureProxyResult>;
|
|
||||||
reauth?: () => number;
|
|
||||||
log?: (message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the proxy readiness gate: preflight → (device reauth if OAuth needs it) →
|
|
||||||
* (start the proxy if nothing trusted is live) → re-preflight. Returns `ok` only
|
|
||||||
* when the final preflight passes (binary present, OAuth valid, a TRUSTED-live
|
|
||||||
* listener — identity verified by PR-1's `verifyListenerIdentity`). All surfaced
|
|
||||||
* problems are non-sensitive (port + verdict only; never a token).
|
|
||||||
*/
|
|
||||||
export async function runClaudexProxyGate(deps: ProxyGateDeps = {}): Promise<ProxyGateResult> {
|
|
||||||
const preflight = deps.preflight ?? (() => runProxyPreflight());
|
|
||||||
const ensureProxy = deps.ensureProxy ?? (() => ensureProxyRunning());
|
|
||||||
const reauth = deps.reauth ?? (() => runDeviceReauth());
|
|
||||||
const log = deps.log ?? (() => {});
|
|
||||||
|
|
||||||
let report = await preflight();
|
|
||||||
|
|
||||||
// A missing binary is unrecoverable here — don't attempt reauth or a start.
|
|
||||||
if (!report.binaryPresent) {
|
|
||||||
return { ok: false, report, problems: report.problems };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (report.needsReauth) {
|
|
||||||
log('claudex: claude-code-proxy OAuth needs re-authentication — starting device flow…');
|
|
||||||
const code = reauth();
|
|
||||||
if (code !== 0) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
report,
|
|
||||||
problems: [...report.problems, 'claudex: device re-authentication did not complete.'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
report = await preflight();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!report.live) {
|
|
||||||
log('claudex: no trusted claude-code-proxy responding — starting it…');
|
|
||||||
const started = await ensureProxy();
|
|
||||||
if (!started.live) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
report,
|
|
||||||
problems: [
|
|
||||||
...report.problems,
|
|
||||||
`claudex: could not bring up a trusted claude-code-proxy (${started.method}).`,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
report = await preflight();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: report.ok, report, problems: report.problems };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Launch orchestration ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The launch.ts-provided seam. Keeps `claudex.ts` free of a circular import back
|
|
||||||
* into `launch.ts` while letting the orchestration reuse the harness preflight,
|
|
||||||
* the composed runtime contract, and the process-replacing exec.
|
|
||||||
*/
|
|
||||||
export interface ClaudexHarnessAdapter {
|
|
||||||
/** Runs the Claude-harness preflight (mosaic home, SOUL, `claude` on PATH,
|
|
||||||
* sequential-thinking). May terminate the process on a hard failure. */
|
|
||||||
harnessPreflight: () => void;
|
|
||||||
/** Compose the full Claude runtime contract (== `composeContract('claude')`). */
|
|
||||||
composePrompt: () => string;
|
|
||||||
/** Replace the current process with `claude` using the composed env. */
|
|
||||||
exec: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LaunchClaudexDeps {
|
|
||||||
baseEnv?: NodeJS.ProcessEnv;
|
|
||||||
proxyGate?: () => Promise<ProxyGateResult>;
|
|
||||||
resolveConfigDir?: () => string;
|
|
||||||
models?: () => ClaudexModels;
|
|
||||||
buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv;
|
|
||||||
log?: (message: string) => void;
|
|
||||||
errorLog?: (message: string) => void;
|
|
||||||
fail?: (code: number) => never;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Orchestrate a `mosaic [yolo] claudex` launch. Runs the harness preflight, the
|
|
||||||
* proxy gate, composes the isolated env (REQ 1 + REQ 2), appends the EXPERIMENTAL
|
|
||||||
* note, and exec's Claude Code. FAIL CLOSED: on any gate failure or guard throw
|
|
||||||
* it reports non-sensitive detail and exits WITHOUT reaching exec.
|
|
||||||
*/
|
|
||||||
export async function launchClaudex(
|
|
||||||
args: string[],
|
|
||||||
yolo: boolean,
|
|
||||||
adapter: ClaudexHarnessAdapter,
|
|
||||||
deps: LaunchClaudexDeps = {},
|
|
||||||
): Promise<void> {
|
|
||||||
const log = deps.log ?? ((m: string) => console.log(m));
|
|
||||||
const errorLog = deps.errorLog ?? ((m: string) => console.error(m));
|
|
||||||
const fail = deps.fail ?? ((code: number) => process.exit(code));
|
|
||||||
const baseEnv = deps.baseEnv ?? process.env;
|
|
||||||
const proxyGate = deps.proxyGate ?? (() => runClaudexProxyGate({ log }));
|
|
||||||
const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv));
|
|
||||||
const models = deps.models ?? (() => resolveClaudexModels(baseEnv));
|
|
||||||
const buildEnv = deps.buildEnv ?? buildClaudexEnv;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Harness readiness first (claude on PATH, mosaic home, sequential-thinking).
|
|
||||||
adapter.harnessPreflight();
|
|
||||||
|
|
||||||
// Proxy readiness (binary, OAuth, trusted-live listener).
|
|
||||||
const gate = await proxyGate();
|
|
||||||
if (!gate.ok) {
|
|
||||||
errorLog('[mosaic] claudex preflight failed:');
|
|
||||||
for (const problem of gate.problems) errorLog(` - ${problem}`);
|
|
||||||
return fail(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compose the isolated launch env (guard throws → caught below, fail closed).
|
|
||||||
const resolvedModels = models();
|
|
||||||
const configDir = resolveConfigDir();
|
|
||||||
const env = buildEnv(baseEnv, { configDir, models: resolvedModels });
|
|
||||||
const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`;
|
|
||||||
|
|
||||||
log(buildClaudexBanner(resolvedModels));
|
|
||||||
|
|
||||||
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
|
||||||
cliArgs.push('--append-system-prompt', prompt, ...args);
|
|
||||||
adapter.exec('claude', cliArgs, env);
|
|
||||||
} catch (err) {
|
|
||||||
errorLog(
|
|
||||||
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
return fail(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { registerFleetMigrationCommand } from './fleet-migration-command.js';
|
|
||||||
|
|
||||||
let cleanup: string | undefined;
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
|
||||||
cleanup = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fleet migrate-v1 preview command', (): void => {
|
|
||||||
it('emits one ready JSON result without any runtime or file mutation API', async () => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'fleet-migration-command-'));
|
|
||||||
const rolesDir = join(cleanup, 'roles');
|
|
||||||
const overrideDir = join(cleanup, 'roles.local');
|
|
||||||
await mkdir(rolesDir);
|
|
||||||
await mkdir(overrideDir);
|
|
||||||
await writeFile(join(rolesDir, 'code.md'), '# code\n');
|
|
||||||
const files: Record<string, string> = {
|
|
||||||
source: `version: 1\ntransport: tmux\ntmux:\n socket_name: test\ndefaults:\n working_directory: /srv\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n runtime: pi\n class: implementer\n`,
|
|
||||||
decisions: JSON.stringify({
|
|
||||||
generation: 2,
|
|
||||||
defaultRuntime: 'pi',
|
|
||||||
agents: {
|
|
||||||
coder0: {
|
|
||||||
provider: 'openai',
|
|
||||||
model: 'gpt-5.6-sol',
|
|
||||||
reasoning: 'high',
|
|
||||||
enabled: true,
|
|
||||||
launchYolo: false,
|
|
||||||
toolPolicyDisposition: { action: 'replace', className: 'code' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
observations: JSON.stringify({ coder0: { systemd: 'inactive', tmux: 'missing' } }),
|
|
||||||
};
|
|
||||||
const printJson = vi.fn();
|
|
||||||
const setExitCode = vi.fn();
|
|
||||||
const program = new Command();
|
|
||||||
const fleet = program.command('fleet').option('--mosaic-home <path>', '', cleanup);
|
|
||||||
registerFleetMigrationCommand(fleet, {
|
|
||||||
mosaicHome: cleanup,
|
|
||||||
rolesDir,
|
|
||||||
overrideDir,
|
|
||||||
readText: async (path): Promise<string> => files[path] ?? '',
|
|
||||||
printJson,
|
|
||||||
setExitCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
await program.parseAsync([
|
|
||||||
'node',
|
|
||||||
'test',
|
|
||||||
'fleet',
|
|
||||||
'migrate-v1',
|
|
||||||
'preview',
|
|
||||||
'--source',
|
|
||||||
'source',
|
|
||||||
'--decisions',
|
|
||||||
'decisions',
|
|
||||||
'--observations',
|
|
||||||
'observations',
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(printJson).toHaveBeenCalledTimes(1);
|
|
||||||
expect(printJson).toHaveBeenCalledWith(expect.objectContaining({ status: 'ready' }));
|
|
||||||
expect(setExitCode).not.toHaveBeenCalled();
|
|
||||||
expect(fleet.helpInformation()).toContain('migrate-v1');
|
|
||||||
const migration = fleet.commands.find((command) => command.name() === 'migrate-v1');
|
|
||||||
expect(migration?.helpInformation()).not.toMatch(/--write|apply|canary|rollback/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits one stable blocked JSON result when --observations is missing', async () => {
|
|
||||||
const printJson = vi.fn();
|
|
||||||
const setExitCode = vi.fn();
|
|
||||||
const program = new Command().exitOverride();
|
|
||||||
program.configureOutput({
|
|
||||||
writeErr: vi.fn(),
|
|
||||||
writeOut: vi.fn(),
|
|
||||||
});
|
|
||||||
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
|
|
||||||
registerFleetMigrationCommand(fleet, {
|
|
||||||
mosaicHome: '/unused',
|
|
||||||
readText: vi.fn(),
|
|
||||||
printJson,
|
|
||||||
setExitCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program.parseAsync([
|
|
||||||
'node',
|
|
||||||
'test',
|
|
||||||
'fleet',
|
|
||||||
'migrate-v1',
|
|
||||||
'preview',
|
|
||||||
'--source',
|
|
||||||
'source',
|
|
||||||
'--decisions',
|
|
||||||
'decisions',
|
|
||||||
]),
|
|
||||||
).resolves.toBe(program);
|
|
||||||
expect(printJson).toHaveBeenCalledTimes(1);
|
|
||||||
expect(printJson).toHaveBeenCalledWith({
|
|
||||||
status: 'blocked',
|
|
||||||
blockers: [
|
|
||||||
{
|
|
||||||
code: 'missing-migration-preview-option',
|
|
||||||
path: 'request.observations',
|
|
||||||
detail: 'Required migration preview option is missing.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(setExitCode).toHaveBeenCalledWith(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['source', 'decisions', 'observations'] as const)(
|
|
||||||
'emits one stable blocked JSON result when bare --%s has no value',
|
|
||||||
async (bareOption) => {
|
|
||||||
const printJson = vi.fn();
|
|
||||||
const setExitCode = vi.fn();
|
|
||||||
const readText = vi.fn();
|
|
||||||
const writeErr = vi.fn();
|
|
||||||
const program = new Command().exitOverride();
|
|
||||||
program.configureOutput({ writeErr, writeOut: vi.fn() });
|
|
||||||
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
|
|
||||||
registerFleetMigrationCommand(fleet, {
|
|
||||||
mosaicHome: '/unused',
|
|
||||||
readText,
|
|
||||||
printJson,
|
|
||||||
setExitCode,
|
|
||||||
});
|
|
||||||
const args = [
|
|
||||||
'node',
|
|
||||||
'test',
|
|
||||||
'fleet',
|
|
||||||
'migrate-v1',
|
|
||||||
'preview',
|
|
||||||
'--source=source',
|
|
||||||
'--decisions=decisions',
|
|
||||||
'--observations=observations',
|
|
||||||
];
|
|
||||||
args[args.findIndex((argument) => argument.startsWith(`--${bareOption}=`))] =
|
|
||||||
`--${bareOption}`;
|
|
||||||
|
|
||||||
await expect(program.parseAsync(args)).resolves.toBe(program);
|
|
||||||
|
|
||||||
expect(printJson).toHaveBeenCalledTimes(1);
|
|
||||||
expect(printJson).toHaveBeenCalledWith({
|
|
||||||
status: 'blocked',
|
|
||||||
blockers: [
|
|
||||||
{
|
|
||||||
code: 'missing-migration-preview-option-value',
|
|
||||||
path: `request.${bareOption}`,
|
|
||||||
detail: 'Required migration preview option value is missing.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(setExitCode).toHaveBeenCalledTimes(1);
|
|
||||||
expect(setExitCode).toHaveBeenCalledWith(1);
|
|
||||||
expect(readText).not.toHaveBeenCalled();
|
|
||||||
expect(writeErr).not.toHaveBeenCalled();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each(['source', 'decisions', 'observations'] as const)(
|
|
||||||
'emits one stable blocked JSON result when --%s is present-empty',
|
|
||||||
async (emptyOption) => {
|
|
||||||
const printJson = vi.fn();
|
|
||||||
const setExitCode = vi.fn();
|
|
||||||
const readText = vi.fn();
|
|
||||||
const program = new Command().exitOverride();
|
|
||||||
program.configureOutput({ writeErr: vi.fn(), writeOut: vi.fn() });
|
|
||||||
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
|
|
||||||
registerFleetMigrationCommand(fleet, {
|
|
||||||
mosaicHome: '/unused',
|
|
||||||
readText,
|
|
||||||
printJson,
|
|
||||||
setExitCode,
|
|
||||||
});
|
|
||||||
const paths = { source: 'source', decisions: 'decisions', observations: 'observations' };
|
|
||||||
paths[emptyOption] = '';
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program.parseAsync([
|
|
||||||
'node',
|
|
||||||
'test',
|
|
||||||
'fleet',
|
|
||||||
'migrate-v1',
|
|
||||||
'preview',
|
|
||||||
`--source=${paths.source}`,
|
|
||||||
`--decisions=${paths.decisions}`,
|
|
||||||
`--observations=${paths.observations}`,
|
|
||||||
]),
|
|
||||||
).resolves.toBe(program);
|
|
||||||
|
|
||||||
expect(printJson).toHaveBeenCalledTimes(1);
|
|
||||||
expect(printJson).toHaveBeenCalledWith({
|
|
||||||
status: 'blocked',
|
|
||||||
blockers: [
|
|
||||||
{
|
|
||||||
code: 'empty-migration-preview-option',
|
|
||||||
path: `request.${emptyOption}`,
|
|
||||||
detail: 'Required migration preview option must be a non-empty path.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(setExitCode).toHaveBeenCalledTimes(1);
|
|
||||||
expect(setExitCode).toHaveBeenCalledWith(1);
|
|
||||||
expect(readText).not.toHaveBeenCalled();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('emits a blocked result and sets exit 1 for malformed evidence', async () => {
|
|
||||||
const printJson = vi.fn();
|
|
||||||
const setExitCode = vi.fn();
|
|
||||||
const program = new Command();
|
|
||||||
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
|
|
||||||
registerFleetMigrationCommand(fleet, {
|
|
||||||
mosaicHome: '/unused',
|
|
||||||
readText: async (path): Promise<string> => (path === 'decisions' ? 'not-json' : '{}'),
|
|
||||||
printJson,
|
|
||||||
setExitCode,
|
|
||||||
});
|
|
||||||
await program.parseAsync([
|
|
||||||
'node',
|
|
||||||
'test',
|
|
||||||
'fleet',
|
|
||||||
'migrate-v1',
|
|
||||||
'preview',
|
|
||||||
'--source',
|
|
||||||
'source',
|
|
||||||
'--decisions',
|
|
||||||
'decisions',
|
|
||||||
'--observations',
|
|
||||||
'observations',
|
|
||||||
]);
|
|
||||||
expect(printJson).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
status: 'blocked',
|
|
||||||
blockers: [expect.objectContaining({ code: 'migration-preview-failed' })],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(setExitCode).toHaveBeenCalledWith(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redacts adversarial values from validation failures', async () => {
|
|
||||||
const secret = 'never-print-command-or-token';
|
|
||||||
const printJson = vi.fn();
|
|
||||||
const setExitCode = vi.fn();
|
|
||||||
const program = new Command();
|
|
||||||
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
|
|
||||||
registerFleetMigrationCommand(fleet, {
|
|
||||||
mosaicHome: '/unused',
|
|
||||||
readText: async (path): Promise<string> =>
|
|
||||||
path === 'decisions'
|
|
||||||
? JSON.stringify({ generation: 2, agents: {}, [secret]: secret })
|
|
||||||
: '{}',
|
|
||||||
printJson,
|
|
||||||
setExitCode,
|
|
||||||
});
|
|
||||||
await program.parseAsync([
|
|
||||||
'node',
|
|
||||||
'test',
|
|
||||||
'fleet',
|
|
||||||
'migrate-v1',
|
|
||||||
'preview',
|
|
||||||
'--source',
|
|
||||||
'source',
|
|
||||||
'--decisions',
|
|
||||||
'decisions',
|
|
||||||
'--observations',
|
|
||||||
'observations',
|
|
||||||
]);
|
|
||||||
expect(JSON.stringify(printJson.mock.calls)).not.toContain(secret);
|
|
||||||
expect(setExitCode).toHaveBeenCalledWith(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import type { Command } from 'commander';
|
|
||||||
import {
|
|
||||||
parseV1MigrationObservations,
|
|
||||||
parseV1ToV2MigrationDecisions,
|
|
||||||
previewV1ToV2Migration,
|
|
||||||
} from '../fleet/v1-v2-migration.js';
|
|
||||||
|
|
||||||
export interface FleetMigrationCommandDeps {
|
|
||||||
readonly mosaicHome?: string;
|
|
||||||
readonly rolesDir?: string;
|
|
||||||
readonly overrideDir?: string;
|
|
||||||
readonly readText?: (path: string) => Promise<string>;
|
|
||||||
readonly printJson?: (value: unknown) => void;
|
|
||||||
readonly setExitCode?: (code: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PreviewOptions {
|
|
||||||
readonly source?: string | boolean;
|
|
||||||
readonly decisions?: string | boolean;
|
|
||||||
readonly observations?: string | boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Registers preview-only v1 migration. This command has no mutation verbs or runners. */
|
|
||||||
export function registerFleetMigrationCommand(
|
|
||||||
fleetCommand: Command,
|
|
||||||
deps: FleetMigrationCommandDeps = {},
|
|
||||||
): void {
|
|
||||||
fleetCommand
|
|
||||||
.command('migrate-v1')
|
|
||||||
.description('Preview a field-complete v1-to-v2 roster migration')
|
|
||||||
.command('preview')
|
|
||||||
.description('Compile migration evidence without writing files or changing runtimes')
|
|
||||||
.option('--source [path]', 'v1 roster YAML or JSON')
|
|
||||||
.option('--decisions [path]', 'explicit migration decisions JSON')
|
|
||||||
.option('--observations [path]', 'reviewed lifecycle observations JSON')
|
|
||||||
.action(async (options: PreviewOptions): Promise<void> => {
|
|
||||||
const readText = deps.readText ?? ((path: string): Promise<string> => readFile(path, 'utf8'));
|
|
||||||
const printJson =
|
|
||||||
deps.printJson ?? ((value: unknown): void => console.log(JSON.stringify(value)));
|
|
||||||
const setExitCode =
|
|
||||||
deps.setExitCode ?? ((code: number): void => void (process.exitCode = code));
|
|
||||||
try {
|
|
||||||
const requiredOptionNames = ['source', 'decisions', 'observations'] as const;
|
|
||||||
const missingOption = requiredOptionNames.find((name) => options[name] === undefined);
|
|
||||||
if (missingOption !== undefined) {
|
|
||||||
printJson({
|
|
||||||
status: 'blocked',
|
|
||||||
blockers: [
|
|
||||||
{
|
|
||||||
code: 'missing-migration-preview-option',
|
|
||||||
path: `request.${missingOption}`,
|
|
||||||
detail: 'Required migration preview option is missing.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
setExitCode(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const missingValueOption = requiredOptionNames.find((name) => options[name] === true);
|
|
||||||
if (missingValueOption !== undefined) {
|
|
||||||
printJson({
|
|
||||||
status: 'blocked',
|
|
||||||
blockers: [
|
|
||||||
{
|
|
||||||
code: 'missing-migration-preview-option-value',
|
|
||||||
path: `request.${missingValueOption}`,
|
|
||||||
detail: 'Required migration preview option value is missing.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
setExitCode(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const emptyOption = requiredOptionNames.find(
|
|
||||||
(name) => typeof options[name] === 'string' && options[name].trim() === '',
|
|
||||||
);
|
|
||||||
if (emptyOption !== undefined) {
|
|
||||||
printJson({
|
|
||||||
status: 'blocked',
|
|
||||||
blockers: [
|
|
||||||
{
|
|
||||||
code: 'empty-migration-preview-option',
|
|
||||||
path: `request.${emptyOption}`,
|
|
||||||
detail: 'Required migration preview option must be a non-empty path.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
setExitCode(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sourcePath = options.source;
|
|
||||||
const decisionsPath = options.decisions;
|
|
||||||
const observationsPath = options.observations;
|
|
||||||
if (
|
|
||||||
typeof sourcePath !== 'string' ||
|
|
||||||
typeof decisionsPath !== 'string' ||
|
|
||||||
typeof observationsPath !== 'string'
|
|
||||||
) {
|
|
||||||
throw new Error('Validated migration preview options became unavailable.');
|
|
||||||
}
|
|
||||||
const [source, decisionsSource, observationsSource] = await Promise.all([
|
|
||||||
readText(sourcePath),
|
|
||||||
readText(decisionsPath),
|
|
||||||
readText(observationsPath),
|
|
||||||
]);
|
|
||||||
const decisions = parseV1ToV2MigrationDecisions(
|
|
||||||
parseJsonObject(decisionsSource, 'migration decisions'),
|
|
||||||
);
|
|
||||||
const observations = parseV1MigrationObservations(
|
|
||||||
parseJsonObject(observationsSource, 'lifecycle observations'),
|
|
||||||
);
|
|
||||||
const mosaicHome =
|
|
||||||
deps.mosaicHome ?? fleetCommand.opts<{ mosaicHome: string }>().mosaicHome;
|
|
||||||
const preview = await previewV1ToV2Migration({
|
|
||||||
source,
|
|
||||||
sourcePath,
|
|
||||||
decisions,
|
|
||||||
observations,
|
|
||||||
personaDirs: {
|
|
||||||
rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
|
|
||||||
overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
|
|
||||||
},
|
|
||||||
environment: {
|
|
||||||
mosaicHome,
|
|
||||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
printJson(preview);
|
|
||||||
if (preview.status === 'blocked') setExitCode(1);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
printJson({
|
|
||||||
status: 'blocked',
|
|
||||||
blockers: [
|
|
||||||
{
|
|
||||||
code: 'migration-preview-failed',
|
|
||||||
path: 'request',
|
|
||||||
detail: safeErrorDetail(error),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
setExitCode(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonObject(source: string, label: string): unknown {
|
|
||||||
let value: unknown;
|
|
||||||
try {
|
|
||||||
value = JSON.parse(source) as unknown;
|
|
||||||
} catch {
|
|
||||||
throw new Error(`${label} must be valid JSON.`);
|
|
||||||
}
|
|
||||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
||||||
throw new Error(`${label} must be a JSON object.`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeErrorDetail(error: unknown): string {
|
|
||||||
if (error instanceof Error && isPublishableValidationMessage(error.message)) return error.message;
|
|
||||||
return 'Migration preview failed without publishable detail.';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPublishableValidationMessage(message: string): boolean {
|
|
||||||
return /^(migration decisions|lifecycle observations) must be (valid JSON|a JSON object)\.$/.test(
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,896 +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('reports a missing canonical roster with the shared initialization hint', async (): Promise<void> => {
|
|
||||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-regen-missing-roster-'));
|
|
||||||
cleanup = home;
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program(home, recordingRunner([])).parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']),
|
|
||||||
).rejects.toThrow(
|
|
||||||
`No fleet roster found at ${join(home, 'fleet', 'roster.yaml')}. Run \`mosaic fleet init\``,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
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,446 +0,0 @@
|
|||||||
import { 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';
|
|
||||||
import { FleetRosterConfigurationError, readFleetRosterText } from '../fleet/fleet-roster-v1.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 readFleetRosterText(rosterPath), '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) {
|
|
||||||
if (error instanceof FleetRosterConfigurationError) {
|
|
||||||
fleetCommand.error(error.message, { code: 'fleet.roster', exitCode: 1 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
process.exitCode = 1;
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
process.stderr.write(`mosaic fleet regen failed: ${message}\n`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -60,7 +60,6 @@ import {
|
|||||||
type SleepFn,
|
type SleepFn,
|
||||||
} from './fleet.js';
|
} from './fleet.js';
|
||||||
import { registerAgentCommand } from './agent.js';
|
import { registerAgentCommand } from './agent.js';
|
||||||
import { parseFleetRosterDocument, readFleetRosterText } from '../fleet/fleet-roster-v1.js';
|
|
||||||
|
|
||||||
function buildProgram(): Command {
|
function buildProgram(): Command {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -91,14 +90,12 @@ describe('registerFleetCommand', () => {
|
|||||||
'init',
|
'init',
|
||||||
'install',
|
'install',
|
||||||
'install-systemd',
|
'install-systemd',
|
||||||
'migrate-v1',
|
|
||||||
'persona',
|
'persona',
|
||||||
'plan',
|
'plan',
|
||||||
'profile',
|
'profile',
|
||||||
'provision',
|
'provision',
|
||||||
'ps',
|
'ps',
|
||||||
'reconcile',
|
'reconcile',
|
||||||
'regen',
|
|
||||||
'remove',
|
'remove',
|
||||||
'restart',
|
'restart',
|
||||||
'start',
|
'start',
|
||||||
@@ -167,55 +164,6 @@ describe('registerFleetCommand', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fleet roster configuration failures', () => {
|
|
||||||
it('reports a missing roster with an actionable initialization hint', async () => {
|
|
||||||
const home = await tempDir();
|
|
||||||
try {
|
|
||||||
const program = buildProgram();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program.parseAsync(['node', 'mosaic', 'fleet', '--mosaic-home', home, 'ps']),
|
|
||||||
).rejects.toThrow(
|
|
||||||
`No fleet roster found at ${join(home, 'fleet', 'roster.json')}. Run \`mosaic fleet init\``,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports a malformed roster with the path and repair hint', async () => {
|
|
||||||
const home = await tempDir();
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.json');
|
|
||||||
try {
|
|
||||||
await mkdir(dirname(rosterPath), { recursive: true });
|
|
||||||
await writeFile(rosterPath, '{not valid json');
|
|
||||||
|
|
||||||
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
|
||||||
`Fleet roster at ${rosterPath} is invalid. Fix the file or run \`mosaic fleet init --force\``,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports an unreadable roster without exposing the filesystem error', async () => {
|
|
||||||
const home = await tempDir();
|
|
||||||
try {
|
|
||||||
await expect(readFleetRosterText(home)).rejects.toThrow(
|
|
||||||
`Could not read fleet roster at ${home}. Check the file exists and is readable.`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports a malformed roster while selecting the v1/v2 command path', () => {
|
|
||||||
expect(() => parseFleetRosterDocument('version: [', '/srv/mosaic/fleet/roster.yaml')).toThrow(
|
|
||||||
'Fleet roster at /srv/mosaic/fleet/roster.yaml is invalid. Fix the file or run `mosaic fleet init --force`.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fleet roster parsing', () => {
|
describe('fleet roster parsing', () => {
|
||||||
let cleanup: string | undefined;
|
let cleanup: string | undefined;
|
||||||
|
|
||||||
@@ -249,26 +197,6 @@ describe('fleet roster parsing', () => {
|
|||||||
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
|
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses /clear for an explicitly declared empty pi runtime config', async () => {
|
|
||||||
cleanup = await tempDir();
|
|
||||||
const rosterPath = join(cleanup, 'roster.yaml');
|
|
||||||
await writeFile(
|
|
||||||
rosterPath,
|
|
||||||
[
|
|
||||||
'version: 1',
|
|
||||||
'transport: tmux',
|
|
||||||
'runtimes:',
|
|
||||||
' pi: {}',
|
|
||||||
'agents:',
|
|
||||||
' - name: canary-pi',
|
|
||||||
' runtime: pi',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const loaded = await loadFleetRoster(rosterPath);
|
|
||||||
expect(loaded.runtimes.pi?.resetCommand).toBe('/clear');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts optional agent alias and provider metadata without requiring them', async () => {
|
it('accepts optional agent alias and provider metadata without requiring them', async () => {
|
||||||
cleanup = await tempDir();
|
cleanup = await tempDir();
|
||||||
const rosterPath = join(cleanup, 'roster.yaml');
|
const rosterPath = join(cleanup, 'roster.yaml');
|
||||||
@@ -342,32 +270,6 @@ describe('fleet roster parsing', () => {
|
|||||||
expect(env).toContain('MOSAIC_TMUX_SOCKET=\n');
|
expect(env).toContain('MOSAIC_TMUX_SOCKET=\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves home-relative traversal until the shared environment boundary rejects it', async () => {
|
|
||||||
for (const workingDirectory of ['~/../escape', '~/src/../../escape']) {
|
|
||||||
cleanup = await tempDir();
|
|
||||||
const rosterPath = join(cleanup, 'roster.json');
|
|
||||||
await writeFile(
|
|
||||||
rosterPath,
|
|
||||||
JSON.stringify({
|
|
||||||
version: 1,
|
|
||||||
transport: 'tmux',
|
|
||||||
defaults: { working_directory: workingDirectory },
|
|
||||||
agents: [{ name: 'coder0', runtime: 'pi' }],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const roster = await loadFleetRoster(rosterPath);
|
|
||||||
|
|
||||||
expect(() => generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toThrow(
|
|
||||||
expect.objectContaining({
|
|
||||||
diagnostic: expect.objectContaining({
|
|
||||||
code: 'unsafe-path',
|
|
||||||
key: 'MOSAIC_AGENT_WORKDIR',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates deterministic per-agent EnvironmentFile content', async () => {
|
it('generates deterministic per-agent EnvironmentFile content', async () => {
|
||||||
cleanup = await tempDir();
|
cleanup = await tempDir();
|
||||||
const rosterPath = join(cleanup, 'roster.json');
|
const rosterPath = join(cleanup, 'roster.json');
|
||||||
|
|||||||
@@ -19,11 +19,8 @@ import * as readline from 'node:readline';
|
|||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
import {
|
import {
|
||||||
FleetRosterConfigurationError,
|
|
||||||
getRosterAgent,
|
getRosterAgent,
|
||||||
loadFleetRoster,
|
loadFleetRoster,
|
||||||
parseFleetRosterDocument,
|
|
||||||
readFleetRosterText,
|
|
||||||
resolveInstalledFleetRosterPath,
|
resolveInstalledFleetRosterPath,
|
||||||
type FleetAgent,
|
type FleetAgent,
|
||||||
type FleetRoster,
|
type FleetRoster,
|
||||||
@@ -38,16 +35,11 @@ import {
|
|||||||
registerFleetAgentCrudCommands,
|
registerFleetAgentCrudCommands,
|
||||||
type FleetAgentCrudCommandDeps,
|
type FleetAgentCrudCommandDeps,
|
||||||
} from './fleet-agent-crud-command.js';
|
} from './fleet-agent-crud-command.js';
|
||||||
import {
|
|
||||||
registerFleetMigrationCommand,
|
|
||||||
type FleetMigrationCommandDeps,
|
|
||||||
} from './fleet-migration-command.js';
|
|
||||||
import {
|
import {
|
||||||
executeReconcilerCommandJson,
|
executeReconcilerCommandJson,
|
||||||
registerFleetReconcilerCommands,
|
registerFleetReconcilerCommands,
|
||||||
type FleetReconcilerCommandDeps,
|
type FleetReconcilerCommandDeps,
|
||||||
} from './fleet-reconciler-command.js';
|
} from './fleet-reconciler-command.js';
|
||||||
import { registerFleetRegenCommand } from './fleet-regen-command.js';
|
|
||||||
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
|
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
|
||||||
import {
|
import {
|
||||||
applyPreparedAgentEnvironmentProjection,
|
applyPreparedAgentEnvironmentProjection,
|
||||||
@@ -105,7 +97,6 @@ export interface FleetCommandDeps {
|
|||||||
isStdinTTY?: boolean;
|
isStdinTTY?: boolean;
|
||||||
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
|
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
|
||||||
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
|
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
|
||||||
migrationDeps?: Omit<FleetMigrationCommandDeps, 'mosaicHome'>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FleetPaths {
|
export interface FleetPaths {
|
||||||
@@ -489,7 +480,7 @@ function generateAgentEnvValues(
|
|||||||
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
||||||
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
|
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
|
||||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
|
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
|
||||||
MOSAIC_AGENT_WORKDIR: workingDirectory,
|
MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory),
|
||||||
MOSAIC_TMUX_SOCKET: agent.socket ?? roster.tmux.socketName,
|
MOSAIC_TMUX_SOCKET: agent.socket ?? roster.tmux.socketName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1916,7 +1907,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
||||||
const roster = await loadRosterAtPath(cmd, rosterPath);
|
const roster = await loadFleetRoster(rosterPath);
|
||||||
|
|
||||||
const newAgent: FleetAgent = {
|
const newAgent: FleetAgent = {
|
||||||
name,
|
name,
|
||||||
@@ -1976,7 +1967,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
||||||
const roster = await loadRosterAtPath(cmd, rosterPath);
|
const roster = await loadFleetRoster(rosterPath);
|
||||||
|
|
||||||
// Guard: throws if removing leaves 0 orchestrators or agent not in roster
|
// Guard: throws if removing leaves 0 orchestrators or agent not in roster
|
||||||
const updatedRoster = removeAgentFromRoster(roster, name);
|
const updatedRoster = removeAgentFromRoster(roster, name);
|
||||||
@@ -2057,28 +2048,12 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
// Roster-v2 desired-state mutations belong directly to the fleet control
|
// Roster-v2 desired-state mutations belong directly to the fleet control
|
||||||
// plane; they do not share the root `mosaic agent` gateway-backed surface.
|
// plane; they do not share the root `mosaic agent` gateway-backed surface.
|
||||||
registerFleetAgentCrudCommands(cmd, deps);
|
registerFleetAgentCrudCommands(cmd, deps);
|
||||||
registerFleetMigrationCommand(cmd, {
|
|
||||||
...deps.migrationDeps,
|
|
||||||
mosaicHome: deps.mosaicHome,
|
|
||||||
});
|
|
||||||
registerFleetReconcilerCommands(cmd, {
|
registerFleetReconcilerCommands(cmd, {
|
||||||
runner,
|
runner,
|
||||||
mosaicHome: deps.mosaicHome,
|
mosaicHome: deps.mosaicHome,
|
||||||
reconcileDeps: deps.reconcileDeps,
|
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;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2405,19 +2380,14 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
|
|
||||||
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
||||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
return loadRosterAtPath(cmd, await resolveRosterPath(opts.mosaicHome, opts.roster));
|
return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */
|
/** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */
|
||||||
async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
|
async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
|
||||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
|
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
|
||||||
let parsed: unknown;
|
const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
|
||||||
try {
|
|
||||||
parsed = parseFleetRosterDocument(await readFleetRosterText(path), path);
|
|
||||||
} catch (error) {
|
|
||||||
reportFleetRosterConfigurationError(cmd, error);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
typeof parsed === 'object' &&
|
typeof parsed === 'object' &&
|
||||||
parsed !== null &&
|
parsed !== null &&
|
||||||
@@ -2433,22 +2403,7 @@ async function loadRosterFromAgentCommand(
|
|||||||
): Promise<FleetRoster> {
|
): Promise<FleetRoster> {
|
||||||
const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>();
|
const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>();
|
||||||
const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome();
|
const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome();
|
||||||
return loadRosterAtPath(command, await resolveRosterPath(mosaicHome, opts.roster));
|
return loadFleetRoster(await resolveRosterPath(mosaicHome, opts.roster));
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRosterAtPath(command: Command, path: string): Promise<FleetRoster> {
|
|
||||||
try {
|
|
||||||
return await loadFleetRoster(path);
|
|
||||||
} catch (error) {
|
|
||||||
reportFleetRosterConfigurationError(command, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportFleetRosterConfigurationError(command: Command, error: unknown): never {
|
|
||||||
if (error instanceof FleetRosterConfigurationError) {
|
|
||||||
command.error(error.message, { code: 'fleet.roster', exitCode: 1 });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMosaicHomeFromCommand(command: Command, override?: string): string {
|
function resolveMosaicHomeFromCommand(command: Command, override?: string): string {
|
||||||
@@ -2456,6 +2411,10 @@ function resolveMosaicHomeFromCommand(command: Command, override?: string): stri
|
|||||||
return opts.mosaicHome ?? override ?? defaultMosaicHome();
|
return opts.mosaicHome ?? override ?? defaultMosaicHome();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expandHome(path: string): string {
|
||||||
|
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
|
||||||
|
}
|
||||||
|
|
||||||
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
|
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
for (const agentName of agentNames) {
|
for (const agentName of agentNames) {
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
const INSTALLER_PATH = fileURLToPath(new URL('../../../../tools/install.sh', import.meta.url));
|
|
||||||
|
|
||||||
describe('installer CLI package heading', () => {
|
|
||||||
it('renders the scoped package name intact', async () => {
|
|
||||||
const installer = await readFile(INSTALLER_PATH, 'utf8');
|
|
||||||
const step = installer.match(/^step\(\)\s*\{.*\}$/m)?.[0];
|
|
||||||
|
|
||||||
expect(step).toBeDefined();
|
|
||||||
|
|
||||||
expect(step).toContain('printf \'\\n%s%s%s\\n\' "$BOLD" "$*" "$RESET"');
|
|
||||||
expect(installer).toContain('step "@mosaicstack/mosaic (npm package)"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
piForceSkillNames,
|
piForceSkillNames,
|
||||||
registerRuntimeLaunchers,
|
registerRuntimeLaunchers,
|
||||||
type RuntimeLaunchHandler,
|
type RuntimeLaunchHandler,
|
||||||
type ClaudexLaunchHandler,
|
|
||||||
} from './launch.js';
|
} from './launch.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,16 +31,6 @@ function buildProgram(handler: RuntimeLaunchHandler): Command {
|
|||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProgramWithClaudex(
|
|
||||||
handler: RuntimeLaunchHandler,
|
|
||||||
claudexHandler: ClaudexLaunchHandler,
|
|
||||||
): Command {
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerRuntimeLaunchers(program, handler, claudexHandler);
|
|
||||||
return program;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fakeSkills = ['--skill', '/skills/test-driven-development', '--skill', '/skills/pdf'];
|
const fakeSkills = ['--skill', '/skills/test-driven-development', '--skill', '/skills/pdf'];
|
||||||
const fakeForced = ['--skill', '/skills/mosaic-tools'];
|
const fakeForced = ['--skill', '/skills/mosaic-tools'];
|
||||||
|
|
||||||
@@ -291,61 +280,3 @@ describe('registerRuntimeLaunchers — yolo <runtime>', () => {
|
|||||||
expect(mockExit).toHaveBeenCalledWith(1);
|
expect(mockExit).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => {
|
|
||||||
let mockExit: MockInstance<typeof process.exit>;
|
|
||||||
let mockError: MockInstance<typeof console.error>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockExit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
|
|
||||||
mockError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
mockExit.mockRestore();
|
|
||||||
mockError.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('dispatches `claudex` to the claudex handler (yolo=false), not the runtime handler', () => {
|
|
||||||
const handler = vi.fn();
|
|
||||||
const claudex = vi.fn();
|
|
||||||
const program = buildProgramWithClaudex(handler, claudex);
|
|
||||||
program.parse(['node', 'mosaic', 'claudex']);
|
|
||||||
|
|
||||||
expect(claudex).toHaveBeenCalledTimes(1);
|
|
||||||
expect(claudex).toHaveBeenCalledWith([], false);
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forwards excess args after `claudex`', () => {
|
|
||||||
const handler = vi.fn();
|
|
||||||
const claudex = vi.fn();
|
|
||||||
const program = buildProgramWithClaudex(handler, claudex);
|
|
||||||
program.parse(['node', 'mosaic', 'claudex', '--print', 'hi']);
|
|
||||||
|
|
||||||
expect(claudex).toHaveBeenCalledWith(['--print', 'hi'], false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('dispatches `yolo claudex` with yolo=true and slices off the runtime name (#454)', () => {
|
|
||||||
const handler = vi.fn();
|
|
||||||
const claudex = vi.fn();
|
|
||||||
const program = buildProgramWithClaudex(handler, claudex);
|
|
||||||
program.parse(['node', 'mosaic', 'yolo', 'claudex']);
|
|
||||||
|
|
||||||
expect(claudex).toHaveBeenCalledTimes(1);
|
|
||||||
// extraArgs must be empty — the positional 'claudex' must not leak through.
|
|
||||||
expect(claudex).toHaveBeenCalledWith([], true);
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
|
||||||
expect(mockExit).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forwards true excess args after `yolo claudex`', () => {
|
|
||||||
const handler = vi.fn();
|
|
||||||
const claudex = vi.fn();
|
|
||||||
const program = buildProgramWithClaudex(handler, claudex);
|
|
||||||
program.parse(['node', 'mosaic', 'yolo', 'claudex', '--model', 'gpt-5.6-sol']);
|
|
||||||
|
|
||||||
expect(claudex).toHaveBeenCalledWith(['--model', 'gpt-5.6-sol'], true);
|
|
||||||
expect(mockExit).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
||||||
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
||||||
import { canonicalizeRoleClass } from './fleet-personas.js';
|
import { canonicalizeRoleClass } from './fleet-personas.js';
|
||||||
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
|
||||||
|
|
||||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||||
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
||||||
@@ -807,12 +806,12 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** exec into the runtime, replacing the current process. */
|
/** exec into the runtime, replacing the current process. */
|
||||||
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
function execRuntime(cmd: string, args: string[]): void {
|
||||||
try {
|
try {
|
||||||
// Use execFileSync with inherited stdio to replace the process
|
// Use execFileSync with inherited stdio to replace the process
|
||||||
const result = spawnSync(cmd, args, {
|
const result = spawnSync(cmd, args, {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env,
|
env: process.env,
|
||||||
});
|
});
|
||||||
process.exit(result.status ?? 0);
|
process.exit(result.status ?? 0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -821,29 +820,6 @@ function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = proce
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Production glue for `mosaic [yolo] claudex` (EXPERIMENTAL — GPT models inside
|
|
||||||
* the Claude Code harness via claude-code-proxy). Assembles the real harness
|
|
||||||
* adapter and delegates the security-critical composition + fail-closed
|
|
||||||
* orchestration to `launchClaudex` in `claudex.ts`. Kept thin so the tested
|
|
||||||
* logic lives in the DI module, not here.
|
|
||||||
*/
|
|
||||||
function launchClaudexProduction(args: string[], yolo: boolean): void {
|
|
||||||
writeSessionLock('claude');
|
|
||||||
const adapter: ClaudexHarnessAdapter = {
|
|
||||||
harnessPreflight: () => {
|
|
||||||
checkMosaicHome();
|
|
||||||
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
|
|
||||||
checkSoul();
|
|
||||||
checkRuntime('claude');
|
|
||||||
checkSequentialThinking('claude');
|
|
||||||
},
|
|
||||||
composePrompt: () => buildRuntimePrompt('claude'),
|
|
||||||
exec: (cmd, cmdArgs, env) => execRuntime(cmd, cmdArgs, env),
|
|
||||||
};
|
|
||||||
void launchClaudex(args, yolo, adapter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Framework script/tool delegation ───────────────────────────────────────
|
// ─── Framework script/tool delegation ───────────────────────────────────────
|
||||||
|
|
||||||
function delegateToScript(scriptPath: string, args: string[], env?: Record<string, string>): never {
|
function delegateToScript(scriptPath: string, args: string[], env?: Record<string, string>): never {
|
||||||
@@ -1058,25 +1034,12 @@ export type RuntimeLaunchHandler = (
|
|||||||
yolo: boolean,
|
yolo: boolean,
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
/**
|
|
||||||
* Handler invoked for `claudex` / `yolo claudex`. Kept separate from
|
|
||||||
* `RuntimeLaunchHandler` because claudex is an EXPERIMENTAL harness overlay
|
|
||||||
* (GPT-via-proxy), not one of the first-class runtimes. Exposed + injectable so
|
|
||||||
* the commander wiring can be exercised without composing a real launch.
|
|
||||||
*/
|
|
||||||
export type ClaudexLaunchHandler = (extraArgs: string[], yolo: boolean) => void;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire `<runtime>` and `yolo <runtime>` subcommands onto `program` using a
|
* Wire `<runtime>` and `yolo <runtime>` subcommands onto `program` using a
|
||||||
* pluggable launch handler. Separated from `registerLaunchCommands` so tests
|
* pluggable launch handler. Separated from `registerLaunchCommands` so tests
|
||||||
* can inject a spy and verify argument forwarding.
|
* can inject a spy and verify argument forwarding.
|
||||||
*/
|
*/
|
||||||
export function registerRuntimeLaunchers(
|
export function registerRuntimeLaunchers(program: Command, handler: RuntimeLaunchHandler): void {
|
||||||
program: Command,
|
|
||||||
handler: RuntimeLaunchHandler,
|
|
||||||
claudexHandler: ClaudexLaunchHandler = (extraArgs, yolo) =>
|
|
||||||
launchClaudexProduction(extraArgs, yolo),
|
|
||||||
): void {
|
|
||||||
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
|
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
|
||||||
program
|
program
|
||||||
.command(runtime)
|
.command(runtime)
|
||||||
@@ -1088,37 +1051,16 @@ export function registerRuntimeLaunchers(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// claudex — EXPERIMENTAL: GPT models inside the Claude Code harness via
|
|
||||||
// claude-code-proxy (ChatGPT-subscription OAuth). Isolated CLAUDE_CONFIG_DIR
|
|
||||||
// + zero-token-leak env injection live in claudex.ts.
|
|
||||||
program
|
|
||||||
.command('claudex')
|
|
||||||
.description('EXPERIMENTAL: launch Claude Code harness against GPT via claude-code-proxy')
|
|
||||||
.allowUnknownOption(true)
|
|
||||||
.allowExcessArguments(true)
|
|
||||||
.action((_opts: unknown, cmd: Command) => {
|
|
||||||
claudexHandler(cmd.args, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('yolo <runtime>')
|
.command('yolo <runtime>')
|
||||||
.description(
|
.description('Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi)')
|
||||||
'Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi|claudex)',
|
|
||||||
)
|
|
||||||
.allowUnknownOption(true)
|
.allowUnknownOption(true)
|
||||||
.allowExcessArguments(true)
|
.allowExcessArguments(true)
|
||||||
.action((runtime: string, _opts: unknown, cmd: Command) => {
|
.action((runtime: string, _opts: unknown, cmd: Command) => {
|
||||||
// claudex is an EXPERIMENTAL overlay, not a RuntimeName — dispatch it
|
|
||||||
// before the runtime allowlist check. Slice off the positional runtime
|
|
||||||
// name for the same reason as below (#454).
|
|
||||||
if (runtime === 'claudex') {
|
|
||||||
claudexHandler(cmd.args.slice(1), true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
|
const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
|
||||||
if (!valid.includes(runtime as RuntimeName)) {
|
if (!valid.includes(runtime as RuntimeName)) {
|
||||||
console.error(
|
console.error(
|
||||||
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}|claudex`,
|
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import {
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
||||||
mkdtempSync,
|
|
||||||
mkdirSync,
|
|
||||||
writeFileSync,
|
|
||||||
rmSync,
|
|
||||||
readFileSync,
|
|
||||||
existsSync,
|
|
||||||
copyFileSync,
|
|
||||||
} from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { FileConfigAdapter, DEFAULT_SEED_FILES } from './file-adapter.js';
|
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.
|
* 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(defaultsDir, { recursive: true });
|
||||||
mkdirSync(mosaicHome, { 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.
|
// Framework-contract defaults we expect the wizard to seed.
|
||||||
writeFileSync(join(defaultsDir, 'CONSTITUTION.md'), '# CONSTITUTION default\n');
|
writeFileSync(join(defaultsDir, 'CONSTITUTION.md'), '# CONSTITUTION default\n');
|
||||||
writeFileSync(join(defaultsDir, 'AGENTS.md'), '# AGENTS default\n');
|
writeFileSync(join(defaultsDir, 'AGENTS.md'), '# AGENTS default\n');
|
||||||
@@ -122,10 +102,9 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('overwrites framework-owned files (backup-once) but preserves user-seeded files', async () => {
|
it('overwrites framework-owned files (backup-once) but preserves user-seeded files', async () => {
|
||||||
// Contract files (CONSTITUTION/AGENTS/STANDARDS) ship only under defaults/ —
|
// Plant a root-level AGENTS.md in sourceDir so syncDirectory's preserve is exercised.
|
||||||
// reconcile_framework_files is their sole writer (backup-once). The bulk
|
writeFileSync(join(fixture.sourceDir, 'AGENTS.md'), '# shipped AGENTS from source root\n');
|
||||||
// sync never sees a root-level copy, so a user's edited root file is backed
|
|
||||||
// up, not silently clobbered, on upgrade.
|
|
||||||
writeFileSync(join(fixture.mosaicHome, 'TOOLS.md'), '# user-customized TOOLS\n');
|
writeFileSync(join(fixture.mosaicHome, 'TOOLS.md'), '# user-customized TOOLS\n');
|
||||||
writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n');
|
writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n');
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
buildToolsTemplateVars,
|
buildToolsTemplateVars,
|
||||||
} from '../template/builders.js';
|
} from '../template/builders.js';
|
||||||
import { atomicWrite, backupFile, syncDirectory } from '../platform/file-ops.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.
|
* Parse a SoulConfig from an existing SOUL.md file.
|
||||||
@@ -156,22 +155,38 @@ export class FileConfigAdapter implements ConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async syncFramework(action: InstallAction): Promise<void> {
|
async syncFramework(action: InstallAction): Promise<void> {
|
||||||
// #791: ownership is derived from the shared framework manifest
|
// Must match PRESERVE_PATHS in packages/mosaic/framework/install.sh so
|
||||||
// (packages/mosaic/framework/framework-manifest.txt) — the SAME file the
|
// the bash and TS install paths have the same upgrade-preservation
|
||||||
// bash installer reads — so the TS and bash paths can never drift. On an
|
// semantics. Contract files (AGENTS.md, STANDARDS.md, TOOLS.md) are
|
||||||
// upgrade (keep/reconfigure) the sync must NEVER write an operator-owned
|
// seeded from defaults/ on first install and preserved thereafter;
|
||||||
// path: every operator file, and any path the manifest never anticipated
|
// identity files (SOUL.md, USER.md) are generated by wizard stages and
|
||||||
// (which resolves to operator by the fail-safe default), is left untouched.
|
// must never be touched by the framework sync.
|
||||||
// A fresh install ('overwrite'/'reconfigure' onto an empty home) seeds the
|
const preservePaths =
|
||||||
// full tree, so the guard applies only when preserving an existing home.
|
action === 'keep' || action === 'reconfigure'
|
||||||
const guardOwnership = action === 'keep' || action === 'reconfigure';
|
? [
|
||||||
const manifest = guardOwnership ? loadManifest(this.sourceDir) : undefined;
|
'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, {
|
syncDirectory(this.sourceDir, this.mosaicHome, {
|
||||||
|
preserve: preservePaths,
|
||||||
excludeGit: true,
|
excludeGit: true,
|
||||||
isOperatorOwned: manifest
|
|
||||||
? (relPath) => resolveOwnership(manifest, relPath) === 'operator'
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reconcile framework-contract files from framework/defaults/ into the mosaic
|
// Reconcile framework-contract files from framework/defaults/ into the mosaic
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
/** Locale-independent Unicode code-point ordering for canonical fleet evidence. */
|
|
||||||
export function compareCodePoints(left: string, right: string): number {
|
|
||||||
const leftPoints = Array.from(left, (character): number => character.codePointAt(0) ?? 0);
|
|
||||||
const rightPoints = Array.from(right, (character): number => character.codePointAt(0) ?? 0);
|
|
||||||
const sharedLength = Math.min(leftPoints.length, rightPoints.length);
|
|
||||||
|
|
||||||
for (let index = 0; index < sharedLength; index += 1) {
|
|
||||||
const leftPoint = leftPoints[index];
|
|
||||||
const rightPoint = rightPoints[index];
|
|
||||||
if (leftPoint === undefined || rightPoint === undefined) continue;
|
|
||||||
if (leftPoint !== rightPoint) return leftPoint < rightPoint ? -1 : 1;
|
|
||||||
}
|
|
||||||
return leftPoints.length < rightPoints.length
|
|
||||||
? -1
|
|
||||||
: leftPoints.length > rightPoints.length
|
|
||||||
? 1
|
|
||||||
: 0;
|
|
||||||
}
|
|
||||||
@@ -381,22 +381,6 @@ function isMissingFile(error: unknown): boolean {
|
|||||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
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>> {
|
async function acquireMutationLock(lockPath: string): Promise<() => Promise<void>> {
|
||||||
try {
|
try {
|
||||||
const handle = await open(lockPath, 'wx', 0o600);
|
const handle = await open(lockPath, 'wx', 0o600);
|
||||||
|
|||||||
@@ -1,484 +0,0 @@
|
|||||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { registerFleetCommand, type CommandResult, type CommandRunner } from '../commands/fleet.js';
|
|
||||||
import {
|
|
||||||
executeFleetReconcile,
|
|
||||||
type FleetReconcileCommandResult,
|
|
||||||
type FleetReconcileDeps,
|
|
||||||
type FleetReconcileResult,
|
|
||||||
} from './fleet-reconciler.js';
|
|
||||||
import {
|
|
||||||
parseRosterV2,
|
|
||||||
renderRosterV2Yaml,
|
|
||||||
type FleetRosterV2,
|
|
||||||
type FleetRosterV2Agent,
|
|
||||||
} from './roster-v2.js';
|
|
||||||
import { FleetTmuxRuntimeTransport } from './tmux-runtime-transport.js';
|
|
||||||
|
|
||||||
const holderIdentity = '11111111-1111-4111-8111-111111111111';
|
|
||||||
const stoppedAgent: FleetRosterV2Agent = {
|
|
||||||
name: 'coder0',
|
|
||||||
alias: 'Coder 0',
|
|
||||||
className: 'code',
|
|
||||||
runtime: 'pi',
|
|
||||||
provider: 'openai',
|
|
||||||
model: 'gpt-5.6-sol',
|
|
||||||
reasoning: 'high',
|
|
||||||
toolPolicy: 'code',
|
|
||||||
workingDirectory: '/srv/mosaic',
|
|
||||||
persistentPersona: false,
|
|
||||||
resetBetweenTasks: true,
|
|
||||||
lifecycle: { enabled: true, desiredState: 'stopped' },
|
|
||||||
launch: { yolo: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseRoster: FleetRosterV2 = {
|
|
||||||
version: 2,
|
|
||||||
generation: 7,
|
|
||||||
transport: 'tmux',
|
|
||||||
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
|
|
||||||
defaults: { workingDirectory: '/srv/mosaic', runtime: 'pi' },
|
|
||||||
runtimes: { pi: { resetCommand: '/new' } },
|
|
||||||
agents: [stoppedAgent],
|
|
||||||
};
|
|
||||||
|
|
||||||
interface InjectedLifecycleFailure {
|
|
||||||
readonly action: 'start' | 'stop' | 'restart';
|
|
||||||
readonly service: string;
|
|
||||||
readonly diagnostic: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeLifecycleHost {
|
|
||||||
readonly calls: string[][] = [];
|
|
||||||
readonly sessions = new Set<string>();
|
|
||||||
readonly activeServices = new Set<string>();
|
|
||||||
private failure: InjectedLifecycleFailure | undefined;
|
|
||||||
|
|
||||||
constructor(readonly roster: FleetRosterV2) {
|
|
||||||
this.sessions.add(roster.tmux.holderSession);
|
|
||||||
}
|
|
||||||
|
|
||||||
injectFailure(failure: InjectedLifecycleFailure): void {
|
|
||||||
this.failure = failure;
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly run = async (
|
|
||||||
command: string,
|
|
||||||
args: readonly string[],
|
|
||||||
): Promise<FleetReconcileCommandResult> => {
|
|
||||||
this.calls.push([command, ...args]);
|
|
||||||
if (command === 'tmux') return this.runTmux(args);
|
|
||||||
if (command === 'systemctl') return this.runSystemctl(args);
|
|
||||||
return { stdout: '', stderr: 'unsupported fake command', exitCode: 127 };
|
|
||||||
};
|
|
||||||
|
|
||||||
private runTmux(args: readonly string[]): FleetReconcileCommandResult {
|
|
||||||
if (args.includes('list-sessions')) {
|
|
||||||
return { stdout: `${[...this.sessions].join('\n')}\n`, stderr: '', exitCode: 0 };
|
|
||||||
}
|
|
||||||
if (args.includes('has-session')) {
|
|
||||||
const targetArgument = args[args.indexOf('-t') + 1];
|
|
||||||
const sessionName = targetArgument?.replace(/^=/, '').split(':')[0];
|
|
||||||
return {
|
|
||||||
stdout: '',
|
|
||||||
stderr: '',
|
|
||||||
exitCode: sessionName !== undefined && this.sessions.has(sessionName) ? 0 : 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (args.includes('show-environment')) {
|
|
||||||
return {
|
|
||||||
stdout: [
|
|
||||||
'HOME=/home/mosaic',
|
|
||||||
`MOSAIC_FLEET_OWNER=${holderIdentity}`,
|
|
||||||
`MOSAIC_TMUX_HOLDER=${this.roster.tmux.holderSession}`,
|
|
||||||
`MOSAIC_TMUX_SOCKET=${this.roster.tmux.socketName}`,
|
|
||||||
'PATH=/usr/bin:/bin',
|
|
||||||
'PWD=/home/mosaic',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
stderr: '',
|
|
||||||
exitCode: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { stdout: '', stderr: 'destructive tmux action rejected by fake', exitCode: 125 };
|
|
||||||
}
|
|
||||||
|
|
||||||
private runSystemctl(args: readonly string[]): FleetReconcileCommandResult {
|
|
||||||
const action = args[1];
|
|
||||||
const service = args[2];
|
|
||||||
if (action === 'show' && service !== undefined) {
|
|
||||||
return {
|
|
||||||
stdout: `ActiveState=${this.activeServices.has(service) ? 'active' : 'inactive'}\n`,
|
|
||||||
stderr: '',
|
|
||||||
exitCode: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(action === 'start' || action === 'stop' || action === 'restart') &&
|
|
||||||
service !== undefined
|
|
||||||
) {
|
|
||||||
this.applyLifecycleEffect(action, service);
|
|
||||||
if (this.failure?.action === action && this.failure.service === service) {
|
|
||||||
const diagnostic = this.failure.diagnostic;
|
|
||||||
this.failure = undefined;
|
|
||||||
return { stdout: '', stderr: diagnostic, exitCode: 1 };
|
|
||||||
}
|
|
||||||
return { stdout: '', stderr: '', exitCode: 0 };
|
|
||||||
}
|
|
||||||
return { stdout: '', stderr: 'unsupported fake systemctl action', exitCode: 125 };
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyLifecycleEffect(action: 'start' | 'stop' | 'restart', service: string): void {
|
|
||||||
if (service === 'mosaic-tmux-holder.service') return;
|
|
||||||
const match = /^mosaic-agent@(.+)\.service$/.exec(service);
|
|
||||||
if (!match) return;
|
|
||||||
const agentName = match[1];
|
|
||||||
if (agentName === undefined) return;
|
|
||||||
if (action === 'stop') {
|
|
||||||
this.activeServices.delete(service);
|
|
||||||
this.sessions.delete(agentName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.activeServices.add(service);
|
|
||||||
this.sessions.add(agentName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanupDirectories: string[] = [];
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.exitCode = undefined;
|
|
||||||
await Promise.all(
|
|
||||||
cleanupDirectories.splice(0).map(async (directory: string): Promise<void> => {
|
|
||||||
await rm(directory, { recursive: true, force: true });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function reconcileDeps(host: FakeLifecycleHost): FleetReconcileDeps {
|
|
||||||
return {
|
|
||||||
runner: host.run,
|
|
||||||
homeDirectory: '/home/mosaic',
|
|
||||||
readHolderIdentity: async () => holderIdentity,
|
|
||||||
validateRoster: async () => undefined,
|
|
||||||
prepareProjections: async () => [{ agentName: 'coder0' }],
|
|
||||||
applyProjection: async () => undefined,
|
|
||||||
readRoster: async () => host.roster,
|
|
||||||
acquireMutationLock: async () => async () => undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function execute(
|
|
||||||
host: FakeLifecycleHost,
|
|
||||||
command: 'apply' | 'reconcile' | 'restart' | 'status' | 'stop',
|
|
||||||
agentName?: string,
|
|
||||||
): Promise<FleetReconcileResult> {
|
|
||||||
return executeFleetReconcile({
|
|
||||||
roster: host.roster,
|
|
||||||
command,
|
|
||||||
...(agentName === undefined ? {} : { agentName }),
|
|
||||||
...(command === 'status' ? {} : { expectedGeneration: host.roster.generation }),
|
|
||||||
deps: reconcileDeps(host),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fixtureHome(roster: FleetRosterV2): Promise<string> {
|
|
||||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-reconciler-acceptance-'));
|
|
||||||
cleanupDirectories.push(home);
|
|
||||||
const fleetDirectory = join(home, 'fleet');
|
|
||||||
await mkdir(fleetDirectory, { mode: 0o700 });
|
|
||||||
await chmod(home, 0o700);
|
|
||||||
await chmod(fleetDirectory, 0o700);
|
|
||||||
await writeFile(join(fleetDirectory, 'roster.yaml'), renderRosterV2Yaml(roster), { mode: 0o600 });
|
|
||||||
return home;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fixtureLegacyRoster(): Promise<string> {
|
|
||||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-reconciler-acceptance-v1-'));
|
|
||||||
cleanupDirectories.push(home);
|
|
||||||
const fleetDirectory = join(home, 'fleet');
|
|
||||||
await mkdir(fleetDirectory, { mode: 0o700 });
|
|
||||||
await chmod(home, 0o700);
|
|
||||||
await chmod(fleetDirectory, 0o700);
|
|
||||||
const rosterPath = join(fleetDirectory, 'roster.yaml');
|
|
||||||
await writeFile(
|
|
||||||
rosterPath,
|
|
||||||
[
|
|
||||||
'version: 1',
|
|
||||||
'transport: tmux',
|
|
||||||
'tmux:',
|
|
||||||
' holder_session: _holder',
|
|
||||||
'agents:',
|
|
||||||
' - name: coder0',
|
|
||||||
' runtime: pi',
|
|
||||||
' class: code',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
return rosterPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cliProgram(home: string, host: FakeLifecycleHost): Command {
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerFleetCommand(program, {
|
|
||||||
mosaicHome: home,
|
|
||||||
runner: async (command: string, args: string[]): Promise<CommandResult> =>
|
|
||||||
host.run(command, args),
|
|
||||||
reconcileDeps: reconcileDeps(host),
|
|
||||||
});
|
|
||||||
return program;
|
|
||||||
}
|
|
||||||
|
|
||||||
function captureJson(): string[] {
|
|
||||||
const output: string[] = [];
|
|
||||||
vi.spyOn(console, 'log').mockImplementation((line: string): void => {
|
|
||||||
output.push(line);
|
|
||||||
});
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('FCM-M3-002 reconciler lifecycle acceptance', (): void => {
|
|
||||||
it('observes named-socket drift through canonical roster-v2 parsing without runtime mutation', async (): Promise<void> => {
|
|
||||||
const roster: FleetRosterV2 = {
|
|
||||||
...baseRoster,
|
|
||||||
agents: [
|
|
||||||
stoppedAgent,
|
|
||||||
{
|
|
||||||
...stoppedAgent,
|
|
||||||
name: 'reviewer0',
|
|
||||||
alias: 'Reviewer 0',
|
|
||||||
lifecycle: { enabled: true, desiredState: 'running' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...stoppedAgent,
|
|
||||||
name: 'validator0',
|
|
||||||
alias: 'Validator 0',
|
|
||||||
lifecycle: { enabled: false, desiredState: 'stopped' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const home = await fixtureHome(roster);
|
|
||||||
const host = new FakeLifecycleHost(roster);
|
|
||||||
host.sessions.add('coder0');
|
|
||||||
host.sessions.add('validator0');
|
|
||||||
host.sessions.add('coder0-shadow');
|
|
||||||
const output = captureJson();
|
|
||||||
|
|
||||||
await cliProgram(home, host).parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
|
||||||
|
|
||||||
expect(output).toHaveLength(1);
|
|
||||||
expect(JSON.parse(output[0] ?? '{}')).toMatchObject({
|
|
||||||
plan: {
|
|
||||||
agents: [
|
|
||||||
{ name: 'coder0', drift: ['unexpected-session'] },
|
|
||||||
{ name: 'reviewer0', drift: ['missing-session'] },
|
|
||||||
{ name: 'validator0', drift: ['unexpected-session', 'disabled-running'] },
|
|
||||||
],
|
|
||||||
unmanagedSessions: ['coder0-shadow'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(host.calls[0]).toEqual([
|
|
||||||
'tmux',
|
|
||||||
'-L',
|
|
||||||
'mosaic-fleet',
|
|
||||||
'list-sessions',
|
|
||||||
'-F',
|
|
||||||
'#{session_name}',
|
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
host.calls.every(
|
|
||||||
(call: string[]): boolean =>
|
|
||||||
call[0] !== 'tmux' || (call[1] === '-L' && call[2] === 'mosaic-fleet'),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
host.calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
|
|
||||||
).toBe(true);
|
|
||||||
expect(process.exitCode).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a missing canonical roster-v2 tmux socket through parseRosterV2', (): void => {
|
|
||||||
const source = renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*\n/m, '');
|
|
||||||
expect(() => parseRosterV2(source, 'yaml')).toThrow(
|
|
||||||
'Roster v2 tmux socket_name is required and must be a string.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts an explicit empty canonical roster-v2 socket as the literal default server', (): void => {
|
|
||||||
const source = renderRosterV2Yaml(baseRoster).replace(
|
|
||||||
/^ socket_name:.*$/m,
|
|
||||||
' socket_name: ""',
|
|
||||||
);
|
|
||||||
expect(parseRosterV2(source, 'yaml').tmux.socketName).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits -L for the literal default tmux server at the runtime transport boundary', async (): Promise<void> => {
|
|
||||||
const rosterPath = await fixtureLegacyRoster();
|
|
||||||
const runner = vi.fn<CommandRunner>(
|
|
||||||
async (): Promise<CommandResult> => ({
|
|
||||||
stdout: '111 pi 0 0 0 0\n',
|
|
||||||
stderr: '',
|
|
||||||
exitCode: 0,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const transport = new FleetTmuxRuntimeTransport({
|
|
||||||
mosaicHome: '/unused',
|
|
||||||
rosterPath,
|
|
||||||
runner,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(transport.verifySession('coder0')).resolves.toEqual({
|
|
||||||
id: 'coder0',
|
|
||||||
runtimeId: 'pi',
|
|
||||||
socketName: '',
|
|
||||||
});
|
|
||||||
expect(runner).toHaveBeenCalledTimes(1);
|
|
||||||
expect(runner).toHaveBeenCalledWith('tmux', [
|
|
||||||
'list-panes',
|
|
||||||
'-t',
|
|
||||||
'=coder0:0.0',
|
|
||||||
'-F',
|
|
||||||
'#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity} #{window_activity} #{session_activity}',
|
|
||||||
]);
|
|
||||||
expect(runner.mock.calls[0]?.[1]).not.toContain('-L');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classifies unmanaged near-collisions and stops only the exact roster-owned service', async (): Promise<void> => {
|
|
||||||
const host = new FakeLifecycleHost(baseRoster);
|
|
||||||
host.sessions.add('coder0');
|
|
||||||
host.sessions.add('coder0-shadow');
|
|
||||||
host.sessions.add('unmanaged');
|
|
||||||
host.activeServices.add('mosaic-agent@coder0.service');
|
|
||||||
host.activeServices.add('mosaic-agent@coder0-shadow.service');
|
|
||||||
|
|
||||||
const observed = await execute(host, 'status');
|
|
||||||
const stopped = await execute(host, 'stop', 'coder0');
|
|
||||||
|
|
||||||
expect(observed.plan.unmanagedSessions).toEqual(['coder0-shadow', 'unmanaged']);
|
|
||||||
expect(stopped).toMatchObject({ applied: true, lifecycle: 'complete' });
|
|
||||||
expect(host.activeServices.has('mosaic-agent@coder0.service')).toBe(false);
|
|
||||||
expect(host.activeServices.has('mosaic-agent@coder0-shadow.service')).toBe(true);
|
|
||||||
expect(host.sessions.has('coder0-shadow')).toBe(true);
|
|
||||||
expect(host.sessions.has('unmanaged')).toBe(true);
|
|
||||||
expect(host.calls).toContainEqual([
|
|
||||||
'systemctl',
|
|
||||||
'--user',
|
|
||||||
'stop',
|
|
||||||
'mosaic-agent@coder0.service',
|
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
host.calls.some(
|
|
||||||
(call: string[]): boolean =>
|
|
||||||
call.includes('kill-session') ||
|
|
||||||
call.includes('coder0-shadow.service') ||
|
|
||||||
call.includes('unmanaged.service'),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves persisted stopped state through apply, reconcile, restart failure, and recovery reconcile', async (): Promise<void> => {
|
|
||||||
const host = new FakeLifecycleHost(baseRoster);
|
|
||||||
host.sessions.add('coder0');
|
|
||||||
host.activeServices.add('mosaic-agent@coder0.service');
|
|
||||||
|
|
||||||
const applied = await execute(host, 'apply');
|
|
||||||
const reconciled = await execute(host, 'reconcile');
|
|
||||||
host.injectFailure({
|
|
||||||
action: 'restart',
|
|
||||||
service: 'mosaic-agent@coder0.service',
|
|
||||||
diagnostic: 'crash after effect: TOKEN=acceptance-secret',
|
|
||||||
});
|
|
||||||
const partialRestart = await execute(host, 'restart', 'coder0');
|
|
||||||
|
|
||||||
expect(applied).toMatchObject({ applied: true, lifecycle: 'complete' });
|
|
||||||
expect(reconciled).toMatchObject({ applied: true, lifecycle: 'complete' });
|
|
||||||
expect(host.roster.agents[0]?.lifecycle.desiredState).toBe('stopped');
|
|
||||||
expect(partialRestart).toMatchObject({
|
|
||||||
applied: false,
|
|
||||||
authoritativeRoster: 'unchanged',
|
|
||||||
projections: 'not-applied',
|
|
||||||
lifecycle: 'incomplete',
|
|
||||||
recovery: {
|
|
||||||
code: 'lifecycle-apply-failed',
|
|
||||||
action: 'rerun-after-inspecting-owned-resources',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(host.activeServices.has('mosaic-agent@coder0.service')).toBe(true);
|
|
||||||
|
|
||||||
const recovered = await execute(host, 'reconcile');
|
|
||||||
|
|
||||||
expect(recovered).toMatchObject({ applied: true, lifecycle: 'complete' });
|
|
||||||
expect(host.roster.agents[0]?.lifecycle.desiredState).toBe('stopped');
|
|
||||||
expect(host.activeServices.has('mosaic-agent@coder0.service')).toBe(false);
|
|
||||||
expect(host.sessions.has('coder0')).toBe(false);
|
|
||||||
const destructiveCalls = host.calls.filter(
|
|
||||||
(call: string[]): boolean =>
|
|
||||||
call[0] === 'systemctl' && ['start', 'stop', 'restart'].includes(call[2] ?? ''),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
destructiveCalls.every(
|
|
||||||
(call: string[]): boolean => call[3] === 'mosaic-agent@coder0.service',
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(destructiveCalls.some((call: string[]): boolean => call[2] === 'start')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits stable non-zero redacted JSON for a partial lifecycle effect', async (): Promise<void> => {
|
|
||||||
const home = await fixtureHome(baseRoster);
|
|
||||||
const host = new FakeLifecycleHost(baseRoster);
|
|
||||||
host.injectFailure({
|
|
||||||
action: 'restart',
|
|
||||||
service: 'mosaic-agent@coder0.service',
|
|
||||||
diagnostic: 'simulated runner stderr with PASSWORD=acceptance-secret',
|
|
||||||
});
|
|
||||||
const output = captureJson();
|
|
||||||
|
|
||||||
await cliProgram(home, host).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'restart',
|
|
||||||
'coder0',
|
|
||||||
'--expected-generation',
|
|
||||||
'7',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const line = output.at(-1) ?? '';
|
|
||||||
expect(output).toHaveLength(1);
|
|
||||||
expect(JSON.parse(line)).toEqual({
|
|
||||||
applied: false,
|
|
||||||
authoritativeRoster: 'unchanged',
|
|
||||||
projections: 'not-applied',
|
|
||||||
lifecycle: 'incomplete',
|
|
||||||
plan: {
|
|
||||||
generation: 7,
|
|
||||||
holder: 'owned',
|
|
||||||
agents: [
|
|
||||||
{
|
|
||||||
name: 'coder0',
|
|
||||||
desiredState: 'stopped',
|
|
||||||
enabled: true,
|
|
||||||
systemd: 'inactive',
|
|
||||||
tmux: 'missing',
|
|
||||||
drift: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
unmanagedSessions: [],
|
|
||||||
},
|
|
||||||
recovery: {
|
|
||||||
code: 'lifecycle-apply-failed',
|
|
||||||
action: 'rerun-after-inspecting-owned-resources',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(line).not.toContain('PASSWORD');
|
|
||||||
expect(line).not.toContain('acceptance-secret');
|
|
||||||
expect(line).not.toContain('simulated runner stderr');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -274,95 +274,6 @@ describe('fleet roster-owned reconciler', (): void => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['plan', 'status', 'doctor', 'verify'] as const)(
|
|
||||||
'keeps default-server %s observational and free of lifecycle effects',
|
|
||||||
async (command) => {
|
|
||||||
const calls: string[][] = [];
|
|
||||||
const defaultServerRoster: FleetRosterV2 = {
|
|
||||||
...roster,
|
|
||||||
tmux: { ...roster.tmux, socketName: '' },
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
executeFleetReconcile({
|
|
||||||
roster: defaultServerRoster,
|
|
||||||
command,
|
|
||||||
deps: deps({
|
|
||||||
runner: async (executable, args) => {
|
|
||||||
calls.push([executable, ...args]);
|
|
||||||
if (executable === 'tmux' && args.includes('list-sessions')) {
|
|
||||||
return { stdout: '_holder\n', stderr: '', exitCode: 0 };
|
|
||||||
}
|
|
||||||
if (executable === 'tmux' && args.includes('show-environment')) {
|
|
||||||
return {
|
|
||||||
stdout:
|
|
||||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
|
||||||
stderr: '',
|
|
||||||
exitCode: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { stdout: 'ActiveState=inactive\n', stderr: '', exitCode: 0 };
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
).resolves.toMatchObject({ applied: false, lifecycle: 'not-applied' });
|
|
||||||
expect(
|
|
||||||
calls.some(
|
|
||||||
([executable, , action]): boolean =>
|
|
||||||
executable === 'systemctl' &&
|
|
||||||
(action === 'start' || action === 'stop' || action === 'restart'),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each(['start', 'stop', 'restart', 'apply', 'reconcile'] as const)(
|
|
||||||
'fails closed before %s can route fixed named-socket services for a default-server roster',
|
|
||||||
async (command) => {
|
|
||||||
const calls: string[][] = [];
|
|
||||||
let projectionPrepares = 0;
|
|
||||||
let projectionApplies = 0;
|
|
||||||
const defaultServerRoster: FleetRosterV2 = {
|
|
||||||
...roster,
|
|
||||||
tmux: { ...roster.tmux, socketName: '' },
|
|
||||||
agents: [
|
|
||||||
{
|
|
||||||
...roster.agents[0]!,
|
|
||||||
lifecycle: {
|
|
||||||
enabled: true,
|
|
||||||
desiredState: command === 'start' || command === 'restart' ? 'running' : 'stopped',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
executeFleetReconcile({
|
|
||||||
roster: defaultServerRoster,
|
|
||||||
command,
|
|
||||||
expectedGeneration: 7,
|
|
||||||
deps: deps({
|
|
||||||
readRoster: async () => defaultServerRoster,
|
|
||||||
prepareProjections: async () => {
|
|
||||||
projectionPrepares += 1;
|
|
||||||
return [{ agentName: 'coder0' }];
|
|
||||||
},
|
|
||||||
applyProjection: async () => {
|
|
||||||
projectionApplies += 1;
|
|
||||||
},
|
|
||||||
runner: async (executable, args) => {
|
|
||||||
calls.push([executable, ...args]);
|
|
||||||
return { stdout: '', stderr: '', exitCode: 0 };
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
).rejects.toMatchObject({ code: 'lifecycle-precondition-failed' });
|
|
||||||
expect(projectionPrepares).toBe(0);
|
|
||||||
expect(projectionApplies).toBe(0);
|
|
||||||
expect(calls).toEqual([]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('starts only an explicitly running roster agent with exact systemd targets', async (): Promise<void> => {
|
it('starts only an explicitly running roster agent with exact systemd targets', async (): Promise<void> => {
|
||||||
const calls: string[][] = [];
|
const calls: string[][] = [];
|
||||||
const runningRoster: FleetRosterV2 = {
|
const runningRoster: FleetRosterV2 = {
|
||||||
|
|||||||
@@ -176,7 +176,6 @@ export async function executeFleetReconcile(
|
|||||||
assertLocalRosterOnly(request.roster);
|
assertLocalRosterOnly(request.roster);
|
||||||
const validateRoster = request.deps.validateRoster ?? defaultValidateRoster(request);
|
const validateRoster = request.deps.validateRoster ?? defaultValidateRoster(request);
|
||||||
await validateRoster(request.roster);
|
await validateRoster(request.roster);
|
||||||
assertLifecycleSocketAuthority(request);
|
|
||||||
const plan = scopePlan(await observeFleet(request.roster, request.deps), request.agentName);
|
const plan = scopePlan(await observeFleet(request.roster, request.deps), request.agentName);
|
||||||
|
|
||||||
if (request.command === 'status' || request.command === 'doctor') {
|
if (request.command === 'status' || request.command === 'doctor') {
|
||||||
@@ -478,19 +477,6 @@ function assertVerificationSafe(plan: FleetReconcilePlan): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertLifecycleSocketAuthority(request: FleetReconcileRequest): void {
|
|
||||||
const usesFixedLifecycleUnits =
|
|
||||||
request.command === 'apply' ||
|
|
||||||
request.command === 'reconcile' ||
|
|
||||||
isLifecycleCommand(request.command);
|
|
||||||
if (usesFixedLifecycleUnits && request.roster.tmux.socketName === '') {
|
|
||||||
throw new FleetReconcileError(
|
|
||||||
'lifecycle-precondition-failed',
|
|
||||||
'Default-server lifecycle mutation is unsupported by the fixed named-socket systemd units.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function targetAgentsFor(roster: FleetRosterV2, agentName?: string): readonly FleetRosterV2Agent[] {
|
function targetAgentsFor(roster: FleetRosterV2, agentName?: string): readonly FleetRosterV2Agent[] {
|
||||||
if (agentName === undefined) return roster.agents;
|
if (agentName === undefined) return roster.agents;
|
||||||
const agent = roster.agents.find(
|
const agent = roster.agents.find(
|
||||||
@@ -584,29 +570,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(
|
function defaultPrepareProjections(
|
||||||
request: FleetReconcileRequest,
|
request: FleetReconcileRequest,
|
||||||
): (roster: FleetRosterV2) => Promise<readonly PreparedAgentEnvironmentProjection[]> {
|
): (roster: FleetRosterV2) => Promise<readonly PreparedAgentEnvironmentProjection[]> {
|
||||||
@@ -619,7 +582,16 @@ function defaultPrepareProjections(
|
|||||||
mosaicHome,
|
mosaicHome,
|
||||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||||
agentName: agent.name,
|
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 +606,62 @@ function mosaicHomeFor(deps: FleetReconcileDeps): string {
|
|||||||
return deps.mosaicHome ?? join(homedir(), '.config', 'mosaic');
|
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(
|
export function acquirePrivateReconcileLock(
|
||||||
mosaicHome: string,
|
mosaicHome: string,
|
||||||
openLock: typeof open = open,
|
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>> {
|
): () => Promise<() => Promise<void>> {
|
||||||
const fleetDir = join(mosaicHome, 'fleet');
|
const fleetDir = join(mosaicHome, 'fleet');
|
||||||
const lockPath = join(fleetDir, lockLeaf);
|
const lockPath = join(fleetDir, 'roster.yaml.reconcile.lock');
|
||||||
// The message-producing helpers below serve BOTH managed locks, so every fault
|
|
||||||
// names the actual lock file (`fleet/<leaf>`) — an operator needs to know WHICH
|
|
||||||
// lock is stale, not a hardcoded "reconciliation lock".
|
|
||||||
const lockLabel = `fleet/${lockLeaf}`;
|
|
||||||
return async (): Promise<() => Promise<void>> => {
|
return async (): Promise<() => Promise<void>> => {
|
||||||
await assertPrivateManagedDirectory(mosaicHome);
|
await assertPrivateManagedDirectory(mosaicHome);
|
||||||
await assertPrivateManagedDirectory(fleetDir);
|
await assertPrivateManagedDirectory(fleetDir);
|
||||||
await assertSafeLockLeafIfPresent(lockPath, lockLabel);
|
await assertSafeLockLeafIfPresent(lockPath);
|
||||||
|
|
||||||
let handle: FileHandle;
|
let handle: FileHandle;
|
||||||
try {
|
try {
|
||||||
handle = await openLock(lockPath, 'wx', 0o600);
|
handle = await openLock(lockPath, 'wx', 0o600);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (isCode(error, 'EEXIST')) {
|
if (isCode(error, 'EEXIST')) {
|
||||||
await assertSafeLockLeafIfPresent(lockPath, lockLabel);
|
await assertSafeLockLeafIfPresent(lockPath);
|
||||||
throw new FleetReconcileError('concurrent-mutation', busyMessage);
|
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();
|
const token = randomUUID();
|
||||||
let tokenPersisted = false;
|
|
||||||
try {
|
try {
|
||||||
await handle.writeFile(`${token}\n`, 'utf8');
|
await handle.writeFile(`${token}\n`, 'utf8');
|
||||||
tokenPersisted = true;
|
const opened = await handle.stat();
|
||||||
await handle.close();
|
await handle.close();
|
||||||
if (!created)
|
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'unsafe-lock');
|
||||||
throw new FleetReconcileError(
|
|
||||||
'lock-io-failed',
|
|
||||||
`The ${lockLabel} lock cannot be initialized.`,
|
|
||||||
);
|
|
||||||
await assertLockOwnership(
|
|
||||||
lockPath,
|
|
||||||
created.dev,
|
|
||||||
created.ino,
|
|
||||||
token,
|
|
||||||
'unsafe-lock',
|
|
||||||
lockLabel,
|
|
||||||
);
|
|
||||||
return async (): Promise<void> => {
|
return async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await assertLockOwnership(
|
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
|
||||||
lockPath,
|
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
|
||||||
created.dev,
|
|
||||||
created.ino,
|
|
||||||
token,
|
|
||||||
'lock-cleanup-failed',
|
|
||||||
lockLabel,
|
|
||||||
);
|
|
||||||
await unlink(lockPath);
|
await unlink(lockPath);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof FleetReconcileError) throw error;
|
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) {
|
} catch (error: unknown) {
|
||||||
await handle.close().catch((): void => {});
|
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;
|
if (error instanceof FleetReconcileError) throw error;
|
||||||
throw new FleetReconcileError(
|
throw new FleetReconcileError(
|
||||||
'lock-io-failed',
|
'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> {
|
async function assertPrivateManagedDirectory(path: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const metadata = await lstat(path);
|
const metadata = await lstat(path);
|
||||||
@@ -836,16 +677,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 {
|
try {
|
||||||
const metadata = await lstat(lockPath);
|
const metadata = await lstat(lockPath);
|
||||||
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
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) {
|
} catch (error: unknown) {
|
||||||
if (isCode(error, 'ENOENT')) return;
|
if (isCode(error, 'ENOENT')) return;
|
||||||
if (error instanceof FleetReconcileError) throw error;
|
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 +696,6 @@ async function assertLockOwnership(
|
|||||||
inode: number,
|
inode: number,
|
||||||
token: string,
|
token: string,
|
||||||
failureCode: 'unsafe-lock' | 'lock-cleanup-failed',
|
failureCode: 'unsafe-lock' | 'lock-cleanup-failed',
|
||||||
lockLabel: string,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const metadata = await lstat(lockPath);
|
const metadata = await lstat(lockPath);
|
||||||
@@ -866,21 +706,24 @@ async function assertLockOwnership(
|
|||||||
metadata.dev !== device ||
|
metadata.dev !== device ||
|
||||||
metadata.ino !== inode
|
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);
|
const handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||||
try {
|
try {
|
||||||
const opened = await handle.stat();
|
const opened = await handle.stat();
|
||||||
const contents = await handle.readFile({ encoding: 'utf8' });
|
const contents = await handle.readFile({ encoding: 'utf8' });
|
||||||
if (opened.dev !== device || opened.ino !== inode || contents !== `${token}\n`) {
|
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 {
|
} finally {
|
||||||
await handle.close();
|
await handle.close();
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof FleetReconcileError) throw error;
|
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.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,10 +104,6 @@ export interface FleetRoster {
|
|||||||
|
|
||||||
export type FleetRosterInputFormat = 'yaml' | 'json';
|
export type FleetRosterInputFormat = 'yaml' | 'json';
|
||||||
|
|
||||||
export class FleetRosterConfigurationError extends Error {
|
|
||||||
override name = 'FleetRosterConfigurationError';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveInstalledFleetRosterPath(mosaicHome: string): string {
|
export function resolveInstalledFleetRosterPath(mosaicHome: string): string {
|
||||||
const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||||
try {
|
try {
|
||||||
@@ -142,52 +138,8 @@ export function parseFleetRosterV1(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
||||||
const source = await readFleetRosterText(path);
|
const source = await readFile(path, 'utf8');
|
||||||
try {
|
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
||||||
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
|
||||||
} catch (error) {
|
|
||||||
if (isRosterParserError(error)) throw invalidFleetRosterError(path);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read an operator-owned roster with errors that say how to recover. */
|
|
||||||
export async function readFleetRosterText(path: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
return await readFile(path, 'utf8');
|
|
||||||
} catch (error) {
|
|
||||||
if (isNodeErrorCode(error, 'ENOENT')) {
|
|
||||||
throw new FleetRosterConfigurationError(
|
|
||||||
`No fleet roster found at ${path}. Run \`mosaic fleet init\` to create one.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new FleetRosterConfigurationError(
|
|
||||||
`Could not read fleet roster at ${path}. Check the file exists and is readable.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse a roster document needed only to select the v1/v2 command path. */
|
|
||||||
export function parseFleetRosterDocument(source: string, path: string): unknown {
|
|
||||||
try {
|
|
||||||
return YAML.parse(source);
|
|
||||||
} catch (error) {
|
|
||||||
if (isRosterParserError(error)) throw invalidFleetRosterError(path);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalidFleetRosterError(path: string): FleetRosterConfigurationError {
|
|
||||||
return new FleetRosterConfigurationError(
|
|
||||||
`Fleet roster at ${path} is invalid. Fix the file or run \`mosaic fleet init --force\`.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRosterParserError(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
error instanceof SyntaxError ||
|
|
||||||
(error instanceof Error && (error.name === 'YAMLParseError' || error.name === 'YAMLWarning'))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ import {
|
|||||||
symlink,
|
symlink,
|
||||||
writeFile,
|
writeFile,
|
||||||
} from 'node:fs/promises';
|
} from 'node:fs/promises';
|
||||||
import { homedir, tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
AgentEnvBoundaryError,
|
AgentEnvBoundaryError,
|
||||||
parseAgentEnvironment,
|
parseAgentEnvironment,
|
||||||
previewAgentEnvironmentProjection,
|
|
||||||
renderGeneratedAgentEnvironment,
|
renderGeneratedAgentEnvironment,
|
||||||
writeAgentEnvironmentProjection,
|
writeAgentEnvironmentProjection,
|
||||||
} from './generated-env-boundary.js';
|
} from './generated-env-boundary.js';
|
||||||
@@ -87,77 +86,6 @@ describe('generated fleet agent environment boundary', (): void => {
|
|||||||
}).toThrow(AgentEnvBoundaryError);
|
}).toThrow(AgentEnvBoundaryError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects traversal in home-relative workdirs before expansion', (): void => {
|
|
||||||
for (const workingDirectory of ['~/../escape', '~/src/../../escape']) {
|
|
||||||
expect((): void => {
|
|
||||||
renderGeneratedAgentEnvironment({
|
|
||||||
...generatedValues,
|
|
||||||
MOSAIC_AGENT_WORKDIR: workingDirectory,
|
|
||||||
});
|
|
||||||
}).toThrow(
|
|
||||||
expect.objectContaining({
|
|
||||||
diagnostic: expect.objectContaining({
|
|
||||||
code: 'unsafe-path',
|
|
||||||
key: 'MOSAIC_AGENT_WORKDIR',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('expands home-relative workdirs before preserving absolute-path validation', (): void => {
|
|
||||||
expect(
|
|
||||||
renderGeneratedAgentEnvironment({
|
|
||||||
...generatedValues,
|
|
||||||
MOSAIC_AGENT_WORKDIR: '~/src',
|
|
||||||
}),
|
|
||||||
).toContain(`MOSAIC_AGENT_WORKDIR=${join(homedir(), 'src')}\n`);
|
|
||||||
|
|
||||||
for (const workingDirectory of ['relative/path', '../outside']) {
|
|
||||||
expect((): void => {
|
|
||||||
renderGeneratedAgentEnvironment({
|
|
||||||
...generatedValues,
|
|
||||||
MOSAIC_AGENT_WORKDIR: workingDirectory,
|
|
||||||
});
|
|
||||||
}).toThrow(AgentEnvBoundaryError);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('previews legacy relocation and quarantine without exposing content or mutating files', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
|
||||||
const mosaicHome = join(cleanup, 'mosaic');
|
|
||||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
|
||||||
const legacyPath = join(agentEnvDir, 'coder0.env');
|
|
||||||
const legacy = 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\nMOSAIC_AGENT_COMMAND=never-print-command\n';
|
|
||||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
|
||||||
await writeFile(legacyPath, legacy, { mode: 0o600 });
|
|
||||||
|
|
||||||
const preview = await previewAgentEnvironmentProjection({
|
|
||||||
mosaicHome,
|
|
||||||
agentEnvDir,
|
|
||||||
agentName: 'coder0',
|
|
||||||
generated: generatedValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(preview).toMatchObject({
|
|
||||||
agentName: 'coder0',
|
|
||||||
generated: 'rebuild',
|
|
||||||
legacy: 'quarantine',
|
|
||||||
relocatedKeys: ['MOSAIC_RUNTIME_BIN'],
|
|
||||||
diagnostics: [
|
|
||||||
expect.objectContaining({
|
|
||||||
code: 'unknown-key',
|
|
||||||
key: 'MOSAIC_AGENT_COMMAND',
|
|
||||||
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(JSON.stringify(preview)).not.toContain('never-print-command');
|
|
||||||
await expect(readFile(legacyPath, 'utf8')).resolves.toBe(legacy);
|
|
||||||
await expect(readFile(join(agentEnvDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
|
|
||||||
await expect(readFile(join(agentEnvDir, 'coder0.env.quarantine'), 'utf8')).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates missing managed directories privately before writing a projection', async (): Promise<void> => {
|
it('creates missing managed directories privately before writing a projection', async (): Promise<void> => {
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||||
const mosaicHome = join(cleanup, 'mosaic');
|
const mosaicHome = join(cleanup, 'mosaic');
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { compareCodePoints } from './deterministic-order.js';
|
|
||||||
|
|
||||||
export type AgentEnvironmentKind = 'generated' | 'local';
|
export type AgentEnvironmentKind = 'generated' | 'local';
|
||||||
|
|
||||||
@@ -32,15 +30,6 @@ export interface AgentEnvironmentProjectionResult {
|
|||||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sanitized, non-mutating projection evidence safe for migration output. */
|
|
||||||
export interface AgentEnvironmentProjectionPreview {
|
|
||||||
readonly agentName: string;
|
|
||||||
readonly generated: 'rebuild';
|
|
||||||
readonly legacy: 'absent' | 'regenerate-only' | 'relocate-local' | 'quarantine';
|
|
||||||
readonly relocatedKeys: readonly string[];
|
|
||||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A projection fully validated without changing managed files. */
|
/** A projection fully validated without changing managed files. */
|
||||||
export interface PreparedAgentEnvironmentProjection {
|
export interface PreparedAgentEnvironmentProjection {
|
||||||
readonly mosaicHome: string;
|
readonly mosaicHome: string;
|
||||||
@@ -51,7 +40,6 @@ export interface PreparedAgentEnvironmentProjection {
|
|||||||
readonly generated: string;
|
readonly generated: string;
|
||||||
readonly local: string;
|
readonly local: string;
|
||||||
readonly legacy?: string;
|
readonly legacy?: string;
|
||||||
readonly legacyRelocatedKeys: readonly string[];
|
|
||||||
readonly quarantinePath?: string;
|
readonly quarantinePath?: string;
|
||||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
||||||
}
|
}
|
||||||
@@ -198,59 +186,11 @@ export async function prepareAgentEnvironmentProjection(
|
|||||||
generated,
|
generated,
|
||||||
local,
|
local,
|
||||||
...(legacy === undefined ? {} : { legacy }),
|
...(legacy === undefined ? {} : { legacy }),
|
||||||
legacyRelocatedKeys: Object.keys(legacyDisposition.localValues).sort(compareCodePoints),
|
|
||||||
...(quarantinePath === undefined ? {} : { quarantinePath }),
|
...(quarantinePath === undefined ? {} : { quarantinePath }),
|
||||||
diagnostics: legacyDisposition.diagnostics,
|
diagnostics: legacyDisposition.diagnostics,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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.
|
* Validates only the exact generated projection eligible for a roster delete.
|
||||||
* Local overrides, legacy input, and quarantine records are operator-retained and
|
* Local overrides, legacy input, and quarantine records are operator-retained and
|
||||||
@@ -268,33 +208,6 @@ export async function prepareAgentGeneratedProjectionDeletion(
|
|||||||
return generatedPath;
|
return generatedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function previewAgentEnvironmentProjection(
|
|
||||||
options: AgentEnvironmentProjectionOptions,
|
|
||||||
): Promise<AgentEnvironmentProjectionPreview> {
|
|
||||||
const prepared = await prepareAgentEnvironmentProjection(options);
|
|
||||||
const relocatedKeys = prepared.legacyRelocatedKeys;
|
|
||||||
const legacy =
|
|
||||||
prepared.legacy === undefined
|
|
||||||
? 'absent'
|
|
||||||
: prepared.diagnostics.length > 0
|
|
||||||
? 'quarantine'
|
|
||||||
: relocatedKeys.length > 0
|
|
||||||
? 'relocate-local'
|
|
||||||
: 'regenerate-only';
|
|
||||||
return {
|
|
||||||
agentName: options.agentName,
|
|
||||||
generated: 'rebuild',
|
|
||||||
legacy,
|
|
||||||
relocatedKeys,
|
|
||||||
diagnostics: [...prepared.diagnostics].sort((left, right): number =>
|
|
||||||
compareCodePoints(
|
|
||||||
`${left.key}:${left.code}:${left.sha256}`,
|
|
||||||
`${right.key}:${right.code}:${right.sha256}`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Applies a previously prepared deterministic projection. */
|
/** Applies a previously prepared deterministic projection. */
|
||||||
export async function applyPreparedAgentEnvironmentProjection(
|
export async function applyPreparedAgentEnvironmentProjection(
|
||||||
prepared: PreparedAgentEnvironmentProjection,
|
prepared: PreparedAgentEnvironmentProjection,
|
||||||
@@ -366,7 +279,7 @@ function normalizeGeneratedValues(
|
|||||||
for (const key of GENERATED_AGENT_ENV_KEYS) {
|
for (const key of GENERATED_AGENT_ENV_KEYS) {
|
||||||
const value = values[key];
|
const value = values[key];
|
||||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
||||||
normalized[key] = key === 'MOSAIC_AGENT_WORKDIR' ? expandHomeDirectory(value) : value;
|
normalized[key] = value;
|
||||||
}
|
}
|
||||||
for (const [key, value] of Object.entries(values)) {
|
for (const [key, value] of Object.entries(values)) {
|
||||||
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
||||||
@@ -375,23 +288,17 @@ function normalizeGeneratedValues(
|
|||||||
return Object.freeze(normalized);
|
return Object.freeze(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandHomeDirectory(path: string): string {
|
|
||||||
if (path === '~') return homedir();
|
|
||||||
if (!path.startsWith('~/') || path.split('/').includes('..')) return path;
|
|
||||||
return join(homedir(), path.slice(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLocalAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
function renderLocalAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
||||||
if (Object.keys(values).length === 0) return '';
|
if (Object.keys(values).length === 0) return '';
|
||||||
const parsed = parseAgentEnvironment(
|
const parsed = parseAgentEnvironment(
|
||||||
Object.entries(values)
|
Object.entries(values)
|
||||||
.sort(([left], [right]): number => compareCodePoints(left, right))
|
.sort(([left], [right]): number => left.localeCompare(right))
|
||||||
.map(([key, value]): string => `${key}=${value}`)
|
.map(([key, value]): string => `${key}=${value}`)
|
||||||
.join('\n'),
|
.join('\n'),
|
||||||
'local',
|
'local',
|
||||||
);
|
);
|
||||||
return `${Object.entries(parsed)
|
return `${Object.entries(parsed)
|
||||||
.sort(([left], [right]): number => compareCodePoints(left, right))
|
.sort(([left], [right]): number => left.localeCompare(right))
|
||||||
.map(([key, value]): string => `${key}=${value}`)
|
.map(([key, value]): string => `${key}=${value}`)
|
||||||
.join('\n')}\n`;
|
.join('\n')}\n`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,12 +45,6 @@ agents:
|
|||||||
|
|
||||||
let semanticTmp: string | undefined;
|
let semanticTmp: string | undefined;
|
||||||
|
|
||||||
it('preserves an explicit empty socket as the literal default tmux server', () => {
|
|
||||||
const roster = parseRosterV2(validRoster.replace('socket_name: mosaic-fleet', "socket_name: ''"));
|
|
||||||
expect(roster.tmux.socketName).toBe('');
|
|
||||||
expect(renderRosterV2Yaml(roster)).toContain('socket_name: ""');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
afterEach(async (): Promise<void> => {
|
||||||
if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true });
|
if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true });
|
||||||
semanticTmp = undefined;
|
semanticTmp = undefined;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
type PersonaResolution,
|
type PersonaResolution,
|
||||||
type RoleAuthority,
|
type RoleAuthority,
|
||||||
} from '../commands/fleet-personas.js';
|
} from '../commands/fleet-personas.js';
|
||||||
import { compareCodePoints } from './deterministic-order.js';
|
|
||||||
|
|
||||||
export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const;
|
export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const;
|
||||||
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
|
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
|
||||||
@@ -173,7 +172,7 @@ export const ROSTER_V2_JSON_SCHEMA: JsonSchema = {
|
|||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
required: ['socket_name', 'holder_session'],
|
required: ['socket_name', 'holder_session'],
|
||||||
properties: {
|
properties: {
|
||||||
socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]*$' },
|
socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
|
||||||
holder_session: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
|
holder_session: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -273,7 +272,6 @@ const AGENT_KEYS = [
|
|||||||
const LIFECYCLE_KEYS = ['enabled', 'desired_state'];
|
const LIFECYCLE_KEYS = ['enabled', 'desired_state'];
|
||||||
const LAUNCH_KEYS = ['yolo'];
|
const LAUNCH_KEYS = ['yolo'];
|
||||||
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||||
const TMUX_SOCKET_IDENTIFIER = /^[A-Za-z0-9_.-]*$/;
|
|
||||||
const TMUX_IDENTIFIER = /^[A-Za-z0-9_.-]+$/;
|
const TMUX_IDENTIFIER = /^[A-Za-z0-9_.-]+$/;
|
||||||
const POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/;
|
const POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/;
|
||||||
|
|
||||||
@@ -329,7 +327,7 @@ function normalizeTmux(value: unknown): FleetRosterV2Tmux {
|
|||||||
const raw = requiredObject(value, 'Roster v2 tmux');
|
const raw = requiredObject(value, 'Roster v2 tmux');
|
||||||
assertKnownKeys(raw, 'Roster v2 tmux', TMUX_KEYS);
|
assertKnownKeys(raw, 'Roster v2 tmux', TMUX_KEYS);
|
||||||
return {
|
return {
|
||||||
socketName: requiredTmuxSocket(raw.socket_name, 'Roster v2 tmux socket_name'),
|
socketName: requiredTmuxIdentifier(raw.socket_name, 'Roster v2 tmux socket_name'),
|
||||||
holderSession: requiredTmuxIdentifier(raw.holder_session, 'Roster v2 tmux holder_session'),
|
holderSession: requiredTmuxIdentifier(raw.holder_session, 'Roster v2 tmux holder_session'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -350,7 +348,7 @@ function normalizeRuntimes(value: unknown): Readonly<Record<string, FleetRosterV
|
|||||||
throw new RosterV2ValidationError('Roster v2 runtimes must not be empty.');
|
throw new RosterV2ValidationError('Roster v2 runtimes must not be empty.');
|
||||||
|
|
||||||
const result: Record<string, FleetRosterV2Runtime> = {};
|
const result: Record<string, FleetRosterV2Runtime> = {};
|
||||||
for (const name of names.sort(compareCodePoints)) {
|
for (const name of names.sort()) {
|
||||||
const runtime = requiredRuntime(name, 'Roster v2 runtime name');
|
const runtime = requiredRuntime(name, 'Roster v2 runtime name');
|
||||||
const config = requiredObject(raw[name], `Roster v2 runtime "${runtime}"`);
|
const config = requiredObject(raw[name], `Roster v2 runtime "${runtime}"`);
|
||||||
assertKnownKeys(config, `Roster v2 runtime "${runtime}"`, RUNTIME_KEYS);
|
assertKnownKeys(config, `Roster v2 runtime "${runtime}"`, RUNTIME_KEYS);
|
||||||
@@ -418,7 +416,7 @@ function normalizeAgents(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
return agents.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
|
return agents.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
|
||||||
compareCodePoints(left.name, right.name),
|
left.name.localeCompare(right.name),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,17 +473,6 @@ function requiredIdentifier(value: unknown, label: string): string {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requiredTmuxSocket(value: unknown, label: string): string {
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
throw new RosterV2ValidationError(`${label} is required and must be a string.`);
|
|
||||||
}
|
|
||||||
const result = value.trim();
|
|
||||||
if (!TMUX_SOCKET_IDENTIFIER.test(result)) {
|
|
||||||
throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requiredTmuxIdentifier(value: unknown, label: string): string {
|
function requiredTmuxIdentifier(value: unknown, label: string): string {
|
||||||
const result = requiredString(value, label);
|
const result = requiredString(value, label);
|
||||||
if (!TMUX_IDENTIFIER.test(result))
|
if (!TMUX_IDENTIFIER.test(result))
|
||||||
@@ -537,7 +524,7 @@ function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
|
|||||||
},
|
},
|
||||||
runtimes: Object.fromEntries(
|
runtimes: Object.fromEntries(
|
||||||
Object.entries(roster.runtimes)
|
Object.entries(roster.runtimes)
|
||||||
.sort(([left], [right]): number => compareCodePoints(left, right))
|
.sort(([left], [right]): number => left.localeCompare(right))
|
||||||
.map(([name, runtime]): [string, unknown] => [
|
.map(([name, runtime]): [string, unknown] => [
|
||||||
name,
|
name,
|
||||||
{ reset_command: runtime.resetCommand },
|
{ reset_command: runtime.resetCommand },
|
||||||
@@ -545,7 +532,7 @@ function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
|
|||||||
),
|
),
|
||||||
agents: [...roster.agents]
|
agents: [...roster.agents]
|
||||||
.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
|
.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
|
||||||
compareCodePoints(left.name, right.name),
|
left.name.localeCompare(right.name),
|
||||||
)
|
)
|
||||||
.map(
|
.map(
|
||||||
(agent: FleetRosterV2Agent): Record<string, unknown> => ({
|
(agent: FleetRosterV2Agent): Record<string, unknown> => ({
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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.
|
* Sync a source directory to a target, with optional preserve paths.
|
||||||
* Replaces the rsync/cp logic from install.sh.
|
* 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(
|
export function syncDirectory(
|
||||||
source: string,
|
source: string,
|
||||||
target: string,
|
target: string,
|
||||||
options: {
|
options: { preserve?: string[]; excludeGit?: boolean } = {},
|
||||||
preserve?: string[];
|
|
||||||
excludeGit?: boolean;
|
|
||||||
isOperatorOwned?: (relPath: string) => boolean;
|
|
||||||
} = {},
|
|
||||||
): void {
|
): void {
|
||||||
// Guard: source and target are the same directory — nothing to sync
|
// Guard: source and target are the same directory — nothing to sync
|
||||||
if (resolve(source) === resolve(target)) return;
|
if (resolve(source) === resolve(target)) return;
|
||||||
|
|
||||||
const preserveSet = new Set(options.preserve ?? []);
|
const preserveSet = new Set(options.preserve ?? []);
|
||||||
const isOperatorOwned = options.isOperatorOwned ?? (() => false);
|
|
||||||
|
|
||||||
// Collect files from source
|
// Collect files from source
|
||||||
function copyRecursive(src: string, dest: string, relBase: string): void {
|
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;
|
if (options.excludeGit && (dirName === '.git' || relPath.includes('/.git'))) return;
|
||||||
|
|
||||||
// Skip preserved paths at top level
|
// 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 });
|
mkdirSync(dest, { recursive: true });
|
||||||
for (const entry of readdirSync(src)) {
|
for (const entry of readdirSync(src)) {
|
||||||
@@ -113,10 +101,6 @@ export function syncDirectory(
|
|||||||
// Skip preserved files at top level
|
// Skip preserved files at top level
|
||||||
if (preserveSet.has(relPath) && existsSync(dest)) return;
|
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 });
|
mkdirSync(dirname(dest), { recursive: true });
|
||||||
copyFileSync(src, dest);
|
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
|
// `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.
|
// files in ~/.config/mosaic/ stale — so shipped launcher/runtime changes (e.g.
|
||||||
// the agent-name export + native heartbeat) never ACTIVATE until a re-seed.
|
// 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
|
// These helpers run the package's own install.sh in sync-only mode (the P4
|
||||||
// is manifest-driven (#791): keep mode writes ONLY framework-owned paths from the
|
// data-safe reconcile: framework-owned overwrite + backup-once; SOUL/USER/
|
||||||
// shared framework-manifest.txt and prunes only retired framework files inside
|
// *.local/credentials preserved) and, opt-in, relaunch durable agents.
|
||||||
// shipped subtrees — every operator path (SOUL/USER/*.local/credentials, fleet
|
|
||||||
// roster + agents + backlog, and anything the manifest never anticipated) is
|
|
||||||
// left byte-identical. Contract files are still reconciled (overwrite +
|
|
||||||
// backup-once). Opt-in, this also relaunches durable agents.
|
|
||||||
|
|
||||||
/** Resolve the framework/ directory bundled in the installed package. */
|
/** Resolve the framework/ directory bundled in the installed package. */
|
||||||
export function resolveBundledFrameworkRoot(): string {
|
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 { ConfigService } from '../config/config-service.js';
|
||||||
import type { WizardState } from '../types.js';
|
import type { WizardState } from '../types.js';
|
||||||
import { getShellProfilePath } from '../platform/detect.js';
|
import { getShellProfilePath } from '../platform/detect.js';
|
||||||
import { ManifestError } from '../framework/manifest.js';
|
|
||||||
|
|
||||||
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
|
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
|
||||||
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
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)
|
// 1. Sync framework files (before config writes so identity files aren't overwritten)
|
||||||
spin.update('Syncing framework files...');
|
spin.update('Syncing framework files...');
|
||||||
try {
|
await config.syncFramework(state.installAction);
|
||||||
await config.syncFramework(state.installAction);
|
|
||||||
} catch (err) {
|
|
||||||
// Stop the spinner loudly and re-raise so the process exits non-zero — never
|
|
||||||
// fall through to "Installation complete" on an aborted sync (#791 B2).
|
|
||||||
// A ManifestError is a PRE-sync validation abort: the manifest is loaded and
|
|
||||||
// validated before any file is written, so nothing was touched. Any other
|
|
||||||
// error can surface AFTER files were partially copied, so we must NOT claim
|
|
||||||
// "no files were changed" for it — that would misdirect recovery (#791 blocker-C).
|
|
||||||
if (err instanceof ManifestError) {
|
|
||||||
spin.stop(
|
|
||||||
'Framework sync aborted — the framework manifest is missing, empty, or malformed; no files were changed.',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
spin.stop(
|
|
||||||
'Framework sync aborted — the update did not complete and may be partially applied; see the error below.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Write config files (after sync so they aren't overwritten by source templates)
|
// 2. Write config files (after sync so they aren't overwritten by source templates)
|
||||||
if (state.installAction !== 'keep') {
|
if (state.installAction !== 'keep') {
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["//"],
|
|
||||||
"tasks": {
|
|
||||||
"test": {
|
|
||||||
"dependsOn": ["^build", "build"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -212,7 +212,7 @@ ok() { echo "${G}✔${RESET} $*"; }
|
|||||||
warn() { echo "${Y}⚠${RESET} $*"; }
|
warn() { echo "${Y}⚠${RESET} $*"; }
|
||||||
fail() { echo "${R}✖${RESET} $*" >&2; }
|
fail() { echo "${R}✖${RESET} $*" >&2; }
|
||||||
dim() { echo "${DIM}$*${RESET}"; }
|
dim() { echo "${DIM}$*${RESET}"; }
|
||||||
step() { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; }
|
step() { echo ""; echo "${BOLD}$*${RESET}"; }
|
||||||
|
|
||||||
# ─── helpers ──────────────────────────────────────────────────────────────────
|
# ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user