Compare commits
8 Commits
feat/791-u
...
fix/795-ci
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa5fc3cd80 | ||
| 9ddc6fbda8 | |||
| 31607a4af6 | |||
| 32a0ffba13 | |||
| 8536454257 | |||
| 4f29cc604d | |||
| 3be443c96d | |||
| 59f5f51ffd |
@@ -42,6 +42,27 @@ steps:
|
|||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --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:
|
||||||
@@ -50,6 +71,7 @@ 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:
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ FROM node:24-alpine
|
|||||||
|
|
||||||
# Native toolchain required to compile node-gyp deps on musl, plus the
|
# Native toolchain required to compile node-gyp deps on musl, plus the
|
||||||
# postgresql-client used by the test step's pg_isready readiness probe. `bash`
|
# postgresql-client used by the test step's pg_isready readiness probe. `bash`
|
||||||
# is baked here too — the sanitization step in ci.yml otherwise does a per-run
|
# and `git` are baked here too — framework shell tests require both without
|
||||||
# `apk add bash`.
|
# paying for per-run package installation in ci.yml.
|
||||||
RUN apk add --no-cache python3 make g++ postgresql-client bash
|
RUN apk add --no-cache python3 make g++ postgresql-client bash git
|
||||||
|
|
||||||
# Pin pnpm to the repo's packageManager version via corepack.
|
# Pin pnpm to the repo's packageManager version via corepack.
|
||||||
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate
|
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate
|
||||||
|
|||||||
290
docs/design/791-upgrade-config-protection.md
Normal file
290
docs/design/791-upgrade-config-protection.md
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
# Design — #791: Framework upgrades must not destroy operator-owned config under `~/.config/mosaic`
|
||||||
|
|
||||||
|
- **Issue:** mosaicstack/stack#791
|
||||||
|
- **Branch:** `feat/791-upgrade-config-protection` (off `origin/main` `9745bc3f`)
|
||||||
|
- **Author:** ms-791 worker lane
|
||||||
|
- **Status:** Phase 1 — DESIGN, awaiting MS-LEAD confirmation before implementation
|
||||||
|
- **Ratified scope (Mos-approved, not re-litigated):** deliver **(b) strict ownership separation [PRIMARY]** + **(a) transactional pre-update snapshot [safety net]** + **(d) regeneration-from-SSOT [recovery]**. **(c) periodic backup timer is DEFERRED** — noted as future work only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Current updater behavior + exact wipe mechanism (evidence)
|
||||||
|
|
||||||
|
### 1.1 What runs on `mosaic update`
|
||||||
|
|
||||||
|
`mosaic update` re-seeds the framework by invoking the **bash installer** in sync-only, keep mode:
|
||||||
|
|
||||||
|
- `packages/mosaic/src/runtime/update-checker.ts:509` `buildReseedCommand()` returns
|
||||||
|
`bash <frameworkRoot>/install.sh` with env `MOSAIC_SYNC_ONLY=1`, `MOSAIC_INSTALL_MODE=keep`,
|
||||||
|
`MOSAIC_HOME=<mosaicHome>`.
|
||||||
|
- The same `install.sh` is the direct/`tools/install.sh` upgrade path and the framework-vN migration path.
|
||||||
|
|
||||||
|
So the destructive surface is **`packages/mosaic/framework/install.sh`**.
|
||||||
|
|
||||||
|
### 1.2 The wipe
|
||||||
|
|
||||||
|
`sync_framework()` (`install.sh:177`) performs, in `keep` mode:
|
||||||
|
|
||||||
|
```
|
||||||
|
rsync -a --delete --exclude .git --exclude .framework-version --exclude '*.pre-constitution.bak' \
|
||||||
|
[--exclude "/$path" for each PRESERVE_PATHS entry] SOURCE_DIR/ TARGET_DIR/
|
||||||
|
```
|
||||||
|
|
||||||
|
- `install.sh:199` — `rsync -a --delete`. **`--delete` prunes every path in `~/.config/mosaic`
|
||||||
|
that is NOT present in the shipped framework source**, unless excluded.
|
||||||
|
- `install.sh:47` — `PRESERVE_PATHS` is the **only** thing standing between `--delete` and operator
|
||||||
|
data. It is a _denylist of exclusions_:
|
||||||
|
```
|
||||||
|
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md"
|
||||||
|
"memory" "sources" "credentials" "fleet/roster.yaml" "fleet/roster.json" "fleet/agents"
|
||||||
|
"fleet/run" "fleet/backlog" "fleet/roles.local")
|
||||||
|
```
|
||||||
|
- The cp-fallback (no rsync) is equally destructive: `install.sh:223`
|
||||||
|
`find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ... -exec rm -rf {} +` then re-copies source, restoring
|
||||||
|
only PRESERVE_PATHS globs.
|
||||||
|
|
||||||
|
**Root-cause model:** _"Everything under `~/.config/mosaic` is framework-owned and pruneable UNLESS
|
||||||
|
explicitly preserved."_ Any operator path the list forgets is destroyed on the next upgrade.
|
||||||
|
|
||||||
|
### 1.3 The exact operator paths wiped
|
||||||
|
|
||||||
|
Cross-referencing the issue's operator-owned list against `PRESERVE_PATHS`:
|
||||||
|
|
||||||
|
| Operator path (issue #791) | In PRESERVE_PATHS? | Fate on `mosaic update` |
|
||||||
|
| ----------------------------------------------------------------- | --------------------------------------- | ----------------------- |
|
||||||
|
| `agents/*.conf` (per-agent runtime) | **NO** | **WIPED** |
|
||||||
|
| `policy/*.md` (operator overlays) | **NO** | **WIPED** |
|
||||||
|
| `*.local.md` (SOUL/USER/STANDARDS) | **NO** | **WIPED** |
|
||||||
|
| harvester / SOP artifacts + timers | **NO** | **WIPED** |
|
||||||
|
| `tools/_lib/credentials.json` | **NO** (`credentials/` dir ≠ this path) | **WIPED** |
|
||||||
|
| `fleet/agents/*.env` | yes (`fleet/agents`, added by #631) | survives |
|
||||||
|
| `memory/`, `fleet/roster.*`, `fleet/backlog`, `fleet/roles.local` | yes | survives |
|
||||||
|
|
||||||
|
The `fleet/agents`, `memory`, `fleet/backlog` entries were **retro-added after prior incidents**
|
||||||
|
(#631). This whack-a-mole is the structural signature of a denylist.
|
||||||
|
|
||||||
|
**Stale-comment evidence:** `update-checker.ts:492` claims the reseed preserves
|
||||||
|
"`SOUL/USER/*.local/credentials`" — but `PRESERVE_PATHS` contains **no `*.local` entry**. The code
|
||||||
|
documents protection it does not deliver.
|
||||||
|
|
||||||
|
### 1.4 Second code path (TS) — already non-destructive, but drifted
|
||||||
|
|
||||||
|
`FileConfigAdapter.syncFramework()` (`packages/mosaic/src/config/file-adapter.ts:157`) →
|
||||||
|
`syncDirectory()` (`packages/mosaic/src/platform/file-ops.ts:66`) is a **copy-overlay**: it copies
|
||||||
|
source over target and skips preserved paths, but **never deletes** target paths absent from source
|
||||||
|
(`file-ops.ts:77-109`). It is used by the wizard/init flow, not `mosaic update`.
|
||||||
|
|
||||||
|
Two problems remain:
|
||||||
|
|
||||||
|
1. Its `preservePaths` (`file-adapter.ts:164-185`) has **already diverged** from `install.sh` — it is
|
||||||
|
**missing `fleet/backlog` and `fleet/roles.local`**. Two hand-maintained denylists, drifted. This
|
||||||
|
is direct evidence for a single shared SSOT manifest.
|
||||||
|
2. Even non-destructive, it will happily _overwrite_ an operator file that collides with a
|
||||||
|
framework-shipped path unless that path is on its (incomplete) preserve list.
|
||||||
|
|
||||||
|
### 1.5 Existing snapshot is inadequate for rollback
|
||||||
|
|
||||||
|
`make_snapshot()`/`restore_snapshot()` (`install.sh:76-87`) copy `TARGET_DIR` to `mktemp -d` under
|
||||||
|
`/tmp`, restore **only on `ERR/INT/TERM` trap**, and are **deleted on success** (`cleanup_snapshot`,
|
||||||
|
`install.sh:345`). Consequences: ephemeral `/tmp`, no retention, no post-success rollback, and **no
|
||||||
|
`mosaic restore`**. It is crash-safety only, not the transactional safety net #791 requires.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Fix (b) — Strict ownership separation [PRIMARY / root cause]
|
||||||
|
|
||||||
|
### 2.1 Ownership model (invert to allow-list)
|
||||||
|
|
||||||
|
Replace _"framework-owned unless preserved"_ with _"operator-owned unless framework-owned"_, resolved
|
||||||
|
**per target path** with operator carve-outs winning inside shared framework subtrees.
|
||||||
|
|
||||||
|
Two declared lists, one SSOT data file shipped in the framework
|
||||||
|
(`framework/framework-manifest.json`), consumed by **both** bash and TS:
|
||||||
|
|
||||||
|
- **`framework` globs** — paths the updater is entitled to create / overwrite / prune. Authored to
|
||||||
|
match exactly what the framework ships in `packages/mosaic/framework/` (e.g. `CONSTITUTION.md`,
|
||||||
|
`AGENTS.md`, `STANDARDS.md`, `TOOLS.md`, `guides/**`, `constitution/**`, `templates/**`, `tools/**`,
|
||||||
|
`skills/**`, `mcp/**`, `defaults/**`, `fleet/examples/**`, `fleet/roles/**`, `fleet/profiles/**`,
|
||||||
|
`fleet/roster.schema.json`).
|
||||||
|
- **`operatorReserved` globs** — NEVER written or pruned, even nested inside a `framework` subtree;
|
||||||
|
these **win** over `framework` (deny-wins / most-specific-wins). At minimum:
|
||||||
|
`agents/**`, `policy/**`, `memory/**`, `sources/**`, `credentials/**`, `*.local.md`,
|
||||||
|
`tools/_lib/credentials.json`, `fleet/roster.yaml`, `fleet/roster.json`, `fleet/agents/**`,
|
||||||
|
`fleet/run/**`, `fleet/backlog/**`, `fleet/roles.local/**`, plus operator harvester/SOP artifacts.
|
||||||
|
|
||||||
|
### 2.2 Ownership resolution for a target path `P`
|
||||||
|
|
||||||
|
1. `P` matches `operatorReserved` → **operator-owned**: updater MUST NOT write, MUST NOT delete.
|
||||||
|
2. else `P` matches `framework` → **framework-owned**: may overwrite; may prune **only if absent from
|
||||||
|
the current SOURCE** (a genuinely retired framework file).
|
||||||
|
3. else (matches neither) → **UNKNOWN ⇒ operator-owned by default (fail-safe)**: never delete.
|
||||||
|
|
||||||
|
Rule 3 is the actual root-cause fix: an operator path the manifest authors forget is still protected,
|
||||||
|
because _unknown defaults to operator_. A denylist can never provide this guarantee.
|
||||||
|
|
||||||
|
### 2.3 Sync mechanism change (the mechanically-critical part)
|
||||||
|
|
||||||
|
`--delete` cannot express "prune only framework-owned" without re-enumerating every operator path
|
||||||
|
(the denylist trap). So:
|
||||||
|
|
||||||
|
1. **Drop `--delete` from the bulk sync.** Copy `SOURCE → TARGET` non-destructively (writes/overwrites
|
||||||
|
all framework files; deletes nothing). rsync without `--delete`, or the existing overlay copy.
|
||||||
|
2. **Explicit manifest-scoped prune pass.** Iterate the **`framework` manifest** (not the whole tree);
|
||||||
|
for each framework path present in `TARGET` but **absent in `SOURCE`**, delete it — after
|
||||||
|
re-checking it does not match `operatorReserved`. Because the prune iterates only declared
|
||||||
|
framework globs, operator/unknown paths are **structurally unreachable** by deletion.
|
||||||
|
|
||||||
|
This is implemented in both bash `sync_framework()` and TS `syncFramework()` from the shared manifest.
|
||||||
|
A pure **prune-planner** function (TS) computes the delete-set from
|
||||||
|
`(manifest, sourceListing, targetListing)` so the invariant is unit-testable in isolation.
|
||||||
|
`PRESERVE_PATHS` becomes redundant (kept as a defense-in-depth alias mapping to `operatorReserved`, or
|
||||||
|
removed) — either way the two lists stop drifting because they read one file.
|
||||||
|
|
||||||
|
### 2.4 HARD GATE test — "upgrade touches no path outside the manifest"
|
||||||
|
|
||||||
|
Filesystem-observation test in the existing `test-install-migration.sh` harness pattern (mktemp
|
||||||
|
`MOSAIC_HOME`, `MOSAIC_SYNC_ONLY=1`), plus TS specs:
|
||||||
|
|
||||||
|
1. Seed a throwaway `TARGET` with a realistic operator mix — one sentinel per operator class:
|
||||||
|
`agents/x.conf`, `policy/p.md`, `SOUL.local.md`, `memory/m.md`,
|
||||||
|
`tools/_lib/credentials.json` (with a secret value), `fleet/agents/a.env`, `fleet/roster.yaml`,
|
||||||
|
`harvester/sop.md`, **and a deliberately-unanticipated `unknown-operator-dir/x`**.
|
||||||
|
2. Record hash+mtime of every sentinel.
|
||||||
|
3. Run the upgrade from a `SOURCE` containing none of those operator paths.
|
||||||
|
4. **Assert:** every sentinel exists, byte-identical, **mtime unchanged** (not even rewritten). The
|
||||||
|
`unknown-operator-dir` surviving proves the fail-safe default — a denylist could not pass this case.
|
||||||
|
5. **Positive controls:** framework files WERE updated; a retired framework file WAS pruned.
|
||||||
|
6. **Property test** (TS prune-planner): for fuzzed operator paths, `deleteSet ⊆ {matches framework ∧
|
||||||
|
in target ∧ not in source}` and `deleteSet ∩ operatorReserved = ∅`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Fix (a) — Transactional pre-update snapshot [safety net]
|
||||||
|
|
||||||
|
- **Destination:** `${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-ts>/`.
|
||||||
|
**Outside `~/.config/mosaic`** (so no future sync can sweep it) and outside any repo.
|
||||||
|
- **Perms:** dir `0700`, files `0600` — enforced with `umask 077` around the copy **and** explicit
|
||||||
|
`chmod`. Never world-readable.
|
||||||
|
- **Scope:** the operator-owned surface (`operatorReserved` paths that exist) — bounded; does not copy
|
||||||
|
the framework tree.
|
||||||
|
- **Timing:** taken before ANY mutation in the upgrade flow.
|
||||||
|
- **Post-sync verify + selective restore:** after sync, diff the operator surface against the snapshot;
|
||||||
|
since (b) should never touch operator paths, any diff means a manifest bug — restore the affected
|
||||||
|
paths from the snapshot and warn loudly. This is precisely (a) catching a miss in (b).
|
||||||
|
- **Retention:** keep N most-recent (default 5; `MOSAIC_BACKUP_RETENTION` override); prune older.
|
||||||
|
- **`mosaic restore`:** `--list` (default, dry-run) enumerates snapshots by timestamp;
|
||||||
|
`--from <ts>` restores that snapshot over the operator surface, confirmation-gated. Reports
|
||||||
|
counts/paths only.
|
||||||
|
- **Secret-safety:** snapshot copy and restore never emit file **contents**; only paths/counts.
|
||||||
|
Tests assert `0700/0600` and that no secret value appears in stdout/stderr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Fix (d) — Regeneration-from-SSOT [recovery]
|
||||||
|
|
||||||
|
The incident's live blast radius: `fleet/agents/*.env` (systemd `EnvironmentFile` sources) gone →
|
||||||
|
`mosaic-agent@<name>` boots **unit defaults** on restart (because `EnvironmentFile=-...` is
|
||||||
|
absent-tolerant) → **silent identity/runtime/workdir downgrade**.
|
||||||
|
|
||||||
|
The SSOT for those `.env` files is the roster. The reconciler **already** separates a
|
||||||
|
`regenerate-projections-from-roster` projection phase from lifecycle
|
||||||
|
(`packages/mosaic/src/fleet/fleet-reconciler.ts:93,234`; env rendering in
|
||||||
|
`generated-env-boundary.ts:149-264`).
|
||||||
|
|
||||||
|
**`mosaic fleet regen`** is therefore a **thin recovery-framed wrapper over the existing projection
|
||||||
|
phase** — it does NOT reimplement fleet logic and does NOT preempt in-flight FCM cards (M4/M5):
|
||||||
|
|
||||||
|
- Regenerates derivable config (per-agent `*.env.generated`, unit files) from roster SSOT.
|
||||||
|
- **Preview-first:** dry-run default; `--write` to apply. Idempotent.
|
||||||
|
- **Never restarts agents** (the recovery order forbids restart-before-verify).
|
||||||
|
- Prints the runbook's next step (verify `EnvironmentFile` resolves, THEN restart).
|
||||||
|
|
||||||
|
Alternatively documentable as `install.sh --relink` per the issue; `mosaic fleet regen` is preferred
|
||||||
|
because it reuses the merged reconciler plumbing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Secret-safety approach (secrev surface)
|
||||||
|
|
||||||
|
- Snapshots/backups: `0700`/`0600`, outside any repo, never world-readable. (§3)
|
||||||
|
- No secret **value** ever emitted to logs/stdout/stderr by snapshot, restore, sync, or regen —
|
||||||
|
paths/counts only. Adversarial test: a secret value placed in `tools/_lib/credentials.json` must
|
||||||
|
never appear in installer or command output.
|
||||||
|
- `tools/_lib/credentials.json` is an explicit `operatorReserved` carve-out inside the framework-owned
|
||||||
|
`tools/**` subtree — it is never overwritten or pruned.
|
||||||
|
- The HARD GATE test doubles as a secret-safety test (asserts the credentials sentinel is untouched).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Test plan (TDD, tests-first, ≥85% on new code, co-located `*.spec.ts`)
|
||||||
|
|
||||||
|
1. **Manifest SSOT parity** — bash and TS resolve identical framework/operator sets from the one file;
|
||||||
|
a test fails if either path hard-codes a divergent list.
|
||||||
|
2. **Manifest completeness** — every path shipped in `framework/` is covered by a `framework` glob (so
|
||||||
|
a new shipped file cannot silently fall outside the manifest and become un-prunable/undeclared).
|
||||||
|
3. **HARD GATE** — upgrade touches nothing outside the manifest, incl. the unanticipated-path case
|
||||||
|
(§2.4).
|
||||||
|
4. **Prune-planner** unit + property tests (§2.4.6).
|
||||||
|
5. **Snapshot** — perms `0700/0600`, correct destination, retention prune, secret value absent from
|
||||||
|
output.
|
||||||
|
6. **Restore** — `--list` / `--from` round-trip restores operator surface byte-exact; confirmation
|
||||||
|
gate; no secret leakage.
|
||||||
|
7. **Regen** — roster→env projection deterministic + idempotent; dry-run makes no writes; `--write`
|
||||||
|
restores `*.env`; **never** issues a lifecycle/restart call.
|
||||||
|
8. **Cross-path regression** — TS `syncFramework` and bash `install.sh` agree on a shared fixture
|
||||||
|
(closes the current #631-style drift).
|
||||||
|
|
||||||
|
Gates before every push: `pnpm typecheck && pnpm lint && pnpm format:check` + mosaic package tests
|
||||||
|
green. Never `--no-verify`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. web1 recovery runbook (operator-agnostic; web1 specifics live in the issue as evidence only)
|
||||||
|
|
||||||
|
For a currently-wiped fleet EnvironmentFile state — **do NOT service-restart while
|
||||||
|
`fleet/agents/*.env` is absent** (a restart boots unit defaults and silently downgrades identity):
|
||||||
|
|
||||||
|
1. **Regenerate:** `mosaic fleet regen --write` — rebuild `~/.config/mosaic/fleet/agents/*.env` from
|
||||||
|
roster SSOT.
|
||||||
|
2. **Verify each unit resolves to the intended runtime/workdir** _before_ any restart:
|
||||||
|
`systemctl --user show mosaic-agent@<name> -p EnvironmentFile` and confirm the generated env exists
|
||||||
|
and carries the intended `MOSAIC_AGENT_*` runtime/workdir values.
|
||||||
|
3. **Only then** `systemctl --user restart mosaic-agent@<name>`, one unit at a time.
|
||||||
|
|
||||||
|
If config (not just fleet env) was lost, `mosaic restore --list` → `mosaic restore --from <ts>` before
|
||||||
|
step 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Proposed PR split (reviewable; DAG-ordered)
|
||||||
|
|
||||||
|
| PR | Scope | Depends | Review focus |
|
||||||
|
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -------------------------- |
|
||||||
|
| PR1 | **PRIMARY** — shared `framework-manifest.json` + ownership resolver + non-deleting sync + scoped prune (bash + TS) + **HARD GATE** + prune-planner tests | — | correctness (root fix) |
|
||||||
|
| PR2 | **Safety net** — pre-update snapshot (`~/.local/state`, 0700/0600, retention) + post-sync verify/restore + `mosaic restore` | PR1 | **secrev** (backup/secret) |
|
||||||
|
| PR3 | **Recovery** — `mosaic fleet regen` (projection-only, preview-first, no restart) + docs (upgrade-safety + recovery runbook) | PR1 | correctness + docs |
|
||||||
|
|
||||||
|
Rationale: PR1 closes the failure class on its own; if PR2/PR3 slip, the class stays fixed. Each PR is
|
||||||
|
one reviewable unit with its own tests ≥85%. Independent review (author≠reviewer) on all; **secrev** on
|
||||||
|
PR2 (and PR1's secret-sentinel assertions).
|
||||||
|
|
||||||
|
## 9. Deferred (noted per scope)
|
||||||
|
|
||||||
|
**(c) periodic backup timer** — a systemd user timer snapshotting operator dirs on a cadence
|
||||||
|
(defense-in-depth for non-upgrade losses). Explicitly **out of scope now**; future phase.
|
||||||
|
|
||||||
|
## 10. Constraints honored
|
||||||
|
|
||||||
|
- **Framework-PR firewall:** manifest + logic are operator-agnostic; no SOUL/USER/operator specifics
|
||||||
|
in framework code; web1 details are issue evidence only.
|
||||||
|
- **Capacity-fill:** must not preempt M5-001 or #790; `fleet regen` reuses merged FCM-M3 plumbing and
|
||||||
|
does not overlap FCM-M4/M5 migration cards.
|
||||||
|
- **Delivery gates:** TDD tests-first, ≥85% new-code coverage, trunk-based squash PRs, independent
|
||||||
|
review + secrev, completion = merged PR + descendant-main green + #791 closed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Requesting MS-LEAD confirmation of:** (1) the manifest allow-list + non-deleting-sync + scoped-prune
|
||||||
|
approach as the (b) root-cause fix; (2) snapshot destination/retention + `mosaic restore` UX;
|
||||||
|
(3) `mosaic fleet regen` as a projection-only wrapper; (4) the 3-PR split. Implementation begins only
|
||||||
|
on your confirmation.
|
||||||
@@ -98,6 +98,39 @@ 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:
|
||||||
|
|||||||
147
docs/guides/upgrade-safety-and-recovery.md
Normal file
147
docs/guides/upgrade-safety-and-recovery.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Upgrade Safety & Recovery
|
||||||
|
|
||||||
|
How Mosaic protects operator-owned configuration under `~/.config/mosaic` across
|
||||||
|
framework upgrades, and how to recover if a projection is ever lost.
|
||||||
|
|
||||||
|
A framework upgrade runs `install.sh` in keep-mode (`MOSAIC_INSTALL_MODE=keep`,
|
||||||
|
`MOSAIC_SYNC_ONLY=1`) to refresh framework-owned files in place. The incident
|
||||||
|
this hardening addresses: an upgrade that silently overwrites or deletes a file
|
||||||
|
the operator owns — credentials, personas, a roster, or a generated agent env —
|
||||||
|
with no snapshot to fall back to.
|
||||||
|
|
||||||
|
Protection is layered. Each layer is independent; a later layer catches what an
|
||||||
|
earlier one misses.
|
||||||
|
|
||||||
|
## Layer 1 — Manifest-owned sync (prevention)
|
||||||
|
|
||||||
|
The single source of truth for ownership is
|
||||||
|
[`framework-manifest.txt`](../../packages/mosaic/framework/framework-manifest.txt).
|
||||||
|
Both the bash installer and the TypeScript sync path resolve every path against
|
||||||
|
this one file (parity is enforced by test), so they can never drift.
|
||||||
|
|
||||||
|
- Ownership is **allow-list, deny-wins**: a path is framework-owned only if a
|
||||||
|
`[framework]` glob matches and no `[operator]` carve-out overrides it.
|
||||||
|
- **Unknown paths default to operator** (fail-safe): a file the manifest never
|
||||||
|
anticipated is treated as operator-owned and is never pruned.
|
||||||
|
- Keep-mode does a non-deleting copy plus an explicit, manifest-scoped prune that
|
||||||
|
only ever iterates framework globs — operator and unknown paths are
|
||||||
|
structurally unreachable by the prune.
|
||||||
|
|
||||||
|
Result: a correct upgrade cannot touch operator config at all.
|
||||||
|
|
||||||
|
## Layer 2 — Durable pre-update snapshot + verify net (safety + rollback)
|
||||||
|
|
||||||
|
Before **any** mutation, the installer snapshots the operator-owned surface that
|
||||||
|
exists into:
|
||||||
|
|
||||||
|
```
|
||||||
|
${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-timestamp>/
|
||||||
|
```
|
||||||
|
|
||||||
|
- `0700` directories / `0600` files (`umask 077`, scoped and restored),
|
||||||
|
outside `~/.config/mosaic` and outside any repo.
|
||||||
|
- **Fail-open**: a snapshot failure warns but never aborts the upgrade it
|
||||||
|
protects.
|
||||||
|
- Retention is `MOSAIC_BACKUP_RETENTION` snapshots (default 5).
|
||||||
|
|
||||||
|
After the sync, a **verify net** compares each snapshot file against its target
|
||||||
|
and restores (with a loud warning) any operator file the upgrade diverged or
|
||||||
|
removed — a divergence means a manifest bug slipped through Layer 1.
|
||||||
|
|
||||||
|
Inspect and restore snapshots with the CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic restore --list # dry-run: enumerate snapshots by timestamp
|
||||||
|
mosaic restore --from <UTC-timestamp> # restore the operator surface from one snapshot
|
||||||
|
mosaic restore --from <ts> --dry-run # preview a specific restore without writing
|
||||||
|
```
|
||||||
|
|
||||||
|
`mosaic restore` reports **counts and relative paths only** — it never emits file
|
||||||
|
contents, so a secret in `tools/_lib/credentials.json` is never echoed. Restores
|
||||||
|
are confirmation-gated (`--yes` or `MOSAIC_ASSUME_YES`) and write each leaf
|
||||||
|
atomically with `O_NOFOLLOW` (a symlink swapped in after the snapshot fails
|
||||||
|
closed rather than following out of the managed tree).
|
||||||
|
|
||||||
|
## Layer 3 — Regeneration from roster SSOT (recovery)
|
||||||
|
|
||||||
|
Some operator files are **derived** and do not need a byte-for-byte snapshot to
|
||||||
|
recover — they can be rebuilt from their source of truth. The fleet's per-agent
|
||||||
|
generated env projections are the prime case:
|
||||||
|
|
||||||
|
- `~/.config/mosaic/fleet/agents/<name>.env.generated` is a deterministic
|
||||||
|
projection of `~/.config/mosaic/fleet/roster.yaml`.
|
||||||
|
- The launcher (`start-agent-session.sh`, invoked by
|
||||||
|
`mosaic-agent@<name>.service`) sources that generated projection to establish
|
||||||
|
each agent's identity, runtime, model, and working directory. If it is missing
|
||||||
|
or wrong, the agent cannot launch with its intended identity.
|
||||||
|
|
||||||
|
`mosaic fleet regen` rebuilds those projections from the roster SSOT:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic fleet regen # dry-run (default): show what would be rebuilt
|
||||||
|
mosaic fleet regen --json # same, machine-readable
|
||||||
|
mosaic fleet regen --write # rebuild the projections on disk
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Dry-run by default.** Nothing is written until you pass `--write`.
|
||||||
|
- **Deterministic and idempotent** — the projection is a pure function of the
|
||||||
|
roster, so repeated `--write` runs produce byte-identical files.
|
||||||
|
- **Projection-only. It never restarts an agent.** Recovery order forbids
|
||||||
|
restart-before-verify; `regen` has no path to systemd lifecycle at all.
|
||||||
|
- **It rebuilds only `<name>.env.generated`** — it never writes, relocates, or
|
||||||
|
deletes the operator-owned `.env` / `.env.local` surface.
|
||||||
|
- It **validates the roster the same way `reconcile` does** (persona resolution
|
||||||
|
and protected-class tool-policy match), so a hand-edited or corrupt roster is
|
||||||
|
rejected rather than projected, and a `--write` takes the shared reconcile
|
||||||
|
lock so it cannot race a concurrent reconcile.
|
||||||
|
- Output is **paths and counts only** — the rendered `KEY=value` body is never
|
||||||
|
echoed.
|
||||||
|
|
||||||
|
`regen` uses the exact same roster→env mapping as `mosaic fleet reconcile`, so a
|
||||||
|
recovered projection matches what a normal reconcile would have written.
|
||||||
|
|
||||||
|
## Recovery runbook — wiped `fleet/agents/*.env.generated`
|
||||||
|
|
||||||
|
If an upgrade (or a manual mistake) has left an agent without its generated
|
||||||
|
projection, **do not restart the unit first** — a launch against a missing
|
||||||
|
projection fails closed, and any stale state must be corrected before restart,
|
||||||
|
not after.
|
||||||
|
|
||||||
|
1. **Prefer a snapshot restore if one exists** (byte-exact operator state):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic restore --list
|
||||||
|
mosaic restore --from <UTC-timestamp>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Otherwise regenerate the derived projections from the roster SSOT:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic fleet regen # confirm the plan (create vs rebuild per agent)
|
||||||
|
mosaic fleet regen --write # rebuild fleet/agents/<name>.env.generated
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Verify each unit will resolve the intended runtime/workdir _before_ any
|
||||||
|
restart.** The unit sets **no** `EnvironmentFile=` — it launches from a minimal
|
||||||
|
environment and `start-agent-session.sh` sources `.env.generated` itself, so
|
||||||
|
verify the generated file directly and confirm the launcher path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Confirm fleet/agents/<name>.env.generated exists and carries the intended
|
||||||
|
# MOSAIC_AGENT_* values (name, runtime, model, workdir, socket).
|
||||||
|
test -f ~/.config/mosaic/fleet/agents/<name>.env.generated
|
||||||
|
# Confirm the unit launches the session script that reads it.
|
||||||
|
systemctl --user cat mosaic-agent@<name> | grep ExecStart
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Only then restart, one unit at a time:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user restart mosaic-agent@<name>
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- Design: [`docs/design/791-upgrade-config-protection.md`](../design/791-upgrade-config-protection.md)
|
||||||
|
- Fleet operations: [`docs/guides/fleet-local-canary.md`](./fleet-local-canary.md)
|
||||||
|
- Ownership SSOT: [`packages/mosaic/framework/framework-manifest.txt`](../../packages/mosaic/framework/framework-manifest.txt)
|
||||||
529
docs/scratchpads/791-upgrade-config-protection.md
Normal file
529
docs/scratchpads/791-upgrade-config-protection.md
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
# Scratchpad — #791 Upgrade config protection (ms-791 worker lane)
|
||||||
|
|
||||||
|
**Lane:** web1:ms-791 → reports to MS-LEAD (web1:mosaic-100). Do NOT contact Jason/Mos directly.
|
||||||
|
**Worktree:** `/home/hermes/agent-work/stack-agents-dir-791`, branch `feat/791-upgrade-config-protection`
|
||||||
|
off `origin/main` `9745bc3f` (verified exact head).
|
||||||
|
|
||||||
|
## Mission prompt (verbatim intent)
|
||||||
|
Protect operator-owned config under `~/.config/mosaic` from framework-upgrade wipes. Ratified
|
||||||
|
combination (Mos-approved, do NOT re-litigate): (b) strict ownership separation [PRIMARY] + (a)
|
||||||
|
transactional pre-update snapshot [safety net] + (d) regeneration-from-SSOT [recovery]. (c) periodic
|
||||||
|
timer DEFERRED. HARD GATE: unit test that an upgrade run touches NO path outside the manifest.
|
||||||
|
Design-first: write design doc, send to MS-LEAD, WAIT for confirmation before impl.
|
||||||
|
|
||||||
|
## Session 1 (2026-07-16) — Phase 1 design
|
||||||
|
|
||||||
|
### Evidence gathered (wipe mechanism, file/line)
|
||||||
|
- `mosaic update` → `update-checker.ts:509` `buildReseedCommand` → `bash install.sh`
|
||||||
|
(`MOSAIC_SYNC_ONLY=1`, `MOSAIC_INSTALL_MODE=keep`).
|
||||||
|
- Wipe = `packages/mosaic/framework/install.sh:199` `rsync -a --delete` + `PRESERVE_PATHS` denylist
|
||||||
|
(`install.sh:47`). cp-fallback `install.sh:223` `find ... -exec rm -rf`.
|
||||||
|
- Denylist gaps → WIPED: `agents/*.conf`, `policy/*.md`, `*.local.md`, harvester/SOP,
|
||||||
|
`tools/_lib/credentials.json`.
|
||||||
|
- Stale comment `update-checker.ts:492` claims `*.local` preserved — PRESERVE_PATHS has no such entry.
|
||||||
|
- TS path `file-adapter.ts:157` → `file-ops.ts:66` `syncDirectory` = non-destructive copy-overlay, BUT
|
||||||
|
its preserve list (`file-adapter.ts:164`) already DRIFTED from install.sh (missing `fleet/backlog`,
|
||||||
|
`fleet/roles.local`). Evidence for single shared manifest SSOT.
|
||||||
|
- Existing snapshot (`install.sh:76`) = /tmp, crash-trap only, deleted on success → inadequate; no
|
||||||
|
`mosaic restore`.
|
||||||
|
- `fleet-reconciler.ts:93,234` already has `regenerate-projections-from-roster` phase separate from
|
||||||
|
lifecycle → `mosaic fleet regen` = thin projection-only wrapper (no restart), no FCM-M4/M5 preemption.
|
||||||
|
|
||||||
|
### Design decisions
|
||||||
|
- **(b)** Invert to allow-list: shared `framework/framework-manifest.json` (framework globs +
|
||||||
|
operatorReserved carve-outs); resolve per-path, deny-wins; **UNKNOWN ⇒ operator (fail-safe)**.
|
||||||
|
Mechanism: drop `--delete`; non-deleting bulk copy + explicit manifest-scoped prune pass (iterate
|
||||||
|
framework globs only → operator/unknown structurally unreachable). Pure prune-planner fn for tests.
|
||||||
|
- **(a)** Snapshot to `~/.local/state/mosaic/backups/pre-update-<ts>/` 0700/0600, retention N=5,
|
||||||
|
post-sync verify+restore, `mosaic restore --list/--from`. No secret values in output.
|
||||||
|
- **(d)** `mosaic fleet regen` projection-only, preview-first, never restart.
|
||||||
|
- HARD GATE test includes a deliberately-unanticipated operator path to prove fail-safe default.
|
||||||
|
- **PR split:** PR1 manifest+guard (root fix, ships alone) → PR2 snapshot/restore (secrev) → PR3
|
||||||
|
regen+docs. PR2/PR3 depend on PR1.
|
||||||
|
|
||||||
|
### Status
|
||||||
|
Design doc written: `docs/design/791-upgrade-config-protection.md`. Sent to MS-LEAD.
|
||||||
|
|
||||||
|
## Session 1 (cont.) — MS-LEAD CONFIRMED → Phase 2 GO
|
||||||
|
All 4 asks approved. Binding conditions:
|
||||||
|
- TDD tests-first, red-first proof per PR; ≥85% new-code; co-located `*.spec.ts`; never `--no-verify`.
|
||||||
|
- HARD GATE test (§2.4, unanticipated sentinel survives byte-identical + mtime unchanged) = MERGE-BLOCKING for PR1.
|
||||||
|
- Manifest-completeness test (§6.2) required.
|
||||||
|
- Bash+TS read ONE shared `framework-manifest.json`; parity test (§6.1) required (closes #631 drift class).
|
||||||
|
- UNKNOWN⇒operator (rule 3) non-negotiable. Keep prune-planner PURE.
|
||||||
|
- `fleet regen`: NEVER restart; dry-run default, `--write` to apply; "never issues restart" test mandatory.
|
||||||
|
- Independent review every PR; PR2 dedicated secrev.
|
||||||
|
- One PR at a time through DAG. Report PR1 exact head + red→green evidence for review commission.
|
||||||
|
|
||||||
|
### Now: implementing PR1 (manifest + resolver + non-deleting sync + scoped prune + guard tests).
|
||||||
|
|
||||||
|
## Session 2 (2026-07-16) — PR1 built, tests-first, red→green proven
|
||||||
|
|
||||||
|
Deviation noted to MS-LEAD in PR: manifest is `framework-manifest.txt` (line-oriented), NOT `.json`.
|
||||||
|
Rationale: keep the bash installer free of a python3/jq dependency. The "ONE shared file, parity-
|
||||||
|
tested" requirement is honored — `manifest-parity.spec.ts` drives the bash resolver as a subprocess
|
||||||
|
and asserts byte-identical ownership vs the TS resolver over 34 probe paths spanning every class.
|
||||||
|
|
||||||
|
### PR1 artifacts
|
||||||
|
- SSOT: `packages/mosaic/framework/framework-manifest.txt` ([framework]/[operator], deny-wins, fail-safe).
|
||||||
|
- TS resolver: `src/framework/manifest.ts` (pure: parse/matchGlob/resolveOwnership/frameworkSubtreeRoots/
|
||||||
|
planPrune) + `manifest.spec.ts` (18 tests incl. planPrune property test + §6.2 completeness).
|
||||||
|
- Bash resolver: `framework/tools/_lib/manifest.sh` (compiled globs → fork-free `manifest_is_framework`;
|
||||||
|
CLI `resolve|subtree-roots|classify`). Sourced by install.sh.
|
||||||
|
- HARD GATE (§2.4): `framework/tools/quality/scripts/test-upgrade-manifest-guard.sh` — keep-mode reseed,
|
||||||
|
10 operator sentinels (incl. unanticipated `unknown-operator-dir/x`, `harvester/sop.md`,
|
||||||
|
`fleet/my-fleet.yaml`) survive byte-identical + mtime-unchanged; retired framework file pruned;
|
||||||
|
secret value absent from output. RED=31 fail (orig install.sh) → GREEN=48 pass (fixed).
|
||||||
|
- install.sh: keep mode now manifest-driven (`sync_framework_keep`, no `--delete`); overwrite unchanged.
|
||||||
|
PRESERVE_PATHS denylist deleted.
|
||||||
|
- TS sync: `file-ops.syncDirectory` gains `isOperatorOwned` guard; `file-adapter.syncFramework` derives
|
||||||
|
it from `loadManifest` — hardcoded (drifted) preservePaths deleted. Fixture uses the REAL manifest.
|
||||||
|
- Parity: `manifest-parity.spec.ts` (§6.1) — bash↔TS agree on 34 paths + subtree roots.
|
||||||
|
- Migration matrix `test-install-migration.sh`: F6 flipped — `my-fleet.yaml` now MUST survive (fail-safe).
|
||||||
|
- CI: new merge-blocking `upgrade-guard` step (`.woodpecker/ci.yml`) runs both bash suites (adds rsync).
|
||||||
|
- update-checker.ts reseed comment corrected to the manifest model.
|
||||||
|
|
||||||
|
### Gates (all green)
|
||||||
|
- `pnpm typecheck` ✓ · `pnpm lint` ✓ · `pnpm format:check` ✓
|
||||||
|
- Full mosaic vitest: 1062 passed (cli-smoke needs `pnpm build` first — build-artifact dep, not this change).
|
||||||
|
- HARD GATE 48/48 · migration 21/21 · parity 3/3 · manifest 18/18 · file-adapter 8/8.
|
||||||
|
|
||||||
|
### PR opened + reported (2026-07-16)
|
||||||
|
- **PR #802** http://git.mosaicstack.dev/mosaicstack/stack/pulls/802 — base `main`@`9745bc3f`,
|
||||||
|
head `34e55d4a` (commit `feat(mosaic): manifest-owned upgrade guard…`). 15 files, +1160/-142.
|
||||||
|
- Reported PR head + red→green evidence to MS-LEAD (web1:mosaic-100); queued (lead busy).
|
||||||
|
Standing by for the independent-review commission at head `34e55d4a`.
|
||||||
|
- **TWO items flagged to MS-LEAD for decision (awaiting reply):**
|
||||||
|
1. Deviation `.txt` vs `.json` — confirm accept (parity-tested) or convert to `.json`+jq.
|
||||||
|
2. `pr-create -i 791` appended `Fixes #791` → would auto-close the tracking issue on PR1 merge
|
||||||
|
while PR2/PR3 remain. Recommended edit to `Part of #791`; awaiting go-ahead to patch PR body.
|
||||||
|
- DO NOT start PR2/PR3 until PR1 merges (DAG; one PR at a time).
|
||||||
|
|
||||||
|
### MS-LEAD ruling → #797 ledger-survival sentinel folded into PR1 (2026-07-16)
|
||||||
|
MS-LEAD ruled both my decisions: (1) `.txt` format ACCEPTED (parity must be strict/merge-blocking incl.
|
||||||
|
format edge cases + negative probe); (2) trailer `Fixes #791`→`Part of #791` APPROVED (patched PR #802
|
||||||
|
body via Gitea API — tracking issue no longer auto-closes on PR1 merge). Plus Mos-ELEVATED merge-blocker
|
||||||
|
(spec `~/agent-work/planning/epic-796/791-ledger-survival-sentinel-SPEC.md`): #797 Runtime Session Ledger
|
||||||
|
must survive upgrade. Two coupled deliverables landed in PR1:
|
||||||
|
- (i) Carve-out: `fleet/run/**` was ALREADY an explicit `[operator]` entry — glob matches the spec's
|
||||||
|
pinned `fleet/run/**` EXACTLY, so NO divergence to route back to planner-opus. Strengthened its comment
|
||||||
|
to name the ledger (`fleet/run/sessions/` events.ndjson + ledger.json) so it is unmistakably load-bearing.
|
||||||
|
- (ii) HARD-GATE sentinel: seeded populated ledger (events.ndjson 3 events + ledger.json node+edge+gen,
|
||||||
|
0600 under 0700) into test-upgrade-manifest-guard.sh sentinels; asserts byte-identical + mtime-unchanged
|
||||||
|
+ dir-perms unchanged. Negative control (retired framework file IS pruned) relabeled explicitly.
|
||||||
|
HARD GATE now 58/58 (was 48).
|
||||||
|
- Decision-1 parity hardening: format-edge fixtures (comments/blanks/whitespace, duplicate+overlapping
|
||||||
|
globs deny-wins, section/glob-ordering independence) + explicit UNKNOWN→operator negative probe, driven
|
||||||
|
through BOTH resolvers via MANIFEST_FILE override. Parity 7/7 (was 3).
|
||||||
|
- RED-FIRST honesty note: the bash ledger sentinel stays GREEN even against the pre-fix installer (the
|
||||||
|
ledger was incidentally safe from the rsync --delete bug; overall pre-fix run 30/58 as expected). The
|
||||||
|
carve-out's TRUE load-bearing value (deny-wins if framework ownership ever broadens to `fleet/**`) is
|
||||||
|
isolated by a dedicated resolver-seam red→green in manifest.spec.ts: WITHOUT `fleet/run/**` operator
|
||||||
|
entry + hypothetical `fleet/**` framework → ledger resolves framework and planPrune DELETES it (RED);
|
||||||
|
WITH the carve-out → deny-wins → operator, unprunable (GREEN). manifest.spec.ts 21/21 (was 18).
|
||||||
|
- Gates all green: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1069 passed · HARD GATE 58/58
|
||||||
|
· migration 21/21. Committing FORWARD on the branch (NOT rebasing 34e55d4a out from under review).
|
||||||
|
|
||||||
|
### MS-LEAD REQUEST CHANGES @ 0a5e703a → B1/B2/B3 fixed red-first (2026-07-16)
|
||||||
|
MS-LEAD returned REQUEST CHANGES (routed merge-blockers satisfied; 2 CRITICAL reliability defects from
|
||||||
|
the commissioned independent review). Fixed forward on the branch, red-first:
|
||||||
|
- **B1 (CRITICAL) — dead ERR trap.** install.sh had `set -euo pipefail` (no `-E`), so the
|
||||||
|
`trap restore_snapshot ERR` never fired for a failure inside sync_framework_keep() (function body) —
|
||||||
|
a mid-sync abort left a half-written target with NO rollback. Fix: `set -Eeuo pipefail` (errtrace) +
|
||||||
|
disarm the trap at the top of restore_snapshot() to prevent re-entrancy. New gate
|
||||||
|
`test-upgrade-rollback.sh`: injects a mid-sync `cp` EACCES (read-only divergent framework file);
|
||||||
|
Part A asserts the shipped installer rolls back (restore message fires AND target byte-identical to
|
||||||
|
pre-upgrade); Part B control strips `-E` and asserts the rollback message does NOT fire (dead trap) —
|
||||||
|
self-verifying red→green. 7/7.
|
||||||
|
- **B2/B3 (CRITICAL) — empty/unreadable/malformed manifest divergence.** Pre-fix: TS `parseManifest('')`
|
||||||
|
returned `{framework:[],operator:[]}` (NO throw) → silent no-op "Installation complete"; bash aborted
|
||||||
|
fragilely (the `_manifest_compile` `"${MANIFEST_OPERATOR[@]:-}"` artifact returned 1 with no message)
|
||||||
|
AND the CLI dispatch swallowed manifest_load's rc (no `|| exit`) so `resolve` exited 0 resolving
|
||||||
|
everything operator. Fix (fail-loud + identical both langs):
|
||||||
|
* TS `parseManifest`: throw on zero framework entries; `loadManifest`: wrap read error →
|
||||||
|
"Cannot read framework manifest …".
|
||||||
|
* bash `manifest_load`: explicit unreadable guard (`[[ ! -r ]]`) + zero-`[framework]` guard, both loud
|
||||||
|
stderr + return 1; `_manifest_compile` gets explicit `return 0` (kills the empty-array artifact);
|
||||||
|
CLI dispatch `manifest_load … || exit 1`.
|
||||||
|
* `finalize.ts`: wrap syncFramework → `spin.stop('Framework sync aborted …')` + rethrow (never falls
|
||||||
|
through to "Installation complete").
|
||||||
|
Tests: manifest.spec.ts +5 fail-closed (empty/comment-only/operator-only/empty-section/missing);
|
||||||
|
manifest-parity.spec.ts +7 failure-mode parity (both reject empty/comment-only/operator-only/
|
||||||
|
empty-section/entry-before-header/unknown-header/missing — TS throws, bash CLI exits non-zero+stderr);
|
||||||
|
HARD GATE +4 end-to-end fail-closed matrices (empty/operator-only/malformed/missing → abort non-zero,
|
||||||
|
manifest error surfaced, every operator sentinel byte-identical). RED proven by reverting
|
||||||
|
manifest.ts+manifest.sh to HEAD → 12 new tests fail; restore → 40/40 green.
|
||||||
|
- **Non-blocking addressed.** MEDIUM install.sh:222 find-empty now warns on a real failure instead of
|
||||||
|
blanket `|| true`. LOW: corrected the "both destructive paths rsync vs cp" overstatement in the HARD
|
||||||
|
GATE header + cp-fallback comment + ci.yml (keep mode is a single cp-based path; the rsync-present vs
|
||||||
|
-absent runs prove rsync-independence). `.pre-constitution.bak` triage: single-shot backup is
|
||||||
|
intentional (reconcile_framework_files backs up once), no change.
|
||||||
|
- Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1081 passed (was 1069, +12) · HARD GATE
|
||||||
|
118/118 (was 58) · rollback 7/7 (new) · migration 21/21. No --no-verify. Rollback test wired into
|
||||||
|
ci.yml upgrade-guard. Committing FORWARD (no rebase of 34e55d4a/0a5e703a).
|
||||||
|
|
||||||
|
### Codex round 2 (pre-push self-review) → blockers A/B + should-fix C fixed red-first (2026-07-16)
|
||||||
|
Before committing round 1 I re-ran codex on the change set; it surfaced two fresh reliability defects
|
||||||
|
and one messaging defect on the SAME rollback/manifest path. Fixed forward, red-first:
|
||||||
|
- **Blocker-A (CRITICAL) — signal trap resumed instead of terminating.** A bash INT/TERM handler that
|
||||||
|
merely `restore_snapshot` (returns) does NOT terminate the script — execution RESUMES past the
|
||||||
|
interrupt, cleans the snapshot and reports success, leaving a partial post-interrupt update. Fix:
|
||||||
|
`trap 'restore_snapshot; exit 1' ERR INT TERM` so both the errtrace (ERR) and signal (INT/TERM) paths
|
||||||
|
exit non-zero. Rollback test Part C: a `cp` shim that `kill -TERM $PPID` mid-sync then succeeds (so
|
||||||
|
set -e never fires and only the signal path governs) → asserts abort non-zero + restore fires + does
|
||||||
|
NOT print "file phase complete"; control strips `exit 1` and asserts the buggy resume-to-success.
|
||||||
|
- **Blocker-B (CRITICAL) — degenerate `[framework]` section resolved everything operator.** A manifest
|
||||||
|
whose framework entries are all empty / bare-dot (`/`, `./`, `.`, `..`) passed the non-empty guard yet
|
||||||
|
yielded zero usable globs → nothing is framework → a keep-mode sync silently no-ops (bash resolved
|
||||||
|
`operator`, exit 0). Fix (both langs, parity): reject when no entry has a char other than `/`/`.` —
|
||||||
|
TS `isUsableFrameworkGlob` = `/[^/.]/.test(normalizeRel(glob))`, throws `ManifestError`; bash mirror
|
||||||
|
loops `[[ "$(_manifest_norm "$_g")" =~ [^/.] ]]`, loud stderr + return 1. Tests: manifest.spec.ts
|
||||||
|
`it.each(['/','./','.','..','/\n./'])` throw; parity +3 `expectBothReject` (root-slash/dot-slash/
|
||||||
|
bare-dot). RED: reverting the guard makes `[framework]\n/` resolve `operator` exit 0.
|
||||||
|
- **Should-fix-C — misleading abort message.** finalize.ts printed one generic "may be partially
|
||||||
|
applied" for every sync failure. A `ManifestError` is a PRE-sync validation abort (manifest is
|
||||||
|
validated before any copy) → nothing was written; conflating it with a mid-copy failure misdirects
|
||||||
|
recovery. Fix: introduce `ManifestError` (exported from manifest.ts, thrown by every fail-closed
|
||||||
|
parse/load path), and classify in finalize.ts — ManifestError → "no files were changed"; any other →
|
||||||
|
"may be partially applied". New co-located `finalize-sync-abort.spec.ts` (3 tests) asserts both
|
||||||
|
branches re-throw the original error + the correct message, and that config writes are never reached.
|
||||||
|
RED proven by collapsing the classification → the ManifestError test fails.
|
||||||
|
- Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1094 (was 1081, +3 finalize-abort;
|
||||||
|
manifest specs already counted) · HARD GATE 193/193 · rollback 14/14 · migration 21/21.
|
||||||
|
|
||||||
|
### Codex round 3 (pre-push self-review) → blockers D1/D2 fixed red-first (2026-07-16)
|
||||||
|
Re-ran codex again; it found two more rollback-path gaps `set -E` cannot catch. Fixed forward, red-first:
|
||||||
|
- **Blocker-D1 (CRITICAL) — `find` scan failures swallowed by process substitution.** Both the overlay
|
||||||
|
copy and the scoped prune consumed `< <(find … -print0)`. Bash does NOT propagate the producer's exit
|
||||||
|
status to the `while`, so an EACCES/I/O failure mid-scan truncates the file list yet leaves the loop
|
||||||
|
exiting 0 → a partial upgrade commits and reports success; the ERR/restore trap never fires. Fix:
|
||||||
|
`_scan_or_die` runs `find … -print0 > "$tmp"` to completion, checks its status, and returns non-zero
|
||||||
|
(→ ERR trap → restore) on failure; both loops now read from the checked temp file. Rollback test
|
||||||
|
Part D: a `find` shim that fails every `-print0` scan → shipped installer aborts non-zero + restores +
|
||||||
|
emits "Could not enumerate framework files" + target byte-identical; control neuters the `# D1-GUARD`
|
||||||
|
`return 1` → find failure swallowed, upgrade wrongly reports "file phase complete", no rollback.
|
||||||
|
- **Blocker-D2 (CRITICAL) — silent `set -e` exit on a failed target reset.** restore_snapshot did a bare
|
||||||
|
`rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"` (trap disarmed, under set -e). If `rm`/`mkdir` fails —
|
||||||
|
possibly after `rm` deleted part of the target — the script exits immediately, skipping the cp AND the
|
||||||
|
recovery pointer, leaving a half-removed target and an orphaned snapshot the operator can't locate.
|
||||||
|
Fix: `if ! rm -rf … || ! mkdir -p …; then fail "Snapshot restore could not reset … preserved at:
|
||||||
|
$SNAPSHOT_DIR — copy it back …"; return 1; fi` (tested like the cp -a check; snapshot NOT deleted).
|
||||||
|
Rollback test Part E: cp-poison triggers restore + an `rm` shim fails `rm -rf <TARGET>` → shipped
|
||||||
|
emits the recovery pointer, the named snapshot dir survives, secret value never leaked; control deletes
|
||||||
|
the recovery line → operator gets no pointer. RED: reverting D1+D2 → 7 shipped/control assertions fail.
|
||||||
|
- Gates: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1094 · HARD GATE 193/193 ·
|
||||||
|
rollback 28/28 (was 14, +14 for D1/D2 with controls) · migration 21/21. shellcheck clean on new lines.
|
||||||
|
No --no-verify. Committing FORWARD (no rebase of 34e55d4a/0a5e703a).
|
||||||
|
|
||||||
|
## Session 3 (2026-07-16) — PR1 MERGED, starting PR2 (durable snapshot + restore + secrev)
|
||||||
|
PR1 (#802) squash-merged → main `32a0ffba`; issue #791 stays open (3-PR DAG umbrella). Independent Opus
|
||||||
|
adversarial/security review APPROVED at head `af627e75` (Gitea RoR cmt 17892); lead ran rollback 28/28 +
|
||||||
|
HARD GATE 193/193 green; CI #1877 green. PR2 UNBLOCKED.
|
||||||
|
|
||||||
|
PR2 branch: `feat/791-pr2-snapshot-restore` off `origin/main` 32a0ffba. Same treatment applies:
|
||||||
|
tests-first red-first, independent review + durable Gitea Reviewer-of-Record comment BEFORE MS-LEAD runs
|
||||||
|
the queue guard/merge. Report PR2 number + exact head when ready. PR body: `Part of #791` (NOT Fixes).
|
||||||
|
|
||||||
|
### PR2 scope (ratified §3/§5 of design doc, Mos-approved — do NOT re-litigate)
|
||||||
|
- **(a) Durable pre-update snapshot** to `${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-ts>/`
|
||||||
|
— OUTSIDE ~/.config/mosaic and any repo. Perms dir 0700 / files 0600 (umask 077 + explicit chmod).
|
||||||
|
Scope = operator-owned surface that EXISTS (operatorReserved paths), not the framework tree. Taken
|
||||||
|
BEFORE any mutation. Retention N=5 (`MOSAIC_BACKUP_RETENTION`), prune older.
|
||||||
|
- **Post-sync verify + selective restore**: diff operator surface vs snapshot; (b) should never touch
|
||||||
|
operator paths, so ANY diff = manifest bug → restore affected paths + warn loudly. (a) catches a (b) miss.
|
||||||
|
- **`mosaic restore`** (TS CLI): `--list` (default, dry-run) enumerates snapshots by ts; `--from <ts>`
|
||||||
|
restores over operator surface, confirmation-gated. Counts/paths only.
|
||||||
|
- **Secret-safety (secrev)**: snapshot/restore NEVER emit file contents; only paths/counts. Tests assert
|
||||||
|
0700/0600 AND that a secret value seeded in tools/_lib/credentials.json never appears in any output.
|
||||||
|
|
||||||
|
### PR2 implementation status (2026-07-16, ready-for-review)
|
||||||
|
All three tasks implemented, red-first proven, unit-green:
|
||||||
|
- **Task #10 — durable snapshot (install.sh)**: `backup_root()`/`enumerate_operator_files()`/
|
||||||
|
`prune_durable_snapshots()`/`make_durable_snapshot()` wired into keep-mode main() after `manifest_load`,
|
||||||
|
before any mutation. umask 077 + explicit chmod 700/600. UTC ts, collision suffix. FAIL-OPEN (a backup
|
||||||
|
failure never aborts the upgrade it protects). Retention `MOSAIC_BACKUP_RETENTION` (default 5), in-place
|
||||||
|
`sort -r -o` prune (no `mv` — stays inside the rsync-absent coreutils whitelist).
|
||||||
|
- **Task #11 — post-sync verify net (install.sh)**: `verify_operator_surface()` runs after sync (trap
|
||||||
|
disarmed), `cmp -s` each snapshot file vs target; restores any diverged/missing operator file + warns
|
||||||
|
loudly (a divergence = manifest bug). VERIFY-NET wired before `cleanup_snapshot`.
|
||||||
|
- **Task #12 — `mosaic restore` (TS)**: `src/commands/restore.ts` + co-located spec (19 tests).
|
||||||
|
`--list` default (dry-run enumerate), `--from <ts>` confirmation-gated restore, `--dry-run`, `--yes`/
|
||||||
|
`MOSAIC_ASSUME_YES`. Injectable `confirm` for testability (proceed/decline/env-bypass covered). Restored
|
||||||
|
files forced 0600. Registered in `cli.ts`. Path convention mirrors install.sh `backup_root()`.
|
||||||
|
- **CI**: `.woodpecker/ci.yml` upgrade-guard runs the new `test-upgrade-durable-snapshot.sh` gate.
|
||||||
|
- **Gates green**: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1241 (+5) ·
|
||||||
|
durable-snapshot 26/26 · manifest-guard 193/193 · rollback 28/28 · migration 21/21.
|
||||||
|
Est. new-code coverage ≈93% (only the interactive readline default + process.exit-on-error uncovered).
|
||||||
|
- Regression fixed: PR2's `date`/`sort`/`mv` broke the rsync-absent manifest-guard PATH whitelist →
|
||||||
|
made date/sort fail-open, replaced `mv` with in-place `sort -o`, added `date sort` to the test whitelist
|
||||||
|
+ isolated `XDG_STATE_HOME`. All 193 manifest-guard assertions green under restricted PATH.
|
||||||
|
- Codex code-review + security-review (secrev) run on the uncommitted diff before commit.
|
||||||
|
|
||||||
|
### PR2 review round 1 — findings + remediations (2026-07-16, pre-PR)
|
||||||
|
Codex code-review returned **request-changes** (1 blocker + 3 should-fix); Codex security-review returned
|
||||||
|
**high** (1 high + 1 medium). Deduped to 5 distinct defects, ALL legitimate, ALL fixed FORWARD, each with
|
||||||
|
a red-first regression test whose control neuters exactly the guard under test:
|
||||||
|
|
||||||
|
- **A · BLOCKER — verify net undid the legacy bin/ migration (install.sh).** On a pre-v2 install `bin/**`
|
||||||
|
is operator-classified, so the durable snapshot captured it; `run_migrations()` deletes bin/ on purpose,
|
||||||
|
but `verify_operator_surface()` then saw it "missing" and healed it back — the migration would be silently
|
||||||
|
undone forever once the version stamps. **Fix:** `MIGRATION_REMOVED_PATHS[]` recorded by run_migrations
|
||||||
|
(`bin`,`rails`) + `is_migration_removed()` skip in the verify loop (`# MIGRATION-SKIP-GUARD`).
|
||||||
|
**Test:** Part 6 — v1 fixture with bin/; shipped keeps it removed + stamps v3; control (guard stripped)
|
||||||
|
wrongly restores bin/tool.sh.
|
||||||
|
- **B · HIGH (CWE-59) — restore/verify wrote secrets THROUGH a symlink (install.sh + restore.ts).** An
|
||||||
|
attacker swapping an operator path (e.g. tools/_lib/credentials.json) for a symlink after the snapshot
|
||||||
|
would make `cp`/`copyFileSync` write the snapshot's secret out through the link. **Fix (bash):** refuse a
|
||||||
|
symlinked ancestor (`has_symlinked_parent`), drop a symlinked leaf before restore
|
||||||
|
(`# SYMLINK-LEAF-GUARD`). **Fix (TS):** reuse audited `secure-file.ts` — `assertCanonicalContainment`
|
||||||
|
+ `ensureManagedDirectory` on every dst, open the leaf `O_NOFOLLOW|O_CREAT|O_TRUNC` 0600 (ELOOP =
|
||||||
|
fail-closed). **Tests:** Part 7 (shipped leaves external exfil target untouched, restores a real 0600
|
||||||
|
file; control leaks the secret through the link) + restore.spec symlinked-leaf/ancestor cases (red-first).
|
||||||
|
- **C · MEDIUM/should-fix (CWE-22) — `--from` traversal escaped the backup root (restore.ts).**
|
||||||
|
`join(root, from)` accepted `../poison`. **Fix:** validate the selector against
|
||||||
|
`^\d{8}T\d{6}Z(?:-\d+)?$`, build exactly `join(root,'pre-update-'+ts)`, `lstat` (reject symlinked snap
|
||||||
|
dir). **Test:** restore.spec `it.each` of 6 malformed selectors + `--from ../poison` fail-closed (red-first).
|
||||||
|
- **D · should-fix — verify `mkdir -p` unguarded under set -e (install.sh).** A parent replaced by a
|
||||||
|
regular file aborted the installer before the recovery pointer printed. **Fix:** guard `mkdir -p`, warn
|
||||||
|
+ `continue` on failure (keeps healing remaining files).
|
||||||
|
- **E · should-fix — snapshot `umask 077` leaked process-global (install.sh).** Later sync copies/dirs
|
||||||
|
inherited 0600/0700. **Fix:** save `old_umask`, restore on EVERY return path (`# UMASK-RESTORE-NORMAL`).
|
||||||
|
**Test:** Part 8 — synced framework file is 0644 while the secret backup stays 0600; control (restore
|
||||||
|
stripped) makes the synced file 0600.
|
||||||
|
|
||||||
|
**Full gate suite re-run after fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic
|
||||||
|
vitest **1252** · restore.spec **30** · durable-snapshot **41** · manifest-guard 193 · rollback 28 ·
|
||||||
|
migration 21. shellcheck clean on all new lines; new test markers mirror the existing `# VERIFY-NET`
|
||||||
|
anchor convention. NOTE: codex self-review does NOT satisfy the independent-review gate — an independent
|
||||||
|
(author≠reviewer) review + durable Gitea Reviewer-of-Record comment is still required before MS-LEAD merges.
|
||||||
|
|
||||||
|
## Session 4 (2026-07-16) — PR2 MERGED, PR3 built (fleet regen — recovery layer)
|
||||||
|
PR2 (#811) squash-merged → main `31607a4a`; issue #791 stays open (final PR of the 3-PR DAG). Independent
|
||||||
|
exact-head RoR at `d12c5f78` APPROVE (Gitea cmt 17904); #1882 green; busybox-portable Part 7 control fix
|
||||||
|
verified in-Alpine. PR3 UNBLOCKED.
|
||||||
|
|
||||||
|
PR3 branch: `feat/791-pr3-fleet-regen` off `origin/main` 31607a4. Same discipline: tests-first red-first,
|
||||||
|
independent review + durable Gitea RoR BEFORE MS-LEAD runs the queue guard/merge. PR body `Part of #791`.
|
||||||
|
|
||||||
|
### PR3 scope (ratified §4/§7 of design doc) — `mosaic fleet regen`
|
||||||
|
Projection-only recovery command: rebuilds each `fleet/agents/<name>.env.generated` from `roster.yaml`
|
||||||
|
(SSOT). Dry-run default; `--write` applies; `--json` machine output. Structural guarantee: NO code path to
|
||||||
|
systemd lifecycle — **never restarts an agent**. Single-SSOT: reuses `projectRosterV2AgentGeneratedEnv`
|
||||||
|
(extracted, shared with the reconciler apply path) so regen and reconcile cannot drift. Secrev: paths +
|
||||||
|
counts only, never the rendered KEY=value body.
|
||||||
|
|
||||||
|
New files: `commands/fleet-regen-command.ts` (+ `.spec.ts`), guide `docs/guides/upgrade-safety-and-recovery.md`
|
||||||
|
(three-layer model: PR1 manifest ownership → PR2 snapshot/restore → PR3 regen; do-NOT-restart-before-verify
|
||||||
|
runbook), regen reference added to `docs/guides/fleet-local-canary.md`. Wired in `commands/fleet.ts`.
|
||||||
|
|
||||||
|
### Independent review (3 reviewers: subagent code-reviewer + codex code-review + codex security) → 4 fixes, red-first
|
||||||
|
- **A · BLOCKER (codex) — regen mutated/deleted legacy operator env.** `applyPreparedAgentEnvironmentProjection`
|
||||||
|
also writes `.env.local`/`.env.quarantine` and unlinks legacy `.env`. Violated projection-only contract.
|
||||||
|
**Fix:** NEW generated-only boundary primitives `prepareGeneratedAgentEnvironmentProjection` +
|
||||||
|
`applyPreparedGeneratedAgentEnvironmentProjection` (write ONLY `<name>.env.generated`). regen now has no
|
||||||
|
code path that touches `.env`/`.env.local`/`.env.quarantine`. **Test:** projection-only leaves legacy `.env`
|
||||||
|
verbatim, no local/quarantine fabricated.
|
||||||
|
- **B · should-fix (codex + subagent + security) — partial write on mid-loop failure.** Interleaved
|
||||||
|
prepare/apply left earlier agents written when a later agent failed prepare. **Fix:** PREPARE ALL agents
|
||||||
|
before writing ANY (mirrors reconciler `defaultPrepareProjections`). **Test:** 2nd agent's projection
|
||||||
|
pre-seeded 0644 → prepare rejects → coder0 NOT written, exit 1.
|
||||||
|
- **C · subagent — semantic-validation bypass.** Default readRoster skipped `validateRosterV2Semantics`, so
|
||||||
|
a tampered protected-class `tool_policy` would be silently projected. **Fix:** default readRoster now runs
|
||||||
|
`validateRosterV2Semantics` (persona resolution + protected-class match), rolesDir/overrideDir defaults
|
||||||
|
mirroring the reconciler. **Test:** merge-gate agent w/ tool_policy=code → fails closed, no write.
|
||||||
|
- **D · MEDIUM (codex security, CWE-362) — concurrent-reconcile race.** regen `--write` wrote without the
|
||||||
|
reconcile lock. **Fix:** `--write` acquires `acquirePrivateReconcileLock(mosaicHome)` for the whole
|
||||||
|
read-prepare-apply sequence, released in `finally`; dry-run stays lock-free. **Test:** pre-held lock →
|
||||||
|
regen fails closed, no write.
|
||||||
|
|
||||||
|
**Gate suite after fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest **1265**
|
||||||
|
(regen spec 13, incl. 4 new red-first regressions). NOTE: codex self-review does NOT satisfy the
|
||||||
|
independent-review gate — an independent (author≠reviewer) review + durable Gitea RoR is still required
|
||||||
|
before MS-LEAD merges. STOP at PR-open for MS-LEAD's exact-head review; do NOT self-merge.
|
||||||
|
|
||||||
|
## Session 5 — PR3 review round 2 (finding L + M1/M2/M3), red-first fixes
|
||||||
|
|
||||||
|
Second review pass on the lock-cleanup plumbing surfaced one round-1 residual (L) and three round-2
|
||||||
|
findings (M1 blocker, M2/M3 should-fix). All fixed red-first (RED proven per-finding, then GREEN).
|
||||||
|
|
||||||
|
- **L · should-fix (codex r1) — mutation-lock release swallowed unlink failures.** regen's
|
||||||
|
`acquirePrivateRosterMutationLock` release copied CRUD's `unlink().catch(()=>{})`, hiding a stale
|
||||||
|
`roster.yaml.mutation.lock`. **Fix:** its release PROPAGATES the unlink fault (finding-J stale-lock
|
||||||
|
warning then fires for this lock too). **Test:** acquire real lock, `rm` it, assert `release()` rejects.
|
||||||
|
- **M1 · BLOCKER (codex r2) — replacement-lock race.** The propagating release from L did an
|
||||||
|
UNCONDITIONAL `unlink(lockPath)` without proving ownership. If the lock is cleared + re-created by
|
||||||
|
another writer mid-op, regen deletes the STRANGER's live lock → a third writer enters → mutual
|
||||||
|
exclusion defeated. **Fix (reuse, not reimplement):** generalized the reconciler's ownership-proving
|
||||||
|
lock body into shared `acquirePrivateManagedRosterLock(mosaicHome, lockLeaf, busyMessage, openLock)`;
|
||||||
|
`acquirePrivateReconcileLock` delegates to it (behavior-identical: same leaf/codes/messages), and a NEW
|
||||||
|
hardened `acquirePrivateRosterMutationLock` (now in fleet-reconciler.ts, leaf `roster.yaml.mutation.lock`)
|
||||||
|
records dev/ino + ownership token and RE-PROVES ownership (`assertLockOwnership`) before unlinking —
|
||||||
|
fails closed as `lock-cleanup-failed` if replaced. Removed the crud-based export; reverted
|
||||||
|
`acquireMutationLock` (fleet-agent-crud.ts) to its original inline empty-file/swallowing-release form
|
||||||
|
(CRUD behavior intentionally unchanged). Compatibility: CRUD empty-file `wx` and regen tokened `wx`
|
||||||
|
contend on the same path but never co-own (wx winner owns; loser → concurrent-mutation), so the token
|
||||||
|
is only ever read back by the same regen invocation. **Test:** acquire, `rm`+recreate lock (new inode),
|
||||||
|
assert `release()` rejects AND the replacement survives (not unlinked).
|
||||||
|
- **M2 · should-fix (codex r2) — acquire-unwind fault dropped.** The acquire-failure catch discarded
|
||||||
|
`releaseFleetLocks`' return (a possible fault on the already-held first lock). **Fix:** capture and
|
||||||
|
augment — `const releaseFault = await releaseFleetLocks(releases); throw augmentWithLockCleanupFault(error, releaseFault);`
|
||||||
|
(symmetric to finding J). **Test:** mutation lock acquires w/ faulting release + reconcile acquire
|
||||||
|
throws → thrown error mentions stale/lock, nothing written.
|
||||||
|
- **M3 · should-fix (codex r2 + subagent REQUEST-CHANGES) — cleanup warning named only reconcile lock.**
|
||||||
|
Finding L made the mutation-lock release fault reachable, so the `cleanup` marker can originate from
|
||||||
|
EITHER lock. **Fix:** `formatFleetRegenReport`'s WARNING now names BOTH `roster.yaml.mutation.lock` and
|
||||||
|
`roster.yaml.reconcile.lock`, matching `augmentWithLockCleanupFault`. **Test:** fault the mutation-lock
|
||||||
|
release specifically → report names both lock files.
|
||||||
|
|
||||||
|
**Refactor note (no cycle):** neither fleet-reconciler nor fleet-agent-crud imports the other; regen
|
||||||
|
imports lock acquirers from fleet-reconciler and the projection mapping from fleet-reconciler. The two
|
||||||
|
reconcile-lock reviewers reconciled: independent reviewer validated acquire-time empty-file compatibility
|
||||||
|
(preserved), codex flagged RELEASE-time replacement race (closed by ownership proof) — non-contradictory.
|
||||||
|
|
||||||
|
**Gate suite after fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest **1275**
|
||||||
|
(regen spec 23, incl. 7 red-first lock regressions E/F/G/K/L/M1/M2/M3). RED proven per-finding by
|
||||||
|
temporary revert before re-applying each fix. Independent (author≠reviewer) review of M1/M2/M3 + codex
|
||||||
|
code/security re-run in flight. STOP at PR-open for MS-LEAD's exact-head review + durable Gitea RoR; do
|
||||||
|
NOT self-merge; #791 umbrella stays OPEN; PR body `Part of #791`.
|
||||||
|
|
||||||
|
### Round 3 review (after M1/M2/M3) — independent review PASS + codex residual-TOCTOU disposition
|
||||||
|
Three reviewers on the post-M1/M2/M3 head:
|
||||||
|
- **Independent (subagent, author≠reviewer) — PASS.** Verified M1/M2/M3 all correctly fixed; "never
|
||||||
|
restarts" is STRUCTURAL (runner never referenced in executable code); no secrets; no deadlock (only
|
||||||
|
regen holds both locks); tests meaningful (assert inode preservation + exact lock-file names). Raised:
|
||||||
|
- **should-fix #1 (fixed, red-first):** generalizing the lock helper left `assertSafeLockLeafIfPresent`/
|
||||||
|
`assertLockOwnership` hardcoding "reconciliation lock" in thrown messages → a MUTATION-lock fault
|
||||||
|
misreported as the reconcile lock, undercutting M3's accurate-diagnosis goal. **Fix:** thread
|
||||||
|
`lockLabel = fleet/<leaf>` through both helpers + the generic lock-io messages, so every fault names
|
||||||
|
the actual lock file. Red-first: strengthened the M1 test to assert `/roster\.yaml\.mutation\.lock/`
|
||||||
|
(RED: got "reconciliation lock"; GREEN after). Also resolves nit #3 (generic-message drift).
|
||||||
|
- **nit #2 (fixed):** `FleetRegenResult.cleanup` JSDoc still said "the shared reconcile lock"; now names
|
||||||
|
both locks (regen holds both).
|
||||||
|
- **nit #4 (fixed):** removed the redundant duplicate `assertLockOwnership` call before unlink
|
||||||
|
(pre-existing in merged main; harmless but dead — dropped since the fn was already being touched).
|
||||||
|
- **Codex security — clean (risk: none).** Validates roster semantics, constrains env values, no shell
|
||||||
|
eval, no secret output, generated-only writes, serialized against both locks.
|
||||||
|
- **Codex code — request-changes, 1 "blocker": residual check-then-unlink TOCTOU.** Between the final
|
||||||
|
`assertLockOwnership` and the path-based `unlink`, an external actor could vacate our inode and a new
|
||||||
|
writer grab the path, so the unlink deletes the stranger's lock. **Disposition: documented known
|
||||||
|
limitation, NOT fixed in PR3.** Rationale: (1) byte-identical to the MERGED, shipped reconcile-lock
|
||||||
|
release on origin/main (fleet-reconciler.ts L654-659) — not introduced here; (2) UNREACHABLE within the
|
||||||
|
`wx` writer protocol — no Mosaic writer removes a lock it doesn't own (wx fails EEXIST while our inode
|
||||||
|
exists), so only external interference can vacate our inode in the sub-instruction window; (3) the
|
||||||
|
ownership guard DOES close the reachable case (stale-lock reaper/operator cleared our lock + another
|
||||||
|
writer took it BEFORE release began → fail closed, don't delete stranger's lock); (4) the true atomic
|
||||||
|
fix — fd-held advisory lock (flock/lockf) adopted by ALL fleet writers (CRUD + reconcile + regen) — is
|
||||||
|
a cross-cutting mechanism change touching merged CRUD + reconciler, out of scope for a projection-only
|
||||||
|
recovery PR. Documented honestly in the acquirer doc + M1 test comment. **The binding independent
|
||||||
|
review did NOT treat this as a blocker.** Recommendation to MS-LEAD: proceed to PR-open + spin a
|
||||||
|
SEPARATE follow-up issue for the fd-advisory-lock migration; MS-LEAD adjudicates scope at exact-head
|
||||||
|
review (merge authority).
|
||||||
|
|
||||||
|
**Gates after round-3 fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest
|
||||||
|
**1275** (regen spec 23). Fresh codex code re-run in flight to confirm no NEW issues from the label fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 6 — Round 4/5 convergence (stranded-lock robustness)
|
||||||
|
|
||||||
|
**Two independent reviewers converged on the SAME should-fix** on the init-failure cleanup path,
|
||||||
|
strengthening confidence it was real:
|
||||||
|
|
||||||
|
- **Codex code-review-5 — 0 blockers, 1 should-fix.** "Stat failure after lock creation strands the new
|
||||||
|
lock." When `handle.stat()` ITSELF fails right after the `wx` create (transient EIO/EBADF), `created`
|
||||||
|
is `undefined`, so `removeOwnedLockLeafBestEffort` had `if (!created) return;` → no cleanup → the
|
||||||
|
just-created `roster.yaml.mutation.lock`/`reconcile.lock` is stranded, permanently blocking future
|
||||||
|
regen + CRUD. (Notably NO blocker, and the TOCTOU is no longer flagged in code-review as of r5.)
|
||||||
|
- **Independent delta reviewer (author≠reviewer, pr-review-toolkit) — no blockers, same should-fix.**
|
||||||
|
Independently flagged the identical `!created` gap; validated FIX 1 (label threading — no call site
|
||||||
|
missed, codes unchanged, no test depended on old text) and FIX 2 (dev/ino-guarded cleanup, best-effort,
|
||||||
|
happy-path release reuses captured dev/ino) as correct. Suggested an unconditional best-effort unlink
|
||||||
|
in the `!created` branch; I took the **safer** variant below.
|
||||||
|
- **Codex security-review-5 — 0 crit / 0 high / 1 medium.** The single medium is the SAME residual
|
||||||
|
check-then-unlink TOCTOU already dispositioned in round 3 (its own remediation = "migrate every writer
|
||||||
|
to an fd-held advisory lock" = the follow-up issue). No new security finding. No secrets.
|
||||||
|
|
||||||
|
**Fix (red-first, safer than an unconditional unlink):** thread the persisted random `token` into
|
||||||
|
`removeOwnedLockLeafBestEffort`. Two independent ownership proofs now: primary dev/ino (unchanged), and a
|
||||||
|
**fallback** when the post-create stat failed — read the leaf and unlink ONLY if its content equals our
|
||||||
|
`randomUUID()` token. Only OUR lock carries that token, so a CRUD (empty) or differently-tokened
|
||||||
|
replacement is never deleted. `tokenPersisted` guards passing the token (only after `writeFile` lands).
|
||||||
|
Doubly-degenerate case (stat fails AND token write never landed) leaves the lock in place rather than
|
||||||
|
risk deleting a stranger's file — requires two independent fs faults on a just-created fd; documented.
|
||||||
|
|
||||||
|
- **Red-first proof:** new test `does not strand the lock file when the post-create stat itself fails`
|
||||||
|
injects a real `wx` create + a Proxy handle whose `stat()` rejects (writeFile/close succeed), asserts
|
||||||
|
`exists(lockPath) === false`. RED before fix (`expected true to be false` — lock stranded); GREEN after.
|
||||||
|
- **Also fixed (delta nit #3):** `fleet-regen-command.ts` `acquireRosterMutationLock` JSDoc said "CRUD's
|
||||||
|
private lock"; the default is the reconciler's hardened ownership-proving acquirer for the same
|
||||||
|
`fleet/roster.yaml.mutation.lock` path. Corrected.
|
||||||
|
- **PR-description note (delta nit #2):** FIX 1 also collapsed a pre-existing duplicate back-to-back
|
||||||
|
`assertLockOwnership` call in the release closure (identical args, no intervening logic) into one — a
|
||||||
|
no-op simplification of merged code, not a behavior change. Called out so a future reader doesn't
|
||||||
|
wonder if the duplicate had a purpose.
|
||||||
|
|
||||||
|
**Gates after round-4 fixes (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest
|
||||||
|
**1277** (regen spec now 25: +1 stat-failure stranded-lock regression). Residual TOCTOU still deferred to
|
||||||
|
the fd-advisory-lock follow-up issue; MS-LEAD adjudicates scope at exact-head review (merge authority).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 6 — Round 6 (persona-root wiring)
|
||||||
|
|
||||||
|
**Codex code-review-6 — 0 blockers, 1 should-fix (NEW, distinct from the lock work).** "Forward
|
||||||
|
configured persona directories to regen." `registerFleetRegenCommand` was registered at
|
||||||
|
`fleet.ts:2069` with only `{ runner, mosaicHome }`, discarding `deps.reconcileDeps.rolesDir` /
|
||||||
|
`overrideDir`. The regen command ALREADY has those seams (validates roster semantics via
|
||||||
|
`validateRosterV2Semantics({ rolesDir, overrideDir })`, defaulting to `<mosaicHome>/fleet/roles{,.local}`),
|
||||||
|
but the top-level wiring never forwarded the configured roots. **Impact:** in a deployment with custom
|
||||||
|
persona roots, `fleet reconcile` (which honors the overrides) would ACCEPT a roster while `fleet regen`
|
||||||
|
REJECTS the same roster (persona resolution against the wrong default dir) — blocking the recovery
|
||||||
|
command and violating the documented "resolves personas the SAME way reconcile does" contract.
|
||||||
|
|
||||||
|
**Fix (red-first):** forward `rolesDir`/`overrideDir` from `deps.reconcileDeps` into
|
||||||
|
`registerFleetRegenCommand` at `fleet.ts:2069`. Red-first test `forwards configured persona roots
|
||||||
|
(rolesDir/overrideDir) from reconcileDeps into regen`: seeds personas ONLY under a custom root, leaves
|
||||||
|
the default `<home>/fleet/roles` empty, registers with `reconcileDeps: { rolesDir, overrideDir }`, and
|
||||||
|
requires `fleet regen` to SUCCEED. RED before fix (`expected 1 not to be 1` — regen validated against the
|
||||||
|
empty default and exited 1); GREEN after.
|
||||||
|
|
||||||
|
**Codex security-review-6 — 0 crit / 0 high / 1 medium.** Same residual check-then-unlink TOCTOU, now
|
||||||
|
noted at BOTH the release closure and the init-cleanup path; remediation = fd-held advisory lock across
|
||||||
|
all writers = the SAME deferred follow-up item. No new security finding, no secrets.
|
||||||
|
|
||||||
|
**Independent confirmation review of the token-fallback fix (Session 6/round 4) — PASS, no findings.**
|
||||||
|
All 7 verification points confirmed; reviewer mechanically reverted `removeOwnedLockLeafBestEffort` to
|
||||||
|
the pre-fix `if (!created) return;` and re-ran the new test → RED (`expected true to be false`),
|
||||||
|
confirming the test genuinely pins the fix; restored after. No lint/type issues; doc-comment accurate.
|
||||||
|
|
||||||
|
**Gates after round-6 fix (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest
|
||||||
|
**1278** (regen spec now 26: +1 persona-root wiring regression).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 6 — Round 7 convergence (review CLOSED for PR-open)
|
||||||
|
|
||||||
|
- **Codex code-review-7 — 0 blockers, 1 should-fix = the residual TOCTOU** (previously a "blocker" in r3,
|
||||||
|
dropped in r4/r5, now re-surfaced as a should-fix). **Codex security-review-7 — 0 crit / 0 high /
|
||||||
|
1 medium = the SAME residual TOCTOU.** Codex has CONVERGED: the only remaining finding across both
|
||||||
|
streams is that one race, whose own remediation is "fd-held advisory lock shared by all fleet writers"
|
||||||
|
= the deferred follow-up. No new distinct finding; the wiring fix introduced nothing.
|
||||||
|
- **Independent confirmation review of the persona-root wiring fix — PASS, no findings.** Reviewer
|
||||||
|
mechanically reverted the two forwarded lines → RED (`Roster v2 agent "coder0" class "code" does not
|
||||||
|
resolve to a readable persona` → exit 1), restored → GREEN (26 regen + 204 fleet tests). Confirmed the
|
||||||
|
optional-chaining fallback preserves default-deployment behavior and no type/lint issue.
|
||||||
|
|
||||||
|
**Review disposition for PR-open:** ALL actionable findings fixed red-first across rounds 3–6 (label
|
||||||
|
threading, stranded-lock on init failure, stat-failure strand, persona-root wiring). The residual
|
||||||
|
check-then-unlink TOCTOU is the ONLY open item and is DEFERRED to a follow-up issue (fd-advisory-lock
|
||||||
|
migration across CRUD + reconcile + regen) — byte-identical to merged origin/main's reconcile-lock
|
||||||
|
release, unreachable within the `wx` writer protocol (no Mosaic writer removes a lock it doesn't own;
|
||||||
|
only external `rm`/a stale-lock reaper can vacate the inode mid-release), and its true fix is a
|
||||||
|
cross-cutting mechanism change out of scope for a projection-only recovery PR. Two independent human-agent
|
||||||
|
reviews (author≠reviewer) treated it as non-blocking. MS-LEAD adjudicates scope at exact-head review
|
||||||
|
(merge authority); recommendation = proceed to PR-open + spin the follow-up issue.
|
||||||
|
|
||||||
|
**Final gates (all green):** typecheck ✓ · lint ✓ · format:check ✓ · full mosaic vitest **1278**
|
||||||
|
(regen spec 26). No secret values in any snapshot/projection/report output (counts + paths only). Regen
|
||||||
|
NEVER issues a lifecycle/restart call (load-bearing recordingRunner gate). STOP at PR-open for MS-LEAD's
|
||||||
|
exact-head review + durable Reviewer-of-Record before any merge; do NOT self-merge.
|
||||||
64
docs/scratchpads/807-glpi-partial-content.md
Normal file
64
docs/scratchpads/807-glpi-partial-content.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Issue #807 — GLPI list wrappers accept HTTP 206
|
||||||
|
|
||||||
|
- **Branch:** `fix/807-glpi-206`
|
||||||
|
- **Task:** Gitea issue #807
|
||||||
|
- **Role:** Author-only worker reporting to `mosaic-100`; no self-review or merge
|
||||||
|
- **Started:** 2026-07-16
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Fix the shipped GLPI ticket, computer, and user list wrappers so ranged responses with HTTP 206 Partial Content render successfully while genuine HTTP failures remain non-zero errors.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Modify only the three affected list wrappers and a focused shell regression test.
|
||||||
|
- Do not touch `session-init.sh`, `ticket-create.sh`, or `docs/TASKS.md`.
|
||||||
|
- Add task-local delivery evidence here as required by the mission protocol.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add a deterministic shell harness that copies each wrapper beside stubbed `session-init.sh`, credentials, and `curl` boundaries.
|
||||||
|
2. Prove RED against the current 200-only gates: 206 must fail before the implementation change.
|
||||||
|
3. Update all three status gates to accept exactly 200 or 206.
|
||||||
|
4. Prove GREEN for 206 rendering and genuine 401/500 failures, then run repository quality gates.
|
||||||
|
5. Commit with co-author attribution, run the push queue guard, push, and open a PR for independent review and merge by the team lead.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
- No explicit token cap supplied.
|
||||||
|
- Soft estimate: 8K tokens; narrow single-worker execution with no exploratory scope.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [x] Mission, task, PRD, QA, documentation, and code-review guidance loaded.
|
||||||
|
- [x] RED regression evidence captured: `test-list-http-status.sh` exited 1; all three wrappers rejected 206 while retaining 401 failures.
|
||||||
|
- [x] Implementation complete.
|
||||||
|
- [x] Relevant tests and repository gates green.
|
||||||
|
- [ ] Commit pushed and PR opened.
|
||||||
|
|
||||||
|
## Tests and evidence
|
||||||
|
|
||||||
|
- RED (before source fix): `packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → exit 1; ticket/computer/user 206 assertions failed, all 401 assertions passed.
|
||||||
|
- GREEN: `bash -n packages/mosaic/framework/tools/glpi/{ticket-list.sh,computer-list.sh,user-list.sh,test-list-http-status.sh}` → pass.
|
||||||
|
- GREEN: `shellcheck packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → pass.
|
||||||
|
- GREEN: `packages/mosaic/framework/tools/glpi/test-list-http-status.sh` → 6 assertions pass (206 renders and 401 errors for all three wrappers).
|
||||||
|
- GREEN: `pnpm typecheck` → 42/42 tasks pass.
|
||||||
|
- GREEN: `pnpm lint` → 23/23 tasks pass.
|
||||||
|
- GREEN: `pnpm format:check` → all matched files pass.
|
||||||
|
- Setup note: initial gate attempts could not start because the fresh worktree lacked dependencies; `pnpm install --frozen-lockfile --store-dir /home/hermes/.local/share/pnpm/store` restored the locked workspace dependencies without lockfile changes.
|
||||||
|
|
||||||
|
## Acceptance criteria mapping
|
||||||
|
|
||||||
|
| Criterion | Evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| HTTP 206 succeeds and renders each ranged list | Focused test's three 206 render assertions pass |
|
||||||
|
| Genuine HTTP failures remain non-zero with existing diagnostics | Focused test's three HTTP 401 assertions pass |
|
||||||
|
| Only affected list wrappers change | Diff contains the three status predicates plus focused test/evidence; session and create wrappers untouched |
|
||||||
|
|
||||||
|
## Documentation decision
|
||||||
|
|
||||||
|
No operator/API documentation change is needed: this restores documented list behavior for a healthy GLPI response without changing command syntax, output, configuration, or public contracts. This task scratchpad records delivery evidence.
|
||||||
|
|
||||||
|
## Risks / blockers
|
||||||
|
|
||||||
|
- Existing dirty `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` are runtime-owned and will not be edited or committed.
|
||||||
37
docs/scratchpads/808-agent-send-sender-identity.md
Normal file
37
docs/scratchpads/808-agent-send-sender-identity.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Issue #808 — agent-send sender identity
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Fix cross-socket `agent-send.sh` preambles so replies route to the real sender rather than a destination-socket holder session.
|
||||||
|
|
||||||
|
## Scope and acceptance criteria
|
||||||
|
|
||||||
|
- Prefer exported `MOSAIC_AGENT_NAME` as the authoritative sender session name.
|
||||||
|
- If it is unset, query the sender's local/default tmux socket for `#S` without destination `-L` arguments.
|
||||||
|
- Preserve `?` when sender identity cannot be determined.
|
||||||
|
- Do not alter destination socket dispatch.
|
||||||
|
- Add red-first regressions for all three identity paths.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Extend `agent-send.test.sh` with deterministic fake-tmux coverage.
|
||||||
|
2. Run the test against the unpatched implementation and record RED evidence.
|
||||||
|
3. Apply the minimal sender lookup fix only.
|
||||||
|
4. Run the focused suite and repository quality gates.
|
||||||
|
5. Commit, queue-guard, push, and open an author-only PR for independent review.
|
||||||
|
|
||||||
|
## Constraints and risks
|
||||||
|
|
||||||
|
- Worker lane is author-only: no self-review or merge.
|
||||||
|
- `docs/TASKS.md` and mission state are orchestrator-owned and will not be modified.
|
||||||
|
- Pre-existing runtime changes under `.mosaic/orchestrator/` are excluded from this work.
|
||||||
|
- Budget: no explicit token cap; keep changes limited to the shell tool, sibling regression test, and this scratchpad.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- RED: `bash packages/mosaic/framework/tools/tmux/agent-send.test.sh` failed on the unpatched implementation with `PASS=12 FAIL=3`; it selected `destination-holder` instead of both `MOSAIC_AGENT_NAME=authoritative-agent` and local session `local-agent`. The genuinely unavailable sender case already exercised and preserved `?`.
|
||||||
|
- GREEN: `bash packages/mosaic/framework/tools/tmux/agent-send.test.sh` passed with `PASS=15 FAIL=0`; coverage includes env authority, local/default tmux fallback across a destination `-L`, explicit rejection of the destination holder, and `?` fallback.
|
||||||
|
- Syntax: `bash -n packages/mosaic/framework/tools/tmux/agent-send.sh packages/mosaic/framework/tools/tmux/agent-send.test.sh` passed.
|
||||||
|
- Quality gates: `pnpm typecheck` (42/42 tasks), `pnpm lint` (23/23 tasks), and `pnpm format:check` all passed after installing the frozen lockfile dependencies. The first install attempt failed because pnpm's configured store pointed at `/root`; retrying with the existing user-owned store (`--store-dir /home/hermes/.local/share/pnpm/store`) succeeded without changing tracked dependency files.
|
||||||
|
- Documentation: no public API or operator workflow changed; the source comment, regression-test contract, and this implementation record cover the internal bug fix.
|
||||||
|
- Independent review: intentionally pending for the reviewer assigned by `mosaic-100`; this author-only lane will not self-review or merge.
|
||||||
@@ -47,6 +47,61 @@ 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`.
|
||||||
|
|||||||
85
packages/mosaic/framework/framework-manifest.txt
Normal file
85
packages/mosaic/framework/framework-manifest.txt
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Mosaic framework path-ownership manifest — SSOT for the updater.
|
||||||
|
#
|
||||||
|
# This single file is the source of truth consumed by BOTH the bash installer
|
||||||
|
# (packages/mosaic/framework/install.sh) and the TypeScript config adapter
|
||||||
|
# (packages/mosaic/src/config/file-adapter.ts). A parity test asserts both
|
||||||
|
# paths resolve the same ownership from this file, so the two can never drift
|
||||||
|
# (the failure mode that #631 patched by hand in two places).
|
||||||
|
#
|
||||||
|
# Format: one glob per line, relative to the mosaic home (~/.config/mosaic).
|
||||||
|
# - Lines starting with '#' and blank lines are ignored.
|
||||||
|
# - '[framework]' / '[operator]' switch the active section.
|
||||||
|
# - '**' matches any depth; '*' matches within a single path segment.
|
||||||
|
#
|
||||||
|
# Ownership resolution for a path P (deny-wins / fail-safe):
|
||||||
|
# 1. P matches an [operator] glob -> operator-owned.
|
||||||
|
# 2. else P matches a [framework] glob -> framework-owned.
|
||||||
|
# 3. else (matches neither) -> OPERATOR-OWNED BY DEFAULT.
|
||||||
|
#
|
||||||
|
# Rule 3 is the root-cause fix for #791: a path the manifest authors never
|
||||||
|
# anticipated is protected because UNKNOWN defaults to operator. The updater
|
||||||
|
# may only ever create/overwrite framework-owned paths, and may only prune a
|
||||||
|
# framework-owned path that lives inside a shipped framework subtree and is
|
||||||
|
# absent from the current framework source (a genuinely retired file).
|
||||||
|
# Operator-owned and unknown paths are structurally unreachable by pruning.
|
||||||
|
|
||||||
|
[framework]
|
||||||
|
# Top-level framework contract files (also reconciled from defaults/ on upgrade).
|
||||||
|
CONSTITUTION.md
|
||||||
|
AGENTS.md
|
||||||
|
STANDARDS.md
|
||||||
|
# Shipped framework subtrees — pruning is scoped to these roots.
|
||||||
|
adapters/**
|
||||||
|
constitution/**
|
||||||
|
CONTRIBUTING.md
|
||||||
|
defaults/**
|
||||||
|
examples/**
|
||||||
|
guides/**
|
||||||
|
install.sh
|
||||||
|
install.ps1
|
||||||
|
LICENSE
|
||||||
|
profiles/**
|
||||||
|
runtime/**
|
||||||
|
systemd/**
|
||||||
|
templates/**
|
||||||
|
tools/**
|
||||||
|
# Fleet: only the framework-seeded fleet subtrees are framework-owned.
|
||||||
|
fleet/README.md
|
||||||
|
fleet/examples/**
|
||||||
|
fleet/profiles/**
|
||||||
|
fleet/roles/**
|
||||||
|
fleet/roster.schema.json
|
||||||
|
fleet/services/**
|
||||||
|
# The manifest itself is framework-owned.
|
||||||
|
framework-manifest.txt
|
||||||
|
|
||||||
|
[operator]
|
||||||
|
# Identity / user-seeded contract files — generated by the wizard or seeded
|
||||||
|
# once from defaults/, then owned by the operator. Never overwritten on upgrade.
|
||||||
|
SOUL.md
|
||||||
|
USER.md
|
||||||
|
TOOLS.md
|
||||||
|
# Local overlays (tighten-only) authored by the operator.
|
||||||
|
*.local.md
|
||||||
|
# Operator-owned trees the updater must never write over or prune.
|
||||||
|
agents/**
|
||||||
|
policy/**
|
||||||
|
memory/**
|
||||||
|
sources/**
|
||||||
|
credentials/**
|
||||||
|
# Secret-bearing operator file INSIDE the framework-owned tools/ subtree.
|
||||||
|
# Listed explicitly so the deny-wins rule carves it out of tools/**.
|
||||||
|
tools/_lib/credentials.json
|
||||||
|
# Operator-owned fleet state (roster SSOT, per-agent env, heartbeats, backlog,
|
||||||
|
# persona overrides). Losing these silently downgrades a running fleet (#791).
|
||||||
|
fleet/roster.yaml
|
||||||
|
fleet/roster.json
|
||||||
|
fleet/agents/**
|
||||||
|
# Runtime state, incl. the #797 Runtime Session Ledger at fleet/run/sessions/
|
||||||
|
# (events.ndjson journal + ledger.json projection). This carve-out is the
|
||||||
|
# mechanism that makes the ledger upgrade-safe: an upgrade that wiped it would
|
||||||
|
# defeat its reason to exist. The HARD GATE (test-upgrade-manifest-guard.sh)
|
||||||
|
# proves a populated ledger survives byte-identical + mtime-unchanged.
|
||||||
|
fleet/run/**
|
||||||
|
fleet/backlog/**
|
||||||
|
fleet/roles.local/**
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
# -E (errtrace): the ERR trap must propagate INTO functions and command
|
||||||
|
# substitutions. Without it the `trap restore_snapshot ERR` set below is dead
|
||||||
|
# code for any failure inside sync_framework_keep() (its whole body runs in a
|
||||||
|
# function) — a mid-sync failure would abort with a half-written target and NO
|
||||||
|
# rollback (#791 B1). Keep -E first so every later function inherits the trap.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
# ─── Mosaic Framework Installer ──────────────────────────────────────────────
|
# ─── Mosaic Framework Installer ──────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
@@ -19,32 +24,19 @@ 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}"
|
||||||
|
|
||||||
# Files/dirs protected from rsync --delete during sync. NOTE: framework-owned
|
# Shared framework path-ownership manifest reader (#791). Parity with
|
||||||
# entries (CONSTITUTION/AGENTS/STANDARDS) ARE re-applied afterward by
|
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
||||||
# reconcile_framework_files (overwrite + backup-once); the rest stay user-owned.
|
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
||||||
# User-created content in these paths survives rsync --delete.
|
# shellcheck source=tools/_lib/manifest.sh
|
||||||
#
|
source "$SOURCE_DIR/tools/_lib/manifest.sh"
|
||||||
# fleet/* — the framework SEEDS fleet/examples, fleet/roles, fleet/profiles, and
|
|
||||||
# fleet/roster.schema.json (synced normally — every fleet/roles/*.md role contract
|
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
|
||||||
# and fleet/profiles/*.yaml system-type profile lands automatically via this sync,
|
# denylist. It is derived from the shared framework-manifest.txt (#791): the
|
||||||
# so no per-file entry is needed; exact preserved roster paths are anchored to
|
# updater only ever creates/overwrites framework-owned paths and only prunes a
|
||||||
# the top level only and do NOT shadow fleet/profiles/*.yaml). The user's
|
# retired framework file inside a shipped framework subtree. Everything else —
|
||||||
# own fleet files MUST
|
# every operator file, and every path the manifest never anticipated — is
|
||||||
# survive `mosaic update` (which runs this sync automatically): the active
|
# operator-owned by default (fail-safe) and is never written or deleted. See
|
||||||
# rosters (`fleet/roster.yaml` and `fleet/roster.json`), per-agent env
|
# sync_framework_keep() below and packages/mosaic/src/framework/manifest.ts.
|
||||||
# (`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).
|
||||||
@@ -75,17 +67,267 @@ 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")"
|
||||||
cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/" 2>/dev/null || true
|
# The snapshot MUST be complete: restore rebuilds the target from it, so a
|
||||||
|
# partial capture (unreadable file, disk-full, I/O error) would silently
|
||||||
|
# discard whatever it missed. If cp -a cannot copy the whole tree, abort NOW —
|
||||||
|
# before the restore trap is armed and before anything is mutated. Fail closed
|
||||||
|
# rather than proceed with a snapshot we cannot trust (#791 blocker-2).
|
||||||
|
if ! cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/"; then
|
||||||
|
fail "Could not capture a complete pre-upgrade snapshot of $TARGET_DIR — aborting before any changes were made (fail-closed)."
|
||||||
|
rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
restore_snapshot() {
|
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"
|
||||||
rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"
|
# Reset the target before rebuilding from the snapshot — but CHECK it. Under
|
||||||
cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/" 2>/dev/null || true
|
# `set -e` (trap already disarmed) a bare `rm -rf; mkdir -p` that fails would
|
||||||
|
# exit the whole script immediately, after `rm` may have deleted part of the
|
||||||
|
# target, WITHOUT ever printing the recovery pointer below — the operator would
|
||||||
|
# be left with a half-removed target and no idea the snapshot survives in /tmp.
|
||||||
|
# Test the reset explicitly (like the cp -a below), and on failure keep the
|
||||||
|
# snapshot and tell the operator where it is (#791 blocker-D2).
|
||||||
|
if ! rm -rf "$TARGET_DIR" || ! mkdir -p "$TARGET_DIR"; then
|
||||||
|
fail "Snapshot restore could not reset $TARGET_DIR. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# Surface an incomplete restore instead of swallowing it: the snapshot is the
|
||||||
|
# last good copy, so if cp cannot fully rebuild the target we must NOT delete
|
||||||
|
# the snapshot — point the operator at it for manual recovery (#791 blocker-2).
|
||||||
|
if ! cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/"; then
|
||||||
|
fail "Snapshot restore did not complete cleanly. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
cleanup_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] && rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""; }
|
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() {
|
||||||
@@ -184,63 +426,105 @@ sync_framework() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v rsync >/dev/null 2>&1; then
|
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||||
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak")
|
# The `mosaic update` path. Manifest-driven, never-deleting-outside-framework:
|
||||||
|
# operator config is structurally protected (#791). No rsync --delete here.
|
||||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
# The manifest is already loaded+validated in main() BEFORE the snapshot/trap
|
||||||
# Anchor to the transfer root (leading /) so we preserve the TOP-LEVEL
|
# (a fail-closed manifest must abort without ever restoring over operator
|
||||||
# ~/.config/mosaic/<file> without also excluding defaults/<file> from sync
|
# files — see the pre-flight in main, #791 blocker-1).
|
||||||
# (reconcile_framework_files needs the freshly-synced defaults/ copies).
|
sync_framework_keep
|
||||||
for path in "${PRESERVE_PATHS[@]}"; do
|
|
||||||
rsync_args+=(--exclude "/$path")
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
rsync "${rsync_args[@]}" "$SOURCE_DIR/" "$TARGET_DIR/"
|
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fallback: cp-based sync. Exact top-level preserved paths mirror the
|
# overwrite mode — a full replace, chosen only for a fresh install or when the
|
||||||
# root-anchored rsync excludes above.
|
# operator explicitly asks to replace everything. No operator state to protect.
|
||||||
local preserve_tmp=""
|
sync_framework_overwrite
|
||||||
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 {} +
|
# Enumerate a NUL-delimited file list via `find` into the temp file $1, failing
|
||||||
|
# CLOSED if find errors. We capture to a checked file instead of consuming
|
||||||
|
# `< <(find …)` directly because a process substitution discards the producer's
|
||||||
|
# exit status: an EACCES/I/O failure partway through a scan would truncate the
|
||||||
|
# list yet leave the reading `while` loop exiting 0, so a partial upgrade would
|
||||||
|
# commit and report success and the ERR/restore trap would never fire. Running
|
||||||
|
# find to completion first, then checking its status, turns that silent
|
||||||
|
# truncation into a fail-closed abort that the restore trap can act on (#791
|
||||||
|
# blocker-D1). $1 after the shift is the scan root — named in the error.
|
||||||
|
_scan_or_die() {
|
||||||
|
local out="$1"; shift
|
||||||
|
if ! find "$@" -print0 > "$out"; then
|
||||||
|
fail "Could not enumerate framework files under '$1' — aborting before committing an incomplete sync (fail-closed)."
|
||||||
|
return 1 # D1-GUARD
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep-mode sync: create/refresh framework-owned files and prune only retired
|
||||||
|
# framework files inside shipped framework subtrees. Operator-owned and unknown
|
||||||
|
# paths (fail-safe default) are never written and never deleted — the #791 HARD
|
||||||
|
# GATE. Single code path (no rsync) so it is byte-for-byte parity-testable.
|
||||||
|
sync_framework_keep() {
|
||||||
|
local src="$SOURCE_DIR" dst="$TARGET_DIR" abs rel root list
|
||||||
|
|
||||||
|
# 1) Overlay copy — every framework-owned source file, refreshed only when its
|
||||||
|
# bytes changed (no mtime churn on unchanged files, never on operator files).
|
||||||
|
# The source scan is captured fail-closed (#791 blocker-D1): a find failure
|
||||||
|
# aborts the sync (→ ERR trap → restore) rather than silently truncating it.
|
||||||
|
list="$(mktemp)"
|
||||||
|
_scan_or_die "$list" "$src" -type f || { rm -f "$list"; return 1; }
|
||||||
|
while IFS= read -r -d '' abs; do
|
||||||
|
rel="${abs#"$src"/}"
|
||||||
|
case "$rel" in
|
||||||
|
.git|.git/*|.framework-version|*.pre-constitution.bak) continue ;;
|
||||||
|
esac
|
||||||
|
manifest_is_framework "$rel" || continue
|
||||||
|
if [[ -f "$dst/$rel" ]] && cmp -s "$abs" "$dst/$rel"; then continue; fi
|
||||||
|
[[ "$rel" == */* ]] && mkdir -p "$dst/${rel%/*}"
|
||||||
|
cp "$abs" "$dst/$rel"
|
||||||
|
done < "$list"
|
||||||
|
rm -f "$list"
|
||||||
|
|
||||||
|
# 2) Scoped prune — within each shipped framework subtree root, remove
|
||||||
|
# framework-owned target files the current source no longer ships. Operator
|
||||||
|
# carve-outs (e.g. tools/_lib/credentials.json) resolve to operator and are
|
||||||
|
# skipped; unknown paths resolve to operator too — both are unreachable here.
|
||||||
|
# Each subtree scan is captured fail-closed for the same reason as the copy.
|
||||||
|
while IFS= read -r root; do
|
||||||
|
[[ -n "$root" && -d "$dst/$root" ]] || continue
|
||||||
|
list="$(mktemp)"
|
||||||
|
_scan_or_die "$list" "$dst/$root" -type f || { rm -f "$list"; return 1; }
|
||||||
|
while IFS= read -r -d '' abs; do
|
||||||
|
rel="${abs#"$dst"/}"
|
||||||
|
case "$rel" in *.pre-constitution.bak) continue ;; esac
|
||||||
|
[[ -f "$src/$rel" ]] && continue # still shipped
|
||||||
|
manifest_is_framework "$rel" || continue
|
||||||
|
rm -f "$abs"
|
||||||
|
done < "$list"
|
||||||
|
rm -f "$list"
|
||||||
|
# Drop framework dirs left empty by the prune (never touches a dir that still
|
||||||
|
# holds an operator file — those are never emptied). A genuine find failure
|
||||||
|
# (unreadable dir) is surfaced as a warning rather than silently swallowed;
|
||||||
|
# the "directory not empty" races we tolerate are ignored via -delete's own
|
||||||
|
# rc, not by hiding stderr — so a real error is still visible to the operator.
|
||||||
|
if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then
|
||||||
|
warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
|
||||||
|
fi
|
||||||
|
done < <(manifest_subtree_roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Overwrite-mode sync: full replace. Only reached for a fresh install or an
|
||||||
|
# explicit operator "replace everything" choice, so nothing is preserved.
|
||||||
|
sync_framework_overwrite() {
|
||||||
|
if command -v rsync >/dev/null 2>&1; then
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak" \
|
||||||
|
"$SOURCE_DIR/" "$TARGET_DIR/"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 \
|
||||||
|
! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" \
|
||||||
|
-exec rm -rf {} +
|
||||||
cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -261,6 +545,10 @@ 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"
|
||||||
@@ -311,9 +599,26 @@ 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' ERR INT TERM
|
trap 'restore_snapshot; exit 1' ERR INT TERM
|
||||||
|
|
||||||
sync_framework
|
sync_framework
|
||||||
|
|
||||||
@@ -342,6 +647,10 @@ 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
|
||||||
|
|||||||
253
packages/mosaic/framework/tools/_lib/manifest.sh
Normal file
253
packages/mosaic/framework/tools/_lib/manifest.sh
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared bash reader for framework-manifest.txt (#791).
|
||||||
|
#
|
||||||
|
# This is the bash half of the SSOT ownership resolver; the TypeScript half is
|
||||||
|
# packages/mosaic/src/framework/manifest.ts. BOTH read the same
|
||||||
|
# framework-manifest.txt and MUST resolve identical ownership for any path — the
|
||||||
|
# parity test (manifest-parity.spec.ts) invokes this file's `resolve` CLI and
|
||||||
|
# compares it against the TS resolver, so the two can never drift (the #631
|
||||||
|
# two-copies failure class this closes).
|
||||||
|
#
|
||||||
|
# Ownership resolution (deny-wins / fail-safe):
|
||||||
|
# 1. operator glob matches -> operator
|
||||||
|
# 2. else framework glob -> framework
|
||||||
|
# 3. else -> operator (UNKNOWN defaults to operator, #791)
|
||||||
|
#
|
||||||
|
# Globs are compiled once at load into exact-prefix checks or POSIX EREs, so the
|
||||||
|
# hot resolver (manifest_is_framework) forks no subprocesses — the installer
|
||||||
|
# calls it once per file across the whole tree.
|
||||||
|
#
|
||||||
|
# Usage as a library (source it, then):
|
||||||
|
# manifest_load [manifest-file] # populates + compiles the manifest
|
||||||
|
# manifest_is_framework <rel-path> # rc 0 = framework-owned, rc 1 = operator
|
||||||
|
# manifest_resolve <rel-path> # echoes: framework | operator
|
||||||
|
# manifest_subtree_roots # echoes shipped framework `dir/**` roots
|
||||||
|
#
|
||||||
|
# Usage as a CLI (parity harness):
|
||||||
|
# bash manifest.sh resolve <rel-path>
|
||||||
|
# bash manifest.sh subtree-roots
|
||||||
|
# bash manifest.sh classify # reads paths on stdin -> "<own>\t<path>"
|
||||||
|
|
||||||
|
MANIFEST_FRAMEWORK=()
|
||||||
|
MANIFEST_OPERATOR=()
|
||||||
|
|
||||||
|
# Compiled forms (parallel arrays). _*_KIND[i] is "exact" or "re".
|
||||||
|
_MF_KIND=(); _MF_EXACT=(); _MF_RE=()
|
||||||
|
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
|
||||||
|
_MF_ROOTS=()
|
||||||
|
|
||||||
|
_manifest_default_root() { cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd; }
|
||||||
|
|
||||||
|
# Normalize a path/glob: backslashes -> slashes, strip leading ./ and /, strip
|
||||||
|
# trailing / (mirrors normalizeRel in manifest.ts).
|
||||||
|
_manifest_norm() {
|
||||||
|
local p="$1"
|
||||||
|
p="${p//\\//}"
|
||||||
|
p="${p#./}"
|
||||||
|
while [[ "$p" == /* ]]; do p="${p#/}"; done
|
||||||
|
while [[ "$p" == */ ]]; do p="${p%/}"; done
|
||||||
|
printf '%s' "$p"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Translate a normalized glob into a POSIX ERE body (mirrors globToRegExpBody).
|
||||||
|
_manifest_glob_to_ere() {
|
||||||
|
local pattern; pattern="$(_manifest_norm "$1")"
|
||||||
|
local out="" c n i len=${#pattern} trailing
|
||||||
|
for (( i = 0; i < len; i++ )); do
|
||||||
|
c="${pattern:i:1}"
|
||||||
|
if [[ "$c" == "*" ]]; then
|
||||||
|
n="${pattern:i+1:1}"
|
||||||
|
if [[ "$n" == "*" ]]; then
|
||||||
|
i=$((i + 1))
|
||||||
|
trailing=0
|
||||||
|
if [[ "${pattern:i+1:1}" == "/" ]]; then i=$((i + 1)); trailing=1; fi
|
||||||
|
if [[ "$out" == */ ]]; then
|
||||||
|
out="${out%/}(/.*)?"
|
||||||
|
elif [[ "$trailing" -eq 1 ]]; then
|
||||||
|
out="$out(.*/)?"
|
||||||
|
else
|
||||||
|
out="$out.*"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
out="$out[^/]*"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
case "$c" in
|
||||||
|
.|+|\?|^|\$|\{|\}|\(|\)|\||\[|\]|\\) out="$out\\$c" ;;
|
||||||
|
*) out="$out$c" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf '%s' "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compile one raw glob into (kind, exact, re) appended to the given section.
|
||||||
|
# $1 = raw glob, $2 = section letter (F|O).
|
||||||
|
_manifest_compile_one() {
|
||||||
|
local norm; norm="$(_manifest_norm "$1")"
|
||||||
|
[[ -n "$norm" ]] || return 0
|
||||||
|
if [[ "$norm" == *"*"* ]]; then
|
||||||
|
local re="^$(_manifest_glob_to_ere "$norm")\$"
|
||||||
|
if [[ "$2" == F ]]; then
|
||||||
|
_MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re")
|
||||||
|
else
|
||||||
|
_MO_KIND+=(re); _MO_EXACT+=(""); _MO_RE+=("$re")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [[ "$2" == F ]]; then
|
||||||
|
_MF_KIND+=(exact); _MF_EXACT+=("$norm"); _MF_RE+=("")
|
||||||
|
else
|
||||||
|
_MO_KIND+=(exact); _MO_EXACT+=("$norm"); _MO_RE+=("")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[[ "$2" == F && "$norm" == */"**" ]] && _MF_ROOTS+=("${norm%/**}")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
_manifest_compile() {
|
||||||
|
_MF_KIND=(); _MF_EXACT=(); _MF_RE=(); _MF_ROOTS=()
|
||||||
|
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
|
||||||
|
local g
|
||||||
|
for g in "${MANIFEST_FRAMEWORK[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" F; done
|
||||||
|
for g in "${MANIFEST_OPERATOR[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" O; done
|
||||||
|
# Explicit success: an empty operator array makes the final `[[ -n "" ]] && …`
|
||||||
|
# short-circuit to rc 1, which would otherwise become this function's (and
|
||||||
|
# manifest_load's) return code — a spurious failure (#791 B2). Never rely on
|
||||||
|
# the last loop's exit status here.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load + compile the manifest. Rejects a malformed file the same way
|
||||||
|
# parseManifest() does (entry before a section header / unknown header).
|
||||||
|
manifest_load() {
|
||||||
|
local file="${1:-}"
|
||||||
|
[[ -n "$file" ]] || file="$(_manifest_default_root)/framework-manifest.txt"
|
||||||
|
# Fail CLOSED on a missing/unreadable manifest. Without this, `done < "$file"`
|
||||||
|
# aborts on a raw redirection error with no explanation; downstream that reads
|
||||||
|
# as "no framework paths" and an upgrade could no-op silently (#791 B2/B3).
|
||||||
|
if [[ ! -r "$file" ]]; then
|
||||||
|
echo "manifest: cannot read manifest file: $file — refusing to sync (fail-closed)." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
MANIFEST_FRAMEWORK=()
|
||||||
|
MANIFEST_OPERATOR=()
|
||||||
|
local section="" line
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
line="${line#"${line%%[![:space:]]*}"}" # ltrim
|
||||||
|
line="${line%"${line##*[![:space:]]}"}" # rtrim
|
||||||
|
[[ -z "$line" || "${line:0:1}" == "#" ]] && continue
|
||||||
|
case "$line" in
|
||||||
|
"[framework]") section=framework; continue ;;
|
||||||
|
"[operator]") section=operator; continue ;;
|
||||||
|
"["*) echo "manifest: unknown section header: $line" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
if [[ -z "$section" ]]; then
|
||||||
|
echo "manifest: entry before any [section] header: $line" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$section" == framework ]]; then
|
||||||
|
MANIFEST_FRAMEWORK+=("$line")
|
||||||
|
else
|
||||||
|
MANIFEST_OPERATOR+=("$line")
|
||||||
|
fi
|
||||||
|
done < "$file"
|
||||||
|
# An empty or comment-only manifest defines NO framework-owned paths. Treating
|
||||||
|
# that as valid would make every path resolve operator and an upgrade prune
|
||||||
|
# nothing / write nothing — a silent no-op indistinguishable from success.
|
||||||
|
# Fail loud instead, mirroring parseManifest()'s throw in manifest.ts (#791 B2).
|
||||||
|
if [[ ${#MANIFEST_FRAMEWORK[@]} -eq 0 ]]; then
|
||||||
|
echo "manifest: no [framework] entries in $file — refusing to sync (empty or malformed manifest)." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# An entry like `/` or `./` normalizes to nothing and compiles to a glob that
|
||||||
|
# matches no path — so a manifest whose only [framework] entries are degenerate
|
||||||
|
# passes the count guard above but leaves the framework matcher empty: every
|
||||||
|
# path resolves operator, the exact silent no-op we fail closed against. Require
|
||||||
|
# at least one entry with a real (non-slash, non-dot) character. Mirrors
|
||||||
|
# parseManifest()'s `isUsableFrameworkGlob` `/[^/.]/` test in manifest.ts (#791 blocker-B).
|
||||||
|
local _g _usable=0
|
||||||
|
for _g in "${MANIFEST_FRAMEWORK[@]:-}"; do
|
||||||
|
if [[ "$(_manifest_norm "$_g")" =~ [^/.] ]]; then _usable=1; break; fi
|
||||||
|
done
|
||||||
|
if [[ "$_usable" -eq 0 ]]; then
|
||||||
|
echo "manifest: no usable [framework] entries in $file (every entry is empty or a bare dot segment) — refusing to sync (malformed manifest)." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
_manifest_compile
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fork-free: does $1 (a mosaic-home-relative path) match an operator glob?
|
||||||
|
_mo_matches() {
|
||||||
|
local path="$1" i n=${#_MO_KIND[@]} re pat
|
||||||
|
for (( i = 0; i < n; i++ )); do
|
||||||
|
if [[ "${_MO_KIND[i]}" == exact ]]; then
|
||||||
|
pat="${_MO_EXACT[i]}"
|
||||||
|
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
|
||||||
|
else
|
||||||
|
re="${_MO_RE[i]}"
|
||||||
|
[[ "$path" =~ $re ]] && return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fork-free: does $1 match a framework glob?
|
||||||
|
_mf_matches() {
|
||||||
|
local path="$1" i n=${#_MF_KIND[@]} re pat
|
||||||
|
for (( i = 0; i < n; i++ )); do
|
||||||
|
if [[ "${_MF_KIND[i]}" == exact ]]; then
|
||||||
|
pat="${_MF_EXACT[i]}"
|
||||||
|
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
|
||||||
|
else
|
||||||
|
re="${_MF_RE[i]}"
|
||||||
|
[[ "$path" =~ $re ]] && return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The installer hot path — no subshell. rc 0 = framework-owned, rc 1 = operator
|
||||||
|
# (deny-wins / fail-safe). Assumes an already-clean POSIX relative path.
|
||||||
|
manifest_is_framework() {
|
||||||
|
_mo_matches "$1" && return 1
|
||||||
|
_mf_matches "$1" && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Echo the ownership of a path: framework | operator. Normalizes first, so it is
|
||||||
|
# safe for CLI / test callers passing unnormalized input.
|
||||||
|
manifest_resolve() {
|
||||||
|
local path; path="$(_manifest_norm "$1")"
|
||||||
|
if manifest_is_framework "$path"; then echo framework; else echo operator; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Echo each shipped framework subtree root (a `dir/**` entry, without the /**).
|
||||||
|
manifest_subtree_roots() {
|
||||||
|
local r
|
||||||
|
for r in "${_MF_ROOTS[@]:-}"; do [[ -n "$r" ]] && printf '%s\n' "$r"; done
|
||||||
|
}
|
||||||
|
|
||||||
|
# CLI dispatch — only when executed directly, never when sourced.
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
set -o pipefail
|
||||||
|
# Propagate a fail-closed manifest_load (missing/empty/malformed) as a non-zero
|
||||||
|
# exit instead of continuing to resolve against empty compiled arrays — that is
|
||||||
|
# what lets the parity test assert bash and TS reject the same bad inputs (#791 B2).
|
||||||
|
manifest_load "${MANIFEST_FILE:-}" || exit 1
|
||||||
|
cmd="${1:-}"
|
||||||
|
case "$cmd" in
|
||||||
|
resolve) manifest_resolve "${2:?path required}" ;;
|
||||||
|
subtree-roots) manifest_subtree_roots ;;
|
||||||
|
classify)
|
||||||
|
while IFS= read -r p; do
|
||||||
|
[[ -z "$p" ]] && continue
|
||||||
|
printf '%s\t%s\n' "$(manifest_resolve "$p")" "$p"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "usage: manifest.sh {resolve <path>|subtree-roots|classify}" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
@@ -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" ]]; then
|
if [[ "$http_code" != "200" && "$http_code" != "206" ]]; 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
|
||||||
|
|||||||
84
packages/mosaic/framework/tools/glpi/test-list-http-status.sh
Executable file
84
packages/mosaic/framework/tools/glpi/test-list-http-status.sh
Executable file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for #807: ranged GLPI list requests may return HTTP 206.
|
||||||
|
#
|
||||||
|
# Each shipped list wrapper must render a healthy 206 response and must retain
|
||||||
|
# its non-zero error behavior for a genuine HTTP failure.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/glpi-list-http-status}"
|
||||||
|
TOOL_DIR="$WORK_DIR/tools/glpi"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$TOOL_DIR" "$BIN_DIR" "$WORK_DIR/tools/_lib"
|
||||||
|
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||||
|
|
||||||
|
for wrapper in ticket-list.sh computer-list.sh user-list.sh; do
|
||||||
|
cp "$SCRIPT_DIR/$wrapper" "$TOOL_DIR/$wrapper"
|
||||||
|
done
|
||||||
|
|
||||||
|
cat > "$WORK_DIR/tools/_lib/credentials.sh" <<'SH'
|
||||||
|
load_credentials() {
|
||||||
|
export GLPI_URL="https://glpi.test/apirest.php"
|
||||||
|
export GLPI_APP_TOKEN="test-app-token"
|
||||||
|
}
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$TOOL_DIR/session-init.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' 'test-session-token'
|
||||||
|
SH
|
||||||
|
chmod +x "$TOOL_DIR/session-init.sh"
|
||||||
|
|
||||||
|
cat > "$BIN_DIR/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf '%s\n' '[{"id":42,"priority":3,"status":1,"name":"Regression fixture","date_mod":"2026-07-16 10:00:00","serial":"SER-42","states_id":1,"realname":"Fixture","firstname":"GLPI","is_active":1}]'
|
||||||
|
printf '%s\n' "${GLPI_TEST_HTTP_CODE:?GLPI_TEST_HTTP_CODE is required}"
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/curl"
|
||||||
|
|
||||||
|
export MOSAIC_HOME="$WORK_DIR"
|
||||||
|
export PATH="$BIN_DIR:$PATH"
|
||||||
|
|
||||||
|
wrappers=(ticket-list.sh computer-list.sh user-list.sh)
|
||||||
|
resources=(tickets computers users)
|
||||||
|
headings=(PRIORITY SERIAL USERNAME)
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
for index in "${!wrappers[@]}"; do
|
||||||
|
wrapper="${wrappers[$index]}"
|
||||||
|
resource="${resources[$index]}"
|
||||||
|
heading="${headings[$index]}"
|
||||||
|
path="$TOOL_DIR/$wrapper"
|
||||||
|
|
||||||
|
if ! output=$(GLPI_TEST_HTTP_CODE=206 bash "$path" 2>&1); then
|
||||||
|
echo "FAIL: $wrapper rejected healthy HTTP 206" >&2
|
||||||
|
fail=1
|
||||||
|
elif [[ "$output" != *"$heading"* || "$output" != *"Regression fixture"* ]]; then
|
||||||
|
echo "FAIL: $wrapper did not render the HTTP 206 list response" >&2
|
||||||
|
fail=1
|
||||||
|
else
|
||||||
|
echo "PASS: $wrapper renders HTTP 206"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rc=0
|
||||||
|
output=$(GLPI_TEST_HTTP_CODE=401 bash "$path" 2>&1) || rc=$?
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL: $wrapper accepted HTTP 401" >&2
|
||||||
|
fail=1
|
||||||
|
elif [[ "$output" != *"Error: Failed to list $resource (HTTP 401)"* ]]; then
|
||||||
|
echo "FAIL: $wrapper changed the HTTP failure diagnostic" >&2
|
||||||
|
fail=1
|
||||||
|
else
|
||||||
|
echo "PASS: $wrapper rejects HTTP 401"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
echo "ALL PASS: test-list-http-status.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$fail"
|
||||||
@@ -55,7 +55,7 @@ response=$(curl -sk -w "\n%{http_code}" \
|
|||||||
http_code=$(echo "$response" | tail -n1)
|
http_code=$(echo "$response" | tail -n1)
|
||||||
body=$(echo "$response" | sed '$d')
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
if [[ "$http_code" != "200" ]]; then
|
if [[ "$http_code" != "200" && "$http_code" != "206" ]]; then
|
||||||
echo "Error: Failed to list tickets (HTTP $http_code)" >&2
|
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" ]]; then
|
if [[ "$http_code" != "200" && "$http_code" != "206" ]]; 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,8 +61,10 @@ MOSAIC_HOME="$T5" MOSAIC_INSTALL_MODE=bogus MOSAIC_SYNC_ONLY=1 bash "$INSTALL" >
|
|||||||
chk "F5 failure: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]"
|
chk "F5 failure: 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 only the
|
# F6 — keep-mode re-seed (the `mosaic update` path) MUST preserve ALL user-owned
|
||||||
# exact user-owned roster paths while refreshing framework-owned schema/examples.
|
# fleet state — including an unanticipated file the manifest never names, which
|
||||||
|
# resolves to operator-owned by the #791 fail-safe — while refreshing the
|
||||||
|
# framework-owned schema/examples.
|
||||||
T6=$(mktemp -d); mkdir -p "$T6/fleet/examples" "$T6/fleet/run" "$T6/fleet/agents"
|
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"
|
||||||
@@ -75,13 +77,14 @@ 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: unrelated fleet YAML is not preserved" "[ ! -f '$T6/fleet/my-fleet.yaml' ]"
|
chk "F6 reseed: unanticipated operator fleet file survives (fail-safe, #791)" "cmp -s '$T6/fleet/my-fleet.yaml' '$E6/my-fleet.expected'"
|
||||||
chk "F6 reseed: per-agent env bytes survive" "cmp -s '$T6/fleet/agents/coder0.env' '$E6/agent.expected'"
|
chk "F6 reseed: 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'"
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-upgrade-durable-snapshot.sh — the #791 PR2 regression gate.
|
||||||
|
#
|
||||||
|
# PR1 gave keep-mode upgrades two protections: the manifest (a keep-sync only
|
||||||
|
# ever writes framework-owned paths — operator config is structurally untouched)
|
||||||
|
# and an EPHEMERAL /tmp snapshot that rolls the whole target back if the sync
|
||||||
|
# CRASHES mid-write. PR2 adds a third, independent layer for the case neither
|
||||||
|
# covers: a "successful" upgrade that a manifest/logic bug silently let touch an
|
||||||
|
# operator file. That layer is a DURABLE, operator-scoped pre-update snapshot:
|
||||||
|
#
|
||||||
|
# Part 1 (scope): before any mutation, the installer copies exactly the
|
||||||
|
# operator-owned files that exist into a retained backup
|
||||||
|
# under $XDG_STATE_HOME/mosaic/backups/pre-update-<ts>/ —
|
||||||
|
# framework files are NOT captured.
|
||||||
|
# Part 2 (perms): the backup root, snapshot dir and every nested dir are
|
||||||
|
# 0700; every backed-up file is 0600 (never world-readable,
|
||||||
|
# even though operator config may hold secrets).
|
||||||
|
# Part 3 (no leak): a secret seeded into credentials.json is copied into the
|
||||||
|
# snapshot (proving coverage) but its value never appears
|
||||||
|
# on stdout/stderr — the snapshot reports counts/paths only.
|
||||||
|
# Part 4 (retention): only the newest MOSAIC_BACKUP_RETENTION snapshots survive;
|
||||||
|
# older ones are pruned.
|
||||||
|
# Part 5 (verify net): if the upgrade DID modify an operator file (injected here
|
||||||
|
# with a cp shim that scribbles on SOUL.md while a framework
|
||||||
|
# file is copied), the post-sync verify restores that file
|
||||||
|
# from the durable snapshot and warns loudly. The control —
|
||||||
|
# the same installer with the verify call stripped — leaves
|
||||||
|
# the corruption in place, proving the net is load-bearing.
|
||||||
|
#
|
||||||
|
# Usage: bash test-upgrade-durable-snapshot.sh
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
|
||||||
|
INSTALL="$FW/install.sh"
|
||||||
|
ORIG_PATH="$PATH"
|
||||||
|
FRAMEWORK_VERSION="$(grep -m1 '^FRAMEWORK_VERSION=' "$INSTALL" | cut -d= -f2)"
|
||||||
|
|
||||||
|
# Control installers must live INSIDE $FW: install.sh derives SOURCE_DIR from its
|
||||||
|
# own path and sources tools/_lib/manifest.sh relative to it, so a copy anywhere
|
||||||
|
# else aborts before the sync. Each control is a shipped installer with one guard
|
||||||
|
# line stripped (keyed off a `# <MARKER>` anchor), proving that guard load-bearing.
|
||||||
|
# All controls share the .install-*.tmp.sh glob so one trap sweeps them on exit.
|
||||||
|
VERIFYCTRL="$FW/.install-verifynet-control.tmp.sh"
|
||||||
|
rm -f "$FW"/.install-*.tmp.sh
|
||||||
|
trap 'rm -f "$FW"/.install-*.tmp.sh' EXIT
|
||||||
|
|
||||||
|
# mk_control <marker-regex> <name> — echo a control installer path ($FW-local) that
|
||||||
|
# is $INSTALL with every line matching /<marker-regex>/ deleted.
|
||||||
|
mk_control() {
|
||||||
|
local path="$FW/.install-$2.tmp.sh"
|
||||||
|
sed "/$1/d" "$INSTALL" > "$path"
|
||||||
|
printf '%s' "$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
pass=0; fail=0
|
||||||
|
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||||
|
|
||||||
|
SECRET='SUPER-SECRET-TOKEN-do-not-log-pr2'
|
||||||
|
SOUL_ORIG='# persona'
|
||||||
|
# A framework file the sync copies (source ships it; the seeded target omits it,
|
||||||
|
# so the bytes differ and cp is attempted). The Part-5 shim keys off this path.
|
||||||
|
POISON_REL='guides/E2E-DELIVERY.md'
|
||||||
|
|
||||||
|
# Seed a recognized keep-mode install holding four operator-owned files across
|
||||||
|
# the identity file, an operator subtree, memory, and the credentials carve-out.
|
||||||
|
seed_home() {
|
||||||
|
local H="$1"
|
||||||
|
mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory"
|
||||||
|
printf '%s\n' "$SOUL_ORIG" > "$H/SOUL.md" # recognized install → keep mode
|
||||||
|
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
|
||||||
|
printf '# operator memory\n' > "$H/memory/note.md"
|
||||||
|
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
|
||||||
|
echo 3 > "$H/.framework-version"
|
||||||
|
# Deliberately NO guides/E2E-DELIVERY.md so the sync copies it (framework file,
|
||||||
|
# bytes differ) — that copy is where the Part-5 corruption shim fires.
|
||||||
|
}
|
||||||
|
|
||||||
|
# A pre-v2 (legacy) keep-mode install: SOUL.md marks it recognized, and a bin/
|
||||||
|
# tree with NO .framework-version makes installed_framework_version() report 1, so
|
||||||
|
# the v1→v2 migration (which deletes bin/) runs. bin/ is unknown⇒operator, so the
|
||||||
|
# durable snapshot captures it — the verify net must NOT heal the intended removal.
|
||||||
|
seed_home_v1() {
|
||||||
|
local H="$1"
|
||||||
|
mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory" "$H/bin"
|
||||||
|
printf '%s\n' "$SOUL_ORIG" > "$H/SOUL.md"
|
||||||
|
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
|
||||||
|
printf '#!/bin/sh\necho legacy\n' > "$H/bin/tool.sh"; chmod +x "$H/bin/tool.sh"
|
||||||
|
# Deliberately NO .framework-version and NO guides/E2E-DELIVERY.md (see seed_home).
|
||||||
|
}
|
||||||
|
|
||||||
|
# A cp shim that, while the framework POISON file is being copied during sync,
|
||||||
|
# swaps the operator credentials file for a symlink pointing at an attacker-
|
||||||
|
# readable file OUTSIDE the target — simulating post-snapshot tampering (CWE-59).
|
||||||
|
# The durable snapshot already holds the real credentials (it is taken before any
|
||||||
|
# sync), so the verify net must restore a REAL file in place WITHOUT following the
|
||||||
|
# link (which would write the snapshot's secret out through it). $EXFIL_TARGET is
|
||||||
|
# expanded at shim-write time from the caller's environment.
|
||||||
|
#
|
||||||
|
# PORTABILITY (why this shim, not the real `cp`): the CWE-59 leak this exercises is
|
||||||
|
# `cp` writing THROUGH a symlinked destination. GNU/BSD cp — what a real operator
|
||||||
|
# runs `mosaic update` under — follows the dest symlink and leaks. busybox cp (the
|
||||||
|
# Alpine CI image) REPLACES a symlinked dest instead of following it, so under the
|
||||||
|
# CI harness the leak vector simply does not exist and the negative control could
|
||||||
|
# never reproduce it. This shim therefore emulates the real-target GNU cp behavior
|
||||||
|
# PORTABLY: when the destination is a symlink it writes the source bytes through the
|
||||||
|
# link via redirection (which follows symlinks on every coreutils, busybox included);
|
||||||
|
# otherwise it delegates to the host's real cp unchanged. Both the shipped-case and
|
||||||
|
# the negative control run through this identical shim, so the ONLY difference
|
||||||
|
# between them remains the SYMLINK-LEAF-GUARD — the control stays load-bearing and
|
||||||
|
# non-tautological. It does NOT touch install.sh (approved) or the real assertions:
|
||||||
|
# with the guard present the symlinked leaf is dropped BEFORE this cp runs, so the
|
||||||
|
# dest is a real file and the delegate path is taken exactly as on a GNU host.
|
||||||
|
make_symlink_leaf_shim() {
|
||||||
|
local dir="$1" home="$2"
|
||||||
|
cat > "$dir/cp" <<SHIM
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
dest="\${@: -1}"
|
||||||
|
src="\${@:(-2):1}"
|
||||||
|
case "\$dest" in
|
||||||
|
*/$POISON_REL)
|
||||||
|
rm -f "$home/tools/_lib/credentials.json"
|
||||||
|
ln -s "$EXFIL_TARGET" "$home/tools/_lib/credentials.json"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
# Coreutils-agnostic emulation of GNU cp's follow-through-dest-symlink behavior.
|
||||||
|
if [[ -L "\$dest" && -f "\$src" ]]; then
|
||||||
|
cat "\$src" > "\$dest"
|
||||||
|
exit \$?
|
||||||
|
fi
|
||||||
|
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||||
|
SHIM
|
||||||
|
chmod +x "$dir/cp"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A cp shim that, while the framework POISON file is being copied during sync,
|
||||||
|
# also appends garbage to the operator SOUL.md — simulating a manifest bug that
|
||||||
|
# writes outside the framework lane. The framework copy itself still succeeds
|
||||||
|
# (real cp runs), so the sync completes 0 and the post-sync verify is what must
|
||||||
|
# catch and undo the operator-file damage. The snapshot's own cp only ever
|
||||||
|
# targets operator files (never guides/…), so it is never corrupted by this shim.
|
||||||
|
make_corrupt_shim() {
|
||||||
|
local dir="$1" home="$2"
|
||||||
|
cat > "$dir/cp" <<SHIM
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
dest="\${@: -1}"
|
||||||
|
case "\$dest" in
|
||||||
|
*/$POISON_REL) printf 'CORRUPTION-mid-sync\n' >> "$home/SOUL.md" 2>/dev/null || true ;;
|
||||||
|
esac
|
||||||
|
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||||
|
SHIM
|
||||||
|
chmod +x "$dir/cp"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run one keep-mode, sync-only upgrade with $XDG_STATE_HOME redirected to a
|
||||||
|
# throwaway dir (so the real ~/.local/state is never touched). Optional args:
|
||||||
|
# $2 shim-maker (default none), $3 MOSAIC_BACKUP_RETENTION (default unset).
|
||||||
|
# Echoes: "<exit>\t<out>\t<state-dir>\t<home>".
|
||||||
|
# $4 seeder (default seed_home) — swap in seed_home_v1 for the migration case.
|
||||||
|
run_snap() {
|
||||||
|
local installer="$1" shim_maker="${2:-}" retention="${3:-}" seeder="${4:-seed_home}" H STATE OUT SHIM rc pathpre
|
||||||
|
H=$(mktemp -d); STATE=$(mktemp -d); OUT=$(mktemp); pathpre="$ORIG_PATH"
|
||||||
|
"$seeder" "$H"
|
||||||
|
if [[ -n "$shim_maker" ]]; then
|
||||||
|
SHIM=$(mktemp -d); "$shim_maker" "$SHIM" "$H"; pathpre="$SHIM:$ORIG_PATH"
|
||||||
|
fi
|
||||||
|
set +e
|
||||||
|
env PATH="$pathpre" XDG_STATE_HOME="$STATE" \
|
||||||
|
${retention:+MOSAIC_BACKUP_RETENTION="$retention"} \
|
||||||
|
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 \
|
||||||
|
bash "$installer" >"$OUT" 2>&1
|
||||||
|
rc=$?
|
||||||
|
set -e 2>/dev/null || true
|
||||||
|
[[ -n "$shim_maker" ]] && rm -rf "$SHIM"
|
||||||
|
printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$STATE" "$H"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve the single pre-update-* snapshot dir under a state dir (newest if many).
|
||||||
|
snap_dir() {
|
||||||
|
find "$1/mosaic/backups" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null \
|
||||||
|
| LC_ALL=C sort -r | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "── Part 1/2/3: durable snapshot scope, perms, no-leak ──────────────────"
|
||||||
|
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL")
|
||||||
|
SNAP="$(snap_dir "$STATE")"
|
||||||
|
|
||||||
|
chk "upgrade succeeds" "[ '$rc' -eq 0 ]"
|
||||||
|
chk "exactly one pre-update snapshot created" "[ \$(find '$STATE/mosaic/backups' -maxdepth 1 -type d -name 'pre-update-*' | wc -l) -eq 1 ]"
|
||||||
|
chk "snapshot: SOUL.md captured" "[ -f '$SNAP/SOUL.md' ]"
|
||||||
|
chk "snapshot: operator subtree captured" "[ -f '$SNAP/agents/coder0.conf' ]"
|
||||||
|
chk "snapshot: memory captured" "[ -f '$SNAP/memory/note.md' ]"
|
||||||
|
chk "snapshot: credentials carve-out captured" "[ -f '$SNAP/tools/_lib/credentials.json' ]"
|
||||||
|
chk "snapshot: SOUL.md bytes preserved" "[ \"\$(cat '$SNAP/SOUL.md')\" = '$SOUL_ORIG' ]"
|
||||||
|
chk "snapshot: framework file NOT captured" "[ ! -e '$SNAP/CONSTITUTION.md' ] && [ ! -e '$SNAP/$POISON_REL' ]"
|
||||||
|
|
||||||
|
# Part 2 — permissions (0700 dirs, 0600 files); never world-readable.
|
||||||
|
chk "perms: backup root is 0700" "[ \$(stat -c '%a' '$STATE/mosaic/backups') -eq 700 ]"
|
||||||
|
chk "perms: snapshot dir is 0700" "[ \$(stat -c '%a' '$SNAP') -eq 700 ]"
|
||||||
|
chk "perms: nested dir is 0700" "[ \$(stat -c '%a' '$SNAP/agents') -eq 700 ]"
|
||||||
|
chk "perms: credentials backup is 0600" "[ \$(stat -c '%a' '$SNAP/tools/_lib/credentials.json') -eq 600 ]"
|
||||||
|
chk "perms: SOUL.md backup is 0600" "[ \$(stat -c '%a' '$SNAP/SOUL.md') -eq 600 ]"
|
||||||
|
|
||||||
|
# Part 3 — the secret is backed up but never emitted to stdout/stderr.
|
||||||
|
chk "no-leak: secret IS in the backup file" "grep -q '$SECRET' '$SNAP/tools/_lib/credentials.json'"
|
||||||
|
chk "no-leak: secret NOT on stdout/stderr" "! grep -q '$SECRET' '$OUT'"
|
||||||
|
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||||
|
|
||||||
|
echo "── Part 4: retention prune (MOSAIC_BACKUP_RETENTION) ───────────────────"
|
||||||
|
# Pre-seed four dated snapshots, then take one real snapshot with retention=2:
|
||||||
|
# only the two newest (the fresh real one + the newest pre-seeded) must survive.
|
||||||
|
IFS=$'\t' read -r rc OUT STATE H < <(
|
||||||
|
H=$(mktemp -d); STATE=$(mktemp -d); OUT=$(mktemp)
|
||||||
|
seed_home "$H"
|
||||||
|
mkdir -p "$STATE/mosaic/backups"
|
||||||
|
for ts in 20200101T000000Z 20210101T000000Z 20220101T000000Z 20230101T000000Z; do
|
||||||
|
mkdir -p "$STATE/mosaic/backups/pre-update-$ts"
|
||||||
|
done
|
||||||
|
set +e
|
||||||
|
env PATH="$ORIG_PATH" XDG_STATE_HOME="$STATE" MOSAIC_BACKUP_RETENTION=2 \
|
||||||
|
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 \
|
||||||
|
bash "$INSTALL" >"$OUT" 2>&1
|
||||||
|
rc=$?
|
||||||
|
set -e 2>/dev/null || true
|
||||||
|
printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$STATE" "$H"
|
||||||
|
)
|
||||||
|
chk "retention: upgrade succeeds" "[ '$rc' -eq 0 ]"
|
||||||
|
chk "retention: pruned to exactly 2 snapshots" "[ \$(find '$STATE/mosaic/backups' -maxdepth 1 -type d -name 'pre-update-*' | wc -l) -eq 2 ]"
|
||||||
|
chk "retention: newest pre-seeded survives" "[ -d '$STATE/mosaic/backups/pre-update-20230101T000000Z' ]"
|
||||||
|
chk "retention: oldest pre-seeded pruned" "[ ! -d '$STATE/mosaic/backups/pre-update-20200101T000000Z' ]"
|
||||||
|
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||||
|
|
||||||
|
echo "── Part 5: post-sync verify restores an operator file (+ control) ──────"
|
||||||
|
# Shipped installer: the cp shim corrupts SOUL.md mid-sync; verify must restore it.
|
||||||
|
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" make_corrupt_shim)
|
||||||
|
chk "verify: upgrade still succeeds" "[ '$rc' -eq 0 ]"
|
||||||
|
chk "verify: SOUL.md restored to original" "[ \"\$(cat '$H/SOUL.md')\" = '$SOUL_ORIG' ]"
|
||||||
|
chk "verify: no corruption remains in SOUL.md" "! grep -q 'CORRUPTION-mid-sync' '$H/SOUL.md'"
|
||||||
|
chk "verify: loud restore warning emitted" "grep -qi 'restored from the pre-update snapshot' '$OUT'"
|
||||||
|
chk "verify: secret still not leaked" "! grep -q '$SECRET' '$OUT'"
|
||||||
|
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||||
|
|
||||||
|
# Control: strip the verify call → the corruption must SURVIVE (net is load-bearing).
|
||||||
|
sed '/# VERIFY-NET/d' "$INSTALL" > "$VERIFYCTRL"
|
||||||
|
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$VERIFYCTRL" make_corrupt_shim)
|
||||||
|
chk "control: SOUL.md corruption survives" "grep -q 'CORRUPTION-mid-sync' '$H/SOUL.md'"
|
||||||
|
chk "control: no restore warning emitted" "! grep -qi 'restored from the pre-update snapshot' '$OUT'"
|
||||||
|
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||||
|
|
||||||
|
echo "── Part 6: verify net honors an intentional migration removal (+ control) ─"
|
||||||
|
# BLOCKER regression: on a pre-v2 install, bin/ is operator-classified so the durable
|
||||||
|
# snapshot captures it — but the v1→v2 migration deletes bin/ ON PURPOSE. The verify
|
||||||
|
# net must SKIP that removal (is_migration_removed), or it heals bin/ back and the
|
||||||
|
# migration is silently undone forever once the version is stamped.
|
||||||
|
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" "" "" seed_home_v1)
|
||||||
|
chk "migration: upgrade succeeds" "[ '$rc' -eq 0 ]"
|
||||||
|
chk "migration: legacy bin/ stays removed" "[ ! -e '$H/bin' ]"
|
||||||
|
chk "migration: operator SOUL.md untouched" "[ \"\$(cat '$H/SOUL.md')\" = '$SOUL_ORIG' ]"
|
||||||
|
chk "migration: version stamped to $FRAMEWORK_VERSION" "[ \"\$(cat '$H/.framework-version')\" = '$FRAMEWORK_VERSION' ]"
|
||||||
|
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||||
|
|
||||||
|
# Control: strip the MIGRATION-SKIP-GUARD → the verify net restores bin/ from the
|
||||||
|
# snapshot, silently undoing the migration (proves the guard is load-bearing).
|
||||||
|
MIGCTRL="$(mk_control 'MIGRATION-SKIP-GUARD' migration-control)"
|
||||||
|
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$MIGCTRL" "" "" seed_home_v1)
|
||||||
|
chk "control: bin/ wrongly restored by verify" "[ -e '$H/bin/tool.sh' ]"
|
||||||
|
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||||
|
|
||||||
|
echo "── Part 7: verify net never restores a secret through a symlink (+ control) ─"
|
||||||
|
# HIGH (CWE-59) regression: an attacker who swaps an operator file for a symlink
|
||||||
|
# AFTER the durable snapshot must not cause the verify net's restore to write the
|
||||||
|
# snapshot's secret out THROUGH that link. The shipped net drops a symlinked leaf and
|
||||||
|
# writes a real file in its place, leaving the external target untouched.
|
||||||
|
EXFIL_DIR=$(mktemp -d); EXFIL_TARGET="$EXFIL_DIR/stolen"
|
||||||
|
printf 'ATTACKER-PLACEHOLDER\n' > "$EXFIL_TARGET"
|
||||||
|
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" make_symlink_leaf_shim)
|
||||||
|
chk "symlink-leaf: upgrade succeeds" "[ '$rc' -eq 0 ]"
|
||||||
|
chk "symlink-leaf: secret NOT written through link" "! grep -q '$SECRET' '$EXFIL_TARGET'"
|
||||||
|
chk "symlink-leaf: credentials.json is a real file" "[ -f '$H/tools/_lib/credentials.json' ] && [ ! -L '$H/tools/_lib/credentials.json' ]"
|
||||||
|
chk "symlink-leaf: credentials.json restored intact" "grep -q '$SECRET' '$H/tools/_lib/credentials.json'"
|
||||||
|
chk "symlink-leaf: secret not leaked to stdout/stderr" "! grep -q '$SECRET' '$OUT'"
|
||||||
|
rm -rf "$STATE" "$H" "$EXFIL_DIR"; rm -f "$OUT"
|
||||||
|
|
||||||
|
# Control: strip the SYMLINK-LEAF-GUARD → cp follows the swapped-in link and writes
|
||||||
|
# the snapshot secret out through it (proves the guard is load-bearing).
|
||||||
|
EXFIL_DIR=$(mktemp -d); EXFIL_TARGET="$EXFIL_DIR/stolen"
|
||||||
|
printf 'ATTACKER-PLACEHOLDER\n' > "$EXFIL_TARGET"
|
||||||
|
LEAFCTRL="$(mk_control 'SYMLINK-LEAF-GUARD' symlinkleaf-control)"
|
||||||
|
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$LEAFCTRL" make_symlink_leaf_shim)
|
||||||
|
chk "control: secret leaked through the symlink" "grep -q '$SECRET' '$EXFIL_TARGET'"
|
||||||
|
rm -rf "$STATE" "$H" "$EXFIL_DIR"; rm -f "$OUT"
|
||||||
|
|
||||||
|
echo "── Part 8: snapshot umask 077 does not leak into synced files (+ control) ──"
|
||||||
|
# SHOULD-FIX regression: umask 077 is process-global. Scoped to the snapshot it keeps
|
||||||
|
# backups 0600; leaked past it, every later cp/mkdir inherits 0600/0700. A freshly-
|
||||||
|
# synced framework file must be 0644 (per the ambient 022 umask) while the backup of
|
||||||
|
# a secret stays 0600.
|
||||||
|
IFS=$'\t' read -r rc OUT STATE H < <(umask 022; run_snap "$INSTALL")
|
||||||
|
SNAP="$(snap_dir "$STATE")"
|
||||||
|
chk "umask: upgrade succeeds" "[ '$rc' -eq 0 ]"
|
||||||
|
chk "umask: synced framework file is 0644" "[ \$(stat -c '%a' '$H/$POISON_REL') -eq 644 ]"
|
||||||
|
chk "umask: backup of a secret stays 0600" "[ \$(stat -c '%a' '$SNAP/tools/_lib/credentials.json') -eq 600 ]"
|
||||||
|
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||||
|
|
||||||
|
# Control: strip the UMASK-RESTORE-NORMAL line → umask 077 leaks past the snapshot,
|
||||||
|
# so the newly-synced framework file is created 0600 (proves the restore matters).
|
||||||
|
UMASKCTRL="$(mk_control 'UMASK-RESTORE-NORMAL' umask-control)"
|
||||||
|
IFS=$'\t' read -r rc OUT STATE H < <(umask 022; run_snap "$UMASKCTRL")
|
||||||
|
chk "control: leaked umask makes synced file 0600" "[ \$(stat -c '%a' '$H/$POISON_REL') -eq 600 ]"
|
||||||
|
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "RESULT: $pass passed, $fail failed"
|
||||||
|
[ "$fail" -eq 0 ]
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-upgrade-manifest-guard.sh — the #791 HARD GATE.
|
||||||
|
#
|
||||||
|
# Proves that a keep-mode framework upgrade (the `mosaic update` path:
|
||||||
|
# install.sh with MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1) touches NO path
|
||||||
|
# outside the framework-owned manifest. Every operator-owned sentinel — including
|
||||||
|
# a deliberately UNANTICIPATED one the manifest never names — must survive
|
||||||
|
# byte-identical with an unchanged mtime (not even rewritten). Framework files
|
||||||
|
# must still update, and a retired framework file inside a shipped subtree must
|
||||||
|
# still be pruned. No operator secret value may appear in installer output.
|
||||||
|
#
|
||||||
|
# Keep mode is a SINGLE code path (sync_framework_keep, a manifest-driven cp
|
||||||
|
# overlay + scoped prune — no rsync). The matrix still runs twice, once with
|
||||||
|
# rsync on PATH and once with it hidden, to prove the keep path is genuinely
|
||||||
|
# rsync-independent: it must obey the manifest identically whether or not rsync
|
||||||
|
# happens to be installed (rsync --delete is only ever used by overwrite mode,
|
||||||
|
# which has no operator state to protect).
|
||||||
|
#
|
||||||
|
# It also runs a fail-closed matrix (#791 B2/B3): an empty, operator-only,
|
||||||
|
# malformed, or missing manifest must ABORT the upgrade loudly and leave every
|
||||||
|
# operator path untouched — never silently no-op to "complete".
|
||||||
|
#
|
||||||
|
# Usage: bash test-upgrade-manifest-guard.sh
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
|
||||||
|
INSTALL="$FW/install.sh"
|
||||||
|
|
||||||
|
pass=0; fail=0
|
||||||
|
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||||
|
|
||||||
|
# Redirect the #791 PR2 durable pre-update snapshot ($XDG_STATE_HOME/mosaic/backups)
|
||||||
|
# into a throwaway so a keep-mode upgrade under test never writes into the real
|
||||||
|
# ~/.local/state. This test asserts operator-surface fidelity, not backup content.
|
||||||
|
export XDG_STATE_HOME
|
||||||
|
XDG_STATE_HOME="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$XDG_STATE_HOME"' EXIT
|
||||||
|
|
||||||
|
SECRET='SUPER-SECRET-TOKEN-do-not-log-3f9a'
|
||||||
|
|
||||||
|
# Seed a throwaway MOSAIC_HOME with an operator sentinel per ownership class.
|
||||||
|
seed_home() {
|
||||||
|
local H="$1"
|
||||||
|
mkdir -p "$H/agents" "$H/policy" "$H/memory" "$H/tools/_lib" \
|
||||||
|
"$H/fleet/agents" "$H/fleet/run/sessions" "$H/harvester" \
|
||||||
|
"$H/unknown-operator-dir" "$H/guides"
|
||||||
|
printf '# persona\n' > "$H/SOUL.md" # marks a recognized existing install → keep mode
|
||||||
|
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
|
||||||
|
printf '# operator policy\n' > "$H/policy/custom.md"
|
||||||
|
printf '# soul overlay\n' > "$H/SOUL.local.md"
|
||||||
|
printf '# operator memory\n' > "$H/memory/note.md"
|
||||||
|
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
|
||||||
|
printf 'MOSAIC_AGENT_NAME=coder0\n' > "$H/fleet/agents/coder0.env"
|
||||||
|
printf 'version: 2\nagents:\n - name: coder0\n' > "$H/fleet/roster.yaml"
|
||||||
|
printf '# harvester SOP\n' > "$H/harvester/sop.md"
|
||||||
|
printf 'operator data the manifest never anticipated\n' > "$H/unknown-operator-dir/x"
|
||||||
|
printf 'version: 1\nagents:\n - name: mine\n' > "$H/fleet/my-fleet.yaml"
|
||||||
|
|
||||||
|
# #797 Runtime Session Ledger (Mos-elevated to a #791 PR1 merge-blocker): a
|
||||||
|
# populated ledger under fleet/run/sessions/ must survive the upgrade — a
|
||||||
|
# runtime ledger an upgrade rsync can wipe is worthless. Seed it exactly as
|
||||||
|
# #797 writes it: a non-empty append journal + a non-empty compacted
|
||||||
|
# projection, files 0600 under a 0700 dir.
|
||||||
|
printf '%s\n%s\n%s\n' \
|
||||||
|
'{"seq":1,"kind":"session.spawn","node":"sess-42","generation":7}' \
|
||||||
|
'{"seq":2,"kind":"lease.grant","node":"sess-42","lease":"web1"}' \
|
||||||
|
'{"seq":3,"kind":"dispatch.create","from":"sess-42","to":"disp-9"}' \
|
||||||
|
> "$H/fleet/run/sessions/events.ndjson"
|
||||||
|
printf '%s\n' \
|
||||||
|
'{"generation":7,"nodes":[{"id":"sess-42","kind":"session"}],"edges":[{"from":"sess-42","to":"disp-9","kind":"dispatch"}]}' \
|
||||||
|
> "$H/fleet/run/sessions/ledger.json"
|
||||||
|
chmod 0700 "$H/fleet/run" "$H/fleet/run/sessions"
|
||||||
|
chmod 0600 "$H/fleet/run/sessions/events.ndjson" "$H/fleet/run/sessions/ledger.json"
|
||||||
|
|
||||||
|
# A retired framework file inside a shipped subtree (absent from source) — must be pruned.
|
||||||
|
printf '# retired guide\n' > "$H/guides/RETIRED-OLD-GUIDE.md"
|
||||||
|
echo 3 > "$H/.framework-version"
|
||||||
|
}
|
||||||
|
|
||||||
|
OPERATOR_SENTINELS=(
|
||||||
|
"agents/coder0.conf"
|
||||||
|
"policy/custom.md"
|
||||||
|
"SOUL.local.md"
|
||||||
|
"memory/note.md"
|
||||||
|
"tools/_lib/credentials.json"
|
||||||
|
"fleet/agents/coder0.env"
|
||||||
|
"fleet/roster.yaml"
|
||||||
|
"harvester/sop.md"
|
||||||
|
"unknown-operator-dir/x"
|
||||||
|
"fleet/my-fleet.yaml"
|
||||||
|
# #797 Runtime Session Ledger — populated journal + projection must survive.
|
||||||
|
"fleet/run/sessions/events.ndjson"
|
||||||
|
"fleet/run/sessions/ledger.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
run_matrix() {
|
||||||
|
local label="$1"; shift # extra env / PATH override applied to the run
|
||||||
|
local H E OUT rel before_hash after_hash before_mt after_mt
|
||||||
|
H=$(mktemp -d); E=$(mktemp -d); OUT=$(mktemp)
|
||||||
|
seed_home "$H"
|
||||||
|
|
||||||
|
# Snapshot hash + mtime of every operator sentinel before the upgrade.
|
||||||
|
for rel in "${OPERATOR_SENTINELS[@]}"; do
|
||||||
|
sha256sum "$H/$rel" | awk '{print $1}' > "$E/$(echo "$rel" | tr / _).hash"
|
||||||
|
stat -c %Y "$H/$rel" > "$E/$(echo "$rel" | tr / _).mt"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Snapshot the ledger directory permission bits (#797 assert: perms unchanged).
|
||||||
|
local before_dirperm after_dirperm
|
||||||
|
before_dirperm=$(stat -c %a "$H/fleet/run/sessions")
|
||||||
|
|
||||||
|
# The upgrade under test (keep + sync-only = the `mosaic update` reseed path).
|
||||||
|
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 "$@" bash "$INSTALL" >"$OUT" 2>&1
|
||||||
|
|
||||||
|
# HARD GATE: every operator sentinel survives byte-identical AND mtime-unchanged.
|
||||||
|
for rel in "${OPERATOR_SENTINELS[@]}"; do
|
||||||
|
before_hash=$(cat "$E/$(echo "$rel" | tr / _).hash")
|
||||||
|
before_mt=$(cat "$E/$(echo "$rel" | tr / _).mt")
|
||||||
|
after_hash=$(sha256sum "$H/$rel" 2>/dev/null | awk '{print $1}')
|
||||||
|
after_mt=$(stat -c %Y "$H/$rel" 2>/dev/null || echo MISSING)
|
||||||
|
chk "[$label] operator sentinel survives byte-identical: $rel" \
|
||||||
|
"[ -n '$after_hash' ] && [ '$before_hash' = '$after_hash' ]"
|
||||||
|
chk "[$label] operator sentinel not rewritten (mtime unchanged): $rel" \
|
||||||
|
"[ '$before_mt' = '$after_mt' ]"
|
||||||
|
done
|
||||||
|
|
||||||
|
# #797 assert 7: the ledger directory's permission bits are unchanged.
|
||||||
|
after_dirperm=$(stat -c %a "$H/fleet/run/sessions" 2>/dev/null || echo MISSING)
|
||||||
|
chk "[$label] ledger dir perms unchanged (#797): $before_dirperm" \
|
||||||
|
"[ '$before_dirperm' = '$after_dirperm' ]"
|
||||||
|
|
||||||
|
# Positive controls / negative controls — prove the test discriminates: the
|
||||||
|
# upgrade DOES write and prune framework-owned paths, so the operator sentinels
|
||||||
|
# (incl. the #797 ledger) survive because of the manifest, not because the
|
||||||
|
# upgrade is a no-op.
|
||||||
|
chk "[$label] positive control: framework file present after upgrade (guides synced)" \
|
||||||
|
"[ -f '$H/guides/E2E-DELIVERY.md' ]"
|
||||||
|
chk "[$label] negative control: retired framework file inside a subtree IS pruned" \
|
||||||
|
"[ ! -f '$H/guides/RETIRED-OLD-GUIDE.md' ]"
|
||||||
|
chk "[$label] manifest itself is installed" "[ -f '$H/framework-manifest.txt' ]"
|
||||||
|
|
||||||
|
# Secret-safety: the operator secret value never appears in installer output.
|
||||||
|
chk "[$label] operator secret value absent from installer stdout/stderr" \
|
||||||
|
"! grep -q '$SECRET' '$OUT'"
|
||||||
|
|
||||||
|
rm -rf "$H" "$E" "$OUT"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fail-closed matrix (#791 B2/B3 + blocker-1): run install.sh from a COPY of the
|
||||||
|
# framework so the shipped manifest can be corrupted. Every corruption must abort
|
||||||
|
# the upgrade non-zero with a manifest error, leaving all operator sentinels
|
||||||
|
# byte-identical AND on the SAME inode. The inode check is the load-bearing part:
|
||||||
|
# manifest validation is hoisted BEFORE make_snapshot/the restore trap, so a bad
|
||||||
|
# manifest must abort without ever snapshotting, deleting, and restoring the
|
||||||
|
# target. Were validation still armed under the ERR trap, restore_snapshot would
|
||||||
|
# rm -rf + rebuild the target — same bytes but a NEW inode (broken hard links,
|
||||||
|
# changed ctime), which a content-only hash would miss (#791 blocker-1).
|
||||||
|
run_failclosed() {
|
||||||
|
local label="$1" mutate="$2"
|
||||||
|
local SRC H E OUT rc rel before_hash after_hash before_ino after_ino key
|
||||||
|
SRC=$(mktemp -d); H=$(mktemp -d); E=$(mktemp -d); OUT=$(mktemp)
|
||||||
|
cp -a "$FW/." "$SRC/"
|
||||||
|
case "$mutate" in
|
||||||
|
empty) : > "$SRC/framework-manifest.txt" ;;
|
||||||
|
operator-only) printf '[operator]\nSOUL.md\n*.local.md\n' > "$SRC/framework-manifest.txt" ;;
|
||||||
|
malformed) printf 'stray.md\n[framework]\nguides/**\n' > "$SRC/framework-manifest.txt" ;;
|
||||||
|
degenerate) printf '[framework]\n/\n./\n[operator]\nSOUL.md\n' > "$SRC/framework-manifest.txt" ;;
|
||||||
|
missing) rm -f "$SRC/framework-manifest.txt" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
seed_home "$H"
|
||||||
|
for rel in "${OPERATOR_SENTINELS[@]}"; do
|
||||||
|
key=$(echo "$rel" | tr / _)
|
||||||
|
sha256sum "$H/$rel" | awk '{print $1}' > "$E/$key.hash"
|
||||||
|
stat -c '%i' "$H/$rel" > "$E/$key.ino"
|
||||||
|
done
|
||||||
|
|
||||||
|
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$SRC/install.sh" >"$OUT" 2>&1
|
||||||
|
rc=$?
|
||||||
|
|
||||||
|
chk "[fail-closed:$label] upgrade aborts non-zero" "[ '$rc' -ne 0 ]"
|
||||||
|
chk "[fail-closed:$label] refuses loudly with a manifest error" \
|
||||||
|
"grep -qi 'manifest' '$OUT'"
|
||||||
|
for rel in "${OPERATOR_SENTINELS[@]}"; do
|
||||||
|
key=$(echo "$rel" | tr / _)
|
||||||
|
before_hash=$(cat "$E/$key.hash")
|
||||||
|
after_hash=$(sha256sum "$H/$rel" 2>/dev/null | awk '{print $1}')
|
||||||
|
chk "[fail-closed:$label] operator sentinel untouched: $rel" \
|
||||||
|
"[ -n '$after_hash' ] && [ '$before_hash' = '$after_hash' ]"
|
||||||
|
before_ino=$(cat "$E/$key.ino")
|
||||||
|
after_ino=$(stat -c '%i' "$H/$rel" 2>/dev/null)
|
||||||
|
chk "[fail-closed:$label] operator sentinel not deleted/recreated (inode stable): $rel" \
|
||||||
|
"[ -n '$after_ino' ] && [ '$before_ino' = '$after_ino' ]"
|
||||||
|
done
|
||||||
|
chk "[fail-closed:$label] operator secret value absent from output" \
|
||||||
|
"! grep -q '$SECRET' '$OUT'"
|
||||||
|
|
||||||
|
chmod -R u+w "$SRC" "$H" 2>/dev/null || true
|
||||||
|
rm -rf "$SRC" "$H" "$E" "$OUT"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "#791 upgrade manifest guard (HARD GATE):"
|
||||||
|
|
||||||
|
# 1) rsync path (if available on this host).
|
||||||
|
if command -v rsync >/dev/null 2>&1; then
|
||||||
|
run_matrix "rsync"
|
||||||
|
else
|
||||||
|
echo " · rsync not installed — skipping rsync-path matrix"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) rsync-absent path — hide rsync behind a scratch PATH. Keep mode never calls
|
||||||
|
# rsync, so this must resolve identically to run (1); it proves the keep path
|
||||||
|
# does not silently depend on rsync being installed. (Provide the coreutils the
|
||||||
|
# installer needs on the stripped PATH.)
|
||||||
|
FBIN=$(mktemp -d)
|
||||||
|
for t in bash cp find mktemp rm mkdir chmod cmp sed grep cat dirname basename stat sha256sum awk tr date sort; do
|
||||||
|
p=$(command -v "$t" 2>/dev/null) && ln -s "$p" "$FBIN/$t"
|
||||||
|
done
|
||||||
|
run_matrix "rsync-absent" env "PATH=$FBIN"
|
||||||
|
rm -rf "$FBIN"
|
||||||
|
|
||||||
|
# 3) fail-closed matrix (#791 B2/B3) — corrupt the shipped manifest four ways.
|
||||||
|
run_failclosed "empty-manifest" empty
|
||||||
|
run_failclosed "operator-only" operator-only
|
||||||
|
run_failclosed "malformed-manifest" malformed
|
||||||
|
run_failclosed "degenerate-framework" degenerate
|
||||||
|
run_failclosed "missing-manifest" missing
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "RESULT: $pass passed, $fail failed"
|
||||||
|
[ "$fail" -eq 0 ]
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-upgrade-rollback.sh — the #791 B1 regression gate.
|
||||||
|
#
|
||||||
|
# A keep-mode upgrade takes a pre-update snapshot and installs an ERR/INT/TERM
|
||||||
|
# trap that restores it if the sync aborts midway (install.sh: make_snapshot +
|
||||||
|
# `trap restore_snapshot`). That trap is only reached if `set -E` (errtrace) is
|
||||||
|
# active — otherwise a failure INSIDE sync_framework_keep() (which runs entirely
|
||||||
|
# in a function) never fires the trap, and the upgrade aborts leaving a
|
||||||
|
# half-written target with NO rollback. This test proves:
|
||||||
|
#
|
||||||
|
# Part A (the gate): the shipped installer rolls back a mid-sync failure —
|
||||||
|
# the restore message fires, the corrupted file is put
|
||||||
|
# back, AND the whole target is byte-identical to its
|
||||||
|
# pre-upgrade state.
|
||||||
|
# Part B (the control): the SAME installer with `-E` stripped does NOT roll back
|
||||||
|
# (dead trap) — the mid-sync corruption survives, proving
|
||||||
|
# errtrace is load-bearing. If anyone removes `set -E`,
|
||||||
|
# Part A goes red.
|
||||||
|
#
|
||||||
|
# The mid-sync failure is injected with a PATH-shadowing `cp` shim rather than
|
||||||
|
# file permissions. The earlier 0400/EACCES approach was NOT portable: Woodpecker
|
||||||
|
# runs steps as root (node:24-alpine has no USER directive), and root overwrites a
|
||||||
|
# 0400 file, so the failure never fired and this gate silently passed (#791
|
||||||
|
# blocker-3). The shim fails deterministically for one framework-owned
|
||||||
|
# destination regardless of uid, and — like a real interrupted cp (disk-full
|
||||||
|
# mid-write) — leaves a partially-written target behind, so rollback has real
|
||||||
|
# damage to undo and the control has real damage to expose.
|
||||||
|
#
|
||||||
|
# Usage: bash test-upgrade-rollback.sh
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
|
||||||
|
INSTALL="$FW/install.sh"
|
||||||
|
ORIG_PATH="$PATH"
|
||||||
|
|
||||||
|
# The `-E`-stripped control installer must live INSIDE $FW: install.sh derives
|
||||||
|
# SOURCE_DIR from its own path and `source`s $SOURCE_DIR/tools/_lib/manifest.sh,
|
||||||
|
# so a copy anywhere else aborts at the source line before ever reaching the sync
|
||||||
|
# loop — which would make the control a false negative. A root dotfile is
|
||||||
|
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
|
||||||
|
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
||||||
|
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
||||||
|
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
||||||
|
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
||||||
|
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||||
|
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||||
|
|
||||||
|
pass=0; fail=0
|
||||||
|
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||||
|
|
||||||
|
SECRET='SUPER-SECRET-TOKEN-do-not-log-b1'
|
||||||
|
# A framework-owned file the shim fails the copy of. The seeded target holds GOOD
|
||||||
|
# bytes; source ships different bytes, so sync_framework_keep() attempts the copy
|
||||||
|
# and the shim intercepts it. Root-level framework files sort before guides/, so
|
||||||
|
# several framework files are already refreshed when the copy reaches this one.
|
||||||
|
POISON_REL='guides/E2E-DELIVERY.md'
|
||||||
|
GOOD='GOOD-REFERENCE-CONTENT-pre-upgrade-b1'
|
||||||
|
GARBAGE='PARTIAL-WRITE-GARBAGE-mid-sync-b1'
|
||||||
|
|
||||||
|
# A `cp` shim: for the poisoned destination, simulate an interrupted copy — write
|
||||||
|
# partial garbage to the target, then fail — otherwise delegate to the real cp
|
||||||
|
# (resolved via the ORIGINAL PATH so make_snapshot/restore still work).
|
||||||
|
make_cp_shim() {
|
||||||
|
local dir="$1"
|
||||||
|
cat > "$dir/cp" <<SHIM
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
dest="\${@: -1}"
|
||||||
|
case "\$dest" in
|
||||||
|
*/$POISON_REL)
|
||||||
|
printf '%s' '$GARBAGE' > "\$dest" 2>/dev/null || true
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
|
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||||
|
SHIM
|
||||||
|
chmod +x "$dir/cp"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A `find` shim that fails every enumeration scan (`-print0`) as if it hit an
|
||||||
|
# EACCES/I/O error partway — it emits the real (here: complete) list first, then
|
||||||
|
# exits non-zero, exactly the class of failure a `< <(find …)` process
|
||||||
|
# substitution silently swallows. All non-`-print0` finds (e.g. the -delete
|
||||||
|
# sweep) delegate to the real find on the original PATH. Used to prove #791
|
||||||
|
# blocker-D1: the shipped installer must honor find's exit status and roll back.
|
||||||
|
make_find_fail_shim() {
|
||||||
|
local dir="$1"
|
||||||
|
cat > "$dir/find" <<SHIM
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
for a in "\$@"; do
|
||||||
|
if [ "\$a" = "-print0" ]; then
|
||||||
|
env PATH="$ORIG_PATH" find "\$@" # emit the real list…
|
||||||
|
exit 1 # …then fail as if the scan hit EACCES
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exec env PATH="$ORIG_PATH" find "\$@"
|
||||||
|
SHIM
|
||||||
|
chmod +x "$dir/find"
|
||||||
|
}
|
||||||
|
|
||||||
|
# An `rm` shim that fails ONLY `rm -rf <FAIL_RM_TARGET>` (the restore's target
|
||||||
|
# reset) and delegates every other rm to the real one. Used to prove #791
|
||||||
|
# blocker-D2: when the target reset inside restore_snapshot fails, the installer
|
||||||
|
# must emit the manual-recovery pointer (snapshot path) instead of exiting
|
||||||
|
# silently under `set -e`. FAIL_RM_TARGET is exported into the installer env.
|
||||||
|
make_rm_fail_shim() {
|
||||||
|
local dir="$1"
|
||||||
|
cat > "$dir/rm" <<'SHIM'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
last="${@: -1}"
|
||||||
|
if [ -n "${FAIL_RM_TARGET:-}" ] && [ "$last" = "$FAIL_RM_TARGET" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exec env PATH="$ORIG_PATH_FOR_RM" rm "$@"
|
||||||
|
SHIM
|
||||||
|
chmod +x "$dir/rm"
|
||||||
|
}
|
||||||
|
|
||||||
|
seed_home() {
|
||||||
|
local H="$1"
|
||||||
|
mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory" "$H/guides"
|
||||||
|
printf '# persona\n' > "$H/SOUL.md" # recognized install → keep mode + snapshot
|
||||||
|
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
|
||||||
|
printf '# operator memory\n' > "$H/memory/note.md"
|
||||||
|
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
|
||||||
|
echo 3 > "$H/.framework-version"
|
||||||
|
# Good pre-upgrade bytes; source ships different bytes, so cp is attempted.
|
||||||
|
printf '%s' "$GOOD" > "$H/$POISON_REL"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run one keep-mode upgrade against $1=installer, seeding a fresh home and a
|
||||||
|
# byte-for-byte reference of the pre-upgrade state, with the cp shim first on
|
||||||
|
# PATH. Echoes: "<exit>\t<out>\t<ref>\t<home>".
|
||||||
|
run_upgrade() {
|
||||||
|
local installer="$1" shim_maker="${2:-make_cp_shim}" H REF OUT SHIM rc
|
||||||
|
H=$(mktemp -d); REF=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
||||||
|
seed_home "$H"
|
||||||
|
env PATH="$ORIG_PATH" cp -a "$H/." "$REF/" # pre-upgrade reference (real cp)
|
||||||
|
"$shim_maker" "$SHIM"
|
||||||
|
set +e
|
||||||
|
PATH="$SHIM:$ORIG_PATH" \
|
||||||
|
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||||
|
rc=$?
|
||||||
|
set -e 2>/dev/null || true
|
||||||
|
rm -rf "$SHIM"
|
||||||
|
printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$REF" "$H"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "#791 upgrade rollback (B1 regression gate):"
|
||||||
|
|
||||||
|
# ── Part A: the shipped installer must roll back a mid-sync failure ───────────
|
||||||
|
IFS=$'\t' read -r rcA OUTA REFA HA < <(run_upgrade "$INSTALL")
|
||||||
|
|
||||||
|
chk "[shipped] upgrade aborts non-zero on the injected mid-sync failure" \
|
||||||
|
"[ '$rcA' -ne 0 ]"
|
||||||
|
chk "[shipped] restore_snapshot fires (rollback message present)" \
|
||||||
|
"grep -q 'restoring previous state from snapshot' '$OUTA'"
|
||||||
|
chk "[shipped] the corrupted file is restored to its pre-upgrade bytes" \
|
||||||
|
"[ \"\$(cat '$HA/$POISON_REL')\" = '$GOOD' ]"
|
||||||
|
chk "[shipped] target rolled back byte-identical to pre-upgrade state" \
|
||||||
|
"diff -r '$REFA' '$HA' >/dev/null 2>&1"
|
||||||
|
chk "[shipped] operator secret value absent from installer output" \
|
||||||
|
"! grep -q '$SECRET' '$OUTA'"
|
||||||
|
|
||||||
|
# ── Part B: control — strip `-E`, the trap is dead, no rollback happens ───────
|
||||||
|
# Proves errtrace is what makes the trap reachable. If `set -E` is ever removed
|
||||||
|
# from install.sh, Part A's rollback assertions fail exactly like this control.
|
||||||
|
sed 's/^set -Eeuo pipefail/set -euo pipefail/' "$INSTALL" > "$STRIPPED"
|
||||||
|
chk "[control] the -E strip actually changed the installer" \
|
||||||
|
"! cmp -s '$INSTALL' '$STRIPPED'"
|
||||||
|
|
||||||
|
IFS=$'\t' read -r rcB OUTB REFB HB < <(run_upgrade "$STRIPPED")
|
||||||
|
chk "[control] without -E the upgrade still aborts non-zero" \
|
||||||
|
"[ '$rcB' -ne 0 ]"
|
||||||
|
# The load-bearing, deterministic proof of B1: without errtrace the ERR trap
|
||||||
|
# never fires for a failure inside sync_framework_keep(), so no rollback runs.
|
||||||
|
chk "[control] without -E the rollback message does NOT fire (dead trap)" \
|
||||||
|
"! grep -q 'restoring previous state from snapshot' '$OUTB'"
|
||||||
|
chk "[control] without -E the mid-sync corruption survives (no rollback)" \
|
||||||
|
"[ \"\$(cat '$HB/$POISON_REL')\" = '$GARBAGE' ]"
|
||||||
|
|
||||||
|
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
||||||
|
# A bash signal trap that merely returns lets the script continue past the
|
||||||
|
# interrupt — restoring the snapshot, then resuming the sync and reporting
|
||||||
|
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
|
||||||
|
# fires and ONLY the signal path governs), and assert the shipped installer
|
||||||
|
# restores AND exits without reporting success. The control strips `exit 1` from
|
||||||
|
# the trap and shows the buggy resume-to-success.
|
||||||
|
make_term_shim() {
|
||||||
|
local dir="$1"
|
||||||
|
cat > "$dir/cp" <<SHIM
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
dest="\${@: -1}"
|
||||||
|
case "\$dest" in
|
||||||
|
*/$POISON_REL)
|
||||||
|
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
|
||||||
|
exec env PATH="$ORIG_PATH" cp "\$@" ;;
|
||||||
|
esac
|
||||||
|
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||||
|
SHIM
|
||||||
|
chmod +x "$dir/cp"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
|
||||||
|
run_signal_upgrade() {
|
||||||
|
local installer="$1" H OUT SHIM rc
|
||||||
|
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
||||||
|
seed_home "$H"
|
||||||
|
make_term_shim "$SHIM"
|
||||||
|
set +e
|
||||||
|
PATH="$SHIM:$ORIG_PATH" \
|
||||||
|
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||||
|
rc=$?
|
||||||
|
set -e 2>/dev/null || true
|
||||||
|
rm -rf "$SHIM"
|
||||||
|
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
||||||
|
}
|
||||||
|
|
||||||
|
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
|
||||||
|
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
|
||||||
|
"[ '$rcC' -ne 0 ]"
|
||||||
|
chk "[signal] restore_snapshot fires on the interrupt" \
|
||||||
|
"grep -q 'restoring previous state from snapshot' '$OUTC'"
|
||||||
|
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
||||||
|
"! grep -q 'file phase complete' '$OUTC'"
|
||||||
|
|
||||||
|
# Control: strip `exit 1` from the signal trap → the handler returns, the script
|
||||||
|
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
|
||||||
|
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
|
||||||
|
"$INSTALL" > "$NOEXIT"
|
||||||
|
chk "[control] the exit-strip actually changed the installer" \
|
||||||
|
"! cmp -s '$INSTALL' '$NOEXIT'"
|
||||||
|
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||||
|
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
||||||
|
"grep -q 'file phase complete' '$OUTD'"
|
||||||
|
|
||||||
|
# ── Part D: a failed source/prune `find` scan must abort + roll back (D1) ─────
|
||||||
|
# A `< <(find …)` process substitution discards find's exit status, so an
|
||||||
|
# EACCES/I/O failure mid-scan would truncate the file list yet leave the reading
|
||||||
|
# loop exiting 0 — a partial upgrade committed and reported as success, with the
|
||||||
|
# ERR/restore trap never firing. The shipped installer captures the scan into a
|
||||||
|
# checked temp file (_scan_or_die) and aborts on failure. We inject a `find` that
|
||||||
|
# fails every `-print0` scan and assert the shipped installer rolls back.
|
||||||
|
IFS=$'\t' read -r rcE OUTE REFE HE < <(run_upgrade "$INSTALL" make_find_fail_shim)
|
||||||
|
chk "[find-fail] a failing framework scan aborts the upgrade non-zero" \
|
||||||
|
"[ '$rcE' -ne 0 ]"
|
||||||
|
chk "[find-fail] restore_snapshot fires on the aborted scan" \
|
||||||
|
"grep -q 'restoring previous state from snapshot' '$OUTE'"
|
||||||
|
chk "[find-fail] the abort is a fail-closed enumeration error (not a silent truncation)" \
|
||||||
|
"grep -q 'Could not enumerate framework files' '$OUTE'"
|
||||||
|
chk "[find-fail] target rolled back byte-identical to pre-upgrade state" \
|
||||||
|
"diff -r '$REFE' '$HE' >/dev/null 2>&1"
|
||||||
|
|
||||||
|
# Control: neuter the D1 guard (turn its `return 1` into a no-op) so a find
|
||||||
|
# failure is swallowed exactly as `< <(find …)` would — the scan appears to
|
||||||
|
# succeed and the upgrade reports completion with NO rollback.
|
||||||
|
sed 's/return 1 # D1-GUARD/: # D1-GUARD-DISABLED/' "$INSTALL" > "$D1CTRL"
|
||||||
|
chk "[control] the D1-guard strip actually changed the installer" \
|
||||||
|
"! cmp -s '$INSTALL' '$D1CTRL'"
|
||||||
|
IFS=$'\t' read -r _rcF OUTF REFF HF < <(run_upgrade "$D1CTRL" make_find_fail_shim)
|
||||||
|
chk "[control] with the D1 guard disabled the find failure is swallowed (no rollback)" \
|
||||||
|
"! grep -q 'restoring previous state from snapshot' '$OUTF'"
|
||||||
|
chk "[control] with the D1 guard disabled the upgrade wrongly reports success" \
|
||||||
|
"grep -q 'file phase complete' '$OUTF'"
|
||||||
|
|
||||||
|
# ── Part E: a failed target reset inside restore must not exit silently (D2) ──
|
||||||
|
# restore_snapshot resets the target (`rm -rf; mkdir -p`) before rebuilding from
|
||||||
|
# the snapshot. Under `set -e` (trap disarmed) a bare reset that fails would exit
|
||||||
|
# the whole script immediately — after `rm` may have deleted part of the target —
|
||||||
|
# WITHOUT printing where the snapshot lives. We trigger a rollback (cp poison) AND
|
||||||
|
# fail the target reset (rm shim), then assert the shipped installer emits the
|
||||||
|
# manual-recovery pointer and preserves the snapshot.
|
||||||
|
run_rmfail_upgrade() {
|
||||||
|
local installer="$1" H OUT SHIM rc
|
||||||
|
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
||||||
|
seed_home "$H"
|
||||||
|
make_cp_shim "$SHIM" # poison cp → triggers the abort + restore
|
||||||
|
make_rm_fail_shim "$SHIM" # rm -rf <H> fails → exercises the D2 reset guard
|
||||||
|
set +e
|
||||||
|
PATH="$SHIM:$ORIG_PATH" ORIG_PATH_FOR_RM="$ORIG_PATH" FAIL_RM_TARGET="$H" \
|
||||||
|
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||||
|
rc=$?
|
||||||
|
set -e 2>/dev/null || true
|
||||||
|
rm -rf "$SHIM"
|
||||||
|
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
||||||
|
}
|
||||||
|
|
||||||
|
IFS=$'\t' read -r rcG OUTG HG < <(run_rmfail_upgrade "$INSTALL")
|
||||||
|
chk "[reset-fail] a failed target reset still aborts non-zero" \
|
||||||
|
"[ '$rcG' -ne 0 ]"
|
||||||
|
chk "[reset-fail] the manual-recovery pointer is emitted (not a silent set -e exit)" \
|
||||||
|
"grep -q 'Snapshot restore could not reset' '$OUTG'"
|
||||||
|
chk "[reset-fail] the recovery message points at a preserved snapshot dir" \
|
||||||
|
"grep -q 'preserved at: .*mosaic-snapshot' '$OUTG'"
|
||||||
|
SNAP_E="$(grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTG" | head -1)"
|
||||||
|
chk "[reset-fail] the named snapshot directory actually survives for recovery" \
|
||||||
|
"[ -n '$SNAP_E' ] && [ -d '$SNAP_E' ]"
|
||||||
|
chk "[reset-fail] operator secret value never appears in installer output" \
|
||||||
|
"! grep -q '$SECRET' '$OUTG'"
|
||||||
|
|
||||||
|
# Control: delete the D2 recovery line so a failed reset returns non-zero with NO
|
||||||
|
# operator pointer — the observable defect (half-reset target, snapshot orphaned
|
||||||
|
# in /tmp with no path told to the operator). Proves the message is load-bearing.
|
||||||
|
sed '/Snapshot restore could not reset/d' "$INSTALL" > "$D2CTRL"
|
||||||
|
chk "[control] the D2-recovery strip actually changed the installer" \
|
||||||
|
"! cmp -s '$INSTALL' '$D2CTRL'"
|
||||||
|
IFS=$'\t' read -r _rcH OUTH HH < <(run_rmfail_upgrade "$D2CTRL")
|
||||||
|
chk "[control] without the D2 recovery line the operator gets no snapshot pointer" \
|
||||||
|
"! grep -q 'Snapshot restore could not reset' '$OUTH'"
|
||||||
|
[ -n "${SNAP_E:-}" ] && rm -rf "$SNAP_E"
|
||||||
|
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
|
||||||
|
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
|
||||||
|
|
||||||
|
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap).
|
||||||
|
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
|
||||||
|
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
|
||||||
|
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "RESULT: $pass passed, $fail failed"
|
||||||
|
[ "$fail" -eq 0 ]
|
||||||
@@ -122,12 +122,11 @@ fi
|
|||||||
|
|
||||||
# Source label: this agent's host:session (auto-detected, overridable).
|
# Source label: this agent's host:session (auto-detected, overridable).
|
||||||
if [ -z "$SRC_LABEL" ]; then
|
if [ -z "$SRC_LABEL" ]; then
|
||||||
tmux_cmd=(tmux)
|
|
||||||
if [ -n "$SOCKET_NAME" ]; then
|
|
||||||
tmux_cmd+=(-L "$SOCKET_NAME")
|
|
||||||
fi
|
|
||||||
src_host=$(hostname -s 2>/dev/null || echo "?")
|
src_host=$(hostname -s 2>/dev/null || echo "?")
|
||||||
src_sess=$("${tmux_cmd[@]}" display-message -p '#S' 2>/dev/null || echo "?")
|
src_sess=${MOSAIC_AGENT_NAME:-}
|
||||||
|
if [ -z "$src_sess" ]; then
|
||||||
|
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
|
||||||
|
fi
|
||||||
SRC_LABEL="${src_host}:${src_sess}"
|
SRC_LABEL="${src_host}:${src_sess}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
# 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)
|
||||||
@@ -21,22 +24,45 @@ 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)
|
||||||
trap 'rm -f "$STUB"' EXIT
|
FAKE_BIN=$(mktemp -d)
|
||||||
|
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 "t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
|
while getopts "L:t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
|
||||||
printf '%s' "$b64" | base64 -d
|
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)\] (.*)$'
|
||||||
@@ -92,6 +118,30 @@ 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 ]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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';
|
||||||
@@ -406,6 +407,10 @@ registerStorageCommand(program);
|
|||||||
|
|
||||||
registerUninstallCommand(program);
|
registerUninstallCommand(program);
|
||||||
|
|
||||||
|
// ─── restore ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
registerRestoreCommand(program);
|
||||||
|
|
||||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerTelemetryCommand(program);
|
registerTelemetryCommand(program);
|
||||||
|
|||||||
862
packages/mosaic/src/commands/claudex-proxy.spec.ts
Normal file
862
packages/mosaic/src/commands/claudex-proxy.spec.ts
Normal file
@@ -0,0 +1,862 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
CLAUDEX_PROXY_HOST,
|
||||||
|
CLAUDEX_PROXY_PORT,
|
||||||
|
CLAUDEX_PROXY_URL,
|
||||||
|
CLAUDEX_PROXY_BINARY,
|
||||||
|
CLAUDEX_HEALTH_PATH,
|
||||||
|
CLAUDEX_HEALTH_URL,
|
||||||
|
buildAuthStatusArgs,
|
||||||
|
buildDeviceAuthArgs,
|
||||||
|
buildServeArgs,
|
||||||
|
parseAuthStatus,
|
||||||
|
checkProxyBinary,
|
||||||
|
checkAuthStatus,
|
||||||
|
runDeviceReauth,
|
||||||
|
probeLiveness,
|
||||||
|
buildSystemdUnitContent,
|
||||||
|
systemdUnitPath,
|
||||||
|
installSystemdUnit,
|
||||||
|
startNohupProxy,
|
||||||
|
verifyListenerIdentity,
|
||||||
|
runProxyPreflight,
|
||||||
|
ensureProxyRunning,
|
||||||
|
type AuthStatus,
|
||||||
|
type ProxyRunResult,
|
||||||
|
type SpawnedChild,
|
||||||
|
type ListenerIdentity,
|
||||||
|
} from './claudex-proxy.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P1 — Proxy preflight + lifecycle helpers for `mosaic yolo claudex`.
|
||||||
|
*
|
||||||
|
* Security-relevant invariants exercised here:
|
||||||
|
* - Liveness probe hits the proxy's dedicated `GET /healthz` and treats only a
|
||||||
|
* 2xx as "alive" — a *proxy-specific* health contract, not arbitrary HTTP on
|
||||||
|
* the port (CWE-345: a local port-squatter must not be trusted as the proxy).
|
||||||
|
* This also honors spec gotcha #1 (never `curl -f` the root, which returns
|
||||||
|
* non-2xx): `/healthz` returns 2xx when the proxy is up, so a healthy proxy is
|
||||||
|
* never mistaken for dead and no duplicate proxy is spawned.
|
||||||
|
* - Auth-status parsing NEVER surfaces OAuth token material — only a coarse
|
||||||
|
* state + optional expiry — even if a token-shaped string appears in output.
|
||||||
|
* - The systemd unit's ExecStart never interpolates an unvalidated path
|
||||||
|
* (CWE-74: a CR/LF in the path could inject arbitrary systemd directives).
|
||||||
|
* - The nohup fallback captures spawn's *async* error event instead of crashing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('claudex-proxy constants', () => {
|
||||||
|
it('pins the proxy endpoint to loopback :18765 (spec table)', () => {
|
||||||
|
expect(CLAUDEX_PROXY_HOST).toBe('127.0.0.1');
|
||||||
|
expect(CLAUDEX_PROXY_PORT).toBe(18765);
|
||||||
|
expect(CLAUDEX_PROXY_URL).toBe('http://127.0.0.1:18765');
|
||||||
|
expect(CLAUDEX_PROXY_BINARY).toBe('claude-code-proxy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the dedicated /healthz liveness endpoint (not the root path)', () => {
|
||||||
|
expect(CLAUDEX_HEALTH_PATH).toBe('/healthz');
|
||||||
|
expect(CLAUDEX_HEALTH_URL).toBe('http://127.0.0.1:18765/healthz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds the documented codex subcommand argv', () => {
|
||||||
|
expect(buildAuthStatusArgs()).toEqual(['codex', 'auth', 'status']);
|
||||||
|
expect(buildDeviceAuthArgs()).toEqual(['codex', 'auth', 'device']);
|
||||||
|
expect(buildServeArgs()).toEqual(['serve', '--no-monitor']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseAuthStatus', () => {
|
||||||
|
it('reports valid on exit 0 with an authenticated marker', () => {
|
||||||
|
const s = parseAuthStatus({
|
||||||
|
status: 0,
|
||||||
|
stdout: 'Authenticated as user; token valid',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
expect(s.state).toBe('valid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports expired when output mentions expiry', () => {
|
||||||
|
const s = parseAuthStatus({ status: 0, stdout: 'Token expired 2 days ago', stderr: '' });
|
||||||
|
expect(s.state).toBe('expired');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unauthenticated when output says not logged in', () => {
|
||||||
|
const s = parseAuthStatus({
|
||||||
|
status: 1,
|
||||||
|
stdout: '',
|
||||||
|
stderr: 'not authenticated: run codex auth device',
|
||||||
|
});
|
||||||
|
expect(s.state).toBe('unauthenticated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unknown on an unrecognized non-zero exit', () => {
|
||||||
|
const s = parseAuthStatus({ status: 2, stdout: 'weird', stderr: '' });
|
||||||
|
expect(s.state).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT trust a signal-terminated check (status null) even with an auth-looking line', () => {
|
||||||
|
// status: null means the process was killed by a signal — an INCOMPLETE
|
||||||
|
// check. An auth-looking line that happened to be flushed must not be read
|
||||||
|
// as valid, or preflight passes on a check that never finished.
|
||||||
|
const s = parseAuthStatus({ status: null, stdout: 'Authenticated', stderr: '' });
|
||||||
|
expect(s.state).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts a best-effort expiry in days when present', () => {
|
||||||
|
const s = parseAuthStatus({
|
||||||
|
status: 0,
|
||||||
|
stdout: 'Authenticated; expires in 9 days',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
expect(s.state).toBe('valid');
|
||||||
|
expect(s.expiresInDays).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a clean exit 0 with no explicit markers as valid', () => {
|
||||||
|
const s = parseAuthStatus({ status: 0, stdout: 'Session active for account foo', stderr: '' });
|
||||||
|
expect(s.state).toBe('valid');
|
||||||
|
expect(s.expiresInDays).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NEVER retains token-shaped material from output', () => {
|
||||||
|
const leaky = 'Authenticated. access_token=sk-abc123SECRETdeadbeef refresh_token=rt-9999';
|
||||||
|
const s: AuthStatus = parseAuthStatus({ status: 0, stdout: leaky, stderr: '' });
|
||||||
|
const serialized = JSON.stringify(s);
|
||||||
|
expect(serialized).not.toContain('sk-abc123SECRETdeadbeef');
|
||||||
|
expect(serialized).not.toContain('rt-9999');
|
||||||
|
expect(serialized).not.toContain('access_token');
|
||||||
|
expect(serialized).not.toContain('refresh_token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkAuthStatus', () => {
|
||||||
|
it('runs the status subcommand and parses the result', () => {
|
||||||
|
const run = vi.fn(
|
||||||
|
(_cmd: string, _args: string[]): ProxyRunResult => ({
|
||||||
|
status: 0,
|
||||||
|
stdout: 'Authenticated; expires in 7 days',
|
||||||
|
stderr: '',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const s = checkAuthStatus(run);
|
||||||
|
expect(run).toHaveBeenCalledWith(CLAUDEX_PROXY_BINARY, ['codex', 'auth', 'status']);
|
||||||
|
expect(s.state).toBe('valid');
|
||||||
|
expect(s.expiresInDays).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces unknown when the default runner cannot find the binary', () => {
|
||||||
|
// Exercises the default spawnSync path against an absent binary: no throw,
|
||||||
|
// status is non-zero/null → unknown. Deterministic on a box without the proxy.
|
||||||
|
const s = checkAuthStatus();
|
||||||
|
expect(['unknown', 'unauthenticated', 'valid', 'expired']).toContain(s.state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runDeviceReauth', () => {
|
||||||
|
it('spawns the device flow with inherited stdio (never captures the code/token)', () => {
|
||||||
|
const calls: Array<{ cmd: string; args: string[]; opts: { stdio: string } }> = [];
|
||||||
|
const status = runDeviceReauth((cmd, args, opts) => {
|
||||||
|
calls.push({ cmd, args, opts });
|
||||||
|
return { status: 0 };
|
||||||
|
});
|
||||||
|
expect(status).toBe(0);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.cmd).toBe(CLAUDEX_PROXY_BINARY);
|
||||||
|
expect(calls[0]!.args).toEqual(['codex', 'auth', 'device']);
|
||||||
|
// stdio 'inherit' is the security-critical bit: the device code streams to
|
||||||
|
// the user's TTY; the launcher never pipes/captures it.
|
||||||
|
expect(calls[0]!.opts.stdio).toBe('inherit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 1 when the child yields no status (absent binary)', () => {
|
||||||
|
const status = runDeviceReauth(() => ({ status: null }));
|
||||||
|
expect(status).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkProxyBinary', () => {
|
||||||
|
it('resolves via the default `which` path (proxy absent → null)', () => {
|
||||||
|
// Covers the default resolver; on CI/dev the proxy is not installed.
|
||||||
|
const r = checkProxyBinary();
|
||||||
|
expect(typeof r.present).toBe('boolean');
|
||||||
|
if (!r.present) expect(r.path).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports present with the resolved path', () => {
|
||||||
|
const r = checkProxyBinary(() => '/home/u/.local/bin/claude-code-proxy');
|
||||||
|
expect(r.present).toBe(true);
|
||||||
|
expect(r.path).toBe('/home/u/.local/bin/claude-code-proxy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports absent when the resolver finds nothing', () => {
|
||||||
|
const r = checkProxyBinary(() => null);
|
||||||
|
expect(r.present).toBe(false);
|
||||||
|
expect(r.path).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('probeLiveness (proxy-specific /healthz, not arbitrary HTTP)', () => {
|
||||||
|
it('defaults to probing the /healthz endpoint, never the root path', async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
await probeLiveness(undefined, async (u) => {
|
||||||
|
seen.push(u);
|
||||||
|
return { status: 200 };
|
||||||
|
});
|
||||||
|
expect(seen[0]).toBe(CLAUDEX_HEALTH_URL);
|
||||||
|
expect(seen[0]).toContain('/healthz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a 200 on /healthz as alive', async () => {
|
||||||
|
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 200 }));
|
||||||
|
expect(live).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a 204 on /healthz as alive', async () => {
|
||||||
|
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 204 }));
|
||||||
|
expect(live).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a 404 as DEAD — does not trust an arbitrary responder on the port (CWE-345)', async () => {
|
||||||
|
// The whole point: a random local process squatting :18765 will not honor the
|
||||||
|
// proxy's /healthz contract, so a non-2xx there must not be mistaken for the proxy.
|
||||||
|
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 404 }));
|
||||||
|
expect(live).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a 500 as DEAD (unhealthy / not the proxy health contract)', async () => {
|
||||||
|
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 500 }));
|
||||||
|
expect(live).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a missing status as dead', async () => {
|
||||||
|
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({}));
|
||||||
|
expect(live).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a connection failure (reject) as dead', async () => {
|
||||||
|
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => {
|
||||||
|
throw new Error('ECONNREFUSED');
|
||||||
|
});
|
||||||
|
expect(live).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a timeout as dead', async () => {
|
||||||
|
const never = () => new Promise<{ status?: number }>(() => {});
|
||||||
|
const live = await probeLiveness(CLAUDEX_HEALTH_URL, never, 20);
|
||||||
|
expect(live).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSystemdUnitContent', () => {
|
||||||
|
it('emits a user unit that execs the given binary with serve args', () => {
|
||||||
|
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
|
||||||
|
expect(unit).toContain('[Unit]');
|
||||||
|
expect(unit).toContain('[Service]');
|
||||||
|
expect(unit).toContain('[Install]');
|
||||||
|
expect(unit).toContain('/home/u/.local/bin/claude-code-proxy serve --no-monitor');
|
||||||
|
expect(unit).toContain('WantedBy=default.target');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never embeds credential material', () => {
|
||||||
|
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
|
||||||
|
expect(unit).not.toMatch(/token/i);
|
||||||
|
expect(unit).not.toMatch(/auth\.json/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a path containing a newline (CWE-74 systemd directive injection)', () => {
|
||||||
|
// A raw newline in ExecStart would let an attacker append arbitrary unit
|
||||||
|
// directives — e.g. `ExecStartPost=curl evil`. Must be rejected outright.
|
||||||
|
expect(() =>
|
||||||
|
buildSystemdUnitContent('/bin/claude-code-proxy\nExecStartPost=/bin/rm -rf /'),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a path containing a carriage return', () => {
|
||||||
|
expect(() => buildSystemdUnitContent('/bin/claude-code-proxy\rmalicious')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a path with other control characters', () => {
|
||||||
|
expect(() => buildSystemdUnitContent('/bin/claude-code-proxy\x00nul')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-absolute path', () => {
|
||||||
|
expect(() => buildSystemdUnitContent('claude-code-proxy')).toThrow();
|
||||||
|
expect(() => buildSystemdUnitContent('')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('systemd-quotes a path that contains spaces', () => {
|
||||||
|
const unit = buildSystemdUnitContent('/home/u/my apps/claude-code-proxy');
|
||||||
|
expect(unit).toContain('ExecStart="/home/u/my apps/claude-code-proxy" serve --no-monitor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes embedded quotes and backslashes when quoting', () => {
|
||||||
|
const unit = buildSystemdUnitContent('/home/u/we"ird\\dir/claude-code-proxy');
|
||||||
|
// No unescaped closing quote can terminate the token early.
|
||||||
|
expect(unit).toContain('ExecStart="/home/u/we\\"ird\\\\dir/claude-code-proxy" serve');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a clean absolute path unquoted (no needless churn)', () => {
|
||||||
|
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
|
||||||
|
expect(unit).toContain('ExecStart=/home/u/.local/bin/claude-code-proxy serve --no-monitor');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('systemdUnitPath', () => {
|
||||||
|
it('targets the systemd --user unit dir', () => {
|
||||||
|
expect(systemdUnitPath('/home/u')).toBe(
|
||||||
|
'/home/u/.config/systemd/user/claude-code-proxy.service',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('installSystemdUnit', () => {
|
||||||
|
it('writes the unit and returns true when daemon-reload succeeds', () => {
|
||||||
|
let written: { path: string; content: string } | null = null;
|
||||||
|
const ok = installSystemdUnit('/bin/claude-code-proxy', {
|
||||||
|
home: '/home/u',
|
||||||
|
writeUnit: (path, content) => {
|
||||||
|
written = { path, content };
|
||||||
|
},
|
||||||
|
run: () => ({ status: 0, stdout: '', stderr: '' }),
|
||||||
|
});
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(written).not.toBeNull();
|
||||||
|
expect(written!.path).toBe('/home/u/.config/systemd/user/claude-code-proxy.service');
|
||||||
|
expect(written!.content).toContain('ExecStart=/bin/claude-code-proxy serve --no-monitor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when daemon-reload fails (systemd --user unavailable)', () => {
|
||||||
|
const ok = installSystemdUnit('/bin/claude-code-proxy', {
|
||||||
|
home: '/home/u',
|
||||||
|
writeUnit: () => {},
|
||||||
|
run: () => ({ status: 1, stdout: '', stderr: 'Failed to connect to bus' }),
|
||||||
|
});
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when writing the unit throws', () => {
|
||||||
|
const ok = installSystemdUnit('/bin/claude-code-proxy', {
|
||||||
|
home: '/home/u',
|
||||||
|
writeUnit: () => {
|
||||||
|
throw new Error('EACCES');
|
||||||
|
},
|
||||||
|
run: () => ({ status: 0, stdout: '', stderr: '' }),
|
||||||
|
});
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to write a unit for an injection-bearing path (never writes a poisoned unit)', () => {
|
||||||
|
const writeUnit = vi.fn();
|
||||||
|
const ok = installSystemdUnit('/bin/claude-code-proxy\nExecStartPost=/bin/rm -rf /', {
|
||||||
|
home: '/home/u',
|
||||||
|
writeUnit,
|
||||||
|
run: () => ({ status: 0, stdout: '', stderr: '' }),
|
||||||
|
});
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
// The poisoned unit content is never even produced, so nothing is written.
|
||||||
|
expect(writeUnit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes to a real temp dir via the default writer', () => {
|
||||||
|
const home = mkdtempSync(join(tmpdir(), 'claudex-unit-'));
|
||||||
|
try {
|
||||||
|
const ok = installSystemdUnit('/bin/claude-code-proxy', {
|
||||||
|
home,
|
||||||
|
run: () => ({ status: 0, stdout: '', stderr: '' }),
|
||||||
|
});
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
const written = readFileSync(systemdUnitPath(home), 'utf8');
|
||||||
|
expect(written).toContain('[Service]');
|
||||||
|
} finally {
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runProxyPreflight', () => {
|
||||||
|
const trustedListener = () => 'ok' as const;
|
||||||
|
|
||||||
|
it('is ok when binary present, auth valid, proxy live, and listener identity-verified', async () => {
|
||||||
|
const report = await runProxyPreflight({
|
||||||
|
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||||
|
checkAuth: () => ({ state: 'valid' }),
|
||||||
|
probe: async () => true,
|
||||||
|
verifyListener: trustedListener,
|
||||||
|
});
|
||||||
|
expect(report.ok).toBe(true);
|
||||||
|
expect(report.listenerVerdict).toBe('ok');
|
||||||
|
expect(report.problems).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a missing binary', async () => {
|
||||||
|
const report = await runProxyPreflight({
|
||||||
|
checkBinary: () => ({ present: false, path: null }),
|
||||||
|
checkAuth: () => ({ state: 'valid' }),
|
||||||
|
probe: async () => true,
|
||||||
|
verifyListener: trustedListener,
|
||||||
|
});
|
||||||
|
expect(report.ok).toBe(false);
|
||||||
|
expect(report.problems.some((p) => /binary/i.test(p))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags expired auth (re-auth needed)', async () => {
|
||||||
|
const report = await runProxyPreflight({
|
||||||
|
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||||
|
checkAuth: () => ({ state: 'expired' }),
|
||||||
|
probe: async () => true,
|
||||||
|
verifyListener: trustedListener,
|
||||||
|
});
|
||||||
|
expect(report.ok).toBe(false);
|
||||||
|
expect(report.needsReauth).toBe(true);
|
||||||
|
expect(report.problems.some((p) => /auth/i.test(p))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a dead proxy', async () => {
|
||||||
|
const report = await runProxyPreflight({
|
||||||
|
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||||
|
checkAuth: () => ({ state: 'valid' }),
|
||||||
|
probe: async () => false,
|
||||||
|
verifyListener: trustedListener,
|
||||||
|
});
|
||||||
|
expect(report.ok).toBe(false);
|
||||||
|
expect(report.live).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags an unknown auth state without marking it for re-auth', async () => {
|
||||||
|
const report = await runProxyPreflight({
|
||||||
|
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||||
|
checkAuth: () => ({ state: 'unknown' }),
|
||||||
|
probe: async () => true,
|
||||||
|
verifyListener: trustedListener,
|
||||||
|
});
|
||||||
|
expect(report.ok).toBe(false);
|
||||||
|
expect(report.needsReauth).toBe(false);
|
||||||
|
expect(report.problems.some((p) => /could not determine/i.test(p))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT pass preflight when the live responder fails identity verification (F2b)', async () => {
|
||||||
|
// A squatter answering /healthz-2xx must not yield ok:true just because the
|
||||||
|
// binary is installed and OAuth is valid — the identity gate holds here too.
|
||||||
|
for (const verdict of ['foreign-user', 'wrong-exe', 'unknown'] as const) {
|
||||||
|
const report = await runProxyPreflight({
|
||||||
|
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||||
|
checkAuth: () => ({ state: 'valid' }),
|
||||||
|
probe: async () => true,
|
||||||
|
verifyListener: () => verdict,
|
||||||
|
});
|
||||||
|
expect(report.ok).toBe(false);
|
||||||
|
expect(report.live).toBe(true);
|
||||||
|
expect(report.listenerVerdict).toBe(verdict);
|
||||||
|
expect(report.problems.some((p) => /identity could not be verified/i.test(p))).toBe(true);
|
||||||
|
// The identity problem is non-sensitive: port + verdict only, no token.
|
||||||
|
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not verify listener identity when the proxy is dead (no listener to trust)', async () => {
|
||||||
|
const verifyListener = vi.fn(() => 'ok' as const);
|
||||||
|
const report = await runProxyPreflight({
|
||||||
|
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||||
|
checkAuth: () => ({ state: 'valid' }),
|
||||||
|
probe: async () => false,
|
||||||
|
verifyListener,
|
||||||
|
});
|
||||||
|
expect(verifyListener).not.toHaveBeenCalled();
|
||||||
|
expect(report.listenerVerdict).toBe('unknown');
|
||||||
|
expect(report.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak token material for any auth state', async () => {
|
||||||
|
const report = await runProxyPreflight({
|
||||||
|
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
|
||||||
|
checkAuth: () => ({ state: 'expired' }),
|
||||||
|
probe: async () => false,
|
||||||
|
verifyListener: trustedListener,
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs end-to-end with all real defaults (no proxy installed → not ok)', async () => {
|
||||||
|
// Exercises the default checkBinary/checkAuth/probe closures against a box
|
||||||
|
// with no proxy: absent binary, spawnSync status, real loopback probe that
|
||||||
|
// fast-fails with ECONNREFUSED. Asserts shape only (never token material).
|
||||||
|
const report = await runProxyPreflight();
|
||||||
|
expect(typeof report.ok).toBe('boolean');
|
||||||
|
expect(Array.isArray(report.problems)).toBe(true);
|
||||||
|
expect(['valid', 'expired', 'unauthenticated', 'unknown']).toContain(report.auth.state);
|
||||||
|
expect(JSON.stringify(report)).not.toMatch(/access_token|refresh_token|sk-/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal fake ChildProcess for the nohup-fallback tests: records once()
|
||||||
|
* handlers so a test can drive the async 'spawn'/'error' events, and tracks
|
||||||
|
* whether the 'error' listener was already attached at the moment unref() ran
|
||||||
|
* (the security-critical ordering from finding #1).
|
||||||
|
*/
|
||||||
|
function fakeChild() {
|
||||||
|
const handlers: Record<string, (arg?: unknown) => void> = {};
|
||||||
|
const state = { unreffed: false, errorHandlerAtUnref: false };
|
||||||
|
const child = {
|
||||||
|
once(event: string, listener: (arg?: unknown) => void) {
|
||||||
|
handlers[event] = listener;
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
unref() {
|
||||||
|
state.unreffed = true;
|
||||||
|
state.errorHandlerAtUnref = typeof handlers.error === 'function';
|
||||||
|
},
|
||||||
|
emit(event: string, arg?: unknown) {
|
||||||
|
handlers[event]?.(arg);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
child: child as unknown as SpawnedChild & { emit(e: string, a?: unknown): void },
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('startNohupProxy (finding #1 — async spawn error must not crash)', () => {
|
||||||
|
it('resolves status 0 only after a confirmed spawn, and unrefs the child', async () => {
|
||||||
|
const { child, state } = fakeChild();
|
||||||
|
const spawnImpl = vi.fn((_cmd: string, _args: string[]) => {
|
||||||
|
queueMicrotask(() => child.emit('spawn'));
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(state.unreffed).toBe(true);
|
||||||
|
// The error listener MUST be registered before unref(), so an ENOENT that
|
||||||
|
// arrives asynchronously can never become an unhandled 'error' crash.
|
||||||
|
expect(state.errorHandlerAtUnref).toBe(true);
|
||||||
|
expect(spawnImpl).toHaveBeenCalledWith('/bin/claude-code-proxy', ['serve', '--no-monitor'], {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures an async spawn error (ENOENT) as a failed start instead of crashing', async () => {
|
||||||
|
const { child, state } = fakeChild();
|
||||||
|
const spawnImpl = () => {
|
||||||
|
queueMicrotask(() => child.emit('error', new Error('spawn claude-code-proxy ENOENT')));
|
||||||
|
return child;
|
||||||
|
};
|
||||||
|
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
|
||||||
|
expect(r.status).toBe(1);
|
||||||
|
expect(r.stderr).toContain('ENOENT');
|
||||||
|
expect(state.unreffed).toBe(false); // never unref a child that failed to start
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures a synchronous spawn throw as a failed start', async () => {
|
||||||
|
const spawnImpl = () => {
|
||||||
|
throw new Error('EACCES');
|
||||||
|
};
|
||||||
|
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
|
||||||
|
expect(r.status).toBe(1);
|
||||||
|
expect(r.stderr).toContain('EACCES');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a late error after a successful spawn (settles once)', async () => {
|
||||||
|
const { child } = fakeChild();
|
||||||
|
const spawnImpl = () => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
child.emit('spawn');
|
||||||
|
child.emit('error', new Error('late boom'));
|
||||||
|
});
|
||||||
|
return child;
|
||||||
|
};
|
||||||
|
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
|
||||||
|
expect(r.status).toBe(0); // first settle wins; the late error cannot flip it
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE-345)', () => {
|
||||||
|
const me: ListenerIdentity = {
|
||||||
|
pid: 4242,
|
||||||
|
uid: 1000,
|
||||||
|
exePath: '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
};
|
||||||
|
// Identity canonicalize for tests: fake paths don't exist on disk, so we map
|
||||||
|
// each path to itself and exercise symlink resolution explicitly where needed.
|
||||||
|
const idc = (p: string) => p;
|
||||||
|
|
||||||
|
it('accepts a listener owned by the current uid whose exe is the expected proxy path', () => {
|
||||||
|
const verdict = verifyListenerIdentity({
|
||||||
|
identify: () => me,
|
||||||
|
currentUid: () => 1000,
|
||||||
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: idc,
|
||||||
|
});
|
||||||
|
expect(verdict).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves symlinks on BOTH sides before comparing (canonical match → ok)', () => {
|
||||||
|
// The listener exe and our resolved binary reach the same real file via
|
||||||
|
// different symlink paths — a canonical comparison must accept it.
|
||||||
|
const canon: Record<string, string> = {
|
||||||
|
'/var/run/proxy.link': '/opt/proxy/bin/claude-code-proxy',
|
||||||
|
'/home/me/.local/bin/claude-code-proxy': '/opt/proxy/bin/claude-code-proxy',
|
||||||
|
};
|
||||||
|
const verdict = verifyListenerIdentity({
|
||||||
|
identify: () => ({ ...me, exePath: '/var/run/proxy.link' }),
|
||||||
|
currentUid: () => 1000,
|
||||||
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: (p) => canon[p] ?? null,
|
||||||
|
});
|
||||||
|
expect(verdict).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT trust a same-uid process at the WRONG path with the right basename (F2a)', () => {
|
||||||
|
// The squatter vector on a shared-uid host: right basename, wrong path. The
|
||||||
|
// basename must NEVER be a trust signal when an expected exact path resolved.
|
||||||
|
const verdict = verifyListenerIdentity({
|
||||||
|
identify: () => ({ ...me, exePath: '/tmp/claude-code-proxy' }),
|
||||||
|
currentUid: () => 1000,
|
||||||
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: idc,
|
||||||
|
});
|
||||||
|
expect(verdict).toBe('wrong-exe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed (unknown) when our own proxy binary path cannot be resolved (F2a)', () => {
|
||||||
|
// No expected path → we cannot assert identity → refuse to trust (no basename
|
||||||
|
// acceptance). Previously this returned `ok` by basename; that was a bypass.
|
||||||
|
const verdict = verifyListenerIdentity({
|
||||||
|
identify: () => me,
|
||||||
|
currentUid: () => 1000,
|
||||||
|
expectedExe: () => null,
|
||||||
|
canonicalize: idc,
|
||||||
|
});
|
||||||
|
expect(verdict).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed (unknown) when a path cannot be canonicalized (F2a)', () => {
|
||||||
|
const verdict = verifyListenerIdentity({
|
||||||
|
identify: () => me,
|
||||||
|
currentUid: () => 1000,
|
||||||
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: () => null, // e.g. binary deleted out from under the listener
|
||||||
|
});
|
||||||
|
expect(verdict).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a listener owned by a DIFFERENT uid (foreign-user) — fail closed', () => {
|
||||||
|
const verdict = verifyListenerIdentity({
|
||||||
|
identify: () => ({ ...me, uid: 0 }),
|
||||||
|
currentUid: () => 1000,
|
||||||
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: idc,
|
||||||
|
});
|
||||||
|
expect(verdict).toBe('foreign-user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a same-user listener whose exe is NOT the proxy (wrong-exe)', () => {
|
||||||
|
const verdict = verifyListenerIdentity({
|
||||||
|
identify: () => ({ ...me, exePath: '/usr/bin/nc' }),
|
||||||
|
currentUid: () => 1000,
|
||||||
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: idc,
|
||||||
|
});
|
||||||
|
expect(verdict).toBe('wrong-exe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown (fail closed) when the listener cannot be identified', () => {
|
||||||
|
const verdict = verifyListenerIdentity({
|
||||||
|
identify: () => null,
|
||||||
|
currentUid: () => 1000,
|
||||||
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: idc,
|
||||||
|
});
|
||||||
|
expect(verdict).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown when the current uid is unavailable (non-posix)', () => {
|
||||||
|
const verdict = verifyListenerIdentity({
|
||||||
|
identify: () => me,
|
||||||
|
currentUid: () => -1,
|
||||||
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: idc,
|
||||||
|
});
|
||||||
|
expect(verdict).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown when the listener exe path cannot be read', () => {
|
||||||
|
const verdict = verifyListenerIdentity({
|
||||||
|
identify: () => ({ ...me, exePath: null }),
|
||||||
|
currentUid: () => 1000,
|
||||||
|
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
|
||||||
|
canonicalize: idc,
|
||||||
|
});
|
||||||
|
expect(verdict).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs with real defaults without throwing (identity may be unresolved → verdict)', () => {
|
||||||
|
const verdict = verifyListenerIdentity();
|
||||||
|
expect(['ok', 'foreign-user', 'wrong-exe', 'unknown']).toContain(verdict);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ensureProxyRunning', () => {
|
||||||
|
const ok: ProxyRunResult = { status: 0, stdout: '', stderr: '' };
|
||||||
|
const nohupOk = async (): Promise<ProxyRunResult> => ok;
|
||||||
|
const trusted = () => 'ok' as const;
|
||||||
|
|
||||||
|
it('is a no-op when the proxy is already live AND identity-verified', async () => {
|
||||||
|
const startSystemd = vi.fn(() => ok);
|
||||||
|
const startNohup = vi.fn(nohupOk);
|
||||||
|
const r = await ensureProxyRunning({
|
||||||
|
probe: async () => true,
|
||||||
|
verifyListener: trusted,
|
||||||
|
startSystemd,
|
||||||
|
startNohup,
|
||||||
|
waitMs: async () => {},
|
||||||
|
});
|
||||||
|
expect(r.method).toBe('already');
|
||||||
|
expect(r.live).toBe(true);
|
||||||
|
expect(startSystemd).not.toHaveBeenCalled();
|
||||||
|
expect(startNohup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed as untrusted when a responder holds :18765 but identity is NOT ours', async () => {
|
||||||
|
// A foreign process answers /healthz but the listener is not our proxy
|
||||||
|
// (foreign uid / wrong exe / unidentifiable). We must NOT trust it and must
|
||||||
|
// NOT start a second proxy (the port is already taken) — fail closed.
|
||||||
|
const startSystemd = vi.fn(() => ok);
|
||||||
|
const startNohup = vi.fn(nohupOk);
|
||||||
|
const r = await ensureProxyRunning({
|
||||||
|
probe: async () => true,
|
||||||
|
verifyListener: () => 'foreign-user',
|
||||||
|
startSystemd,
|
||||||
|
startNohup,
|
||||||
|
waitMs: async () => {},
|
||||||
|
});
|
||||||
|
expect(r.method).toBe('untrusted');
|
||||||
|
expect(r.live).toBe(false);
|
||||||
|
expect(startSystemd).not.toHaveBeenCalled();
|
||||||
|
expect(startNohup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts via systemd when available and then becomes trusted-live', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const r = await ensureProxyRunning({
|
||||||
|
probe: async () => calls++ > 0, // dead first, live after start
|
||||||
|
verifyListener: trusted,
|
||||||
|
startSystemd: () => ok,
|
||||||
|
startNohup: async () => {
|
||||||
|
throw new Error('should not fall back');
|
||||||
|
},
|
||||||
|
waitMs: async () => {},
|
||||||
|
});
|
||||||
|
expect(r.method).toBe('systemd');
|
||||||
|
expect(r.live).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits past a slow systemd bind before giving up (finding #2 — no duplicate proxy)', async () => {
|
||||||
|
// systemd `start` returns 0 (job accepted) but the socket only binds on the
|
||||||
|
// 4th probe — still well within the startup deadline. nohup must NOT run,
|
||||||
|
// or two proxies would contend for :18765.
|
||||||
|
let probes = 0;
|
||||||
|
const startNohup = vi.fn(nohupOk);
|
||||||
|
const r = await ensureProxyRunning({
|
||||||
|
probe: async () => probes++ >= 3,
|
||||||
|
verifyListener: trusted,
|
||||||
|
startSystemd: () => ok,
|
||||||
|
startNohup,
|
||||||
|
waitMs: async () => {},
|
||||||
|
settleMs: 10,
|
||||||
|
startupDeadlineMs: 200,
|
||||||
|
});
|
||||||
|
expect(r.method).toBe('systemd');
|
||||||
|
expect(r.live).toBe(true);
|
||||||
|
expect(startNohup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT fall back to nohup after systemd accepts but never binds (finding #1 — dup-proxy race)', async () => {
|
||||||
|
// systemctl start exit 0 means the job was ACCEPTED, not bound. If it binds
|
||||||
|
// just after our deadline (or systemd restarts it), a nohup fallback would
|
||||||
|
// create a SECOND proxy contending for :18765. Once systemd has accepted the
|
||||||
|
// job we never spawn nohup — we report a managed-service startup failure.
|
||||||
|
const startNohup = vi.fn(nohupOk);
|
||||||
|
const r = await ensureProxyRunning({
|
||||||
|
probe: async () => false, // never becomes live within the deadline
|
||||||
|
verifyListener: trusted,
|
||||||
|
startSystemd: () => ok,
|
||||||
|
startNohup,
|
||||||
|
waitMs: async () => {},
|
||||||
|
settleMs: 10,
|
||||||
|
startupDeadlineMs: 30,
|
||||||
|
});
|
||||||
|
expect(startNohup).not.toHaveBeenCalled();
|
||||||
|
expect(r.method).toBe('failed');
|
||||||
|
expect(r.live).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT trust a systemd-started responder whose identity cannot be verified', async () => {
|
||||||
|
// Dead at first (so we reach the systemd start), then the socket binds — but
|
||||||
|
// identity never verifies (e.g. a squatter beat systemd to the port). A live
|
||||||
|
// responder that fails identity must never be reported as a successful start.
|
||||||
|
let calls = 0;
|
||||||
|
const startNohup = vi.fn(nohupOk);
|
||||||
|
const r = await ensureProxyRunning({
|
||||||
|
probe: async () => calls++ > 0,
|
||||||
|
verifyListener: () => 'wrong-exe',
|
||||||
|
startSystemd: () => ok,
|
||||||
|
startNohup,
|
||||||
|
waitMs: async () => {},
|
||||||
|
settleMs: 10,
|
||||||
|
startupDeadlineMs: 30,
|
||||||
|
});
|
||||||
|
expect(startNohup).not.toHaveBeenCalled();
|
||||||
|
expect(r.method).toBe('failed');
|
||||||
|
expect(r.live).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to nohup only when systemd start FAILS outright (not accepted)', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const r = await ensureProxyRunning({
|
||||||
|
// A failed systemd start skips its post-start poll, so probes are:
|
||||||
|
// #0 initial (dead), #1 after nohup (live). nohup fallback is reachable
|
||||||
|
// ONLY because systemd never accepted the job (status 1).
|
||||||
|
probe: async () => calls++ > 0,
|
||||||
|
verifyListener: trusted,
|
||||||
|
startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }),
|
||||||
|
startNohup: nohupOk,
|
||||||
|
waitMs: async () => {},
|
||||||
|
settleMs: 10,
|
||||||
|
startupDeadlineMs: 30,
|
||||||
|
});
|
||||||
|
expect(r.method).toBe('nohup');
|
||||||
|
expect(r.live).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT trust a nohup-started responder whose identity cannot be verified', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const r = await ensureProxyRunning({
|
||||||
|
probe: async () => calls++ > 0,
|
||||||
|
verifyListener: () => 'unknown',
|
||||||
|
startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }),
|
||||||
|
startNohup: nohupOk,
|
||||||
|
waitMs: async () => {},
|
||||||
|
settleMs: 10,
|
||||||
|
startupDeadlineMs: 30,
|
||||||
|
});
|
||||||
|
expect(r.method).toBe('failed');
|
||||||
|
expect(r.live).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports failed when nothing brings the proxy up', async () => {
|
||||||
|
const r = await ensureProxyRunning({
|
||||||
|
probe: async () => false,
|
||||||
|
verifyListener: trusted,
|
||||||
|
startSystemd: () => ({ status: 1, stdout: '', stderr: '' }),
|
||||||
|
startNohup: async () => ({ status: 1, stdout: '', stderr: '' }),
|
||||||
|
waitMs: async () => {},
|
||||||
|
settleMs: 10,
|
||||||
|
startupDeadlineMs: 30,
|
||||||
|
});
|
||||||
|
expect(r.method).toBe('failed');
|
||||||
|
expect(r.live).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
700
packages/mosaic/src/commands/claudex-proxy.ts
Normal file
700
packages/mosaic/src/commands/claudex-proxy.ts
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
/**
|
||||||
|
* Claudex proxy preflight + lifecycle (P1 of `mosaic yolo claudex`).
|
||||||
|
*
|
||||||
|
* `raine/claude-code-proxy` runs a local server on 127.0.0.1:18765 that speaks
|
||||||
|
* the Anthropic Messages API and translates to the ChatGPT/Codex backend using
|
||||||
|
* ChatGPT-subscription OAuth. This module owns the *preflight* and *lifecycle*
|
||||||
|
* concerns for the launcher: is the binary present, is OAuth valid, is the proxy
|
||||||
|
* listening, and — if not — bring it up (systemd user unit preferred, nohup
|
||||||
|
* fallback).
|
||||||
|
*
|
||||||
|
* Design: every function is pure or dependency-injected so the launch path is
|
||||||
|
* fully unit-testable without touching a real process, socket, or the OAuth
|
||||||
|
* token. Nothing here reads `~/.config/claude-code-proxy/codex/auth.json`; the
|
||||||
|
* proxy holds the real credential and Claude Code only ever sees
|
||||||
|
* `ANTHROPIC_AUTH_TOKEN=unused`. Parsed auth status is deliberately coarse
|
||||||
|
* (state + optional expiry) so no token material can be retained or surfaced.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
||||||
|
import { mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
// ─── Endpoint / command constants (spec table) ──────────────────────────────
|
||||||
|
|
||||||
|
export const CLAUDEX_PROXY_HOST = '127.0.0.1';
|
||||||
|
export const CLAUDEX_PROXY_PORT = 18765;
|
||||||
|
export const CLAUDEX_PROXY_URL = `http://${CLAUDEX_PROXY_HOST}:${CLAUDEX_PROXY_PORT}`;
|
||||||
|
export const CLAUDEX_PROXY_BINARY = 'claude-code-proxy';
|
||||||
|
export const CLAUDEX_SYSTEMD_UNIT = 'claude-code-proxy.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The proxy's dedicated liveness endpoint. We probe this — NOT the root path —
|
||||||
|
* for two reasons: (1) the root returns non-2xx (spec gotcha #1), which is why
|
||||||
|
* the original `curl -f` check spawned duplicate proxies; `/healthz` returns 2xx
|
||||||
|
* when the proxy is healthy. (2) It is a *proxy-specific* contract, so a 2xx here
|
||||||
|
* is a much stronger signal that the responder on :18765 is actually our proxy
|
||||||
|
* and not some other local process squatting the port (CWE-345).
|
||||||
|
*/
|
||||||
|
export const CLAUDEX_HEALTH_PATH = '/healthz';
|
||||||
|
export const CLAUDEX_HEALTH_URL = `${CLAUDEX_PROXY_URL}${CLAUDEX_HEALTH_PATH}`;
|
||||||
|
|
||||||
|
/** argv for `claude-code-proxy codex auth status`. */
|
||||||
|
export function buildAuthStatusArgs(): string[] {
|
||||||
|
return ['codex', 'auth', 'status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** argv for `claude-code-proxy codex auth device` (device-code re-auth flow). */
|
||||||
|
export function buildDeviceAuthArgs(): string[] {
|
||||||
|
return ['codex', 'auth', 'device'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** argv for `claude-code-proxy serve --no-monitor`. */
|
||||||
|
export function buildServeArgs(): string[] {
|
||||||
|
return ['serve', '--no-monitor'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type AuthState = 'valid' | 'expired' | 'unauthenticated' | 'unknown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coarse OAuth status. Intentionally carries NO token material — only a state
|
||||||
|
* and an optional best-effort expiry-in-days for user-facing messaging.
|
||||||
|
*/
|
||||||
|
export interface AuthStatus {
|
||||||
|
state: AuthState;
|
||||||
|
expiresInDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyRunResult {
|
||||||
|
status: number | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a command synchronously and returns its captured result. */
|
||||||
|
export type CommandRunner = (cmd: string, args: string[]) => ProxyRunResult;
|
||||||
|
|
||||||
|
/** Minimal fetch shape used for the liveness probe (any HTTP response = alive). */
|
||||||
|
export type FetchLike = (
|
||||||
|
url: string,
|
||||||
|
init?: { signal?: AbortSignal },
|
||||||
|
) => Promise<{ status?: number }>;
|
||||||
|
|
||||||
|
// ─── Binary presence ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function defaultWhich(cmd: string): string | null {
|
||||||
|
try {
|
||||||
|
return execFileSync('which', [cmd], { encoding: 'utf8' }).trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkProxyBinary(resolve: (cmd: string) => string | null = defaultWhich): {
|
||||||
|
present: boolean;
|
||||||
|
path: string | null;
|
||||||
|
} {
|
||||||
|
const path = resolve(CLAUDEX_PROXY_BINARY);
|
||||||
|
return { present: path !== null && path !== '', path: path || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auth status ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `claude-code-proxy codex auth status` output into a coarse state.
|
||||||
|
*
|
||||||
|
* The proxy's exact wording is not contractually pinned, so this matches
|
||||||
|
* tolerantly on well-known markers and falls back on the exit code. It never
|
||||||
|
* copies the raw output onto the result — only a state and an optional expiry —
|
||||||
|
* so token-shaped strings in the output cannot leak downstream.
|
||||||
|
*/
|
||||||
|
export function parseAuthStatus(result: ProxyRunResult): AuthStatus {
|
||||||
|
const text = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
||||||
|
|
||||||
|
const expired = /\bexpired\b|token has expired|expires?d? \d+ days? ago/.test(text);
|
||||||
|
const unauth =
|
||||||
|
/not authenticated|not logged in|no (?:auth|credentials|token)|please (?:log ?in|authenticate)|run .*auth device/.test(
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
const authed = /\bauthenticated\b|logged in|token valid|valid until|expires? in/.test(text);
|
||||||
|
|
||||||
|
let state: AuthState;
|
||||||
|
if (expired) {
|
||||||
|
state = 'expired';
|
||||||
|
} else if (unauth) {
|
||||||
|
state = 'unauthenticated';
|
||||||
|
} else if (authed && result.status === 0) {
|
||||||
|
// A `null` status means the check was killed by a signal — an INCOMPLETE
|
||||||
|
// run. We require a clean exit 0 for `valid`; a partially-flushed auth line
|
||||||
|
// from a signal-terminated check must never be trusted (finding #3).
|
||||||
|
state = 'valid';
|
||||||
|
} else if (result.status === 0) {
|
||||||
|
state = 'valid';
|
||||||
|
} else {
|
||||||
|
state = 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const status: AuthStatus = { state };
|
||||||
|
const days = /expires? in (\d+) days?/.exec(text);
|
||||||
|
if (state === 'valid' && days) {
|
||||||
|
status.expiresInDays = Number(days[1]);
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRun(cmd: string, args: string[]): ProxyRunResult {
|
||||||
|
const r = spawnSync(cmd, args, { encoding: 'utf8' });
|
||||||
|
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkAuthStatus(run: CommandRunner = defaultRun): AuthStatus {
|
||||||
|
return parseAuthStatus(run(CLAUDEX_PROXY_BINARY, buildAuthStatusArgs()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spawn shape for the interactive device re-auth flow. */
|
||||||
|
export type InheritSpawn = (
|
||||||
|
cmd: string,
|
||||||
|
args: string[],
|
||||||
|
opts: { stdio: 'inherit' },
|
||||||
|
) => { status: number | null };
|
||||||
|
|
||||||
|
function defaultInheritSpawn(cmd: string, args: string[], opts: { stdio: 'inherit' }) {
|
||||||
|
return spawnSync(cmd, args, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the device-code re-auth flow (`claude-code-proxy codex auth device`).
|
||||||
|
*
|
||||||
|
* Deliberately `stdio: 'inherit'` so the device code the proxy prints goes
|
||||||
|
* straight to the user's terminal — the launcher NEVER captures, stores, or logs
|
||||||
|
* it, and never observes the resulting OAuth token (the proxy persists that to
|
||||||
|
* its own config). Returns the child's exit status; 1 on an absent binary.
|
||||||
|
*/
|
||||||
|
export function runDeviceReauth(spawnImpl: InheritSpawn = defaultInheritSpawn): number {
|
||||||
|
const r = spawnImpl(CLAUDEX_PROXY_BINARY, buildDeviceAuthArgs(), { stdio: 'inherit' });
|
||||||
|
return r.status ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Liveness (probe the proxy-specific /healthz; require 2xx) ────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe the proxy for liveness by hitting its dedicated `GET /healthz` endpoint
|
||||||
|
* and requiring a 2xx response.
|
||||||
|
*
|
||||||
|
* This is a LIVENESS check only — it answers "is a healthy proxy responding?",
|
||||||
|
* not "is that responder actually ours?". Requiring a 2xx on the proxy's own
|
||||||
|
* `/healthz` contract (rather than "any HTTP response = alive") resolves spec
|
||||||
|
* gotcha #1: the root path returns non-2xx, but `/healthz` returns 2xx when
|
||||||
|
* healthy, so a live proxy is never mistaken for dead and no duplicate proxy is
|
||||||
|
* spawned.
|
||||||
|
*
|
||||||
|
* Residual risk (CWE-345): the proxy binds loopback with NO client
|
||||||
|
* authentication, so on a shared host a local process could occupy :18765 and
|
||||||
|
* serve a 2xx here. A 2xx therefore does NOT by itself establish that the
|
||||||
|
* listener is our proxy. Identity is verified SEPARATELY and at every trust
|
||||||
|
* point by {@link verifyListenerIdentity} (OS-level uid + executable check),
|
||||||
|
* which fails closed when identity can't be established. See
|
||||||
|
* {@link ensureProxyRunning}. (Broader multi-user hardening — a persistent
|
||||||
|
* warning when a foreign listener is seen — is tracked for a later phase.)
|
||||||
|
*/
|
||||||
|
export async function probeLiveness(
|
||||||
|
url: string = CLAUDEX_HEALTH_URL,
|
||||||
|
fetchImpl: FetchLike = fetch as unknown as FetchLike,
|
||||||
|
timeoutMs = 1500,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
// Bound the probe with our own timeout race rather than trusting the fetch
|
||||||
|
// implementation to honor the abort signal — a hung socket (or a fetch that
|
||||||
|
// ignores the signal) must never wedge the launcher. We still abort() so a
|
||||||
|
// signal-aware fetch tears the request down promptly.
|
||||||
|
const timeout = new Promise<boolean>((resolve) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
resolve(false);
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
const probe = fetchImpl(url, { signal: controller.signal })
|
||||||
|
.then((res) => typeof res.status === 'number' && res.status >= 200 && res.status < 300)
|
||||||
|
.catch(() => false); // connection refused / aborted → dead
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([probe, timeout]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Listener identity (OS-level, CWE-345 mitigation) ─────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result of verifying who actually owns the :18765 listener.
|
||||||
|
* - `ok` — same-user process running the expected proxy binary.
|
||||||
|
* - `foreign-user` — a process owned by a DIFFERENT uid holds the port.
|
||||||
|
* - `wrong-exe` — same-user, but the executable is not the proxy.
|
||||||
|
* - `unknown` — identity could not be established (fail closed).
|
||||||
|
*/
|
||||||
|
export type ListenerVerdict = 'ok' | 'foreign-user' | 'wrong-exe' | 'unknown';
|
||||||
|
|
||||||
|
/** OS-level identity of the process bound to the proxy port. */
|
||||||
|
export interface ListenerIdentity {
|
||||||
|
pid: number;
|
||||||
|
uid: number;
|
||||||
|
/** Absolute path of the process executable, or null if unreadable. */
|
||||||
|
exePath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyListenerDeps {
|
||||||
|
/** Resolve the process bound to the proxy port (null → unidentifiable). */
|
||||||
|
identify?: () => ListenerIdentity | null;
|
||||||
|
/** The current process uid (-1 when unavailable, e.g. non-posix). */
|
||||||
|
currentUid?: () => number;
|
||||||
|
/** The expected proxy executable path (null when it can't be resolved). */
|
||||||
|
expectedExe?: () => string | null;
|
||||||
|
/** Canonicalize a path (resolve symlinks); null when it can't be resolved. */
|
||||||
|
canonicalize?: (p: string) => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a path through symlinks to its canonical form; null on any failure. */
|
||||||
|
function defaultCanonicalize(p: string): string | null {
|
||||||
|
try {
|
||||||
|
return realpathSync(p);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify the process listening on the proxy port via `ss` + `/proc`. Every
|
||||||
|
* failure path returns null so the caller fails closed. Reads no credential
|
||||||
|
* material — only pid/uid/exe path of the listener.
|
||||||
|
*/
|
||||||
|
function defaultIdentifyListener(port: number = CLAUDEX_PROXY_PORT): ListenerIdentity | null {
|
||||||
|
try {
|
||||||
|
const out = execFileSync('ss', ['-H', '-ltnp', `sport = :${port}`], { encoding: 'utf8' });
|
||||||
|
const pidMatch = /pid=(\d+)/.exec(out);
|
||||||
|
if (!pidMatch) return null;
|
||||||
|
const pid = Number(pidMatch[1]);
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) return null;
|
||||||
|
|
||||||
|
const status = readFileSync(`/proc/${pid}/status`, 'utf8');
|
||||||
|
const uidLine = /^Uid:\s*(\d+)/m.exec(status);
|
||||||
|
if (!uidLine) return null;
|
||||||
|
const uid = Number(uidLine[1]);
|
||||||
|
|
||||||
|
let exePath: string | null = null;
|
||||||
|
try {
|
||||||
|
exePath = readlinkSync(`/proc/${pid}/exe`);
|
||||||
|
} catch {
|
||||||
|
exePath = null;
|
||||||
|
}
|
||||||
|
return { pid, uid, exePath };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that the process owning :18765 is genuinely OUR proxy before trusting
|
||||||
|
* it. The proxy binds loopback with NO client authentication, so on a shared
|
||||||
|
* host any local process could squat the port and a liveness 2xx alone does not
|
||||||
|
* prove identity (CWE-345). We FAIL CLOSED (`unknown`) whenever identity cannot
|
||||||
|
* be established. This needs no upstream shared-secret/unix-socket support from
|
||||||
|
* `claude-code-proxy`.
|
||||||
|
*
|
||||||
|
* The executable path is the trust boundary that matters: on a shared-uid host
|
||||||
|
* (every agent session runs as the same operator) same-uid is NOT sufficient, so
|
||||||
|
* we require an EXACT canonical-path match against our resolved proxy binary and
|
||||||
|
* canonicalize both sides for symlinks. There is deliberately NO basename
|
||||||
|
* fallback — a same-uid process running `/tmp/claude-code-proxy` (right name,
|
||||||
|
* wrong path) must never be trusted. If our own binary path can't be resolved,
|
||||||
|
* or either path can't be canonicalized, we fail closed rather than downgrade to
|
||||||
|
* a weaker check.
|
||||||
|
*/
|
||||||
|
export function verifyListenerIdentity(deps: VerifyListenerDeps = {}): ListenerVerdict {
|
||||||
|
const identify = deps.identify ?? (() => defaultIdentifyListener());
|
||||||
|
const currentUid =
|
||||||
|
deps.currentUid ?? (() => (typeof process.getuid === 'function' ? process.getuid() : -1));
|
||||||
|
const expectedExe = deps.expectedExe ?? (() => checkProxyBinary().path);
|
||||||
|
const canonicalize = deps.canonicalize ?? defaultCanonicalize;
|
||||||
|
|
||||||
|
const id = identify();
|
||||||
|
if (!id) return 'unknown'; // can't see the listener → don't trust it
|
||||||
|
const uid = currentUid();
|
||||||
|
if (uid < 0) return 'unknown'; // can't establish our own identity → fail closed
|
||||||
|
if (id.uid !== uid) return 'foreign-user'; // someone else's process holds the port
|
||||||
|
if (!id.exePath) return 'unknown'; // can't confirm the executable → fail closed
|
||||||
|
|
||||||
|
const expected = expectedExe();
|
||||||
|
if (!expected) return 'unknown'; // can't resolve our own binary → fail closed
|
||||||
|
const expectedReal = canonicalize(expected);
|
||||||
|
const actualReal = canonicalize(id.exePath);
|
||||||
|
if (!expectedReal || !actualReal) return 'unknown'; // uncanonicalizable → fail closed
|
||||||
|
return actualReal === expectedReal ? 'ok' : 'wrong-exe';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── systemd user unit ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function systemdUnitPath(home: string = homedir()): string {
|
||||||
|
return join(home, '.config', 'systemd', 'user', CLAUDEX_SYSTEMD_UNIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a path destined for a systemd `ExecStart=` line. A raw newline (or
|
||||||
|
* other control character) in the path would let an attacker inject arbitrary
|
||||||
|
* unit directives (e.g. an extra `ExecStartPost=`), a CWE-74 command injection.
|
||||||
|
* We require a plain absolute path and reject any control character outright.
|
||||||
|
*/
|
||||||
|
function validateExecPath(binaryPath: string): string {
|
||||||
|
if (typeof binaryPath !== 'string' || binaryPath.length === 0) {
|
||||||
|
throw new Error('systemd ExecStart: binary path is empty');
|
||||||
|
}
|
||||||
|
if (!binaryPath.startsWith('/')) {
|
||||||
|
throw new Error(
|
||||||
|
`systemd ExecStart: binary path must be absolute: ${JSON.stringify(binaryPath)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[\x00-\x1f\x7f]/.test(binaryPath)) {
|
||||||
|
throw new Error('systemd ExecStart: binary path contains control characters');
|
||||||
|
}
|
||||||
|
return binaryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a validated path for a systemd `ExecStart=` token. systemd only needs
|
||||||
|
* quoting when the token carries whitespace or quote/backslash characters; a
|
||||||
|
* clean path is emitted verbatim. When quoting, we escape backslashes and double
|
||||||
|
* quotes per systemd's C-style rules so the token cannot be terminated early.
|
||||||
|
*/
|
||||||
|
function systemdQuoteExec(path: string): string {
|
||||||
|
if (!/[\s"'\\]/.test(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
const escaped = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
return `"${escaped}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the `claude-code-proxy.service` user unit. Contains no credential
|
||||||
|
* material — the proxy reads its own OAuth token from its config dir at runtime.
|
||||||
|
* The binary path is validated (absolute, no control characters) and systemd-
|
||||||
|
* quoted so it cannot inject unit directives.
|
||||||
|
*/
|
||||||
|
export function buildSystemdUnitContent(binaryPath: string): string {
|
||||||
|
const exec = `${systemdQuoteExec(validateExecPath(binaryPath))} ${buildServeArgs().join(' ')}`;
|
||||||
|
return [
|
||||||
|
'[Unit]',
|
||||||
|
'Description=claude-code-proxy (Anthropic->Codex translation proxy for mosaic claudex)',
|
||||||
|
'After=network-online.target',
|
||||||
|
'Wants=network-online.target',
|
||||||
|
'',
|
||||||
|
'[Service]',
|
||||||
|
'Type=simple',
|
||||||
|
`ExecStart=${exec}`,
|
||||||
|
'Restart=on-failure',
|
||||||
|
'RestartSec=2',
|
||||||
|
'',
|
||||||
|
'[Install]',
|
||||||
|
'WantedBy=default.target',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the user unit and reload the systemd --user daemon. Returns false when
|
||||||
|
* systemd --user is unavailable (the caller then falls back to nohup).
|
||||||
|
*/
|
||||||
|
export function installSystemdUnit(
|
||||||
|
binaryPath: string,
|
||||||
|
deps: {
|
||||||
|
home?: string;
|
||||||
|
writeUnit?: (path: string, content: string) => void;
|
||||||
|
run?: CommandRunner;
|
||||||
|
} = {},
|
||||||
|
): boolean {
|
||||||
|
const home = deps.home ?? homedir();
|
||||||
|
const write =
|
||||||
|
deps.writeUnit ??
|
||||||
|
((path: string, content: string) => {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
writeFileSync(path, content);
|
||||||
|
});
|
||||||
|
const run = deps.run ?? defaultRun;
|
||||||
|
|
||||||
|
try {
|
||||||
|
write(systemdUnitPath(home), buildSystemdUnitContent(binaryPath));
|
||||||
|
const reload = run('systemctl', ['--user', 'daemon-reload']);
|
||||||
|
return reload.status === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Preflight report ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PreflightReport {
|
||||||
|
binaryPresent: boolean;
|
||||||
|
binaryPath: string | null;
|
||||||
|
auth: AuthStatus;
|
||||||
|
live: boolean;
|
||||||
|
/** OS-level identity verdict for the :18765 listener (`unknown` when dead). */
|
||||||
|
listenerVerdict: ListenerVerdict;
|
||||||
|
needsReauth: boolean;
|
||||||
|
ok: boolean;
|
||||||
|
problems: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreflightDeps {
|
||||||
|
checkBinary?: () => { present: boolean; path: string | null };
|
||||||
|
checkAuth?: () => AuthStatus;
|
||||||
|
probe?: () => Promise<boolean>;
|
||||||
|
verifyListener?: () => ListenerVerdict;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose the preflight checks into a single structured report. `ok` is true
|
||||||
|
* only when the binary is present, OAuth is valid, the proxy responds, AND the
|
||||||
|
* responding listener's OS-level identity verifies as our proxy.
|
||||||
|
*
|
||||||
|
* The identity gate lives here too, not only in {@link ensureProxyRunning}: any
|
||||||
|
* consumer of this report (notably the phase-2 launch path) would otherwise
|
||||||
|
* treat a `/healthz`-2xx squatter as healthy and route Claude traffic to it
|
||||||
|
* (CWE-345). A liveness 2xx is necessary but not sufficient — a live responder
|
||||||
|
* that fails identity fails the preflight.
|
||||||
|
*/
|
||||||
|
export async function runProxyPreflight(deps: PreflightDeps = {}): Promise<PreflightReport> {
|
||||||
|
const checkBinary = deps.checkBinary ?? (() => checkProxyBinary());
|
||||||
|
const checkAuth = deps.checkAuth ?? (() => checkAuthStatus());
|
||||||
|
const probe = deps.probe ?? (() => probeLiveness());
|
||||||
|
const verifyListener = deps.verifyListener ?? (() => verifyListenerIdentity());
|
||||||
|
|
||||||
|
const bin = checkBinary();
|
||||||
|
const auth = checkAuth();
|
||||||
|
const live = await probe();
|
||||||
|
// Only meaningful when something is actually responding; a dead port has no
|
||||||
|
// listener identity to establish.
|
||||||
|
const listenerVerdict: ListenerVerdict = live ? verifyListener() : 'unknown';
|
||||||
|
|
||||||
|
const problems: string[] = [];
|
||||||
|
if (!bin.present) {
|
||||||
|
problems.push(
|
||||||
|
`claude-code-proxy binary not found in PATH. Install it before launching claudex.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const needsReauth = auth.state === 'expired' || auth.state === 'unauthenticated';
|
||||||
|
if (needsReauth) {
|
||||||
|
problems.push(
|
||||||
|
`claude-code-proxy OAuth is ${auth.state}. Re-auth with: ${CLAUDEX_PROXY_BINARY} ${buildDeviceAuthArgs().join(' ')}`,
|
||||||
|
);
|
||||||
|
} else if (auth.state === 'unknown') {
|
||||||
|
problems.push('Could not determine claude-code-proxy OAuth status.');
|
||||||
|
}
|
||||||
|
if (!live) {
|
||||||
|
problems.push(`No proxy responding on ${CLAUDEX_PROXY_URL}.`);
|
||||||
|
} else if (listenerVerdict !== 'ok') {
|
||||||
|
// Non-sensitive: names the port and the verdict only — never any listener
|
||||||
|
// command line, token, or other process detail.
|
||||||
|
problems.push(
|
||||||
|
`A process is listening on ${CLAUDEX_PROXY_URL} but its identity could not be verified as ${CLAUDEX_PROXY_BINARY} (${listenerVerdict}). Refusing to trust it.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = bin.present && auth.state === 'valid' && live && listenerVerdict === 'ok';
|
||||||
|
return {
|
||||||
|
binaryPresent: bin.present,
|
||||||
|
binaryPath: bin.path,
|
||||||
|
auth,
|
||||||
|
live,
|
||||||
|
listenerVerdict,
|
||||||
|
needsReauth,
|
||||||
|
ok,
|
||||||
|
problems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lifecycle: ensure the proxy is running ──────────────────────────────────
|
||||||
|
|
||||||
|
export type ProxyStartMethod = 'already' | 'systemd' | 'nohup' | 'untrusted' | 'failed';
|
||||||
|
|
||||||
|
export interface EnsureProxyResult {
|
||||||
|
live: boolean;
|
||||||
|
method: ProxyStartMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal spawned-child shape used by the nohup fallback (testable seam). */
|
||||||
|
export interface SpawnedChild {
|
||||||
|
once(event: string, listener: (arg?: unknown) => void): unknown;
|
||||||
|
unref(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spawn shape for the detached fallback process. */
|
||||||
|
export type SpawnLike = (
|
||||||
|
cmd: string,
|
||||||
|
args: string[],
|
||||||
|
opts: { detached: boolean; stdio: 'ignore' },
|
||||||
|
) => SpawnedChild;
|
||||||
|
|
||||||
|
export interface StartNohupDeps {
|
||||||
|
resolveBin?: () => string;
|
||||||
|
spawnImpl?: SpawnLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the proxy as a detached background process (the fallback when no systemd
|
||||||
|
* user unit is available).
|
||||||
|
*
|
||||||
|
* `spawn()` reports launch failures (ENOENT/EACCES) ASYNCHRONOUSLY via the
|
||||||
|
* child's `error` event, which a `try/catch` cannot see. If left unhandled that
|
||||||
|
* event throws and crashes the launcher. So we: (1) attach the `error` listener
|
||||||
|
* BEFORE `unref()`, capturing a failed launch as a non-zero result instead of a
|
||||||
|
* crash; and (2) resolve success only after the child's `spawn` event fires —
|
||||||
|
* never optimistically before the process is known to have started.
|
||||||
|
*/
|
||||||
|
export function startNohupProxy(deps: StartNohupDeps = {}): Promise<ProxyRunResult> {
|
||||||
|
const resolveBin = deps.resolveBin ?? (() => checkProxyBinary().path ?? CLAUDEX_PROXY_BINARY);
|
||||||
|
const spawnImpl =
|
||||||
|
deps.spawnImpl ?? ((cmd, args, opts) => spawn(cmd, args, opts) as unknown as SpawnedChild);
|
||||||
|
|
||||||
|
return new Promise<ProxyRunResult>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (r: ProxyRunResult) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve(r);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let child: SpawnedChild;
|
||||||
|
try {
|
||||||
|
child = spawnImpl(resolveBin(), buildServeArgs(), { detached: true, stdio: 'ignore' });
|
||||||
|
} catch (err) {
|
||||||
|
finish({ status: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register error handling BEFORE unref so an async spawn failure is caught.
|
||||||
|
child.once('error', (err) => {
|
||||||
|
finish({
|
||||||
|
status: 1,
|
||||||
|
stdout: '',
|
||||||
|
stderr: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
child.once('spawn', () => {
|
||||||
|
child.unref();
|
||||||
|
finish({ status: 0, stdout: '', stderr: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnsureProxyDeps {
|
||||||
|
probe?: () => Promise<boolean>;
|
||||||
|
/** OS-level identity check for the process holding the proxy port. */
|
||||||
|
verifyListener?: () => ListenerVerdict;
|
||||||
|
startSystemd?: () => ProxyRunResult;
|
||||||
|
startNohup?: () => Promise<ProxyRunResult>;
|
||||||
|
waitMs?: (ms: number) => Promise<void>;
|
||||||
|
/** Interval between liveness polls while waiting for a start to bind. */
|
||||||
|
settleMs?: number;
|
||||||
|
/** Total budget to wait for a started proxy to bind its socket. */
|
||||||
|
startupDeadlineMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultWait(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultStartSystemd(): ProxyRunResult {
|
||||||
|
return defaultRun('systemctl', ['--user', 'start', CLAUDEX_SYSTEMD_UNIT]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll for a TRUSTED-live proxy up to a bounded startup deadline. A start command
|
||||||
|
* returning 0 only means the job was ACCEPTED, not that the socket is bound — so
|
||||||
|
* we keep probing at `intervalMs` until either the deadline elapses or the port
|
||||||
|
* both responds AND passes the OS-level identity check. Liveness alone is not
|
||||||
|
* enough: a responder that fails identity (a squatter) must never be trusted.
|
||||||
|
*/
|
||||||
|
async function waitForTrusted(
|
||||||
|
probe: () => Promise<boolean>,
|
||||||
|
verifyListener: () => ListenerVerdict,
|
||||||
|
waitMs: (ms: number) => Promise<void>,
|
||||||
|
intervalMs: number,
|
||||||
|
deadlineMs: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
let elapsed = 0;
|
||||||
|
while (elapsed < deadlineMs) {
|
||||||
|
await waitMs(intervalMs);
|
||||||
|
elapsed += intervalMs;
|
||||||
|
if ((await probe()) && verifyListener() === 'ok') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a proxy is listening. No-op when already live. Otherwise prefer the
|
||||||
|
* systemd user unit, then fall back to a detached background process.
|
||||||
|
*
|
||||||
|
* Every trust point is gated on OS-level listener identity, not just liveness:
|
||||||
|
* the proxy has no client authentication, so on a shared host a local process
|
||||||
|
* could squat :18765 and a 2xx `/healthz` alone would not prove it is our proxy
|
||||||
|
* (CWE-345, finding #2). We only trust a responder whose owning process is the
|
||||||
|
* current uid running the expected proxy binary; otherwise we fail closed.
|
||||||
|
*
|
||||||
|
* If a responder is already present but its identity does NOT verify, we return
|
||||||
|
* `untrusted` WITHOUT starting anything — the port is taken, so spawning would
|
||||||
|
* only create contention, and we must never route Claude traffic through an
|
||||||
|
* unverified listener.
|
||||||
|
*
|
||||||
|
* After a start command is accepted we poll to a bounded startup deadline before
|
||||||
|
* giving up: `systemctl start` exit 0 means the job was accepted, not that the
|
||||||
|
* socket bound within one probe interval. Critically, once systemd ACCEPTS the
|
||||||
|
* job we do NOT fall back to nohup even if it never becomes trusted-live in the
|
||||||
|
* deadline (finding #1): the accepted unit may bind late or be restarted by
|
||||||
|
* systemd, and a second proxy would then contend for :18765 — the very
|
||||||
|
* duplicate-proxy outcome this function exists to prevent. nohup is reachable
|
||||||
|
* only when systemd never accepted the job at all.
|
||||||
|
*/
|
||||||
|
export async function ensureProxyRunning(deps: EnsureProxyDeps = {}): Promise<EnsureProxyResult> {
|
||||||
|
const probe = deps.probe ?? (() => probeLiveness());
|
||||||
|
const verifyListener = deps.verifyListener ?? (() => verifyListenerIdentity());
|
||||||
|
const startSystemd = deps.startSystemd ?? defaultStartSystemd;
|
||||||
|
const startNohup = deps.startNohup ?? (() => startNohupProxy());
|
||||||
|
const waitMs = deps.waitMs ?? defaultWait;
|
||||||
|
const settleMs = deps.settleMs ?? 500;
|
||||||
|
const startupDeadlineMs = deps.startupDeadlineMs ?? 5000;
|
||||||
|
|
||||||
|
if (await probe()) {
|
||||||
|
// Something answers on :18765 — trust it ONLY if it is provably our proxy.
|
||||||
|
return verifyListener() === 'ok'
|
||||||
|
? { live: true, method: 'already' }
|
||||||
|
: { live: false, method: 'untrusted' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemd = startSystemd();
|
||||||
|
if (systemd.status === 0) {
|
||||||
|
// systemd accepted the job. Wait for a trusted-live bind, but never fall
|
||||||
|
// back to nohup afterward — that would risk a duplicate proxy (finding #1).
|
||||||
|
if (await waitForTrusted(probe, verifyListener, waitMs, settleMs, startupDeadlineMs)) {
|
||||||
|
return { live: true, method: 'systemd' };
|
||||||
|
}
|
||||||
|
return { live: false, method: 'failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nohup = await startNohup();
|
||||||
|
if (nohup.status === 0) {
|
||||||
|
if (await waitForTrusted(probe, verifyListener, waitMs, settleMs, startupDeadlineMs)) {
|
||||||
|
return { live: true, method: 'nohup' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { live: false, method: 'failed' };
|
||||||
|
}
|
||||||
732
packages/mosaic/src/commands/claudex.spec.ts
Normal file
732
packages/mosaic/src/commands/claudex.spec.ts
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { mkdtempSync, mkdirSync, symlinkSync, rmSync, lstatSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir, homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
CLAUDEX_CONFIG_DIR_ENV,
|
||||||
|
CLAUDEX_DEFAULT_PRIMARY_MODEL,
|
||||||
|
CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
|
||||||
|
CLAUDEX_CREDENTIAL_ENV_RE,
|
||||||
|
defaultClaudexConfigDir,
|
||||||
|
assertIsolatedConfigDir,
|
||||||
|
resolveClaudexConfigDir,
|
||||||
|
resolveClaudexModels,
|
||||||
|
buildClaudexEnv,
|
||||||
|
buildClaudexBanner,
|
||||||
|
buildClaudexContractNote,
|
||||||
|
runClaudexProxyGate,
|
||||||
|
launchClaudex,
|
||||||
|
type ClaudexHarnessAdapter,
|
||||||
|
} from './claudex.js';
|
||||||
|
import { CLAUDEX_PROXY_URL, type PreflightReport } from './claudex-proxy.js';
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeReport(overrides: Partial<PreflightReport> = {}): PreflightReport {
|
||||||
|
return {
|
||||||
|
binaryPresent: true,
|
||||||
|
binaryPath: '/usr/bin/claude-code-proxy',
|
||||||
|
auth: { state: 'valid' },
|
||||||
|
live: true,
|
||||||
|
listenerVerdict: 'ok',
|
||||||
|
needsReauth: false,
|
||||||
|
ok: true,
|
||||||
|
problems: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function okAdapter(overrides: Partial<ClaudexHarnessAdapter> = {}): ClaudexHarnessAdapter {
|
||||||
|
return {
|
||||||
|
harnessPreflight: () => {},
|
||||||
|
composePrompt: () => '# Composed Claude contract',
|
||||||
|
exec: () => {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity canonicalizer + no-op FS deps so config-dir logic is tested purely.
|
||||||
|
const idCanon = (p: string): string => p;
|
||||||
|
const noFsDeps = { canonicalize: idCanon, mkdir: () => {}, isSymlink: () => false };
|
||||||
|
|
||||||
|
// ─── isolated config dir (HARD SECURITY REQ 1 — provable isolation) ───────────
|
||||||
|
|
||||||
|
describe('defaultClaudexConfigDir', () => {
|
||||||
|
it('is namespaced under the mosaic home, never ~/.claude', () => {
|
||||||
|
const dir = defaultClaudexConfigDir('/home/agent/.config/mosaic');
|
||||||
|
expect(dir).toBe(join('/home/agent/.config/mosaic', 'claudex', 'home'));
|
||||||
|
expect(dir).not.toBe(join(homedir(), '.claude'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertIsolatedConfigDir — the isolation guard is provable', () => {
|
||||||
|
const realClaude = '/home/agent/.claude';
|
||||||
|
|
||||||
|
it('accepts a dir that does not resolve to ~/.claude', () => {
|
||||||
|
const safe = '/home/agent/.config/mosaic/claudex/home';
|
||||||
|
expect(
|
||||||
|
assertIsolatedConfigDir(safe, { realClaudeDir: realClaude, canonicalize: idCanon }),
|
||||||
|
).toBe(safe);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('REJECTS a candidate that is literally ~/.claude', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertIsolatedConfigDir(realClaude, { realClaudeDir: realClaude, canonicalize: idCanon }),
|
||||||
|
).toThrow(/refusing/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('REJECTS a descendant of ~/.claude (would pollute the real tree)', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertIsolatedConfigDir('/home/agent/.claude/projects/x', {
|
||||||
|
realClaudeDir: realClaude,
|
||||||
|
canonicalize: idCanon,
|
||||||
|
}),
|
||||||
|
).toThrow(/refusing/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('REJECTS a candidate that canonically resolves to ~/.claude (symlink, both sides canonicalized)', () => {
|
||||||
|
const canon = (p: string): string => (p === '/home/agent/link' ? realClaude : p);
|
||||||
|
expect(() =>
|
||||||
|
assertIsolatedConfigDir('/home/agent/link', {
|
||||||
|
realClaudeDir: realClaude,
|
||||||
|
canonicalize: canon,
|
||||||
|
}),
|
||||||
|
).toThrow(/refusing/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canonicalizes the ~/.claude side too (real dir itself may be a symlink)', () => {
|
||||||
|
// realClaudeDir is a symlink whose canonical target equals the candidate's target.
|
||||||
|
const canon = (p: string): string =>
|
||||||
|
p === '/home/agent/.claude' || p === '/home/agent/link' ? '/canonical/claude' : p;
|
||||||
|
expect(() =>
|
||||||
|
assertIsolatedConfigDir('/home/agent/link', {
|
||||||
|
realClaudeDir: realClaude,
|
||||||
|
canonicalize: canon,
|
||||||
|
}),
|
||||||
|
).toThrow(/refusing/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('REJECTS an empty or whitespace candidate (fail closed)', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertIsolatedConfigDir('', { realClaudeDir: realClaude, canonicalize: idCanon }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
assertIsolatedConfigDir(' ', { realClaudeDir: realClaude, canonicalize: idCanon }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('REJECTS a relative candidate (must be absolute)', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertIsolatedConfigDir('relative/dir', { realClaudeDir: realClaude, canonicalize: idCanon }),
|
||||||
|
).toThrow(/absolute/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveClaudexConfigDir', () => {
|
||||||
|
it('uses the namespaced default and never the ambient CLAUDE_CONFIG_DIR', () => {
|
||||||
|
// Ambient CLAUDE_CONFIG_DIR is deliberately ignored (it could be ~/.claude).
|
||||||
|
const env = { CLAUDE_CONFIG_DIR: join(homedir(), '.claude') };
|
||||||
|
const dir = resolveClaudexConfigDir(env, {
|
||||||
|
mosaicHome: '/home/agent/.config/mosaic',
|
||||||
|
realClaudeDir: '/home/agent/.claude',
|
||||||
|
...noFsDeps,
|
||||||
|
});
|
||||||
|
expect(dir).toBe(join('/home/agent/.config/mosaic', 'claudex', 'home'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors the dedicated override env when it is safe', () => {
|
||||||
|
const env = { [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/custom-claudex' };
|
||||||
|
const dir = resolveClaudexConfigDir(env, {
|
||||||
|
mosaicHome: '/home/agent/.config/mosaic',
|
||||||
|
realClaudeDir: '/home/agent/.claude',
|
||||||
|
...noFsDeps,
|
||||||
|
});
|
||||||
|
expect(dir).toBe('/home/agent/custom-claudex');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('REJECTS a dedicated override that points at ~/.claude (before creating anything)', () => {
|
||||||
|
const mkdir = vi.fn();
|
||||||
|
const env = { [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/.claude' };
|
||||||
|
expect(() =>
|
||||||
|
resolveClaudexConfigDir(env, {
|
||||||
|
mosaicHome: '/home/agent/.config/mosaic',
|
||||||
|
realClaudeDir: '/home/agent/.claude',
|
||||||
|
canonicalize: idCanon,
|
||||||
|
mkdir,
|
||||||
|
isSymlink: () => false,
|
||||||
|
}),
|
||||||
|
).toThrow(/refusing/i);
|
||||||
|
expect(mkdir).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TOCTOU: REJECTS when the created target is itself a symlink (pre-created race)', () => {
|
||||||
|
const env = {};
|
||||||
|
expect(() =>
|
||||||
|
resolveClaudexConfigDir(env, {
|
||||||
|
mosaicHome: '/home/agent/.config/mosaic',
|
||||||
|
realClaudeDir: '/home/agent/.claude',
|
||||||
|
canonicalize: idCanon,
|
||||||
|
mkdir: () => {},
|
||||||
|
isSymlink: () => true, // the just-ensured dir is a symlink → fail closed
|
||||||
|
}),
|
||||||
|
).toThrow(/refusing|symlink/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('real-FS: creates the isolated dir 0700 and returns its canonical path', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
|
||||||
|
try {
|
||||||
|
const mosaicHome = join(root, '.config', 'mosaic');
|
||||||
|
const dir = resolveClaudexConfigDir({}, { mosaicHome, realClaudeDir: join(root, '.claude') });
|
||||||
|
expect(dir).toBe(join(mosaicHome, 'claudex', 'home'));
|
||||||
|
const st = lstatSync(dir);
|
||||||
|
expect(st.isDirectory()).toBe(true);
|
||||||
|
// 0700 (owner-only) — mask off the type bits.
|
||||||
|
expect(st.mode & 0o777).toBe(0o700);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('real-FS: catches an override whose ancestor symlinks into ~/.claude', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
|
||||||
|
try {
|
||||||
|
const realClaudeDir = join(root, 'dot-claude');
|
||||||
|
mkdirSync(realClaudeDir, { recursive: true });
|
||||||
|
const link = join(root, 'link'); // link -> dot-claude
|
||||||
|
symlinkSync(realClaudeDir, link, 'dir');
|
||||||
|
const override = join(link, 'sub'); // resolves under ~/.claude
|
||||||
|
expect(() =>
|
||||||
|
resolveClaudexConfigDir(
|
||||||
|
{ [CLAUDEX_CONFIG_DIR_ENV]: override },
|
||||||
|
{ mosaicHome: join(root, '.config', 'mosaic'), realClaudeDir },
|
||||||
|
),
|
||||||
|
).toThrow(/refusing/i);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAIL CLOSED: default canonicalizer rethrows a non-ENOENT error (ELOOP) instead of a literal fallback', () => {
|
||||||
|
// A symlink loop makes realpathSync throw ELOOP. The guard must NOT swallow
|
||||||
|
// it as "does not exist yet, keep walking up" and return a literal path —
|
||||||
|
// it must fail closed. (REQ 1: fails CLOSED on any uncertainty.)
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'claudex-loop-'));
|
||||||
|
try {
|
||||||
|
const a = join(root, 'a');
|
||||||
|
const b = join(root, 'b');
|
||||||
|
symlinkSync(b, a, 'dir'); // a -> b
|
||||||
|
symlinkSync(a, b, 'dir'); // b -> a (loop)
|
||||||
|
const looped = join(a, 'home'); // canonicalizing this hits ELOOP
|
||||||
|
// No canonicalize dep → the real defaultCanonicalizeIntended runs.
|
||||||
|
expect(() =>
|
||||||
|
assertIsolatedConfigDir(looped, { realClaudeDir: join(root, '.claude') }),
|
||||||
|
).toThrow();
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAIL CLOSED: default isSymlink rethrows a non-ENOENT error (ENOTDIR) rather than reporting "not a symlink"', () => {
|
||||||
|
// A candidate whose parent is a regular FILE makes lstat throw ENOTDIR.
|
||||||
|
// The post-create symlink check must fail closed, not treat it as safe.
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'claudex-notdir-'));
|
||||||
|
try {
|
||||||
|
const file = join(root, 'afile');
|
||||||
|
writeFileSync(file, 'x');
|
||||||
|
const candidate = join(file, 'child'); // parent is a file → ENOTDIR on lstat
|
||||||
|
expect(() =>
|
||||||
|
// Bypass the guard/mkdir side-effects; only the default isSymlink runs live.
|
||||||
|
resolveClaudexConfigDir(
|
||||||
|
{ [CLAUDEX_CONFIG_DIR_ENV]: candidate },
|
||||||
|
{
|
||||||
|
realClaudeDir: join(root, '.claude'),
|
||||||
|
canonicalize: idCanon,
|
||||||
|
mkdir: () => {},
|
||||||
|
// isSymlink omitted → real defaultIsSymlink runs on the ENOTDIR path.
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toThrow();
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAIL CLOSED: an injected canonicalize throwing EACCES is not swallowed', () => {
|
||||||
|
const eacces = Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||||
|
expect(() =>
|
||||||
|
resolveClaudexConfigDir(
|
||||||
|
{ [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/custom-claudex' },
|
||||||
|
{
|
||||||
|
realClaudeDir: '/home/agent/.claude',
|
||||||
|
canonicalize: () => {
|
||||||
|
throw eacces;
|
||||||
|
},
|
||||||
|
mkdir: () => {},
|
||||||
|
isSymlink: () => false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toThrow(/permission denied/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── model-tier map (P3) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('resolveClaudexModels', () => {
|
||||||
|
it('defaults primary=sol / smallFast=luna', () => {
|
||||||
|
expect(resolveClaudexModels({})).toEqual({
|
||||||
|
primary: CLAUDEX_DEFAULT_PRIMARY_MODEL,
|
||||||
|
smallFast: CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
|
||||||
|
});
|
||||||
|
expect(CLAUDEX_DEFAULT_PRIMARY_MODEL).toBe('gpt-5.6-sol');
|
||||||
|
expect(CLAUDEX_DEFAULT_SMALL_FAST_MODEL).toBe('gpt-5.6-luna');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('env-provided values WIN over defaults', () => {
|
||||||
|
expect(
|
||||||
|
resolveClaudexModels({ ANTHROPIC_MODEL: 'gpt-x', ANTHROPIC_SMALL_FAST_MODEL: 'gpt-y' }),
|
||||||
|
).toEqual({ primary: 'gpt-x', smallFast: 'gpt-y' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores blank env values (falls back to defaults)', () => {
|
||||||
|
expect(
|
||||||
|
resolveClaudexModels({ ANTHROPIC_MODEL: ' ', ANTHROPIC_SMALL_FAST_MODEL: '' }),
|
||||||
|
).toEqual({
|
||||||
|
primary: CLAUDEX_DEFAULT_PRIMARY_MODEL,
|
||||||
|
smallFast: CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── env injection (HARD SECURITY REQ 2 — zero token leakage) ─────────────────
|
||||||
|
|
||||||
|
describe('buildClaudexEnv — zero token leakage', () => {
|
||||||
|
const models = { primary: 'gpt-5.6-sol', smallFast: 'gpt-5.6-luna' };
|
||||||
|
const configDir = '/home/agent/.config/mosaic/claudex/home';
|
||||||
|
|
||||||
|
it('sets only ANTHROPIC_AUTH_TOKEN=unused and points at the loopback proxy', () => {
|
||||||
|
const env = buildClaudexEnv({}, { configDir, models });
|
||||||
|
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
||||||
|
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
|
||||||
|
expect(env.CLAUDE_CONFIG_DIR).toBe(configDir);
|
||||||
|
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol');
|
||||||
|
expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('gpt-5.6-luna');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OVERWRITES an inherited real auth token with the literal "unused"', () => {
|
||||||
|
const env = buildClaudexEnv(
|
||||||
|
{ ANTHROPIC_AUTH_TOKEN: 'sk-ant-realsecret-should-never-flow' },
|
||||||
|
{ configDir, models },
|
||||||
|
);
|
||||||
|
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETES ANTHROPIC_API_KEY so no real Anthropic key reaches the local proxy', () => {
|
||||||
|
const env = buildClaudexEnv(
|
||||||
|
{ ANTHROPIC_API_KEY: 'sk-ant-api03-realkey' },
|
||||||
|
{ configDir, models },
|
||||||
|
);
|
||||||
|
expect('ANTHROPIC_API_KEY' in env).toBe(false);
|
||||||
|
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweeps the WHOLE credential-bearing env family (token/api-key/secret/oauth), not just two', () => {
|
||||||
|
const env = buildClaudexEnv(
|
||||||
|
{
|
||||||
|
ANTHROPIC_API_KEY: 'sk-ant-api03-leak',
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-leak',
|
||||||
|
SOME_SERVICE_TOKEN: 'tok-leak',
|
||||||
|
VENDOR_API_KEY: 'key-leak',
|
||||||
|
DB_SECRET: 'secret-leak',
|
||||||
|
HARMLESS: 'kept',
|
||||||
|
PATH: '/usr/bin',
|
||||||
|
},
|
||||||
|
{ configDir, models },
|
||||||
|
);
|
||||||
|
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||||
|
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
||||||
|
expect(env.SOME_SERVICE_TOKEN).toBeUndefined();
|
||||||
|
expect(env.VENDOR_API_KEY).toBeUndefined();
|
||||||
|
expect(env.DB_SECRET).toBeUndefined();
|
||||||
|
// Non-credential vars the harness needs are preserved.
|
||||||
|
expect(env.HARMLESS).toBe('kept');
|
||||||
|
expect(env.PATH).toBe('/usr/bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neutralizes Bedrock/Vertex provider switches so Claude cannot bypass the proxy (REQ 2)', () => {
|
||||||
|
// CLAUDE_CODE_USE_BEDROCK / _USE_VERTEX are ROUTING switches: their mere
|
||||||
|
// presence makes Claude Code route to AWS Bedrock / GCP Vertex against the
|
||||||
|
// ambient cloud credential chain — reaching the real Anthropic API and
|
||||||
|
// bypassing ANTHROPIC_BASE_URL (the loopback proxy) entirely. They MUST be
|
||||||
|
// gone from the composed env regardless of the launching env.
|
||||||
|
const env = buildClaudexEnv(
|
||||||
|
{
|
||||||
|
CLAUDE_CODE_USE_BEDROCK: '1',
|
||||||
|
CLAUDE_CODE_USE_VERTEX: '1',
|
||||||
|
CLAUDE_CODE_SKIP_BEDROCK_AUTH: '1',
|
||||||
|
CLAUDE_CODE_SKIP_VERTEX_AUTH: '1',
|
||||||
|
AWS_ACCESS_KEY_ID: 'AKIAREAL',
|
||||||
|
AWS_SECRET_ACCESS_KEY: 'realsecret',
|
||||||
|
AWS_SESSION_TOKEN: 'realsession',
|
||||||
|
AWS_BEARER_TOKEN_BEDROCK: 'bearer-bedrock-real',
|
||||||
|
AWS_REGION: 'us-east-1',
|
||||||
|
GOOGLE_APPLICATION_CREDENTIALS: '/home/agent/gcp.json',
|
||||||
|
GOOGLE_CLOUD_ACCESS_TOKEN: 'gcp-token-real',
|
||||||
|
PATH: '/usr/bin',
|
||||||
|
},
|
||||||
|
{ configDir, models },
|
||||||
|
);
|
||||||
|
// Routing switches gone by construction.
|
||||||
|
expect('CLAUDE_CODE_USE_BEDROCK' in env).toBe(false);
|
||||||
|
expect('CLAUDE_CODE_USE_VERTEX' in env).toBe(false);
|
||||||
|
expect('CLAUDE_CODE_SKIP_BEDROCK_AUTH' in env).toBe(false);
|
||||||
|
expect('CLAUDE_CODE_SKIP_VERTEX_AUTH' in env).toBe(false);
|
||||||
|
// Cloud credentials swept — none of the Claude-capable creds survive.
|
||||||
|
expect(env.AWS_ACCESS_KEY_ID).toBeUndefined();
|
||||||
|
expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined();
|
||||||
|
expect(env.AWS_SESSION_TOKEN).toBeUndefined();
|
||||||
|
expect(env.AWS_BEARER_TOKEN_BEDROCK).toBeUndefined();
|
||||||
|
expect(env.AWS_REGION).toBeUndefined();
|
||||||
|
expect(env.GOOGLE_APPLICATION_CREDENTIALS).toBeUndefined();
|
||||||
|
expect(env.GOOGLE_CLOUD_ACCESS_TOKEN).toBeUndefined();
|
||||||
|
// The proxy routing is still the only path.
|
||||||
|
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
|
||||||
|
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
||||||
|
expect(env.PATH).toBe('/usr/bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the mid-string _KEY / _SECRET gap (STRIPE_SECRET_KEY, SSH_PRIVATE_KEY)', () => {
|
||||||
|
const env = buildClaudexEnv(
|
||||||
|
{
|
||||||
|
STRIPE_SECRET_KEY: 'sk-live-real',
|
||||||
|
SSH_PRIVATE_KEY: '-----BEGIN OPENSSH PRIVATE KEY-----',
|
||||||
|
HARMLESS: 'kept',
|
||||||
|
},
|
||||||
|
{ configDir, models },
|
||||||
|
);
|
||||||
|
expect(env.STRIPE_SECRET_KEY).toBeUndefined();
|
||||||
|
expect(env.SSH_PRIVATE_KEY).toBeUndefined();
|
||||||
|
expect(env.HARMLESS).toBe('kept');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no credential-NAMED key in the composed env carries a real-looking value', () => {
|
||||||
|
const env = buildClaudexEnv(
|
||||||
|
{
|
||||||
|
ANTHROPIC_API_KEY: 'sk-ant-api03-leak',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'access_token_leak',
|
||||||
|
SOME_JWT_TOKEN: 'eyJhbGciOiJIUzI1NiJ9.payload.sig',
|
||||||
|
REFRESH_SECRET: 'refresh_token_value',
|
||||||
|
},
|
||||||
|
{ configDir, models },
|
||||||
|
);
|
||||||
|
for (const [name, value] of Object.entries(env)) {
|
||||||
|
if (CLAUDEX_CREDENTIAL_ENV_RE.test(name)) {
|
||||||
|
// Any surviving credential-named var must carry only a safe sentinel value.
|
||||||
|
expect(value).not.toMatch(/sk-(ant|proj)-/);
|
||||||
|
expect(value).not.toMatch(/access_token|refresh_token/);
|
||||||
|
expect(value).not.toMatch(/eyJ[A-Za-z0-9_-]+\./); // JWT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a caller-provided baseUrl override (loopback default otherwise)', () => {
|
||||||
|
const env = buildClaudexEnv({}, { configDir, models, baseUrl: 'http://127.0.0.1:9999' });
|
||||||
|
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9999');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a fresh object without mutating the base env', () => {
|
||||||
|
const base = { EXISTING: 'kept' };
|
||||||
|
const env = buildClaudexEnv(base, { configDir, models });
|
||||||
|
expect(env.EXISTING).toBe('kept');
|
||||||
|
expect(base).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('buildClaudexBanner / buildClaudexContractNote', () => {
|
||||||
|
const models = { primary: 'gpt-5.6-sol', smallFast: 'gpt-5.6-luna' };
|
||||||
|
|
||||||
|
it('banner marks EXPERIMENTAL and names the models + proxy', () => {
|
||||||
|
const banner = buildClaudexBanner(models);
|
||||||
|
expect(banner).toMatch(/EXPERIMENTAL/);
|
||||||
|
expect(banner).toMatch(/gpt-5\.6-sol/);
|
||||||
|
expect(banner).toMatch(/gpt-5\.6-luna/);
|
||||||
|
expect(banner).toMatch(/claude-code-proxy/);
|
||||||
|
expect(banner).not.toMatch(/unused/); // no token material in the banner
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contract note classifies the runtime as EXPERIMENTAL GPT-via-proxy', () => {
|
||||||
|
const note = buildClaudexContractNote(models);
|
||||||
|
expect(note).toMatch(/EXPERIMENTAL/);
|
||||||
|
expect(note).toMatch(/gpt-5\.6-sol/);
|
||||||
|
expect(note).toMatch(/not.*Anthropic/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── proxy gate ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('runClaudexProxyGate', () => {
|
||||||
|
it('is ok when the first preflight already passes', async () => {
|
||||||
|
const preflight = vi.fn().mockResolvedValue(makeReport());
|
||||||
|
const ensureProxy = vi.fn();
|
||||||
|
const reauth = vi.fn();
|
||||||
|
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth });
|
||||||
|
expect(gate.ok).toBe(true);
|
||||||
|
expect(ensureProxy).not.toHaveBeenCalled();
|
||||||
|
expect(reauth).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails fast when the binary is missing (no reauth, no start)', async () => {
|
||||||
|
const preflight = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
makeReport({ binaryPresent: false, ok: false, problems: ['binary not found'] }),
|
||||||
|
);
|
||||||
|
const ensureProxy = vi.fn();
|
||||||
|
const reauth = vi.fn();
|
||||||
|
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth });
|
||||||
|
expect(gate.ok).toBe(false);
|
||||||
|
expect(reauth).not.toHaveBeenCalled();
|
||||||
|
expect(ensureProxy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs device reauth then re-preflights when OAuth needs it', async () => {
|
||||||
|
const preflight = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makeReport({
|
||||||
|
auth: { state: 'expired' },
|
||||||
|
needsReauth: true,
|
||||||
|
ok: false,
|
||||||
|
problems: ['expired'],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(makeReport());
|
||||||
|
const reauth = vi.fn().mockReturnValue(0);
|
||||||
|
const gate = await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
|
||||||
|
expect(reauth).toHaveBeenCalledTimes(1);
|
||||||
|
expect(preflight).toHaveBeenCalledTimes(2);
|
||||||
|
expect(gate.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT reauth when auth is already valid', async () => {
|
||||||
|
const preflight = vi.fn().mockResolvedValue(makeReport());
|
||||||
|
const reauth = vi.fn();
|
||||||
|
await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
|
||||||
|
expect(reauth).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts when device reauth fails', async () => {
|
||||||
|
const preflight = vi.fn().mockResolvedValue(
|
||||||
|
makeReport({
|
||||||
|
auth: { state: 'unauthenticated' },
|
||||||
|
needsReauth: true,
|
||||||
|
ok: false,
|
||||||
|
problems: ['unauth'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const reauth = vi.fn().mockReturnValue(1);
|
||||||
|
const gate = await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
|
||||||
|
expect(gate.ok).toBe(false);
|
||||||
|
expect(gate.problems.join(' ')).toMatch(/re-auth/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the proxy then re-preflights when nothing is live', async () => {
|
||||||
|
const preflight = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
makeReport({ live: false, listenerVerdict: 'unknown', ok: false, problems: ['dead'] }),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(makeReport());
|
||||||
|
const ensureProxy = vi.fn().mockResolvedValue({ live: true, method: 'systemd' });
|
||||||
|
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth: vi.fn() });
|
||||||
|
expect(ensureProxy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(gate.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts (non-sensitive) when the proxy cannot come up trusted', async () => {
|
||||||
|
const preflight = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
makeReport({ live: false, listenerVerdict: 'unknown', ok: false, problems: ['dead'] }),
|
||||||
|
);
|
||||||
|
const ensureProxy = vi.fn().mockResolvedValue({ live: false, method: 'untrusted' });
|
||||||
|
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth: vi.fn() });
|
||||||
|
expect(gate.ok).toBe(false);
|
||||||
|
expect(gate.problems.join(' ')).toMatch(/untrusted/);
|
||||||
|
// Non-sensitive: no token material in surfaced problems.
|
||||||
|
expect(gate.problems.join(' ')).not.toMatch(/access_token|refresh_token|sk-/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── launch orchestration (fail-closed ordering) ──────────────────────────────
|
||||||
|
|
||||||
|
describe('launchClaudex', () => {
|
||||||
|
const baseDeps = {
|
||||||
|
baseEnv: {},
|
||||||
|
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
|
||||||
|
resolveConfigDir: () => '/home/agent/.config/mosaic/claudex/home',
|
||||||
|
log: () => {},
|
||||||
|
errorLog: () => {},
|
||||||
|
fail: (() => {
|
||||||
|
throw new Error('exit');
|
||||||
|
}) as (code: number) => never,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('yolo=true passes --dangerously-skip-permissions + injected env to claude', async () => {
|
||||||
|
const exec = vi.fn();
|
||||||
|
await launchClaudex(['--print', 'hi'], true, okAdapter({ exec }), baseDeps);
|
||||||
|
expect(exec).toHaveBeenCalledTimes(1);
|
||||||
|
const [cmd, args, env] = exec.mock.calls[0]!;
|
||||||
|
expect(cmd).toBe('claude');
|
||||||
|
expect(args[0]).toBe('--dangerously-skip-permissions');
|
||||||
|
expect(args).toContain('--append-system-prompt');
|
||||||
|
expect(args).toContain('--print');
|
||||||
|
expect(args).toContain('hi');
|
||||||
|
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
||||||
|
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
|
||||||
|
expect(env.CLAUDE_CONFIG_DIR).toBe('/home/agent/.config/mosaic/claudex/home');
|
||||||
|
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-yolo omits --dangerously-skip-permissions but still injects the proxy env', async () => {
|
||||||
|
const exec = vi.fn();
|
||||||
|
await launchClaudex([], false, okAdapter({ exec }), baseDeps);
|
||||||
|
const [, args, env] = exec.mock.calls[0]!;
|
||||||
|
expect(args).not.toContain('--dangerously-skip-permissions');
|
||||||
|
expect(args[0]).toBe('--append-system-prompt');
|
||||||
|
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends the EXPERIMENTAL contract note to the composed prompt', async () => {
|
||||||
|
const exec = vi.fn();
|
||||||
|
await launchClaudex([], true, okAdapter({ exec, composePrompt: () => '# BASE' }), baseDeps);
|
||||||
|
const args = exec.mock.calls[0]![1] as string[];
|
||||||
|
const promptIdx = args.indexOf('--append-system-prompt') + 1;
|
||||||
|
expect(args[promptIdx]).toContain('# BASE');
|
||||||
|
expect(args[promptIdx]).toMatch(/EXPERIMENTAL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the harness preflight BEFORE the proxy gate and exec', async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
const adapter = okAdapter({
|
||||||
|
harnessPreflight: () => order.push('preflight'),
|
||||||
|
exec: () => order.push('exec'),
|
||||||
|
});
|
||||||
|
await launchClaudex([], true, adapter, {
|
||||||
|
...baseDeps,
|
||||||
|
proxyGate: () => {
|
||||||
|
order.push('gate');
|
||||||
|
return Promise.resolve({ ok: true, report: makeReport(), problems: [] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(order).toEqual(['preflight', 'gate', 'exec']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAIL CLOSED: exits WITHOUT exec when the proxy gate fails', async () => {
|
||||||
|
const exec = vi.fn();
|
||||||
|
const errors: string[] = [];
|
||||||
|
await expect(
|
||||||
|
launchClaudex([], true, okAdapter({ exec }), {
|
||||||
|
...baseDeps,
|
||||||
|
proxyGate: () =>
|
||||||
|
Promise.resolve({ ok: false, report: makeReport({ ok: false }), problems: ['no proxy'] }),
|
||||||
|
errorLog: (m: string) => errors.push(m),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('exit');
|
||||||
|
expect(exec).not.toHaveBeenCalled();
|
||||||
|
expect(errors.join('\n')).toMatch(/no proxy/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAIL CLOSED: exits WITHOUT exec when the config-dir guard throws', async () => {
|
||||||
|
const exec = vi.fn();
|
||||||
|
await expect(
|
||||||
|
launchClaudex([], true, okAdapter({ exec }), {
|
||||||
|
...baseDeps,
|
||||||
|
resolveConfigDir: () => {
|
||||||
|
throw new Error('refusing to use ~/.claude');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('exit');
|
||||||
|
expect(exec).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAIL CLOSED: reports a non-Error throw via String() and still aborts', async () => {
|
||||||
|
const exec = vi.fn();
|
||||||
|
const errors: string[] = [];
|
||||||
|
await expect(
|
||||||
|
launchClaudex([], true, okAdapter({ exec }), {
|
||||||
|
...baseDeps,
|
||||||
|
resolveConfigDir: () => {
|
||||||
|
// A non-Error throw exercises the String(err) branch of the catch.
|
||||||
|
throw { toString: () => 'string-shaped failure' };
|
||||||
|
},
|
||||||
|
errorLog: (m: string) => errors.push(m),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('exit');
|
||||||
|
expect(exec).not.toHaveBeenCalled();
|
||||||
|
expect(errors.join('\n')).toMatch(/string-shaped failure/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── production DI defaults (fallback-branch coverage; no real proxy touched) ──
|
||||||
|
|
||||||
|
describe('production dependency defaults', () => {
|
||||||
|
it('assertIsolatedConfigDir defaults realClaudeDir to ~/.claude', () => {
|
||||||
|
const safe = join(tmpdir(), 'mosaic-claudex-default-real', 'home');
|
||||||
|
// Only canonicalize injected; realClaudeDir falls back to ~/.claude.
|
||||||
|
expect(assertIsolatedConfigDir(safe, { canonicalize: idCanon })).toBe(safe);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assertIsolatedConfigDir default canonicalizer resolves a non-existent path', () => {
|
||||||
|
// No canonicalize dep → exercises the real realpath-longest-ancestor walk
|
||||||
|
// (including the not-yet-existing tail), on a path safely outside ~/.claude.
|
||||||
|
const safe = join(tmpdir(), 'mosaic-claudex-canon', 'nested', 'home');
|
||||||
|
expect(assertIsolatedConfigDir(safe)).toContain('mosaic-claudex-canon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveClaudexConfigDir defaults mosaicHome when not injected', () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
|
||||||
|
try {
|
||||||
|
const target = join(dir, 'home');
|
||||||
|
// Override env points elsewhere; mosaicHome dep omitted → MOSAIC_HOME default path is exercised.
|
||||||
|
const out = resolveClaudexConfigDir(
|
||||||
|
{ [CLAUDEX_CONFIG_DIR_ENV]: target },
|
||||||
|
{ canonicalize: idCanon },
|
||||||
|
);
|
||||||
|
expect(out).toBe(target);
|
||||||
|
expect(lstatSync(target).isDirectory()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runClaudexProxyGate defaults ensureProxy/reauth/log without invoking them on a missing binary', async () => {
|
||||||
|
// Only preflight injected; binary missing → returns before the default
|
||||||
|
// ensureProxy/reauth thunks could ever reach the real proxy.
|
||||||
|
const preflight = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(makeReport({ binaryPresent: false, ok: false, problems: ['missing'] }));
|
||||||
|
const gate = await runClaudexProxyGate({ preflight });
|
||||||
|
expect(gate.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('launchClaudex defaults log/errorLog/fail/baseEnv/models/buildEnv on the success path', async () => {
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
const exec = vi.fn();
|
||||||
|
// Inject only the boundaries that would touch the real proxy/FS; let the
|
||||||
|
// rest default. Success path never calls fail/errorLog.
|
||||||
|
await launchClaudex([], true, okAdapter({ exec }), {
|
||||||
|
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
|
||||||
|
resolveConfigDir: () => join(tmpdir(), 'claudex-default-launch'),
|
||||||
|
});
|
||||||
|
expect(exec).toHaveBeenCalledTimes(1);
|
||||||
|
const [, , env] = exec.mock.calls[0]!;
|
||||||
|
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
|
||||||
|
expect(env.ANTHROPIC_MODEL).toBe(CLAUDEX_DEFAULT_PRIMARY_MODEL);
|
||||||
|
} finally {
|
||||||
|
logSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
464
packages/mosaic/src/commands/claudex.ts
Normal file
464
packages/mosaic/src/commands/claudex.ts
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
/**
|
||||||
|
* Claudex launch composition (P2–P4 of `mosaic yolo claudex`).
|
||||||
|
*
|
||||||
|
* Builds the isolated launch environment for running GPT models inside the
|
||||||
|
* Claude Code harness via `raine/claude-code-proxy` (ChatGPT-subscription OAuth).
|
||||||
|
* PR-1 (`claudex-proxy.ts`) owns the proxy preflight/lifecycle; this module owns
|
||||||
|
* the *composition* the launcher hands to Claude Code:
|
||||||
|
*
|
||||||
|
* P2 isolated CLAUDE_CONFIG_DIR (provably never the real ~/.claude) + env
|
||||||
|
* injection that leaks ZERO token material;
|
||||||
|
* P3 the model-tier map (primary → gpt-5.6-sol, small/fast → gpt-5.6-luna,
|
||||||
|
* operator env values win);
|
||||||
|
* P4 the EXPERIMENTAL classification banner + composed-contract note.
|
||||||
|
*
|
||||||
|
* Two hard security invariants (secrev-enforced):
|
||||||
|
* REQ 1 — Provable isolation. {@link assertIsolatedConfigDir} makes the
|
||||||
|
* CLAUDE_CONFIG_DIR seam incapable of resolving to `~/.claude` (or any
|
||||||
|
* descendant of it); it canonicalizes both sides, rejects descendants,
|
||||||
|
* and — after ensuring the dir — re-checks and rejects a symlinked
|
||||||
|
* target (TOCTOU). Fails CLOSED on any uncertainty.
|
||||||
|
* REQ 2 — Zero token leakage. This module never reads the proxy's
|
||||||
|
* `auth.json`; {@link buildClaudexEnv} strips the ENTIRE
|
||||||
|
* credential-bearing env family and hands Claude Code only
|
||||||
|
* `ANTHROPIC_AUTH_TOKEN=unused`. The proxy holds the real credential.
|
||||||
|
*
|
||||||
|
* Every side-effecting boundary is dependency-injected so the launch path is
|
||||||
|
* unit-testable without spawning Claude Code or touching a real config dir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { lstatSync, mkdirSync, realpathSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
|
import {
|
||||||
|
CLAUDEX_PROXY_URL,
|
||||||
|
ensureProxyRunning,
|
||||||
|
runDeviceReauth,
|
||||||
|
runProxyPreflight,
|
||||||
|
type EnsureProxyResult,
|
||||||
|
type PreflightReport,
|
||||||
|
} from './claudex-proxy.js';
|
||||||
|
|
||||||
|
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||||
|
|
||||||
|
// ─── Isolated CLAUDE_CONFIG_DIR (HARD SECURITY REQ 1) ─────────────────────────
|
||||||
|
|
||||||
|
/** Dedicated override env for the isolated config dir. The ambient
|
||||||
|
* `CLAUDE_CONFIG_DIR` is deliberately NOT honored — it may already point at the
|
||||||
|
* real `~/.claude` of the launching session. */
|
||||||
|
export const CLAUDEX_CONFIG_DIR_ENV = 'MOSAIC_CLAUDEX_CONFIG_DIR';
|
||||||
|
|
||||||
|
/** The default isolated config dir — structurally under the mosaic home, so it
|
||||||
|
* can never equal `~/.claude`. */
|
||||||
|
export function defaultClaudexConfigDir(mosaicHome: string = MOSAIC_HOME): string {
|
||||||
|
return join(mosaicHome, 'claudex', 'home');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigDirDeps {
|
||||||
|
/** The real Claude state dir to protect (default `~/.claude`). */
|
||||||
|
realClaudeDir?: string;
|
||||||
|
/** Resolve a path to canonical form, resolving symlinks on the longest
|
||||||
|
* existing ancestor (so a not-yet-created dir still canonicalizes). */
|
||||||
|
canonicalize?: (p: string) => string;
|
||||||
|
/** Ensure the isolated dir exists (mkdir -p, owner-only 0700). */
|
||||||
|
mkdir?: (p: string) => void;
|
||||||
|
/** Whether a path is itself a symlink (lstat). */
|
||||||
|
isSymlink?: (p: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a path to canonical form, resolving symlinks on the LONGEST EXISTING
|
||||||
|
* ancestor and re-appending the not-yet-existing tail. A symlinked ancestor that
|
||||||
|
* points into `~/.claude` is therefore caught even before the leaf exists. */
|
||||||
|
function defaultCanonicalizeIntended(p: string): string {
|
||||||
|
const abs = resolve(p);
|
||||||
|
let existing = abs;
|
||||||
|
const tail: string[] = [];
|
||||||
|
// Walk up until we hit an existing ancestor (or the filesystem root).
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const real = realpathSync(existing);
|
||||||
|
return tail.length > 0 ? join(real, ...tail) : real;
|
||||||
|
} catch (err) {
|
||||||
|
// ONLY a genuine "does not exist yet" (ENOENT) justifies walking up to an
|
||||||
|
// existing ancestor. Any other errno (ELOOP, EACCES, ENOTDIR, …) means we
|
||||||
|
// cannot establish the canonical form — fail CLOSED rather than fall back
|
||||||
|
// to a possibly-wrong literal path.
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||||
|
const parent = dirname(existing);
|
||||||
|
if (parent === existing) return abs; // reached root without an existing prefix
|
||||||
|
tail.unshift(existing.slice(parent.length + 1));
|
||||||
|
existing = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultMkdir(p: string): void {
|
||||||
|
mkdirSync(p, { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultIsSymlink(p: string): boolean {
|
||||||
|
try {
|
||||||
|
return lstatSync(p).isSymbolicLink();
|
||||||
|
} catch (err) {
|
||||||
|
// A missing path is genuinely "not a symlink"; anything else (EACCES, ELOOP,
|
||||||
|
// ENOTDIR, …) is uncertainty the guard must not swallow — fail CLOSED.
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `child` is `parent` itself or a descendant of it (path-wise). */
|
||||||
|
function isWithin(child: string, parent: string): boolean {
|
||||||
|
if (child === parent) return true;
|
||||||
|
const rel = relative(parent, child);
|
||||||
|
return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard: prove that `candidate` is a legitimate ISOLATED config dir and can
|
||||||
|
* never be, resolve to, or live under the real `~/.claude`. Canonicalizes BOTH
|
||||||
|
* sides (either may be a symlink), rejects `~/.claude` and any descendant, and
|
||||||
|
* fails CLOSED (throws) on an empty/relative candidate. Returns the canonical
|
||||||
|
* isolated path on success.
|
||||||
|
*/
|
||||||
|
export function assertIsolatedConfigDir(candidate: string, deps: ConfigDirDeps = {}): string {
|
||||||
|
const canonicalize = deps.canonicalize ?? defaultCanonicalizeIntended;
|
||||||
|
const realClaudeDir = deps.realClaudeDir ?? join(homedir(), '.claude');
|
||||||
|
|
||||||
|
if (typeof candidate !== 'string' || candidate.trim() === '') {
|
||||||
|
throw new Error('claudex: isolated CLAUDE_CONFIG_DIR must be a non-empty path (fail closed).');
|
||||||
|
}
|
||||||
|
if (!isAbsolute(candidate)) {
|
||||||
|
throw new Error(
|
||||||
|
`claudex: isolated CLAUDE_CONFIG_DIR must be an absolute path: ${JSON.stringify(candidate)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonCandidate = canonicalize(candidate);
|
||||||
|
const canonReal = canonicalize(realClaudeDir);
|
||||||
|
|
||||||
|
// Compare canonical forms AND raw resolved forms — belt and suspenders so a
|
||||||
|
// canonicalizer that no-ops on a nonexistent real dir still catches the literal.
|
||||||
|
if (isWithin(canonCandidate, canonReal) || isWithin(resolve(candidate), resolve(realClaudeDir))) {
|
||||||
|
throw new Error(
|
||||||
|
`claudex: refusing to use the real Claude config dir (or a descendant of it) as the ` +
|
||||||
|
`isolated CLAUDE_CONFIG_DIR. Resolved to ${JSON.stringify(canonCandidate)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return canonCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the isolated CLAUDE_CONFIG_DIR: pick the dedicated override or the
|
||||||
|
* namespaced default, run the pre-create guard, ensure the dir (0700), then
|
||||||
|
* RE-CHECK after creation — reject a symlinked target and re-run the guard on
|
||||||
|
* the now-existing (fully canonicalizable) path. This closes the pre-created
|
||||||
|
* symlink race (TOCTOU). Every failure throws (fail closed).
|
||||||
|
*/
|
||||||
|
export function resolveClaudexConfigDir(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
deps: ConfigDirDeps & { mosaicHome?: string } = {},
|
||||||
|
): string {
|
||||||
|
const mosaicHome = deps.mosaicHome ?? MOSAIC_HOME;
|
||||||
|
const mkdir = deps.mkdir ?? defaultMkdir;
|
||||||
|
const isSymlink = deps.isSymlink ?? defaultIsSymlink;
|
||||||
|
|
||||||
|
const override = env[CLAUDEX_CONFIG_DIR_ENV]?.trim();
|
||||||
|
const candidate =
|
||||||
|
override && override.length > 0 ? override : defaultClaudexConfigDir(mosaicHome);
|
||||||
|
|
||||||
|
// Pre-create guard (before touching the filesystem).
|
||||||
|
assertIsolatedConfigDir(candidate, deps);
|
||||||
|
|
||||||
|
// Ensure the dir, then re-verify against the post-create reality.
|
||||||
|
mkdir(candidate);
|
||||||
|
if (isSymlink(candidate)) {
|
||||||
|
throw new Error(
|
||||||
|
'claudex: refusing to use the isolated CLAUDE_CONFIG_DIR — the target is a symlink ' +
|
||||||
|
'(possible pre-created race). Fail closed.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Re-run the guard now that the leaf exists so canonicalization reflects any
|
||||||
|
// symlinked ancestor introduced between the pre-check and mkdir.
|
||||||
|
return assertIsolatedConfigDir(candidate, deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Model-tier map (P3) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CLAUDEX_DEFAULT_PRIMARY_MODEL = 'gpt-5.6-sol';
|
||||||
|
export const CLAUDEX_DEFAULT_SMALL_FAST_MODEL = 'gpt-5.6-luna';
|
||||||
|
|
||||||
|
export interface ClaudexModels {
|
||||||
|
/** Primary tier (opus/sonnet) → ANTHROPIC_MODEL. */
|
||||||
|
primary: string;
|
||||||
|
/** Small/fast tier (haiku) → ANTHROPIC_SMALL_FAST_MODEL. */
|
||||||
|
smallFast: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the model-tier map. Operator-provided env values WIN over defaults;
|
||||||
|
* blank values fall back to the defaults. */
|
||||||
|
export function resolveClaudexModels(env: NodeJS.ProcessEnv = process.env): ClaudexModels {
|
||||||
|
const primary = env['ANTHROPIC_MODEL']?.trim() || CLAUDEX_DEFAULT_PRIMARY_MODEL;
|
||||||
|
const smallFast = env['ANTHROPIC_SMALL_FAST_MODEL']?.trim() || CLAUDEX_DEFAULT_SMALL_FAST_MODEL;
|
||||||
|
return { primary, smallFast };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Env injection (HARD SECURITY REQ 2 — zero token leakage) ─────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names of env vars considered credential-bearing. The whole family is stripped
|
||||||
|
* from the composed env so no real Anthropic key, OAuth token, or third-party /
|
||||||
|
* cloud credential can reach the local proxy or be used by Claude Code to bypass
|
||||||
|
* it. We then re-add ONLY the safe claudex vars (`ANTHROPIC_MODEL`,
|
||||||
|
* `_SMALL_FAST_MODEL`, `_BASE_URL`, `_AUTH_TOKEN=unused`). A name-pattern sweep
|
||||||
|
* can't miss a specific var a short denylist forgot, while still preserving the
|
||||||
|
* arbitrary non-credential env the harness/MCP/hooks require (PATH, HOME, XDG,
|
||||||
|
* terminal, proxies, …), which a strict allowlist would fragilely drop.
|
||||||
|
*
|
||||||
|
* The cloud-provider families (`AWS_*`, `GOOGLE_APPLICATION_CREDENTIALS`,
|
||||||
|
* `GOOGLE_CLOUD_*`, `GCP_*`) are included because Claude Code can route to the
|
||||||
|
* real Anthropic API via AWS Bedrock / GCP Vertex using the ambient cloud
|
||||||
|
* credential chain — a Claude-capable credential that must never survive into a
|
||||||
|
* claudex launch. `_KEY$` / `_SECRET` (not just the `_API_KEY$` tail) close the
|
||||||
|
* mid-string gap (`STRIPE_SECRET_KEY`, `SSH_PRIVATE_KEY`, `AWS_SECRET_ACCESS_KEY`).
|
||||||
|
*/
|
||||||
|
export const CLAUDEX_CREDENTIAL_ENV_RE =
|
||||||
|
/^ANTHROPIC_|^CLAUDE_CODE_OAUTH|^AWS_|^GOOGLE_APPLICATION_CREDENTIALS$|^GOOGLE_CLOUD_|^GCP_|_API_?KEY$|_KEY$|_TOKEN$|_SECRET/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider ROUTING switches whose mere PRESENCE (independent of any credential)
|
||||||
|
* makes Claude Code bypass `ANTHROPIC_BASE_URL` (the loopback proxy) and talk to
|
||||||
|
* the real Anthropic API via Bedrock/Vertex. A name-pattern is the wrong model
|
||||||
|
* for a boolean switch, so these are force-deleted by exact name — REGARDLESS of
|
||||||
|
* value — after the credential sweep. (REQ 2: isolation must hold for any
|
||||||
|
* launching env, including a Bedrock/Vertex-configured enterprise host.)
|
||||||
|
*/
|
||||||
|
export const CLAUDEX_FORCED_UNSET_ENV = [
|
||||||
|
'CLAUDE_CODE_USE_BEDROCK',
|
||||||
|
'CLAUDE_CODE_USE_VERTEX',
|
||||||
|
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
|
||||||
|
'CLAUDE_CODE_SKIP_VERTEX_AUTH',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface BuildClaudexEnvOptions {
|
||||||
|
configDir: string;
|
||||||
|
models: ClaudexModels;
|
||||||
|
/** Override the proxy base URL (defaults to the PR-1 loopback constant). */
|
||||||
|
baseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose the launch env for Claude Code. Returns a FRESH object (never mutates
|
||||||
|
* the base env). Strips the entire credential-bearing family AND force-deletes
|
||||||
|
* the Bedrock/Vertex routing switches (REQ 2), then sets the isolated config dir
|
||||||
|
* and the proxy routing. Claude Code sees only `ANTHROPIC_AUTH_TOKEN=unused`
|
||||||
|
* pointed at the loopback proxy; the proxy holds the real OAuth credential, which
|
||||||
|
* this module never reads.
|
||||||
|
*/
|
||||||
|
export function buildClaudexEnv(
|
||||||
|
baseEnv: NodeJS.ProcessEnv,
|
||||||
|
opts: BuildClaudexEnvOptions,
|
||||||
|
): NodeJS.ProcessEnv {
|
||||||
|
const env: NodeJS.ProcessEnv = {};
|
||||||
|
for (const [name, value] of Object.entries(baseEnv)) {
|
||||||
|
if (CLAUDEX_CREDENTIAL_ENV_RE.test(name)) continue; // drop the whole credential family
|
||||||
|
env[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force-delete routing switches by exact name — their presence (not their
|
||||||
|
// value) is what would route Claude Code off the proxy to the real API.
|
||||||
|
for (const name of CLAUDEX_FORCED_UNSET_ENV) delete env[name];
|
||||||
|
|
||||||
|
env['CLAUDE_CONFIG_DIR'] = opts.configDir;
|
||||||
|
env['ANTHROPIC_BASE_URL'] = opts.baseUrl ?? CLAUDEX_PROXY_URL;
|
||||||
|
env['ANTHROPIC_AUTH_TOKEN'] = 'unused';
|
||||||
|
env['ANTHROPIC_MODEL'] = opts.models.primary;
|
||||||
|
env['ANTHROPIC_SMALL_FAST_MODEL'] = opts.models.smallFast;
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Console banner shown at launch. Contains no token material by construction. */
|
||||||
|
export function buildClaudexBanner(models: ClaudexModels): string {
|
||||||
|
return [
|
||||||
|
'',
|
||||||
|
' ┌─────────────────────────────────────────────────────────────────────┐',
|
||||||
|
' │ ⚠ EXPERIMENTAL — mosaic claudex │',
|
||||||
|
' └─────────────────────────────────────────────────────────────────────┘',
|
||||||
|
` Running GPT models inside the Claude Code harness via claude-code-proxy`,
|
||||||
|
` (ChatGPT-subscription OAuth). This is NOT Anthropic Claude.`,
|
||||||
|
` primary : ${models.primary}`,
|
||||||
|
` small/fast : ${models.smallFast}`,
|
||||||
|
` Model behavior, tool use, and output quality may differ from Claude.`,
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Markdown note appended to the composed runtime contract so the model itself
|
||||||
|
* knows it is running the EXPERIMENTAL GPT-via-proxy configuration. */
|
||||||
|
export function buildClaudexContractNote(models: ClaudexModels): string {
|
||||||
|
return [
|
||||||
|
'# EXPERIMENTAL Runtime — claudex (GPT via claude-code-proxy)',
|
||||||
|
'',
|
||||||
|
'You are running in Mosaic **claudex** mode: the Claude Code harness is wired to',
|
||||||
|
'GPT models through a local `claude-code-proxy` (ChatGPT-subscription OAuth). This',
|
||||||
|
"runtime is NOT Anthropic's Claude API and is not Claude.",
|
||||||
|
'',
|
||||||
|
`- Primary model: \`${models.primary}\``,
|
||||||
|
`- Small/fast model: \`${models.smallFast}\``,
|
||||||
|
'',
|
||||||
|
'Some Claude-specific harness assumptions may not hold under GPT models — verify',
|
||||||
|
'tool output carefully. This classification is EXPERIMENTAL and is not intended for',
|
||||||
|
'production delivery without explicit operator sign-off.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Proxy gate ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ProxyGateResult {
|
||||||
|
ok: boolean;
|
||||||
|
report: PreflightReport;
|
||||||
|
/** Non-sensitive problems suitable for surfacing to the operator. */
|
||||||
|
problems: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyGateDeps {
|
||||||
|
preflight?: () => Promise<PreflightReport>;
|
||||||
|
ensureProxy?: () => Promise<EnsureProxyResult>;
|
||||||
|
reauth?: () => number;
|
||||||
|
log?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the proxy readiness gate: preflight → (device reauth if OAuth needs it) →
|
||||||
|
* (start the proxy if nothing trusted is live) → re-preflight. Returns `ok` only
|
||||||
|
* when the final preflight passes (binary present, OAuth valid, a TRUSTED-live
|
||||||
|
* listener — identity verified by PR-1's `verifyListenerIdentity`). All surfaced
|
||||||
|
* problems are non-sensitive (port + verdict only; never a token).
|
||||||
|
*/
|
||||||
|
export async function runClaudexProxyGate(deps: ProxyGateDeps = {}): Promise<ProxyGateResult> {
|
||||||
|
const preflight = deps.preflight ?? (() => runProxyPreflight());
|
||||||
|
const ensureProxy = deps.ensureProxy ?? (() => ensureProxyRunning());
|
||||||
|
const reauth = deps.reauth ?? (() => runDeviceReauth());
|
||||||
|
const log = deps.log ?? (() => {});
|
||||||
|
|
||||||
|
let report = await preflight();
|
||||||
|
|
||||||
|
// A missing binary is unrecoverable here — don't attempt reauth or a start.
|
||||||
|
if (!report.binaryPresent) {
|
||||||
|
return { ok: false, report, problems: report.problems };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.needsReauth) {
|
||||||
|
log('claudex: claude-code-proxy OAuth needs re-authentication — starting device flow…');
|
||||||
|
const code = reauth();
|
||||||
|
if (code !== 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
report,
|
||||||
|
problems: [...report.problems, 'claudex: device re-authentication did not complete.'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
report = await preflight();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!report.live) {
|
||||||
|
log('claudex: no trusted claude-code-proxy responding — starting it…');
|
||||||
|
const started = await ensureProxy();
|
||||||
|
if (!started.live) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
report,
|
||||||
|
problems: [
|
||||||
|
...report.problems,
|
||||||
|
`claudex: could not bring up a trusted claude-code-proxy (${started.method}).`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
report = await preflight();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: report.ok, report, problems: report.problems };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Launch orchestration ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The launch.ts-provided seam. Keeps `claudex.ts` free of a circular import back
|
||||||
|
* into `launch.ts` while letting the orchestration reuse the harness preflight,
|
||||||
|
* the composed runtime contract, and the process-replacing exec.
|
||||||
|
*/
|
||||||
|
export interface ClaudexHarnessAdapter {
|
||||||
|
/** Runs the Claude-harness preflight (mosaic home, SOUL, `claude` on PATH,
|
||||||
|
* sequential-thinking). May terminate the process on a hard failure. */
|
||||||
|
harnessPreflight: () => void;
|
||||||
|
/** Compose the full Claude runtime contract (== `composeContract('claude')`). */
|
||||||
|
composePrompt: () => string;
|
||||||
|
/** Replace the current process with `claude` using the composed env. */
|
||||||
|
exec: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LaunchClaudexDeps {
|
||||||
|
baseEnv?: NodeJS.ProcessEnv;
|
||||||
|
proxyGate?: () => Promise<ProxyGateResult>;
|
||||||
|
resolveConfigDir?: () => string;
|
||||||
|
models?: () => ClaudexModels;
|
||||||
|
buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv;
|
||||||
|
log?: (message: string) => void;
|
||||||
|
errorLog?: (message: string) => void;
|
||||||
|
fail?: (code: number) => never;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrate a `mosaic [yolo] claudex` launch. Runs the harness preflight, the
|
||||||
|
* proxy gate, composes the isolated env (REQ 1 + REQ 2), appends the EXPERIMENTAL
|
||||||
|
* note, and exec's Claude Code. FAIL CLOSED: on any gate failure or guard throw
|
||||||
|
* it reports non-sensitive detail and exits WITHOUT reaching exec.
|
||||||
|
*/
|
||||||
|
export async function launchClaudex(
|
||||||
|
args: string[],
|
||||||
|
yolo: boolean,
|
||||||
|
adapter: ClaudexHarnessAdapter,
|
||||||
|
deps: LaunchClaudexDeps = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const log = deps.log ?? ((m: string) => console.log(m));
|
||||||
|
const errorLog = deps.errorLog ?? ((m: string) => console.error(m));
|
||||||
|
const fail = deps.fail ?? ((code: number) => process.exit(code));
|
||||||
|
const baseEnv = deps.baseEnv ?? process.env;
|
||||||
|
const proxyGate = deps.proxyGate ?? (() => runClaudexProxyGate({ log }));
|
||||||
|
const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv));
|
||||||
|
const models = deps.models ?? (() => resolveClaudexModels(baseEnv));
|
||||||
|
const buildEnv = deps.buildEnv ?? buildClaudexEnv;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Harness readiness first (claude on PATH, mosaic home, sequential-thinking).
|
||||||
|
adapter.harnessPreflight();
|
||||||
|
|
||||||
|
// Proxy readiness (binary, OAuth, trusted-live listener).
|
||||||
|
const gate = await proxyGate();
|
||||||
|
if (!gate.ok) {
|
||||||
|
errorLog('[mosaic] claudex preflight failed:');
|
||||||
|
for (const problem of gate.problems) errorLog(` - ${problem}`);
|
||||||
|
return fail(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose the isolated launch env (guard throws → caught below, fail closed).
|
||||||
|
const resolvedModels = models();
|
||||||
|
const configDir = resolveConfigDir();
|
||||||
|
const env = buildEnv(baseEnv, { configDir, models: resolvedModels });
|
||||||
|
const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`;
|
||||||
|
|
||||||
|
log(buildClaudexBanner(resolvedModels));
|
||||||
|
|
||||||
|
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
||||||
|
cliArgs.push('--append-system-prompt', prompt, ...args);
|
||||||
|
adapter.exec('claude', cliArgs, env);
|
||||||
|
} catch (err) {
|
||||||
|
errorLog(
|
||||||
|
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
return fail(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
885
packages/mosaic/src/commands/fleet-regen-command.spec.ts
Normal file
885
packages/mosaic/src/commands/fleet-regen-command.spec.ts
Normal file
@@ -0,0 +1,885 @@
|
|||||||
|
import { chmod, mkdir, mkdtemp, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
|
||||||
|
import { executeFleetRegen, formatFleetRegenReport } from './fleet-regen-command.js';
|
||||||
|
import {
|
||||||
|
acquirePrivateRosterMutationLock,
|
||||||
|
projectRosterV2AgentGeneratedEnv,
|
||||||
|
} from '../fleet/fleet-reconciler.js';
|
||||||
|
import { applyPreparedGeneratedAgentEnvironmentProjection } from '../fleet/generated-env-boundary.js';
|
||||||
|
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
||||||
|
|
||||||
|
// A two-agent roster-v2 SSOT. `mosaic fleet regen` must rebuild each agent's
|
||||||
|
// `fleet/agents/<name>.env.generated` projection from exactly this source and
|
||||||
|
// nothing else — deterministically, without ever touching agent lifecycle.
|
||||||
|
const rosterYaml = `
|
||||||
|
version: 2
|
||||||
|
generation: 7
|
||||||
|
transport: tmux
|
||||||
|
tmux:
|
||||||
|
socket_name: mosaic-fleet
|
||||||
|
holder_session: _holder
|
||||||
|
defaults:
|
||||||
|
working_directory: /srv/mosaic
|
||||||
|
runtime: pi
|
||||||
|
runtimes:
|
||||||
|
pi:
|
||||||
|
reset_command: /new
|
||||||
|
agents:
|
||||||
|
- name: coder0
|
||||||
|
alias: Coder 0
|
||||||
|
class: code
|
||||||
|
runtime: pi
|
||||||
|
provider: openai
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
reasoning: high
|
||||||
|
tool_policy: code
|
||||||
|
working_directory: /srv/mosaic
|
||||||
|
persistent_persona: false
|
||||||
|
reset_between_tasks: true
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
desired_state: stopped
|
||||||
|
launch:
|
||||||
|
yolo: true
|
||||||
|
- name: coder1
|
||||||
|
alias: Coder 1
|
||||||
|
class: code
|
||||||
|
runtime: pi
|
||||||
|
provider: openai
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
reasoning: medium
|
||||||
|
tool_policy: code
|
||||||
|
working_directory: /srv/other
|
||||||
|
persistent_persona: false
|
||||||
|
reset_between_tasks: true
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
desired_state: stopped
|
||||||
|
launch:
|
||||||
|
yolo: true
|
||||||
|
`;
|
||||||
|
|
||||||
|
let cleanup: string | undefined;
|
||||||
|
|
||||||
|
afterEach(async (): Promise<void> => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.exitCode = undefined;
|
||||||
|
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||||
|
cleanup = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fleetHome(withRoster = rosterYaml): Promise<string> {
|
||||||
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-regen-command-'));
|
||||||
|
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
||||||
|
await mkdir(join(cleanup, directory), { recursive: true, mode: 0o700 });
|
||||||
|
await chmod(join(cleanup, directory), 0o700);
|
||||||
|
}
|
||||||
|
await chmod(cleanup, 0o700);
|
||||||
|
await writeFile(join(cleanup, 'fleet', 'roster.yaml'), withRoster, { mode: 0o600 });
|
||||||
|
// Semantic roster validation (matching `fleet reconcile`) resolves each agent
|
||||||
|
// class to a persona; seed the classes the fixtures reference.
|
||||||
|
for (const klass of ['code', 'merge-gate']) {
|
||||||
|
await writeFile(
|
||||||
|
join(cleanup, 'fleet', 'roles', `${klass}.md`),
|
||||||
|
`\`class: ${klass}\`\n\n# ${klass} persona\n`,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A runner spy that records EVERY invocation. `regen` is projection-only and
|
||||||
|
* must never issue a lifecycle/restart call, so a non-empty call log is a
|
||||||
|
* hard failure — this is the load-bearing "never restarts" gate.
|
||||||
|
*/
|
||||||
|
function recordingRunner(
|
||||||
|
calls: string[][],
|
||||||
|
): (command: string, args: string[]) => Promise<CommandResult> {
|
||||||
|
return async (command: string, args: string[]): Promise<CommandResult> => {
|
||||||
|
calls.push([command, ...args]);
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function program(mosaicHome: string, runner: FleetCommandDeps['runner']): Command {
|
||||||
|
const result = new Command();
|
||||||
|
result.exitOverride();
|
||||||
|
registerFleetCommand(result, { mosaicHome, runner });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capture(): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||||
|
lines.push(value);
|
||||||
|
});
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await stat(path);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('projectRosterV2AgentGeneratedEnv', (): void => {
|
||||||
|
it('maps a roster-v2 agent to exactly the eight generated projection keys', (): void => {
|
||||||
|
const roster = parseRosterV2(rosterYaml, 'yaml');
|
||||||
|
const agent = roster.agents.find((candidate) => candidate.name === 'coder0');
|
||||||
|
expect(agent).toBeDefined();
|
||||||
|
const values = projectRosterV2AgentGeneratedEnv(roster, agent!);
|
||||||
|
expect(values).toEqual({
|
||||||
|
MOSAIC_AGENT_NAME: 'coder0',
|
||||||
|
MOSAIC_AGENT_CLASS: 'code',
|
||||||
|
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||||
|
MOSAIC_AGENT_MODEL: 'gpt-5.6-sol',
|
||||||
|
MOSAIC_AGENT_REASONING: 'high',
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY: 'code',
|
||||||
|
MOSAIC_AGENT_WORKDIR: '/srv/mosaic',
|
||||||
|
MOSAIC_TMUX_SOCKET: 'mosaic-fleet',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mosaic fleet regen', (): void => {
|
||||||
|
it('is dry-run by default: reports the plan and writes nothing', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await program(home, recordingRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = JSON.parse(lines.pop() ?? '{}');
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
mode: 'dry-run',
|
||||||
|
generation: 7,
|
||||||
|
agentCount: 2,
|
||||||
|
written: 0,
|
||||||
|
});
|
||||||
|
expect(result.agents.map((agent: { name: string }) => agent.name)).toEqual([
|
||||||
|
'coder0',
|
||||||
|
'coder1',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
result.agents.every((agent: { disposition: string }) => agent.disposition === 'create'),
|
||||||
|
).toBe(true);
|
||||||
|
// Nothing on disk.
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(false);
|
||||||
|
// No lifecycle/restart call — ever.
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards configured persona roots (rolesDir/overrideDir) from reconcileDeps into regen', async (): Promise<void> => {
|
||||||
|
// Codex r6: regen must resolve personas the SAME way reconcile does. If the
|
||||||
|
// top-level registration drops reconcileDeps.rolesDir/overrideDir, a deployment
|
||||||
|
// with custom persona roots gets reconcile ACCEPTING a roster while regen
|
||||||
|
// REJECTS it (validating against the wrong default `<home>/fleet/roles`),
|
||||||
|
// blocking the recovery command. Pin the wiring: seed personas ONLY under the
|
||||||
|
// custom roots, leave the default roles dir empty, require regen to succeed.
|
||||||
|
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-regen-wiring-'));
|
||||||
|
cleanup = home;
|
||||||
|
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
||||||
|
await mkdir(join(home, directory), { recursive: true, mode: 0o700 });
|
||||||
|
await chmod(join(home, directory), 0o700);
|
||||||
|
}
|
||||||
|
await chmod(home, 0o700);
|
||||||
|
await writeFile(join(home, 'fleet', 'roster.yaml'), rosterYaml, { mode: 0o600 });
|
||||||
|
// Personas live ONLY under the configured roots — the default `fleet/roles`
|
||||||
|
// stays empty, so broken wiring fails persona resolution.
|
||||||
|
const customRoles = join(home, 'custom-roles');
|
||||||
|
const customOverride = join(home, 'custom-roles.local');
|
||||||
|
await mkdir(customRoles, { recursive: true, mode: 0o700 });
|
||||||
|
await mkdir(customOverride, { recursive: true, mode: 0o700 });
|
||||||
|
for (const klass of ['code', 'merge-gate']) {
|
||||||
|
await writeFile(join(customRoles, `${klass}.md`), `\`class: ${klass}\`\n\n# ${klass}\n`, {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new Command();
|
||||||
|
command.exitOverride();
|
||||||
|
registerFleetCommand(command, {
|
||||||
|
mosaicHome: home,
|
||||||
|
runner: recordingRunner([]),
|
||||||
|
reconcileDeps: { rolesDir: customRoles, overrideDir: customOverride },
|
||||||
|
});
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await command.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']);
|
||||||
|
|
||||||
|
// Regen validated against the CONFIGURED roots → success. Broken wiring
|
||||||
|
// resolves against the empty default and fails (exitCode 1, no JSON result).
|
||||||
|
expect(process.exitCode).not.toBe(1);
|
||||||
|
const result = JSON.parse(lines.pop() ?? '{}');
|
||||||
|
expect(result).toMatchObject({ mode: 'dry-run', agentCount: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('--write rebuilds each generated projection from the roster SSOT', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await program(home, recordingRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = JSON.parse(lines.pop() ?? '{}');
|
||||||
|
expect(result).toMatchObject({ mode: 'write', written: 2, agentCount: 2 });
|
||||||
|
|
||||||
|
const coder0 = await readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8');
|
||||||
|
expect(coder0).toContain('MOSAIC_AGENT_NAME=coder0');
|
||||||
|
expect(coder0).toContain('MOSAIC_AGENT_WORKDIR=/srv/mosaic');
|
||||||
|
expect(coder0).toContain('MOSAIC_TMUX_SOCKET=mosaic-fleet');
|
||||||
|
const coder1 = await readFile(join(home, 'fleet', 'agents', 'coder1.env.generated'), 'utf8');
|
||||||
|
expect(coder1).toContain('MOSAIC_AGENT_NAME=coder1');
|
||||||
|
expect(coder1).toContain('MOSAIC_AGENT_WORKDIR=/srv/other');
|
||||||
|
// No lifecycle/restart call — ever.
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic and idempotent across repeated --write runs', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const path = join(home, 'fleet', 'agents', 'coder0.env.generated');
|
||||||
|
|
||||||
|
const cli = program(home, recordingRunner(calls));
|
||||||
|
capture();
|
||||||
|
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
|
||||||
|
const first = await readFile(path, 'utf8');
|
||||||
|
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
|
||||||
|
const second = await readFile(path, 'utf8');
|
||||||
|
|
||||||
|
expect(second).toBe(first);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports rebuild disposition once the generated projection already exists', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const cli = program(home, recordingRunner(calls));
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
|
||||||
|
lines.length = 0;
|
||||||
|
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']);
|
||||||
|
|
||||||
|
const result = JSON.parse(lines.pop() ?? '{}');
|
||||||
|
expect(
|
||||||
|
result.agents.every((agent: { disposition: string }) => agent.disposition === 'rebuild'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(result.written).toBe(0);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never issues a lifecycle/restart call in either mode', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const cli = program(home, recordingRunner(calls));
|
||||||
|
capture();
|
||||||
|
|
||||||
|
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--json']);
|
||||||
|
await cli.parseAsync(['node', 'mosaic', 'fleet', 'regen', '--write', '--json']);
|
||||||
|
|
||||||
|
// Structural guarantee: regen has no path to systemctl/tmux at all.
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
const systemctlCalls = calls.filter(([command]) => command === 'systemctl');
|
||||||
|
expect(systemctlCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('human-readable --write output prints the do-not-restart recovery runbook', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await program(home, recordingRunner([])).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const output = lines.join('\n');
|
||||||
|
expect(output).toMatch(/do not restart|before.*restart/i);
|
||||||
|
expect(output).toMatch(/env\.generated/);
|
||||||
|
// The unit has no EnvironmentFile= directive, so the runbook must NOT tell the
|
||||||
|
// operator to verify one — verify the launcher/generated file instead.
|
||||||
|
expect(output).not.toContain('EnvironmentFile');
|
||||||
|
expect(output).toMatch(/systemctl --user cat mosaic-agent@<name>/);
|
||||||
|
expect(output).toMatch(/restart/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits paths and counts only — never the projected env body (secrev)', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await program(home, recordingRunner([])).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const output = lines.join('\n');
|
||||||
|
// Relative paths + counts are fine; the rendered projection body (KEY=value
|
||||||
|
// lines) must never be echoed to stdout.
|
||||||
|
expect(output).toContain('coder0.env.generated');
|
||||||
|
expect(output).not.toMatch(/^MOSAIC_AGENT_\w+=/m);
|
||||||
|
expect(output).not.toContain('MOSAIC_TMUX_SOCKET=mosaic-fleet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is projection-only: leaves legacy .env untouched and writes no .env.local/.env.quarantine', async (): Promise<void> => {
|
||||||
|
// Recovery contract: regen rebuilds ONLY <name>.env.generated. It must never
|
||||||
|
// relocate, quarantine, or unlink the operator-owned legacy .env surface — the
|
||||||
|
// full reconciler apply path does, so regen must NOT use it.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const legacyPath = join(home, 'fleet', 'agents', 'coder0.env');
|
||||||
|
await writeFile(legacyPath, 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', { mode: 0o600 });
|
||||||
|
capture();
|
||||||
|
|
||||||
|
await program(home, recordingRunner([])).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Generated projection rebuilt...
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(true);
|
||||||
|
// ...but the operator's legacy .env is preserved verbatim, and no local/quarantine
|
||||||
|
// files were fabricated from it.
|
||||||
|
expect(await readFile(legacyPath, 'utf8')).toBe('MOSAIC_RUNTIME_BIN=/usr/bin/pi\n');
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.local'))).toBe(false);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.quarantine'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepares every agent before writing any: a later prepare failure leaves nothing written', async (): Promise<void> => {
|
||||||
|
// Fail-closed across agents. Pre-seed coder1's projection with world-readable
|
||||||
|
// perms so its prepare rejects; coder0 (valid) must NOT be written because
|
||||||
|
// preparation is fully completed before the first apply.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const coder1Path = join(home, 'fleet', 'agents', 'coder1.env.generated');
|
||||||
|
await writeFile(coder1Path, 'MOSAIC_AGENT_NAME=coder1\n', { mode: 0o644 });
|
||||||
|
const calls: string[][] = [];
|
||||||
|
capture();
|
||||||
|
const errors: string[] = [];
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||||
|
errors.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await program(home, recordingRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(errors.join('')).toMatch(/regen failed/i);
|
||||||
|
// coder0 is valid but must remain unwritten — no partial rebuild.
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed on a semantically invalid roster (protected-class tool-policy mismatch)', async (): Promise<void> => {
|
||||||
|
// A hand-edited roster giving a protected class a weaker tool_policy is rejected
|
||||||
|
// by reconcile/plan/verify; regen must enforce the SAME gate, not silently
|
||||||
|
// project the downgraded policy into <name>.env.generated.
|
||||||
|
const home = await fleetHome(`
|
||||||
|
version: 2
|
||||||
|
generation: 3
|
||||||
|
transport: tmux
|
||||||
|
tmux:
|
||||||
|
socket_name: mosaic-fleet
|
||||||
|
holder_session: _holder
|
||||||
|
defaults:
|
||||||
|
working_directory: /srv/mosaic
|
||||||
|
runtime: pi
|
||||||
|
runtimes:
|
||||||
|
pi:
|
||||||
|
reset_command: /new
|
||||||
|
agents:
|
||||||
|
- name: gate0
|
||||||
|
alias: Gate 0
|
||||||
|
class: merge-gate
|
||||||
|
runtime: pi
|
||||||
|
provider: openai
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
reasoning: high
|
||||||
|
tool_policy: code
|
||||||
|
working_directory: /srv/mosaic
|
||||||
|
persistent_persona: false
|
||||||
|
reset_between_tasks: true
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
desired_state: stopped
|
||||||
|
launch:
|
||||||
|
yolo: true
|
||||||
|
`);
|
||||||
|
const calls: string[][] = [];
|
||||||
|
capture();
|
||||||
|
const errors: string[] = [];
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||||
|
errors.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await program(home, recordingRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(errors.join('')).toMatch(/regen failed/i);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'gate0.env.generated'))).toBe(false);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to write while a concurrent reconcile holds the mutation lock', async (): Promise<void> => {
|
||||||
|
// regen --write mutates the same projections as reconcile; it must take the
|
||||||
|
// shared reconcile lock so a concurrent reconcile cannot race a stale write.
|
||||||
|
const home = await fleetHome();
|
||||||
|
await writeFile(join(home, 'fleet', 'roster.yaml.reconcile.lock'), 'held\n', { mode: 0o600 });
|
||||||
|
const calls: string[][] = [];
|
||||||
|
capture();
|
||||||
|
const errors: string[] = [];
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||||
|
errors.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await program(home, recordingRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(errors.join('')).toMatch(/regen failed/i);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed (non-zero, no writes) on an invalid roster', async (): Promise<void> => {
|
||||||
|
// roster-v2 requires >= 1 agent; a malformed roster must abort regen without
|
||||||
|
// writing any projection and without touching lifecycle.
|
||||||
|
const home = await fleetHome('version: 2\ngeneration: 1\n');
|
||||||
|
const calls: string[][] = [];
|
||||||
|
capture();
|
||||||
|
const errors: string[] = [];
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||||
|
errors.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await program(home, recordingRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(errors.join('')).toMatch(/regen failed/i);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-canonical --roster path (canonical roster only)', async (): Promise<void> => {
|
||||||
|
// `fleet` exposes a global `--roster`; reconcile rejects a non-canonical value
|
||||||
|
// rather than silently reading the canonical roster. regen must enforce the
|
||||||
|
// SAME guard so `--roster /elsewhere regen --write` cannot mislead the operator.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const calls: string[][] = [];
|
||||||
|
capture();
|
||||||
|
const errors: string[] = [];
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||||
|
errors.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await program(home, recordingRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'--roster',
|
||||||
|
join(home, 'other', 'roster.yaml'),
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(errors.join('')).toMatch(/regen failed/i);
|
||||||
|
expect(errors.join('')).toMatch(/canonical roster/i);
|
||||||
|
// No projection written against a misdirected roster path.
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an incomplete rebuild when a later projection write fails mid-loop', async (): Promise<void> => {
|
||||||
|
// Fault injected on the SECOND apply: coder0 is already replaced on disk, so a
|
||||||
|
// bare throw would hide the partial state. regen must surface which agents were
|
||||||
|
// rebuilt and which failed, with a verify-before-restart recovery instruction.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const realApply = applyPreparedGeneratedAgentEnvironmentProjection;
|
||||||
|
const result = await executeFleetRegen(
|
||||||
|
{
|
||||||
|
runner: recordingRunner([]),
|
||||||
|
mosaicHome: home,
|
||||||
|
applyProjection: async (prepared): Promise<string> => {
|
||||||
|
if (prepared.generatedPath.endsWith('coder1.env.generated')) {
|
||||||
|
throw new Error('simulated ENOSPC on coder1');
|
||||||
|
}
|
||||||
|
return realApply(prepared);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ write: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// coder0 written before the fault; coder1 not.
|
||||||
|
expect(result.written).toBe(1);
|
||||||
|
expect(result.incomplete).toMatchObject({
|
||||||
|
code: 'projection-apply-failed',
|
||||||
|
failedAgent: 'coder1',
|
||||||
|
writtenAgents: ['coder0'],
|
||||||
|
});
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(true);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(false);
|
||||||
|
|
||||||
|
// The human report names the failed agent and directs a verify-before-restart
|
||||||
|
// recovery — not a bare success runbook.
|
||||||
|
const report = formatFleetRegenReport(result).join('\n');
|
||||||
|
expect(report).toMatch(/incomplete/i);
|
||||||
|
expect(report).toContain('coder1');
|
||||||
|
expect(report).toMatch(/verify|re-run|retry/i);
|
||||||
|
// Structural: still no lifecycle call anywhere on the failure path.
|
||||||
|
expect(report).not.toMatch(/systemctl --user restart mosaic-agent@<name>\n.*\n/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to write while agent CRUD holds the roster mutation lock', async (): Promise<void> => {
|
||||||
|
// regen --write overwrites the SAME generated projections that `fleet agent
|
||||||
|
// create/update/delete` writes under roster.yaml.mutation.lock. If regen only
|
||||||
|
// took the reconcile lock it could read a stale roster and overwrite a just-
|
||||||
|
// committed projection. It must also take the mutation lock and fail closed.
|
||||||
|
const home = await fleetHome();
|
||||||
|
await writeFile(join(home, 'fleet', 'roster.yaml.mutation.lock'), 'crud\n', { mode: 0o600 });
|
||||||
|
const calls: string[][] = [];
|
||||||
|
capture();
|
||||||
|
const errors: string[] = [];
|
||||||
|
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array): boolean => {
|
||||||
|
errors.push(String(chunk));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await program(home, recordingRunner(calls)).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'regen',
|
||||||
|
'--write',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
expect(errors.join('')).toMatch(/regen failed/i);
|
||||||
|
// No projection written against a roster a concurrent CRUD is mutating.
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(false);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces BOTH the roster failure and a stale-lock warning on a double fault', async (): Promise<void> => {
|
||||||
|
// Worst case with nothing written: run() fails (invalid roster, fail-closed) AND
|
||||||
|
// a lock release faults. The operator must be told the lock may be stale, not
|
||||||
|
// just the roster error — otherwise the next regen/reconcile is silently blocked.
|
||||||
|
const home = await fleetHome('version: 2\ngeneration: 1\n');
|
||||||
|
const throwingRelease =
|
||||||
|
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||||
|
throw new Error('simulated lock unlink fault');
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
executeFleetRegen(
|
||||||
|
{
|
||||||
|
runner: recordingRunner([]),
|
||||||
|
mosaicHome: home,
|
||||||
|
acquireReconcileLock: throwingRelease,
|
||||||
|
acquireRosterMutationLock: throwingRelease,
|
||||||
|
},
|
||||||
|
{ write: true },
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/stale|inspect|lock/i);
|
||||||
|
// Nothing written on the fail-closed path.
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates a roster-mutation-lock release failure instead of silently keeping a stale lock', async (): Promise<void> => {
|
||||||
|
// Finding L: the real `acquirePrivateRosterMutationLock` release must SURFACE an
|
||||||
|
// unlink failure, not swallow it. If it swallowed (like the CRUD-internal
|
||||||
|
// acquireMutationLock does), a stale roster.yaml.mutation.lock left after a
|
||||||
|
// successful regen would block later regen/agent CRUD while the command reported
|
||||||
|
// success — and the finding-J stale-lock warning would never fire for this lock.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const release = await acquirePrivateRosterMutationLock(home)();
|
||||||
|
// Simulate the lock vanishing (or being unremovable) before release runs: the
|
||||||
|
// underlying unlink then faults. A swallowing release would resolve and hide it.
|
||||||
|
await rm(join(home, 'fleet', 'roster.yaml.mutation.lock'));
|
||||||
|
await expect(release()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a complete rebuild result and flags lock-cleanup when release faults', async (): Promise<void> => {
|
||||||
|
// A lock-release fault after a successful write must NOT discard the fact that
|
||||||
|
// projections are now live — the operator needs to know the write happened and
|
||||||
|
// that the shared lock may be stale, not just a bare "regen failed".
|
||||||
|
const home = await fleetHome();
|
||||||
|
const result = await executeFleetRegen(
|
||||||
|
{
|
||||||
|
runner: recordingRunner([]),
|
||||||
|
mosaicHome: home,
|
||||||
|
acquireReconcileLock:
|
||||||
|
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||||
|
throw new Error('simulated lock unlink fault');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ write: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.written).toBe(2);
|
||||||
|
expect(result.incomplete).toBeUndefined();
|
||||||
|
expect(result.cleanup).toMatchObject({
|
||||||
|
code: 'lock-cleanup-failed',
|
||||||
|
action: 'inspect-lock-before-retry',
|
||||||
|
});
|
||||||
|
// Projections are genuinely on disk despite the release fault.
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(true);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder1.env.generated'))).toBe(true);
|
||||||
|
|
||||||
|
const report = formatFleetRegenReport(result).join('\n');
|
||||||
|
expect(report).toMatch(/lock/i);
|
||||||
|
expect(report).toMatch(/stale|inspect|clear/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a partial rebuild AND flags lock-cleanup when both apply and release fault', async (): Promise<void> => {
|
||||||
|
// Worst case: a mid-loop apply fault leaves a partial rebuild, then the release
|
||||||
|
// also faults. Both recovery states must survive so the operator sees exactly
|
||||||
|
// which projections are live and that the lock may be stale.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const realApply = applyPreparedGeneratedAgentEnvironmentProjection;
|
||||||
|
const result = await executeFleetRegen(
|
||||||
|
{
|
||||||
|
runner: recordingRunner([]),
|
||||||
|
mosaicHome: home,
|
||||||
|
applyProjection: async (prepared): Promise<string> => {
|
||||||
|
if (prepared.generatedPath.endsWith('coder1.env.generated')) {
|
||||||
|
throw new Error('simulated ENOSPC on coder1');
|
||||||
|
}
|
||||||
|
return realApply(prepared);
|
||||||
|
},
|
||||||
|
acquireReconcileLock:
|
||||||
|
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||||
|
throw new Error('simulated lock unlink fault');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ write: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.written).toBe(1);
|
||||||
|
expect(result.incomplete).toMatchObject({
|
||||||
|
code: 'projection-apply-failed',
|
||||||
|
failedAgent: 'coder1',
|
||||||
|
writtenAgents: ['coder0'],
|
||||||
|
});
|
||||||
|
expect(result.cleanup).toMatchObject({ code: 'lock-cleanup-failed' });
|
||||||
|
|
||||||
|
const report = formatFleetRegenReport(result).join('\n');
|
||||||
|
expect(report).toMatch(/incomplete/i);
|
||||||
|
expect(report).toMatch(/lock/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed without deleting a REPLACEMENT roster-mutation lock it no longer owns', async (): Promise<void> => {
|
||||||
|
// Finding M1 (replacement-lock race): after this invocation created its lock, the
|
||||||
|
// lock is cleared and re-created by ANOTHER writer (new inode + foreign token)
|
||||||
|
// BEFORE release runs. The release must PROVE ownership (device/inode + token) and
|
||||||
|
// refuse to unlink the stranger's live lock — otherwise it deletes it, a third
|
||||||
|
// writer enters concurrently, and mutual exclusion over the projections is broken.
|
||||||
|
// This pins the REACHABLE window (replaced-before-release); the residual sub-window
|
||||||
|
// between the final check and the path `unlink` is unreachable within the `wx`
|
||||||
|
// writer protocol and matches the merged reconcile lock — see the acquirer doc.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const lockPath = join(home, 'fleet', 'roster.yaml.mutation.lock');
|
||||||
|
const release = await acquirePrivateRosterMutationLock(home)();
|
||||||
|
// A different writer replaces the lock: same path, NEW inode, foreign content.
|
||||||
|
await rm(lockPath);
|
||||||
|
await writeFile(lockPath, 'a-different-writer\n', { mode: 0o600 });
|
||||||
|
const replacement = await stat(lockPath);
|
||||||
|
// Release must reject (it no longer owns this file) AND the diagnosis must name
|
||||||
|
// the MUTATION lock specifically — a fault on this shared helper must not be
|
||||||
|
// mislabeled as the reconcile lock (that would defeat the M3 accurate-diagnosis
|
||||||
|
// goal, since regen serializes on both) ...
|
||||||
|
await expect(release()).rejects.toThrow(/roster\.yaml\.mutation\.lock/);
|
||||||
|
// ... and MUST NOT have unlinked the stranger's live lock.
|
||||||
|
expect(await exists(lockPath)).toBe(true);
|
||||||
|
expect((await stat(lockPath)).ino).toBe(replacement.ino);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not strand the lock file when initialization fails after creating it', async (): Promise<void> => {
|
||||||
|
// Codex r4: if writeFile/stat/close/ownership-check throws AFTER the `wx` create
|
||||||
|
// succeeds, the just-created lock must NOT be left behind — a stranded lock would
|
||||||
|
// permanently block future regen AND agent CRUD (both contend on this path). The
|
||||||
|
// cleanup is dev/ino-guarded (never deletes a replacement) and best-effort (never
|
||||||
|
// masks the initialization error).
|
||||||
|
const home = await fleetHome();
|
||||||
|
const lockPath = join(home, 'fleet', 'roster.yaml.mutation.lock');
|
||||||
|
// Inject an opener that performs the REAL `wx` create (so the lock file really
|
||||||
|
// lands on disk) but whose handle faults on the token write — a post-create
|
||||||
|
// initialization failure.
|
||||||
|
const faultingOpen = (async (
|
||||||
|
path: Parameters<typeof open>[0],
|
||||||
|
flags: Parameters<typeof open>[1],
|
||||||
|
mode: Parameters<typeof open>[2],
|
||||||
|
) => {
|
||||||
|
const handle = await open(path, flags, mode);
|
||||||
|
return new Proxy(handle, {
|
||||||
|
get(target, prop, receiver): unknown {
|
||||||
|
if (prop === 'writeFile') {
|
||||||
|
return async (): Promise<never> => {
|
||||||
|
throw new Error('simulated ENOSPC on token write');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const value = Reflect.get(target, prop, receiver);
|
||||||
|
return typeof value === 'function' ? value.bind(target) : value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}) as typeof open;
|
||||||
|
|
||||||
|
await expect(acquirePrivateRosterMutationLock(home, faultingOpen)()).rejects.toThrow(
|
||||||
|
/mutation\.lock/,
|
||||||
|
);
|
||||||
|
// The lock we created must have been cleaned up — not stranded on disk.
|
||||||
|
expect(await exists(lockPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not strand the lock file when the post-create stat itself fails', async (): Promise<void> => {
|
||||||
|
// Codex r5: the narrower sub-path where `handle.stat()` ITSELF rejects right after
|
||||||
|
// the `wx` create succeeds (transient EIO/EBADF). device/inode identity is then
|
||||||
|
// unavailable, so the init-failure cleanup cannot prove ownership by dev/ino — yet
|
||||||
|
// the lock must STILL not be stranded, because a stranded lock permanently blocks
|
||||||
|
// future regen AND agent CRUD. The token we persisted before the failure uniquely
|
||||||
|
// identifies OUR lock, so cleanup falls back to matching it.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const lockPath = join(home, 'fleet', 'roster.yaml.mutation.lock');
|
||||||
|
// Real `wx` create (lock really lands on disk); the token write SUCCEEDS so our
|
||||||
|
// random token is persisted, then `stat()` faults — a post-create init failure.
|
||||||
|
const faultingStatOpen = (async (
|
||||||
|
path: Parameters<typeof open>[0],
|
||||||
|
flags: Parameters<typeof open>[1],
|
||||||
|
mode: Parameters<typeof open>[2],
|
||||||
|
) => {
|
||||||
|
const handle = await open(path, flags, mode);
|
||||||
|
return new Proxy(handle, {
|
||||||
|
get(target, prop, receiver): unknown {
|
||||||
|
if (prop === 'stat') {
|
||||||
|
return async (): Promise<never> => {
|
||||||
|
throw new Error('simulated EIO on post-create stat');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const value = Reflect.get(target, prop, receiver);
|
||||||
|
return typeof value === 'function' ? value.bind(target) : value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}) as typeof open;
|
||||||
|
|
||||||
|
await expect(acquirePrivateRosterMutationLock(home, faultingStatOpen)()).rejects.toThrow(
|
||||||
|
/mutation\.lock/,
|
||||||
|
);
|
||||||
|
// Even without dev/ino, the token-based fallback must have removed our lock.
|
||||||
|
expect(await exists(lockPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a lock-cleanup fault when unwinding after a later lock acquire fails', async (): Promise<void> => {
|
||||||
|
// Finding M2: the FIRST lock is taken, the SECOND lock's acquire throws, and
|
||||||
|
// unwinding the first lock's release ALSO faults. The acquire-unwind path must
|
||||||
|
// surface both — the already-taken lock may now be stale and silently block the
|
||||||
|
// next regen/reconcile — not just re-throw the acquire error and drop the fault.
|
||||||
|
const home = await fleetHome();
|
||||||
|
await expect(
|
||||||
|
executeFleetRegen(
|
||||||
|
{
|
||||||
|
runner: recordingRunner([]),
|
||||||
|
mosaicHome: home,
|
||||||
|
// Mutation lock (acquired first) succeeds, but its RELEASE faults on unwind.
|
||||||
|
acquireRosterMutationLock:
|
||||||
|
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||||
|
throw new Error('simulated mutation-lock unlink fault');
|
||||||
|
},
|
||||||
|
// Reconcile lock (acquired second) ACQUIRE fails, triggering the unwind.
|
||||||
|
acquireReconcileLock: () => async (): Promise<() => Promise<void>> => {
|
||||||
|
throw new Error('simulated reconcile acquire failure');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ write: true },
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/stale|inspect|lock/i);
|
||||||
|
expect(await exists(join(home, 'fleet', 'agents', 'coder0.env.generated'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names BOTH fleet locks in the stale-lock warning (cleanup can come from either)', async (): Promise<void> => {
|
||||||
|
// Finding M3: finding L made the roster MUTATION lock's release fault reachable, so
|
||||||
|
// the cleanup marker can now originate from either lock. The human report must not
|
||||||
|
// claim only the reconcile lock is stale — it must name both candidate lock files
|
||||||
|
// so the operator inspects the right one.
|
||||||
|
const home = await fleetHome();
|
||||||
|
const result = await executeFleetRegen(
|
||||||
|
{
|
||||||
|
runner: recordingRunner([]),
|
||||||
|
mosaicHome: home,
|
||||||
|
// Fault the MUTATION lock's release specifically (reconcile lock is real).
|
||||||
|
acquireRosterMutationLock:
|
||||||
|
() => async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||||
|
throw new Error('simulated mutation-lock unlink fault');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ write: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.written).toBe(2);
|
||||||
|
expect(result.cleanup).toMatchObject({ code: 'lock-cleanup-failed' });
|
||||||
|
const report = formatFleetRegenReport(result).join('\n');
|
||||||
|
expect(report).toContain('roster.yaml.mutation.lock');
|
||||||
|
expect(report).toContain('roster.yaml.reconcile.lock');
|
||||||
|
});
|
||||||
|
});
|
||||||
441
packages/mosaic/src/commands/fleet-regen-command.ts
Normal file
441
packages/mosaic/src/commands/fleet-regen-command.ts
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join, relative, resolve } from 'node:path';
|
||||||
|
import type { Command } from 'commander';
|
||||||
|
import type { CommandRunner } from './fleet.js';
|
||||||
|
import {
|
||||||
|
applyPreparedGeneratedAgentEnvironmentProjection,
|
||||||
|
prepareGeneratedAgentEnvironmentProjection,
|
||||||
|
type PreparedGeneratedAgentEnvironmentProjection,
|
||||||
|
} from '../fleet/generated-env-boundary.js';
|
||||||
|
import {
|
||||||
|
acquirePrivateReconcileLock,
|
||||||
|
acquirePrivateRosterMutationLock,
|
||||||
|
projectRosterV2AgentGeneratedEnv,
|
||||||
|
} from '../fleet/fleet-reconciler.js';
|
||||||
|
import {
|
||||||
|
parseRosterV2,
|
||||||
|
validateRosterV2Semantics,
|
||||||
|
type FleetRosterV2,
|
||||||
|
} from '../fleet/roster-v2.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `mosaic fleet regen` — recovery-framed regeneration of the roster-derived
|
||||||
|
* agent env projections (`fleet/agents/<name>.env.generated`) from the roster
|
||||||
|
* SSOT. It is the recovery layer of #791: when a wiped/diverged operator surface
|
||||||
|
* has cost an agent its generated projection, regen rebuilds it deterministically
|
||||||
|
* from `roster.yaml` so the launcher can source the intended identity again.
|
||||||
|
*
|
||||||
|
* It is intentionally a THIN projection-only wrapper over the same mapping the
|
||||||
|
* reconciler apply path uses ({@link projectRosterV2AgentGeneratedEnv}), and it
|
||||||
|
* has NO code path to systemd lifecycle: regen never starts, stops, or restarts
|
||||||
|
* an agent. Recovery order forbids restart-before-verify, so the operator must
|
||||||
|
* verify each rebuilt projection resolves before restarting anything.
|
||||||
|
*/
|
||||||
|
export interface FleetRegenAgentPlan {
|
||||||
|
readonly name: string;
|
||||||
|
/** Generated-projection path, relative to the Mosaic home (never absolute in output). */
|
||||||
|
readonly path: string;
|
||||||
|
/** `create` when the projection is currently absent, `rebuild` when it already exists. */
|
||||||
|
readonly disposition: 'create' | 'rebuild';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Present ONLY when a `--write` projection apply failed partway through the
|
||||||
|
* fleet. Every prior agent was already validated and prepared, so `writtenAgents`
|
||||||
|
* are byte-complete on disk; `failedAgent` is where the write faulted. The
|
||||||
|
* command surfaces this instead of a bare throw so the operator can finish the
|
||||||
|
* rebuild and verify every projection before restarting any unit.
|
||||||
|
*/
|
||||||
|
export interface FleetRegenIncomplete {
|
||||||
|
readonly code: 'projection-apply-failed';
|
||||||
|
/** The agent whose generated-projection write faulted. */
|
||||||
|
readonly failedAgent: string;
|
||||||
|
/** Agents whose projections were fully written before the fault (in apply order). */
|
||||||
|
readonly writtenAgents: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetRegenResult {
|
||||||
|
readonly mode: 'dry-run' | 'write';
|
||||||
|
/** Roster path, relative to the Mosaic home. */
|
||||||
|
readonly rosterPath: string;
|
||||||
|
readonly generation: number;
|
||||||
|
readonly agentCount: number;
|
||||||
|
/** Count of projections written to disk (always 0 in dry-run). */
|
||||||
|
readonly written: number;
|
||||||
|
readonly agents: readonly FleetRegenAgentPlan[];
|
||||||
|
/** Set ONLY when a `--write` apply faulted mid-fleet — a partial rebuild. */
|
||||||
|
readonly incomplete?: FleetRegenIncomplete;
|
||||||
|
/**
|
||||||
|
* Set ONLY when a shared fleet lock could not be released after a write completed
|
||||||
|
* (or partially completed) — EITHER `roster.yaml.mutation.lock` or
|
||||||
|
* `roster.yaml.reconcile.lock`, since regen holds both. The projections in this
|
||||||
|
* result are real; the lock file may be stale and must be inspected before the
|
||||||
|
* next mutation. Independent of {@link incomplete} — both can be present at once.
|
||||||
|
*/
|
||||||
|
readonly cleanup?: {
|
||||||
|
readonly code: 'lock-cleanup-failed';
|
||||||
|
readonly action: 'inspect-lock-before-retry';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetRegenDeps {
|
||||||
|
/**
|
||||||
|
* Present ONLY so an accidental future lifecycle wiring is caught by the
|
||||||
|
* "never restarts" test — regen is contractually forbidden to invoke it.
|
||||||
|
*/
|
||||||
|
readonly runner: CommandRunner;
|
||||||
|
readonly mosaicHome?: string;
|
||||||
|
/** Persona roots for semantic roster validation (defaults mirror the reconciler). */
|
||||||
|
readonly rolesDir?: string;
|
||||||
|
readonly overrideDir?: string;
|
||||||
|
/** Test seam: canonical roster reader. Defaults to parse + semantic validation. */
|
||||||
|
readonly readRoster?: (rosterPath: string) => Promise<FleetRosterV2>;
|
||||||
|
/**
|
||||||
|
* Test seam: generated-only projection validator. Defaults to the audited
|
||||||
|
* boundary helper that validates ONLY `<name>.env.generated`.
|
||||||
|
*/
|
||||||
|
readonly prepareProjection?: typeof prepareGeneratedAgentEnvironmentProjection;
|
||||||
|
/** Test seam: generated-only projection writer. Defaults to the audited boundary helper. */
|
||||||
|
readonly applyProjection?: typeof applyPreparedGeneratedAgentEnvironmentProjection;
|
||||||
|
/**
|
||||||
|
* Test seam: acquire the shared reconcile lock before a `--write`. Defaults to
|
||||||
|
* the reconciler's private lock so regen and reconcile are mutually exclusive
|
||||||
|
* and a concurrent reconcile cannot race a stale projection write.
|
||||||
|
*/
|
||||||
|
readonly acquireReconcileLock?: (mosaicHome: string) => () => Promise<() => Promise<void>>;
|
||||||
|
/**
|
||||||
|
* Test seam: acquire the shared roster mutation lock before a `--write`.
|
||||||
|
* Defaults to the reconciler's hardened, ownership-proving acquirer for the
|
||||||
|
* same `fleet/roster.yaml.mutation.lock` path that CRUD contends on, so regen
|
||||||
|
* serializes against agent create/update/delete — both rewrite the same
|
||||||
|
* generated projections, so without this a concurrent CRUD could race a stale
|
||||||
|
* projection write.
|
||||||
|
*/
|
||||||
|
readonly acquireRosterMutationLock?: (mosaicHome: string) => () => Promise<() => Promise<void>>;
|
||||||
|
/** Test seam: existence probe for create/rebuild disposition. */
|
||||||
|
readonly fileExists?: (path: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FleetRegenOptions {
|
||||||
|
readonly write?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultMosaicHome(deps: FleetRegenDeps): string {
|
||||||
|
return deps.mosaicHome ?? process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultFileExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await stat(path);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projection-only regeneration core. Deterministic and side-effect-free in
|
||||||
|
* dry-run; in `--write` it rebuilds each generated projection through the
|
||||||
|
* audited generated-only boundary writer and NOTHING else — it never writes
|
||||||
|
* `.env.local`/`.env.quarantine`, never unlinks the legacy `.env`, and never
|
||||||
|
* touches `deps.runner`.
|
||||||
|
*
|
||||||
|
* Fail-closed by construction: the roster is validated (structural + semantic)
|
||||||
|
* and EVERY agent's projection is prepared before ANY is written, so a single
|
||||||
|
* invalid agent (or a semantically rejected roster) leaves the whole surface
|
||||||
|
* untouched. In `--write` mode the read-prepare-apply sequence runs under the
|
||||||
|
* shared reconcile lock so a concurrent reconcile cannot race a stale write.
|
||||||
|
*/
|
||||||
|
export async function executeFleetRegen(
|
||||||
|
deps: FleetRegenDeps,
|
||||||
|
options: FleetRegenOptions,
|
||||||
|
): Promise<FleetRegenResult> {
|
||||||
|
const mosaicHome = defaultMosaicHome(deps);
|
||||||
|
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||||
|
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||||
|
const readRoster = deps.readRoster ?? defaultReadRoster(deps, mosaicHome);
|
||||||
|
const prepare = deps.prepareProjection ?? prepareGeneratedAgentEnvironmentProjection;
|
||||||
|
const apply = deps.applyProjection ?? applyPreparedGeneratedAgentEnvironmentProjection;
|
||||||
|
const fileExists = deps.fileExists ?? defaultFileExists;
|
||||||
|
const acquireReconcileLock = deps.acquireReconcileLock ?? acquirePrivateReconcileLock;
|
||||||
|
const acquireRosterMutationLock =
|
||||||
|
deps.acquireRosterMutationLock ?? acquirePrivateRosterMutationLock;
|
||||||
|
const write = options.write === true;
|
||||||
|
|
||||||
|
const run = async (): Promise<FleetRegenResult> => {
|
||||||
|
const roster = await readRoster(rosterPath);
|
||||||
|
|
||||||
|
// Prepare (validate) every agent BEFORE any write, so a later failure never
|
||||||
|
// leaves an earlier projection partially rebuilt.
|
||||||
|
const prepared: (PreparedGeneratedAgentEnvironmentProjection & {
|
||||||
|
readonly name: string;
|
||||||
|
readonly disposition: 'create' | 'rebuild';
|
||||||
|
})[] = [];
|
||||||
|
for (const agent of roster.agents) {
|
||||||
|
const projection = await prepare({
|
||||||
|
mosaicHome,
|
||||||
|
agentEnvDir,
|
||||||
|
agentName: agent.name,
|
||||||
|
generated: projectRosterV2AgentGeneratedEnv(roster, agent),
|
||||||
|
});
|
||||||
|
const disposition = (await fileExists(projection.generatedPath)) ? 'rebuild' : 'create';
|
||||||
|
prepared.push({ ...projection, name: agent.name, disposition });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every projection is already validated (prepared); an apply here can only
|
||||||
|
// fault on the underlying I/O (ENOSPC, EIO, a race outside the lock). If a
|
||||||
|
// later write faults, earlier agents are already replaced on disk, so we
|
||||||
|
// stop and report the partial state rather than throwing a bare error that
|
||||||
|
// hides which projections are now live.
|
||||||
|
let written = 0;
|
||||||
|
let incomplete: FleetRegenIncomplete | undefined;
|
||||||
|
if (write) {
|
||||||
|
for (const projection of prepared) {
|
||||||
|
try {
|
||||||
|
await apply(projection);
|
||||||
|
} catch {
|
||||||
|
incomplete = {
|
||||||
|
code: 'projection-apply-failed',
|
||||||
|
failedAgent: projection.name,
|
||||||
|
writtenAgents: prepared.slice(0, written).map((entry) => entry.name),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
written += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: write ? 'write' : 'dry-run',
|
||||||
|
rosterPath: relative(mosaicHome, rosterPath),
|
||||||
|
generation: roster.generation,
|
||||||
|
agentCount: roster.agents.length,
|
||||||
|
written,
|
||||||
|
agents: prepared.map((projection) => ({
|
||||||
|
name: projection.name,
|
||||||
|
path: relative(mosaicHome, projection.generatedPath),
|
||||||
|
disposition: projection.disposition,
|
||||||
|
})),
|
||||||
|
...(incomplete ? { incomplete } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dry-run is preview-only and never mutates, so it stays lock-free (usable even
|
||||||
|
// while a reconcile or CRUD holds a lock). A `--write` must serialize against
|
||||||
|
// BOTH roster writers of the same generated projections: agent CRUD (roster
|
||||||
|
// mutation lock) and reconcile (reconcile lock). Acquire in a fixed order — the
|
||||||
|
// locks are non-blocking (they throw on contention rather than wait), so no
|
||||||
|
// deadlock is possible — and fail closed if either is already held.
|
||||||
|
if (!write) return run();
|
||||||
|
|
||||||
|
const releases: (() => Promise<void>)[] = [];
|
||||||
|
try {
|
||||||
|
releases.push(await acquireRosterMutationLock(mosaicHome)());
|
||||||
|
releases.push(await acquireReconcileLock(mosaicHome)());
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// A later acquire failed; unwind any lock already taken (reverse order). If
|
||||||
|
// that unwind ALSO faults, surface both — the already-taken lock may now be
|
||||||
|
// stale and silently block the next regen/reconcile — rather than dropping it.
|
||||||
|
const releaseFault = await releaseFleetLocks(releases);
|
||||||
|
throw augmentWithLockCleanupFault(error, releaseFault);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: FleetRegenResult | undefined;
|
||||||
|
let primaryError: unknown;
|
||||||
|
try {
|
||||||
|
result = await run();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
primaryError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release both locks (reverse acquire order), continuing past a fault so neither
|
||||||
|
// leaks. A release fault must never discard a completed (or partial) rebuild —
|
||||||
|
// the projections are already on disk — and must not be silently hidden even
|
||||||
|
// when the run itself failed with nothing written.
|
||||||
|
const releaseFault = await releaseFleetLocks(releases);
|
||||||
|
if (result) {
|
||||||
|
return releaseFault === undefined
|
||||||
|
? result
|
||||||
|
: {
|
||||||
|
...result,
|
||||||
|
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// result is undefined ⇒ run() threw ⇒ primaryError is set.
|
||||||
|
throw augmentWithLockCleanupFault(primaryError, releaseFault);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases held locks in reverse acquire order, continuing past a fault so a
|
||||||
|
* single failed release never leaks the others. Returns the first fault seen (or
|
||||||
|
* undefined when every release succeeded).
|
||||||
|
*/
|
||||||
|
async function releaseFleetLocks(releases: readonly (() => Promise<void>)[]): Promise<unknown> {
|
||||||
|
let firstFault: unknown;
|
||||||
|
for (const release of [...releases].reverse()) {
|
||||||
|
try {
|
||||||
|
await release();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (firstFault === undefined) firstFault = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstFault;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a run failed with nothing written AND lock cleanup also faulted, surface
|
||||||
|
* both: the operator needs to know the lock may be stale (else the next
|
||||||
|
* regen/reconcile is silently blocked), not just the original run error.
|
||||||
|
*/
|
||||||
|
function augmentWithLockCleanupFault(primaryError: unknown, releaseFault: unknown): unknown {
|
||||||
|
if (releaseFault === undefined) return primaryError;
|
||||||
|
const base = primaryError instanceof Error ? primaryError.message : String(primaryError);
|
||||||
|
return new Error(
|
||||||
|
`${base} (additionally, a fleet lock could not be released — ` +
|
||||||
|
'fleet/roster.yaml.mutation.lock or roster.yaml.reconcile.lock may be stale; ' +
|
||||||
|
'inspect and clear it before retry)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultReadRoster(
|
||||||
|
deps: FleetRegenDeps,
|
||||||
|
mosaicHome: string,
|
||||||
|
): (rosterPath: string) => Promise<FleetRosterV2> {
|
||||||
|
return async (rosterPath: string): Promise<FleetRosterV2> => {
|
||||||
|
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
||||||
|
// Enforce the SAME semantic gate as reconcile/plan/verify (persona resolution
|
||||||
|
// + protected-class tool-policy match) so regen cannot project a roster the
|
||||||
|
// rest of the fleet surface would reject.
|
||||||
|
await validateRosterV2Semantics(roster, {
|
||||||
|
rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
|
||||||
|
overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
|
||||||
|
});
|
||||||
|
return roster;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable report — paths and counts ONLY. The rendered projection body
|
||||||
|
* (KEY=value lines) is never echoed (secrev). In `--write` mode it prints the
|
||||||
|
* recovery runbook: verify each projection resolves BEFORE any restart.
|
||||||
|
*/
|
||||||
|
export function formatFleetRegenReport(result: FleetRegenResult): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const header =
|
||||||
|
result.mode === 'dry-run'
|
||||||
|
? 'mosaic fleet regen — dry run (no changes written)'
|
||||||
|
: 'mosaic fleet regen — wrote roster-derived projections';
|
||||||
|
lines.push(header);
|
||||||
|
lines.push(
|
||||||
|
`roster: ${result.rosterPath} (generation ${result.generation}, ${result.agentCount} agent(s))`,
|
||||||
|
);
|
||||||
|
for (const agent of result.agents) {
|
||||||
|
lines.push(` ${agent.name} ${agent.path} [${agent.disposition}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.mode === 'dry-run') {
|
||||||
|
lines.push(
|
||||||
|
`Plan: would write ${result.agentCount} generated projection(s). ` +
|
||||||
|
`Re-run with --write to apply. regen never restarts agents.`,
|
||||||
|
);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.incomplete) {
|
||||||
|
const { failedAgent, writtenAgents } = result.incomplete;
|
||||||
|
lines.push(
|
||||||
|
`INCOMPLETE: rebuild stopped at agent ${failedAgent} — its projection write faulted.`,
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
`Rebuilt before the fault: ${writtenAgents.length} projection(s)` +
|
||||||
|
(writtenAgents.length > 0 ? ` (${writtenAgents.join(', ')})` : '') +
|
||||||
|
`; ${result.agentCount - writtenAgents.length} not written.`,
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
'Recover: re-run `mosaic fleet regen --write` to finish, then VERIFY every ' +
|
||||||
|
"agent's fleet/agents/<name>.env.generated resolves BEFORE restarting any " +
|
||||||
|
'unit. regen never restarts agents.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
lines.push(`Wrote ${result.written} generated projection(s).`);
|
||||||
|
lines.push('Next steps — DO NOT restart agents before verifying:');
|
||||||
|
lines.push(
|
||||||
|
" 1. Confirm each agent's fleet/agents/<name>.env.generated exists and carries " +
|
||||||
|
'the intended MOSAIC_AGENT_* values.',
|
||||||
|
);
|
||||||
|
// The unit sets NO EnvironmentFile= — it launches from a minimal env and the
|
||||||
|
// launcher (start-agent-session.sh) sources .env.generated itself. Verify the
|
||||||
|
// launcher path, not a nonexistent EnvironmentFile property.
|
||||||
|
lines.push(
|
||||||
|
' 2. Confirm the unit launches from it: `systemctl --user cat mosaic-agent@<name>` ' +
|
||||||
|
'shows ExecStart running start-agent-session.sh, which reads .env.generated.',
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
' 3. Only then restart one unit at a time: systemctl --user restart mosaic-agent@<name>.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.cleanup) {
|
||||||
|
lines.push(
|
||||||
|
'WARNING: a fleet lock could not be released — ' +
|
||||||
|
'fleet/roster.yaml.mutation.lock or fleet/roster.yaml.reconcile.lock may be ' +
|
||||||
|
'stale; inspect and clear it before the next reconcile or regen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRegenMosaicHome(fleetCommand: Command, deps: FleetRegenDeps): string {
|
||||||
|
const options = fleetCommand.optsWithGlobals<{ mosaicHome?: string }>();
|
||||||
|
return options.mosaicHome ?? defaultMosaicHome(deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* regen reads the roster SSOT at `<mosaicHome>/fleet/roster.yaml` and ignores the
|
||||||
|
* `fleet --roster <path>` global. If an operator passes a non-canonical `--roster`
|
||||||
|
* they must NOT be silently served the canonical file — mirror reconcile's guard
|
||||||
|
* and reject, so `mosaic fleet --roster /elsewhere regen --write` fails closed.
|
||||||
|
*/
|
||||||
|
function assertRegenCanonicalRoster(fleetCommand: Command, mosaicHome: string): void {
|
||||||
|
const options = fleetCommand.optsWithGlobals<{ roster?: string }>();
|
||||||
|
const canonical = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||||
|
if (options.roster !== undefined && resolve(options.roster) !== resolve(canonical)) {
|
||||||
|
throw new Error(
|
||||||
|
'Roster-v2 regeneration requires the canonical roster path (fleet/roster.yaml).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registers the recovery-framed `mosaic fleet regen` command. */
|
||||||
|
export function registerFleetRegenCommand(fleetCommand: Command, deps: FleetRegenDeps): void {
|
||||||
|
fleetCommand
|
||||||
|
.command('regen')
|
||||||
|
.description(
|
||||||
|
'Regenerate roster-derived agent env projections (dry-run default, --write to apply; never restarts)',
|
||||||
|
)
|
||||||
|
.option('--write', 'Write the regenerated projections to disk (default: dry-run)')
|
||||||
|
.option('--json', 'Emit the plan/result as JSON')
|
||||||
|
.action(async (opts: { write?: boolean; json?: boolean }): Promise<void> => {
|
||||||
|
const mosaicHome = resolveRegenMosaicHome(fleetCommand, deps);
|
||||||
|
try {
|
||||||
|
assertRegenCanonicalRoster(fleetCommand, mosaicHome);
|
||||||
|
const result = await executeFleetRegen(
|
||||||
|
{ ...deps, mosaicHome },
|
||||||
|
{ write: opts.write === true },
|
||||||
|
);
|
||||||
|
if (opts.json === true) {
|
||||||
|
console.log(JSON.stringify(result));
|
||||||
|
} else {
|
||||||
|
for (const line of formatFleetRegenReport(result)) console.log(line);
|
||||||
|
}
|
||||||
|
// A partial rebuild or a stale-lock cleanup state is a non-zero outcome:
|
||||||
|
// the operator must finish/verify (and clear the lock) before restarting.
|
||||||
|
if (result.incomplete || result.cleanup) process.exitCode = 1;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
process.stderr.write(`mosaic fleet regen failed: ${message}\n`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -97,6 +97,7 @@ describe('registerFleetCommand', () => {
|
|||||||
'provision',
|
'provision',
|
||||||
'ps',
|
'ps',
|
||||||
'reconcile',
|
'reconcile',
|
||||||
|
'regen',
|
||||||
'remove',
|
'remove',
|
||||||
'restart',
|
'restart',
|
||||||
'start',
|
'start',
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
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,
|
||||||
@@ -2063,6 +2064,18 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
piForceSkillNames,
|
piForceSkillNames,
|
||||||
registerRuntimeLaunchers,
|
registerRuntimeLaunchers,
|
||||||
type RuntimeLaunchHandler,
|
type RuntimeLaunchHandler,
|
||||||
|
type ClaudexLaunchHandler,
|
||||||
} from './launch.js';
|
} from './launch.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +32,16 @@ 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'];
|
||||||
|
|
||||||
@@ -280,3 +291,61 @@ 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,6 +27,7 @@ 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;
|
||||||
@@ -806,12 +807,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[]): void {
|
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): 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: process.env,
|
env,
|
||||||
});
|
});
|
||||||
process.exit(result.status ?? 0);
|
process.exit(result.status ?? 0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -820,6 +821,29 @@ function execRuntime(cmd: string, args: string[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production glue for `mosaic [yolo] claudex` (EXPERIMENTAL — GPT models inside
|
||||||
|
* the Claude Code harness via claude-code-proxy). Assembles the real harness
|
||||||
|
* adapter and delegates the security-critical composition + fail-closed
|
||||||
|
* orchestration to `launchClaudex` in `claudex.ts`. Kept thin so the tested
|
||||||
|
* logic lives in the DI module, not here.
|
||||||
|
*/
|
||||||
|
function launchClaudexProduction(args: string[], yolo: boolean): void {
|
||||||
|
writeSessionLock('claude');
|
||||||
|
const adapter: ClaudexHarnessAdapter = {
|
||||||
|
harnessPreflight: () => {
|
||||||
|
checkMosaicHome();
|
||||||
|
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
|
||||||
|
checkSoul();
|
||||||
|
checkRuntime('claude');
|
||||||
|
checkSequentialThinking('claude');
|
||||||
|
},
|
||||||
|
composePrompt: () => buildRuntimePrompt('claude'),
|
||||||
|
exec: (cmd, cmdArgs, env) => execRuntime(cmd, cmdArgs, env),
|
||||||
|
};
|
||||||
|
void launchClaudex(args, yolo, adapter);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Framework script/tool delegation ───────────────────────────────────────
|
// ─── 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 {
|
||||||
@@ -1034,12 +1058,25 @@ 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(program: Command, handler: RuntimeLaunchHandler): void {
|
export function registerRuntimeLaunchers(
|
||||||
|
program: Command,
|
||||||
|
handler: RuntimeLaunchHandler,
|
||||||
|
claudexHandler: ClaudexLaunchHandler = (extraArgs, yolo) =>
|
||||||
|
launchClaudexProduction(extraArgs, yolo),
|
||||||
|
): void {
|
||||||
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
|
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
|
||||||
program
|
program
|
||||||
.command(runtime)
|
.command(runtime)
|
||||||
@@ -1051,16 +1088,37 @@ export function registerRuntimeLaunchers(program: Command, handler: RuntimeLaunc
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// claudex — EXPERIMENTAL: GPT models inside the Claude Code harness via
|
||||||
|
// claude-code-proxy (ChatGPT-subscription OAuth). Isolated CLAUDE_CONFIG_DIR
|
||||||
|
// + zero-token-leak env injection live in claudex.ts.
|
||||||
|
program
|
||||||
|
.command('claudex')
|
||||||
|
.description('EXPERIMENTAL: launch Claude Code harness against GPT via claude-code-proxy')
|
||||||
|
.allowUnknownOption(true)
|
||||||
|
.allowExcessArguments(true)
|
||||||
|
.action((_opts: unknown, cmd: Command) => {
|
||||||
|
claudexHandler(cmd.args, false);
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('yolo <runtime>')
|
.command('yolo <runtime>')
|
||||||
.description('Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi)')
|
.description(
|
||||||
|
'Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi|claudex)',
|
||||||
|
)
|
||||||
.allowUnknownOption(true)
|
.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('|')}`,
|
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}|claudex`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
466
packages/mosaic/src/commands/restore.spec.ts
Normal file
466
packages/mosaic/src/commands/restore.spec.ts
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
/**
|
||||||
|
* Tests for `mosaic restore` (#791 PR2 Task 12).
|
||||||
|
*
|
||||||
|
* The durable pre-update snapshot (install.sh: make_durable_snapshot) writes the
|
||||||
|
* operator-owned surface to $XDG_STATE_HOME/mosaic/backups/pre-update-<ts>/ with
|
||||||
|
* 0700 dirs / 0600 files. `mosaic restore` is the recovery counterpart:
|
||||||
|
* • --list (default) enumerate snapshots by timestamp — dry-run, never mutates.
|
||||||
|
* • --from <ts> restore that snapshot over MOSAIC_HOME, confirmation-gated.
|
||||||
|
* It reports counts and relative paths ONLY — a snapshot may contain secrets
|
||||||
|
* (credentials.json), so no file content is ever printed (secrev invariant).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
mkdtempSync,
|
||||||
|
rmSync,
|
||||||
|
mkdirSync,
|
||||||
|
writeFileSync,
|
||||||
|
readFileSync,
|
||||||
|
statSync,
|
||||||
|
lstatSync,
|
||||||
|
symlinkSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir, homedir } from 'node:os';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import {
|
||||||
|
resolveBackupRoot,
|
||||||
|
listSnapshots,
|
||||||
|
resolveSnapshotDir,
|
||||||
|
planRestore,
|
||||||
|
applyRestore,
|
||||||
|
runRestore,
|
||||||
|
registerRestoreCommand,
|
||||||
|
} from './restore.js';
|
||||||
|
|
||||||
|
const SECRET = 'SUPER-SECRET-TOKEN-do-not-log-restore';
|
||||||
|
|
||||||
|
function seedSnapshot(root: string, ts: string, files: Record<string, string>): string {
|
||||||
|
const dir = join(root, `pre-update-${ts}`);
|
||||||
|
for (const [rel, content] of Object.entries(files)) {
|
||||||
|
const abs = join(dir, rel);
|
||||||
|
mkdirSync(join(abs, '..'), { recursive: true });
|
||||||
|
writeFileSync(abs, content);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mosaic restore (#791 PR2)', () => {
|
||||||
|
let tmp: string;
|
||||||
|
let backups: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmp = mkdtempSync(join(tmpdir(), 'mosaic-restore-'));
|
||||||
|
backups = join(tmp, 'state', 'mosaic', 'backups');
|
||||||
|
mkdirSync(backups, { recursive: true });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveBackupRoot', () => {
|
||||||
|
it('honors XDG_STATE_HOME', () => {
|
||||||
|
expect(resolveBackupRoot({ XDG_STATE_HOME: '/x/state' })).toBe('/x/state/mosaic/backups');
|
||||||
|
});
|
||||||
|
it('falls back to ~/.local/state', () => {
|
||||||
|
expect(resolveBackupRoot({})).toBe(join(homedir(), '.local', 'state', 'mosaic', 'backups'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listSnapshots', () => {
|
||||||
|
it('returns [] when the backup root does not exist', () => {
|
||||||
|
expect(listSnapshots(join(tmp, 'nope'))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists pre-update snapshots newest-first with file counts, ignoring other dirs', () => {
|
||||||
|
seedSnapshot(backups, '20240101T000000Z', { 'SOUL.md': 'a' });
|
||||||
|
seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'b', 'agents/x.conf': 'c' });
|
||||||
|
mkdirSync(join(backups, 'unrelated-dir'), { recursive: true });
|
||||||
|
|
||||||
|
const snaps = listSnapshots(backups);
|
||||||
|
expect(snaps.map((s) => s.timestamp)).toEqual(['20260101T000000Z', '20240101T000000Z']);
|
||||||
|
expect(snaps[0]!.fileCount).toBe(2);
|
||||||
|
expect(snaps[1]!.fileCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveSnapshotDir', () => {
|
||||||
|
it('resolves by bare timestamp and by full pre-update-<ts> name', () => {
|
||||||
|
const dir = seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'a' });
|
||||||
|
expect(resolveSnapshotDir(backups, '20260101T000000Z')).toBe(dir);
|
||||||
|
expect(resolveSnapshotDir(backups, 'pre-update-20260101T000000Z')).toBe(dir);
|
||||||
|
});
|
||||||
|
it('returns undefined for an unknown timestamp', () => {
|
||||||
|
expect(resolveSnapshotDir(backups, '19990101T000000Z')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('planRestore', () => {
|
||||||
|
it('walks nested dirs and returns every relative file path', () => {
|
||||||
|
const dir = seedSnapshot(backups, '20260101T000000Z', {
|
||||||
|
'SOUL.md': 'a',
|
||||||
|
'agents/x.conf': 'b',
|
||||||
|
'tools/_lib/credentials.json': 'c',
|
||||||
|
});
|
||||||
|
expect(planRestore(dir).sort()).toEqual(
|
||||||
|
['SOUL.md', 'agents/x.conf', 'tools/_lib/credentials.json'].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyRestore', () => {
|
||||||
|
it('restores byte-exact content, creates parent dirs, and sets 0600', () => {
|
||||||
|
const dir = seedSnapshot(backups, '20260101T000000Z', {
|
||||||
|
'SOUL.md': 'original-soul',
|
||||||
|
'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
|
||||||
|
});
|
||||||
|
const home = join(tmp, 'home');
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
// A diverged operator file that restore must overwrite.
|
||||||
|
writeFileSync(join(home, 'SOUL.md'), 'CORRUPTED');
|
||||||
|
|
||||||
|
const n = applyRestore(dir, home, planRestore(dir));
|
||||||
|
expect(n).toBe(2);
|
||||||
|
expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('original-soul');
|
||||||
|
expect(readFileSync(join(home, 'tools/_lib/credentials.json'), 'utf8')).toBe(
|
||||||
|
`TOKEN=${SECRET}\n`,
|
||||||
|
);
|
||||||
|
expect(statSync(join(home, 'SOUL.md')).mode & 0o777).toBe(0o600);
|
||||||
|
expect(statSync(join(home, 'tools/_lib/credentials.json')).mode & 0o777).toBe(0o600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runRestore', () => {
|
||||||
|
it('--list prints timestamps and counts, mutating nothing', async () => {
|
||||||
|
seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'a', 'agents/x.conf': 'b' });
|
||||||
|
const home = join(tmp, 'home');
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const code = await runRestore({
|
||||||
|
list: true,
|
||||||
|
mosaicHome: home,
|
||||||
|
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(code).toBe(0);
|
||||||
|
const out = log.mock.calls.flat().join('\n');
|
||||||
|
expect(out).toContain('20260101T000000Z');
|
||||||
|
expect(out).toMatch(/2\b/); // the file count is surfaced
|
||||||
|
log.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('--from restores the snapshot over MOSAIC_HOME byte-exact (yes bypasses prompt)', async () => {
|
||||||
|
seedSnapshot(backups, '20260101T000000Z', {
|
||||||
|
'SOUL.md': 'restored-soul',
|
||||||
|
'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
|
||||||
|
});
|
||||||
|
const home = join(tmp, 'home');
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
writeFileSync(join(home, 'SOUL.md'), 'STALE');
|
||||||
|
|
||||||
|
const code = await runRestore({
|
||||||
|
from: '20260101T000000Z',
|
||||||
|
yes: true,
|
||||||
|
mosaicHome: home,
|
||||||
|
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('restored-soul');
|
||||||
|
expect(readFileSync(join(home, 'tools/_lib/credentials.json'), 'utf8')).toBe(
|
||||||
|
`TOKEN=${SECRET}\n`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('--from with an unknown timestamp fails without mutating', async () => {
|
||||||
|
const home = join(tmp, 'home');
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
writeFileSync(join(home, 'SOUL.md'), 'KEEP');
|
||||||
|
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const code = await runRestore({
|
||||||
|
from: '19990101T000000Z',
|
||||||
|
yes: true,
|
||||||
|
mosaicHome: home,
|
||||||
|
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(code).toBe(1);
|
||||||
|
expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('KEEP');
|
||||||
|
err.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('--dry-run with --from reports the plan but mutates nothing', async () => {
|
||||||
|
seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'snap' });
|
||||||
|
const home = join(tmp, 'home');
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
writeFileSync(join(home, 'SOUL.md'), 'UNCHANGED');
|
||||||
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const code = await runRestore({
|
||||||
|
from: '20260101T000000Z',
|
||||||
|
dryRun: true,
|
||||||
|
mosaicHome: home,
|
||||||
|
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('UNCHANGED');
|
||||||
|
log.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('--from prompts and applies the restore when the operator confirms', async () => {
|
||||||
|
seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'confirmed-soul' });
|
||||||
|
const home = join(tmp, 'home');
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
writeFileSync(join(home, 'SOUL.md'), 'STALE');
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
const confirm = vi.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
|
const code = await runRestore({
|
||||||
|
from: '20260101T000000Z',
|
||||||
|
mosaicHome: home,
|
||||||
|
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||||
|
confirm,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(confirm).toHaveBeenCalledOnce();
|
||||||
|
expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('confirmed-soul');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('--from aborts without mutating when the operator declines', async () => {
|
||||||
|
seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'snap' });
|
||||||
|
const home = join(tmp, 'home');
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
writeFileSync(join(home, 'SOUL.md'), 'KEEP');
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
const confirm = vi.fn().mockResolvedValue(false);
|
||||||
|
|
||||||
|
const code = await runRestore({
|
||||||
|
from: '20260101T000000Z',
|
||||||
|
mosaicHome: home,
|
||||||
|
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||||
|
confirm,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(confirm).toHaveBeenCalledOnce();
|
||||||
|
expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('KEEP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MOSAIC_ASSUME_YES=1 bypasses the confirmation prompt', async () => {
|
||||||
|
seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'env-yes' });
|
||||||
|
const home = join(tmp, 'home');
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
writeFileSync(join(home, 'SOUL.md'), 'STALE');
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
const confirm = vi.fn().mockResolvedValue(false);
|
||||||
|
|
||||||
|
const code = await runRestore({
|
||||||
|
from: '20260101T000000Z',
|
||||||
|
mosaicHome: home,
|
||||||
|
env: { XDG_STATE_HOME: join(tmp, 'state'), MOSAIC_ASSUME_YES: '1' },
|
||||||
|
confirm,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(confirm).not.toHaveBeenCalled();
|
||||||
|
expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('env-yes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('--list reports gracefully when no snapshots exist', async () => {
|
||||||
|
const home = join(tmp, 'home');
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const code = await runRestore({
|
||||||
|
list: true,
|
||||||
|
mosaicHome: home,
|
||||||
|
env: { XDG_STATE_HOME: join(tmp, 'empty-state') },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(log.mock.calls.flat().join('\n')).toMatch(/No pre-update snapshots/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never prints a secret value found inside a backed-up file', async () => {
|
||||||
|
seedSnapshot(backups, '20260101T000000Z', {
|
||||||
|
'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
|
||||||
|
});
|
||||||
|
const home = join(tmp, 'home');
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await runRestore({
|
||||||
|
list: true,
|
||||||
|
mosaicHome: home,
|
||||||
|
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||||
|
});
|
||||||
|
await runRestore({
|
||||||
|
from: '20260101T000000Z',
|
||||||
|
yes: true,
|
||||||
|
mosaicHome: home,
|
||||||
|
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = [...log.mock.calls, ...err.mock.calls].flat().join('\n');
|
||||||
|
expect(all).not.toContain(SECRET);
|
||||||
|
log.mockRestore();
|
||||||
|
err.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression coverage for the codex code+security review of PR2 (#791):
|
||||||
|
// CWE-22 traversal via --from, and CWE-59 symlink write-through in applyRestore.
|
||||||
|
describe('security hardening', () => {
|
||||||
|
it.each([
|
||||||
|
'../../etc',
|
||||||
|
'pre-update-/../../tmp/poison',
|
||||||
|
'pre-update-../evil',
|
||||||
|
'20260101T000000Z/../../../tmp',
|
||||||
|
'not-a-timestamp',
|
||||||
|
'2026-01-01',
|
||||||
|
])('resolveSnapshotDir rejects traversal / malformed selector %j', (bad) => {
|
||||||
|
// Even if a matching directory exists on disk, a non-timestamp selector
|
||||||
|
// must not resolve — the only accepted shape is <8>T<6>Z[-n].
|
||||||
|
expect(resolveSnapshotDir(backups, bad)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runRestore --from a traversal selector fails closed without copying', async () => {
|
||||||
|
// Plant a real dir one level ABOVE the backup root. The naive resolver
|
||||||
|
// `join(root, from)` with `from='../poison'` would reach it (backups is
|
||||||
|
// .../mosaic/backups, so `../poison` == .../mosaic/poison) and import it.
|
||||||
|
const outside = join(tmp, 'state', 'mosaic', 'poison');
|
||||||
|
mkdirSync(outside, { recursive: true });
|
||||||
|
writeFileSync(join(outside, 'x'), 'attacker');
|
||||||
|
const home = join(tmp, 'home');
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const code = await runRestore({
|
||||||
|
from: '../poison',
|
||||||
|
yes: true,
|
||||||
|
mosaicHome: home,
|
||||||
|
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(code).toBe(1);
|
||||||
|
expect(statSync(home).isDirectory()).toBe(true);
|
||||||
|
// Nothing from `outside` was imported.
|
||||||
|
expect(() => statSync(join(home, 'x'))).toThrow();
|
||||||
|
err.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyRestore refuses to write a secret through a symlinked leaf (CWE-59)', () => {
|
||||||
|
const dir = seedSnapshot(backups, '20260101T000000Z', {
|
||||||
|
'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
|
||||||
|
});
|
||||||
|
const home = join(tmp, 'home');
|
||||||
|
mkdirSync(join(home, 'tools', '_lib'), { recursive: true });
|
||||||
|
// Attacker points the operator credentials file at a file they can read.
|
||||||
|
const exfil = join(tmp, 'exfil-target');
|
||||||
|
writeFileSync(exfil, 'original-attacker-content');
|
||||||
|
symlinkSync(exfil, join(home, 'tools', '_lib', 'credentials.json'));
|
||||||
|
|
||||||
|
expect(() => applyRestore(dir, home, planRestore(dir))).toThrow();
|
||||||
|
// The secret was NOT written through the link into the attacker's file.
|
||||||
|
expect(readFileSync(exfil, 'utf8')).toBe('original-attacker-content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyRestore refuses to write through a symlinked ancestor (CWE-59)', () => {
|
||||||
|
const dir = seedSnapshot(backups, '20260101T000000Z', {
|
||||||
|
'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
|
||||||
|
});
|
||||||
|
const home = join(tmp, 'home');
|
||||||
|
mkdirSync(join(home, 'tools'), { recursive: true });
|
||||||
|
// Attacker replaces the `tools/_lib` ancestor with a symlink out of the root.
|
||||||
|
const exfilDir = join(tmp, 'exfil-dir');
|
||||||
|
mkdirSync(exfilDir, { recursive: true });
|
||||||
|
symlinkSync(exfilDir, join(home, 'tools', '_lib'));
|
||||||
|
|
||||||
|
expect(() => applyRestore(dir, home, planRestore(dir))).toThrow();
|
||||||
|
// Nothing was written into the attacker-controlled directory.
|
||||||
|
expect(() => statSync(join(exfilDir, 'credentials.json'))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runRestore surfaces a symlink violation as exit 1 without leaking the secret', async () => {
|
||||||
|
seedSnapshot(backups, '20260101T000000Z', {
|
||||||
|
'tools/_lib/credentials.json': `TOKEN=${SECRET}\n`,
|
||||||
|
});
|
||||||
|
const home = join(tmp, 'home');
|
||||||
|
mkdirSync(join(home, 'tools', '_lib'), { recursive: true });
|
||||||
|
const exfil = join(tmp, 'exfil-target');
|
||||||
|
writeFileSync(exfil, 'attacker');
|
||||||
|
symlinkSync(exfil, join(home, 'tools', '_lib', 'credentials.json'));
|
||||||
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const code = await runRestore({
|
||||||
|
from: '20260101T000000Z',
|
||||||
|
yes: true,
|
||||||
|
mosaicHome: home,
|
||||||
|
env: { XDG_STATE_HOME: join(tmp, 'state') },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(code).toBe(1);
|
||||||
|
expect(readFileSync(exfil, 'utf8')).toBe('attacker');
|
||||||
|
const all = [...log.mock.calls, ...err.mock.calls].flat().join('\n');
|
||||||
|
expect(all).not.toContain(SECRET);
|
||||||
|
log.mockRestore();
|
||||||
|
err.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyRestore replaces a diverged regular file in place with 0600 (not a symlink)', () => {
|
||||||
|
const dir = seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'restored' });
|
||||||
|
const home = join(tmp, 'home');
|
||||||
|
mkdirSync(home, { recursive: true });
|
||||||
|
writeFileSync(join(home, 'SOUL.md'), 'stale');
|
||||||
|
|
||||||
|
const n = applyRestore(dir, home, planRestore(dir));
|
||||||
|
expect(n).toBe(1);
|
||||||
|
expect(lstatSync(join(home, 'SOUL.md')).isSymbolicLink()).toBe(false);
|
||||||
|
expect(readFileSync(join(home, 'SOUL.md'), 'utf8')).toBe('restored');
|
||||||
|
expect(statSync(join(home, 'SOUL.md')).mode & 0o777).toBe(0o600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerRestoreCommand', () => {
|
||||||
|
it('registers `restore` with the expected flags', () => {
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerRestoreCommand(program);
|
||||||
|
const cmd = program.commands.find((c) => c.name() === 'restore');
|
||||||
|
expect(cmd).toBeDefined();
|
||||||
|
const longs = cmd!.options.map((o) => o.long);
|
||||||
|
expect(longs).toEqual(
|
||||||
|
expect.arrayContaining(['--list', '--from', '--dry-run', '--yes', '--mosaic-home']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the list action end-to-end via the parsed command', async () => {
|
||||||
|
seedSnapshot(backups, '20260101T000000Z', { 'SOUL.md': 'a' });
|
||||||
|
const prevXdg = process.env['XDG_STATE_HOME'];
|
||||||
|
process.env['XDG_STATE_HOME'] = join(tmp, 'state');
|
||||||
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerRestoreCommand(program);
|
||||||
|
await program.parseAsync(['restore', '--list', '--mosaic-home', join(tmp, 'home')], {
|
||||||
|
from: 'user',
|
||||||
|
});
|
||||||
|
expect(log.mock.calls.flat().join('\n')).toContain('20260101T000000Z');
|
||||||
|
} finally {
|
||||||
|
log.mockRestore();
|
||||||
|
if (prevXdg === undefined) delete process.env['XDG_STATE_HOME'];
|
||||||
|
else process.env['XDG_STATE_HOME'] = prevXdg;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
313
packages/mosaic/src/commands/restore.ts
Normal file
313
packages/mosaic/src/commands/restore.ts
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
/**
|
||||||
|
* restore.ts — top-level `mosaic restore` command (#791 PR2 Task 12)
|
||||||
|
*
|
||||||
|
* Recovery counterpart to the durable pre-update snapshot taken by install.sh
|
||||||
|
* (make_durable_snapshot). Before a keep-mode upgrade mutates anything, the
|
||||||
|
* installer copies the operator-owned surface to
|
||||||
|
* $XDG_STATE_HOME/mosaic/backups/pre-update-<UTC-ts>/ (0700 dirs / 0600 files)
|
||||||
|
* This command lets the operator inspect and roll back to those snapshots:
|
||||||
|
*
|
||||||
|
* mosaic restore # == --list: enumerate snapshots (dry-run)
|
||||||
|
* mosaic restore --list
|
||||||
|
* mosaic restore --from <ts> # restore that snapshot over MOSAIC_HOME
|
||||||
|
* mosaic restore --from <ts> --dry-run
|
||||||
|
*
|
||||||
|
* SECREV INVARIANT: a snapshot may contain secrets (e.g. tools/_lib/credentials.json).
|
||||||
|
* This command reports counts and RELATIVE PATHS only — it never reads a backed-up
|
||||||
|
* file into any logged string. Restored files are written back 0600 (owner-only),
|
||||||
|
* matching the snapshot's own private posture. The path convention here mirrors
|
||||||
|
* install.sh `backup_root()`; keep the two in sync (no shared code across the boundary).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
readdirSync,
|
||||||
|
statSync,
|
||||||
|
lstatSync,
|
||||||
|
readFileSync,
|
||||||
|
openSync,
|
||||||
|
writeSync,
|
||||||
|
fchmodSync,
|
||||||
|
closeSync,
|
||||||
|
constants,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { createInterface } from 'node:readline';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join, dirname, relative } from 'node:path';
|
||||||
|
import type { Command } from 'commander';
|
||||||
|
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||||
|
import { assertCanonicalContainment, ensureManagedDirectory } from '../fleet/secure-file.js';
|
||||||
|
|
||||||
|
// ─── types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SnapshotInfo {
|
||||||
|
/** The UTC stamp after the `pre-update-` prefix, e.g. "20260716T232225Z". */
|
||||||
|
readonly timestamp: string;
|
||||||
|
/** Absolute path to the snapshot directory. */
|
||||||
|
readonly dir: string;
|
||||||
|
/** Number of files captured in the snapshot. */
|
||||||
|
readonly fileCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreOptions {
|
||||||
|
list?: boolean;
|
||||||
|
from?: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
yes?: boolean;
|
||||||
|
mosaicHome: string;
|
||||||
|
/** Environment source (injectable for tests); defaults to process.env. */
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
/**
|
||||||
|
* Confirmation gate (injectable for tests); defaults to an interactive
|
||||||
|
* readline prompt. Returns true to proceed with the overwrite.
|
||||||
|
*/
|
||||||
|
confirm?: (question: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SNAPSHOT_PREFIX = 'pre-update-';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact shape install.sh `make_durable_snapshot()` stamps: `<8>T<6>Z` UTC,
|
||||||
|
* with an optional `-<n>` same-second collision suffix. `--from` is matched
|
||||||
|
* against this — nothing containing a path separator or `..` can pass, so a
|
||||||
|
* selector can never escape the backup root (CWE-22).
|
||||||
|
*/
|
||||||
|
const SNAPSHOT_TS_RE = /^\d{8}T\d{6}Z(?:-\d+)?$/;
|
||||||
|
|
||||||
|
// ─── pure helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Resolve the durable-snapshot root, mirroring install.sh `backup_root()`. */
|
||||||
|
export function resolveBackupRoot(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
const stateHome = env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state');
|
||||||
|
return join(stateHome, 'mosaic', 'backups');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively collect every file under `dir` as a path relative to `dir`. */
|
||||||
|
export function planRestore(dir: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const walk = (cur: string): void => {
|
||||||
|
for (const entry of readdirSync(cur, { withFileTypes: true })) {
|
||||||
|
const abs = join(cur, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
walk(abs);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
out.push(relative(dir, abs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (existsSync(dir)) walk(dir);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enumerate snapshots newest-first (the `pre-update-<ts>` names sort chronologically). */
|
||||||
|
export function listSnapshots(root: string): SnapshotInfo[] {
|
||||||
|
if (!existsSync(root)) return [];
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = readdirSync(root);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
.filter((name) => name.startsWith(SNAPSHOT_PREFIX))
|
||||||
|
.map((name) => join(root, name))
|
||||||
|
.filter((dir) => {
|
||||||
|
try {
|
||||||
|
return statSync(dir).isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort()
|
||||||
|
.reverse()
|
||||||
|
.map((dir) => ({
|
||||||
|
timestamp: dir.split('/').at(-1)!.slice(SNAPSHOT_PREFIX.length),
|
||||||
|
dir,
|
||||||
|
fileCount: planRestore(dir).length,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a snapshot dir from a `--from` selector. Accepts ONLY a strict
|
||||||
|
* generated identifier — a bare `<ts>` or the full `pre-update-<ts>` name — and
|
||||||
|
* builds exactly `join(root, 'pre-update-' + ts)`. A selector containing `/`,
|
||||||
|
* `..`, or anything but the timestamp shape is rejected (returns undefined), so
|
||||||
|
* `--from` can never traverse outside the backup root (CWE-22). The resolved dir
|
||||||
|
* must be a real, non-symlink directory (lstat, not stat), so a symlinked
|
||||||
|
* snapshot entry can't redirect the restore either.
|
||||||
|
*/
|
||||||
|
export function resolveSnapshotDir(root: string, from: string): string | undefined {
|
||||||
|
const ts = from.startsWith(SNAPSHOT_PREFIX) ? from.slice(SNAPSHOT_PREFIX.length) : from;
|
||||||
|
if (!SNAPSHOT_TS_RE.test(ts)) return undefined;
|
||||||
|
const dir = join(root, `${SNAPSHOT_PREFIX}${ts}`);
|
||||||
|
try {
|
||||||
|
if (lstatSync(dir).isDirectory()) return dir;
|
||||||
|
} catch {
|
||||||
|
/* absent or inaccessible */
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy each `relPaths` entry from the snapshot back into `mosaicHome`, forcing
|
||||||
|
* 0600 on the restored file (owner-only — the operator surface may hold secrets).
|
||||||
|
* Returns the number of files restored. Never reads a file's content into a
|
||||||
|
* logged string.
|
||||||
|
*
|
||||||
|
* SYMLINK-SAFE (CWE-59): a snapshot may hold secrets, so we must never let a
|
||||||
|
* tampered destination redirect the write. Every destination path is contained
|
||||||
|
* within `mosaicHome` (assertCanonicalContainment) and every ancestor is proven
|
||||||
|
* to be a real, non-symlink directory (ensureManagedDirectory) before we write.
|
||||||
|
* The leaf itself is opened O_NOFOLLOW, so if it was swapped for a symlink the
|
||||||
|
* open fails closed (ELOOP) rather than writing the secret through the link.
|
||||||
|
*/
|
||||||
|
export function applyRestore(
|
||||||
|
snapDir: string,
|
||||||
|
mosaicHome: string,
|
||||||
|
relPaths: readonly string[],
|
||||||
|
): number {
|
||||||
|
let restored = 0;
|
||||||
|
for (const rel of relPaths) {
|
||||||
|
const src = join(snapDir, rel);
|
||||||
|
const dst = join(mosaicHome, rel);
|
||||||
|
// Fail closed if the target path escapes the managed root or any ancestor is
|
||||||
|
// a symlink; create missing ancestors as private (0700) real directories.
|
||||||
|
assertCanonicalContainment(mosaicHome, dst);
|
||||||
|
ensureManagedDirectory(mosaicHome, dirname(dst));
|
||||||
|
// O_NOFOLLOW: refuse to follow a symlink at the leaf (secret exfil guard).
|
||||||
|
// O_CREAT|O_TRUNC: create a fresh 0600 file, or overwrite a diverged real one.
|
||||||
|
const fd = openSync(
|
||||||
|
dst,
|
||||||
|
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW,
|
||||||
|
0o600,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
fchmodSync(fd, 0o600); // enforce 0600 even when the file pre-existed
|
||||||
|
writeSync(fd, readFileSync(src));
|
||||||
|
} finally {
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
restored += 1;
|
||||||
|
}
|
||||||
|
return restored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── orchestration ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function promptConfirm(question: string): Promise<boolean> {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
try {
|
||||||
|
return await new Promise<boolean>((resolve) => {
|
||||||
|
rl.question(`${question} [y/N] `, (ans) => resolve(ans.trim().toLowerCase() === 'y'));
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `mosaic restore`. Returns a process exit code (0 ok, 1 error) rather than
|
||||||
|
* calling process.exit, so it stays unit-testable.
|
||||||
|
*/
|
||||||
|
export async function runRestore(opts: RestoreOptions): Promise<number> {
|
||||||
|
const env = opts.env ?? process.env;
|
||||||
|
const root = resolveBackupRoot(env);
|
||||||
|
|
||||||
|
// Default action (and explicit --list): enumerate, never mutate.
|
||||||
|
if (opts.list || !opts.from) {
|
||||||
|
const snaps = listSnapshots(root);
|
||||||
|
if (snaps.length === 0) {
|
||||||
|
console.log(`No pre-update snapshots found under ${root}.`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
console.log(`Pre-update snapshots under ${root} (newest first):\n`);
|
||||||
|
for (const s of snaps) {
|
||||||
|
console.log(` ${s.timestamp} — ${s.fileCount} file(s)`);
|
||||||
|
}
|
||||||
|
console.log(`\nRestore one with: mosaic restore --from <timestamp>`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --from <ts>: restore over the operator surface.
|
||||||
|
const snapDir = resolveSnapshotDir(root, opts.from);
|
||||||
|
if (!snapDir) {
|
||||||
|
console.error(`No snapshot matching '${opts.from}' under ${root}.`);
|
||||||
|
console.error(`Run 'mosaic restore --list' to see available timestamps.`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relPaths = planRestore(snapDir);
|
||||||
|
const ts = snapDir.split('/').at(-1)!.slice(SNAPSHOT_PREFIX.length);
|
||||||
|
|
||||||
|
if (opts.dryRun) {
|
||||||
|
console.log(
|
||||||
|
`[dry-run] Would restore ${relPaths.length} file(s) from snapshot ${ts} into ${opts.mosaicHome}:`,
|
||||||
|
);
|
||||||
|
for (const rel of relPaths) console.log(` ${rel}`);
|
||||||
|
console.log('[dry-run] No changes made.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assumeYes = opts.yes || env['MOSAIC_ASSUME_YES'] === '1';
|
||||||
|
if (!assumeYes) {
|
||||||
|
console.log(
|
||||||
|
`About to restore ${relPaths.length} operator file(s) from snapshot ${ts} into ${opts.mosaicHome}.`,
|
||||||
|
);
|
||||||
|
console.log('This OVERWRITES those files with their pre-update contents.');
|
||||||
|
const ok = await (opts.confirm ?? promptConfirm)('Proceed?');
|
||||||
|
if (!ok) {
|
||||||
|
console.log('Restore cancelled. No changes made.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let n: number;
|
||||||
|
try {
|
||||||
|
n = applyRestore(snapDir, opts.mosaicHome, relPaths);
|
||||||
|
} catch (err) {
|
||||||
|
// A containment/symlink violation is a fail-closed security stop, not a
|
||||||
|
// routine error — surface it without leaking file contents and abort.
|
||||||
|
console.error(
|
||||||
|
`Restore aborted: a destination path under ${opts.mosaicHome} is unsafe to write ` +
|
||||||
|
`(symlink or escapes the managed root). No files were restored. (${(err as Error).message})`,
|
||||||
|
);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
console.log(`Restored ${n} operator file(s) from snapshot ${ts} into ${opts.mosaicHome}.`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── commander registration ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function registerRestoreCommand(program: Command): void {
|
||||||
|
program
|
||||||
|
.command('restore')
|
||||||
|
.description('List or restore durable pre-update snapshots of your operator config (#791)')
|
||||||
|
.option('--list', 'List available snapshots by timestamp (default action)')
|
||||||
|
.option('--from <timestamp>', 'Restore the snapshot with this timestamp over MOSAIC_HOME')
|
||||||
|
.option('--dry-run', 'With --from: show what would be restored without changing anything')
|
||||||
|
.option('--yes, -y', 'Skip the confirmation prompt (also: MOSAIC_ASSUME_YES=1)')
|
||||||
|
.option(
|
||||||
|
'--mosaic-home <path>',
|
||||||
|
'Override MOSAIC_HOME directory',
|
||||||
|
process.env['MOSAIC_HOME'] ?? DEFAULT_MOSAIC_HOME,
|
||||||
|
)
|
||||||
|
.action(
|
||||||
|
async (opts: {
|
||||||
|
list?: boolean;
|
||||||
|
from?: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
yes?: boolean;
|
||||||
|
mosaicHome: string;
|
||||||
|
}) => {
|
||||||
|
const code = await runRestore({
|
||||||
|
list: opts.list,
|
||||||
|
from: opts.from,
|
||||||
|
dryRun: opts.dryRun,
|
||||||
|
yes: opts.yes,
|
||||||
|
mosaicHome: opts.mosaicHome,
|
||||||
|
});
|
||||||
|
if (code !== 0) process.exit(code);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,22 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
import {
|
||||||
|
mkdtempSync,
|
||||||
|
mkdirSync,
|
||||||
|
writeFileSync,
|
||||||
|
rmSync,
|
||||||
|
readFileSync,
|
||||||
|
existsSync,
|
||||||
|
copyFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { 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.
|
||||||
*
|
*
|
||||||
@@ -34,6 +47,13 @@ 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');
|
||||||
@@ -102,9 +122,10 @@ 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 () => {
|
||||||
// Plant a root-level AGENTS.md in sourceDir so syncDirectory's preserve is exercised.
|
// Contract files (CONSTITUTION/AGENTS/STANDARDS) ship only under defaults/ —
|
||||||
writeFileSync(join(fixture.sourceDir, 'AGENTS.md'), '# shipped AGENTS from source root\n');
|
// reconcile_framework_files is their sole writer (backup-once). The bulk
|
||||||
|
// sync never sees a root-level copy, so a user's edited root file is backed
|
||||||
|
// up, not silently clobbered, on upgrade.
|
||||||
writeFileSync(join(fixture.mosaicHome, 'TOOLS.md'), '# user-customized TOOLS\n');
|
writeFileSync(join(fixture.mosaicHome, '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,6 +34,7 @@ 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.
|
||||||
@@ -155,38 +156,22 @@ export class FileConfigAdapter implements ConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async syncFramework(action: InstallAction): Promise<void> {
|
async syncFramework(action: InstallAction): Promise<void> {
|
||||||
// Must match PRESERVE_PATHS in packages/mosaic/framework/install.sh so
|
// #791: ownership is derived from the shared framework manifest
|
||||||
// the bash and TS install paths have the same upgrade-preservation
|
// (packages/mosaic/framework/framework-manifest.txt) — the SAME file the
|
||||||
// semantics. Contract files (AGENTS.md, STANDARDS.md, TOOLS.md) are
|
// bash installer reads — so the TS and bash paths can never drift. On an
|
||||||
// seeded from defaults/ on first install and preserved thereafter;
|
// upgrade (keep/reconfigure) the sync must NEVER write an operator-owned
|
||||||
// identity files (SOUL.md, USER.md) are generated by wizard stages and
|
// path: every operator file, and any path the manifest never anticipated
|
||||||
// must never be touched by the framework sync.
|
// (which resolves to operator by the fail-safe default), is left untouched.
|
||||||
const preservePaths =
|
// A fresh install ('overwrite'/'reconfigure' onto an empty home) seeds the
|
||||||
action === 'keep' || action === 'reconfigure'
|
// full tree, so the guard applies only when preserving an existing home.
|
||||||
? [
|
const guardOwnership = action === 'keep' || action === 'reconfigure';
|
||||||
'CONSTITUTION.md',
|
const manifest = guardOwnership ? loadManifest(this.sourceDir) : undefined;
|
||||||
'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
|
||||||
|
|||||||
@@ -381,6 +381,22 @@ 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);
|
||||||
|
|||||||
@@ -584,6 +584,29 @@ function defaultValidateRoster(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single roster-v2 → generated-projection mapping. Both the reconciler's
|
||||||
|
* apply path ({@link defaultPrepareProjections}) and the recovery-framed
|
||||||
|
* `mosaic fleet regen` command derive their generated env from THIS one
|
||||||
|
* function, so the two paths can never drift (the #791 single-SSOT invariant).
|
||||||
|
* Pure: no IO, no clock — a deterministic function of the roster alone.
|
||||||
|
*/
|
||||||
|
export function projectRosterV2AgentGeneratedEnv(
|
||||||
|
roster: FleetRosterV2,
|
||||||
|
agent: FleetRosterV2Agent,
|
||||||
|
): Readonly<Record<string, string>> {
|
||||||
|
return {
|
||||||
|
MOSAIC_AGENT_NAME: agent.name,
|
||||||
|
MOSAIC_AGENT_CLASS: agent.className,
|
||||||
|
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||||
|
MOSAIC_AGENT_MODEL: agent.model,
|
||||||
|
MOSAIC_AGENT_REASONING: agent.reasoning,
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
|
||||||
|
MOSAIC_AGENT_WORKDIR: agent.workingDirectory,
|
||||||
|
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function defaultPrepareProjections(
|
function defaultPrepareProjections(
|
||||||
request: FleetReconcileRequest,
|
request: FleetReconcileRequest,
|
||||||
): (roster: FleetRosterV2) => Promise<readonly PreparedAgentEnvironmentProjection[]> {
|
): (roster: FleetRosterV2) => Promise<readonly PreparedAgentEnvironmentProjection[]> {
|
||||||
@@ -596,16 +619,7 @@ function defaultPrepareProjections(
|
|||||||
mosaicHome,
|
mosaicHome,
|
||||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||||
agentName: agent.name,
|
agentName: agent.name,
|
||||||
generated: {
|
generated: projectRosterV2AgentGeneratedEnv(roster, agent),
|
||||||
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,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -620,62 +634,193 @@ function mosaicHomeFor(deps: FleetReconcileDeps): string {
|
|||||||
return deps.mosaicHome ?? join(homedir(), '.config', 'mosaic');
|
return deps.mosaicHome ?? join(homedir(), '.config', 'mosaic');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Acquires a private lock only after proving the canonical managed path. */
|
/** Acquires the private reconcile lock only after proving the canonical managed path. */
|
||||||
export function acquirePrivateReconcileLock(
|
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, 'roster.yaml.reconcile.lock');
|
const lockPath = join(fleetDir, lockLeaf);
|
||||||
|
// The message-producing helpers below serve BOTH managed locks, so every fault
|
||||||
|
// names the actual lock file (`fleet/<leaf>`) — an operator needs to know WHICH
|
||||||
|
// lock is stale, not a hardcoded "reconciliation lock".
|
||||||
|
const lockLabel = `fleet/${lockLeaf}`;
|
||||||
return async (): Promise<() => Promise<void>> => {
|
return async (): Promise<() => Promise<void>> => {
|
||||||
await assertPrivateManagedDirectory(mosaicHome);
|
await assertPrivateManagedDirectory(mosaicHome);
|
||||||
await assertPrivateManagedDirectory(fleetDir);
|
await assertPrivateManagedDirectory(fleetDir);
|
||||||
await assertSafeLockLeafIfPresent(lockPath);
|
await assertSafeLockLeafIfPresent(lockPath, lockLabel);
|
||||||
|
|
||||||
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);
|
await assertSafeLockLeafIfPresent(lockPath, lockLabel);
|
||||||
throw new FleetReconcileError(
|
throw new FleetReconcileError('concurrent-mutation', busyMessage);
|
||||||
'concurrent-mutation',
|
|
||||||
'Another roster reconciliation is in progress.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
throw new FleetReconcileError('lock-io-failed', 'The reconciliation lock cannot be created.');
|
throw new FleetReconcileError('lock-io-failed', `The ${lockLabel} lock cannot be created.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Identity of the lock WE exclusively created (the `wx` create guaranteed it is
|
||||||
|
// ours). Captured up front so both the release closure and the init-failure
|
||||||
|
// cleanup below can prove ownership by device/inode before touching the file.
|
||||||
|
const created = await handle.stat().catch((): undefined => undefined);
|
||||||
|
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
|
let tokenPersisted = false;
|
||||||
try {
|
try {
|
||||||
await handle.writeFile(`${token}\n`, 'utf8');
|
await handle.writeFile(`${token}\n`, 'utf8');
|
||||||
const opened = await handle.stat();
|
tokenPersisted = true;
|
||||||
await handle.close();
|
await handle.close();
|
||||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'unsafe-lock');
|
if (!created)
|
||||||
|
throw new FleetReconcileError(
|
||||||
|
'lock-io-failed',
|
||||||
|
`The ${lockLabel} lock cannot be initialized.`,
|
||||||
|
);
|
||||||
|
await assertLockOwnership(
|
||||||
|
lockPath,
|
||||||
|
created.dev,
|
||||||
|
created.ino,
|
||||||
|
token,
|
||||||
|
'unsafe-lock',
|
||||||
|
lockLabel,
|
||||||
|
);
|
||||||
return async (): Promise<void> => {
|
return async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
|
await assertLockOwnership(
|
||||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
|
lockPath,
|
||||||
|
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(
|
throw new FleetReconcileError('lock-cleanup-failed', `The ${lockLabel} cleanup failed.`);
|
||||||
'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 reconciliation lock cannot be initialized.',
|
`The ${lockLabel} lock cannot be initialized.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort removal of a managed lock leaf THIS process created, used only on the
|
||||||
|
* initialization-failure path. Two independent ownership proofs, so a lock that was
|
||||||
|
* already replaced is never deleted:
|
||||||
|
*
|
||||||
|
* - primary: the captured device/inode of the file we exclusively `wx`-created; or
|
||||||
|
* - fallback: when the post-create stat itself failed (dev/ino unavailable), the
|
||||||
|
* random `ownershipToken` we persisted — only OUR lock carries it, so a CRUD
|
||||||
|
* (empty) or a differently-tokened replacement is never removed.
|
||||||
|
*
|
||||||
|
* Every fault is swallowed because the caller is already surfacing the initialization
|
||||||
|
* error and a leftover lock is recoverable via the documented runbook. If BOTH proofs
|
||||||
|
* are unavailable (stat failed AND the token write never landed) the lock is left in
|
||||||
|
* place rather than risk deleting another writer's file — a doubly-degenerate case
|
||||||
|
* requiring two independent fs faults on a just-created fd.
|
||||||
|
*/
|
||||||
|
async function removeOwnedLockLeafBestEffort(
|
||||||
|
lockPath: string,
|
||||||
|
created: { dev: number; ino: number } | undefined,
|
||||||
|
ownershipToken: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const current = await lstat(lockPath);
|
||||||
|
if (!current.isFile() || current.isSymbolicLink()) return;
|
||||||
|
if (created) {
|
||||||
|
if (current.dev === created.dev && current.ino === created.ino) {
|
||||||
|
await unlink(lockPath);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ownershipToken !== undefined) {
|
||||||
|
const contents = await readFile(lockPath, 'utf8');
|
||||||
|
if (contents.trim() === ownershipToken) {
|
||||||
|
await unlink(lockPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Swallowed: leftover lock is recoverable; do not mask the init error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function assertPrivateManagedDirectory(path: string): Promise<void> {
|
async function assertPrivateManagedDirectory(path: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const metadata = await lstat(path);
|
const metadata = await lstat(path);
|
||||||
@@ -691,16 +836,16 @@ async function assertPrivateManagedDirectory(path: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertSafeLockLeafIfPresent(lockPath: string): Promise<void> {
|
async function assertSafeLockLeafIfPresent(lockPath: string, lockLabel: string): Promise<void> {
|
||||||
try {
|
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 reconciliation lock path is unsafe.');
|
throw new FleetReconcileError('unsafe-lock', `The ${lockLabel} 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 reconciliation lock path is unavailable.');
|
throw new FleetReconcileError('unsafe-lock', `The ${lockLabel} lock path is unavailable.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,6 +855,7 @@ async function assertLockOwnership(
|
|||||||
inode: number,
|
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);
|
||||||
@@ -720,24 +866,21 @@ async function assertLockOwnership(
|
|||||||
metadata.dev !== device ||
|
metadata.dev !== device ||
|
||||||
metadata.ino !== inode
|
metadata.ino !== inode
|
||||||
) {
|
) {
|
||||||
throw new FleetReconcileError(failureCode, 'The reconciliation lock ownership changed.');
|
throw new FleetReconcileError(failureCode, `The ${lockLabel} ownership changed.`);
|
||||||
}
|
}
|
||||||
const handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
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 reconciliation lock ownership changed.');
|
throw new FleetReconcileError(failureCode, `The ${lockLabel} 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(
|
throw new FleetReconcileError(failureCode, `The ${lockLabel} ownership cannot be proven.`);
|
||||||
failureCode,
|
|
||||||
'The reconciliation lock ownership cannot be proven.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,53 @@ export async function prepareAgentEnvironmentProjection(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A generated-only projection validated without touching any legacy/local/quarantine file. */
|
||||||
|
export interface PreparedGeneratedAgentEnvironmentProjection {
|
||||||
|
readonly mosaicHome: string;
|
||||||
|
readonly agentEnvDir: string;
|
||||||
|
readonly generatedPath: string;
|
||||||
|
readonly generated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates ONLY the roster-derived generated projection for a recovery rebuild.
|
||||||
|
* Unlike {@link prepareAgentEnvironmentProjection}, this never reads, classifies,
|
||||||
|
* relocates, or quarantines the legacy `.env` / `.env.local` operator surface — it
|
||||||
|
* exists so `mosaic fleet regen` has no code path that can mutate anything except
|
||||||
|
* `<name>.env.generated`. The existing generated file, if present, must already be
|
||||||
|
* a private regular file.
|
||||||
|
*/
|
||||||
|
export async function prepareGeneratedAgentEnvironmentProjection(
|
||||||
|
options: AgentEnvironmentProjectionOptions,
|
||||||
|
): Promise<PreparedGeneratedAgentEnvironmentProjection> {
|
||||||
|
if (!AGENT_NAME.test(options.agentName)) {
|
||||||
|
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
||||||
|
}
|
||||||
|
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
||||||
|
const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`);
|
||||||
|
const generated = renderGeneratedAgentEnvironment(options.generated);
|
||||||
|
await assertPrivateRegularFileIfPresent(generatedPath);
|
||||||
|
return {
|
||||||
|
mosaicHome: options.mosaicHome,
|
||||||
|
agentEnvDir: options.agentEnvDir,
|
||||||
|
generatedPath,
|
||||||
|
generated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a generated-only projection: writes ONLY `<name>.env.generated` atomically
|
||||||
|
* and touches nothing else. It never writes `.env.local`/`.env.quarantine` and never
|
||||||
|
* unlinks the legacy `.env` — the projection-only recovery guarantee is structural.
|
||||||
|
*/
|
||||||
|
export async function applyPreparedGeneratedAgentEnvironmentProjection(
|
||||||
|
prepared: PreparedGeneratedAgentEnvironmentProjection,
|
||||||
|
): Promise<string> {
|
||||||
|
await ensurePrivateProjectionDirectory(prepared.mosaicHome, prepared.agentEnvDir);
|
||||||
|
await writePrivateAtomically(prepared.generatedPath, prepared.generated);
|
||||||
|
return prepared.generatedPath;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates only the exact generated projection eligible for a roster delete.
|
* 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
|
||||||
|
|||||||
343
packages/mosaic/src/framework/manifest-parity.spec.ts
Normal file
343
packages/mosaic/src/framework/manifest-parity.spec.ts
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import { afterAll, describe, it, expect } from 'vitest';
|
||||||
|
import { execFileSync, spawnSync } from 'node:child_process';
|
||||||
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
loadManifest,
|
||||||
|
parseManifest,
|
||||||
|
resolveOwnership,
|
||||||
|
frameworkSubtreeRoots,
|
||||||
|
} from './manifest.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bash ↔ TS parity (#791, §6.1).
|
||||||
|
*
|
||||||
|
* The installer (bash) and the config adapter (TS) each resolve path ownership
|
||||||
|
* from framework-manifest.txt. If the two resolvers disagreed on a single path,
|
||||||
|
* an upgrade could protect a file on one code path and wipe it on the other —
|
||||||
|
* exactly the two-copies drift that #631 patched by hand. This test drives the
|
||||||
|
* bash resolver (`tools/_lib/manifest.sh`) as a subprocess and asserts it agrees
|
||||||
|
* with the TS resolver for a broad set of paths spanning every ownership class.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FRAMEWORK_ROOT = fileURLToPath(new URL('../../framework', import.meta.url));
|
||||||
|
const MANIFEST_SH = join(FRAMEWORK_ROOT, 'tools', '_lib', 'manifest.sh');
|
||||||
|
|
||||||
|
const hasBash = (() => {
|
||||||
|
try {
|
||||||
|
execFileSync('bash', ['-c', 'true'], { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function bashResolve(relPath: string): string {
|
||||||
|
return execFileSync('bash', [MANIFEST_SH, 'resolve', relPath], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive the bash resolver against an arbitrary manifest file (MANIFEST_FILE override). */
|
||||||
|
function bashResolveWith(manifestFile: string, relPath: string): string {
|
||||||
|
return execFileSync('bash', [MANIFEST_SH, 'resolve', relPath], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
env: { ...process.env, MANIFEST_FILE: manifestFile },
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bashSubtreeRoots(): string[] {
|
||||||
|
return execFileSync('bash', [MANIFEST_SH, 'subtree-roots'], { encoding: 'utf-8' })
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive the bash resolver CLI against a manifest file and report how it exited.
|
||||||
|
* A fail-closed manifest must make the CLI exit non-zero with a message on
|
||||||
|
* stderr — never exit 0 having silently resolved everything to operator.
|
||||||
|
*/
|
||||||
|
function bashCli(manifestFile: string): { status: number; stderr: string } {
|
||||||
|
const res = spawnSync('bash', [MANIFEST_SH, 'resolve', 'CONSTITUTION.md'], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
env: { ...process.env, MANIFEST_FILE: manifestFile },
|
||||||
|
});
|
||||||
|
return { status: res.status ?? -1, stderr: res.stderr ?? '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths spanning every ownership class: framework single-files, framework
|
||||||
|
// subtrees, operator declared trees, operator carve-out inside a framework
|
||||||
|
// subtree, local overlays, and deliberately UNANTICIPATED paths (fail-safe).
|
||||||
|
const PROBE_PATHS = [
|
||||||
|
'CONSTITUTION.md',
|
||||||
|
'AGENTS.md',
|
||||||
|
'STANDARDS.md',
|
||||||
|
'install.sh',
|
||||||
|
'framework-manifest.txt',
|
||||||
|
'guides/E2E-DELIVERY.md',
|
||||||
|
'tools/git/pr-create.sh',
|
||||||
|
'tools/_lib/manifest.sh',
|
||||||
|
'defaults/SOUL.md',
|
||||||
|
'fleet/README.md',
|
||||||
|
'fleet/roles/coder.md',
|
||||||
|
'fleet/roster.schema.json',
|
||||||
|
'fleet/examples/general.yaml',
|
||||||
|
// operator
|
||||||
|
'SOUL.md',
|
||||||
|
'USER.md',
|
||||||
|
'TOOLS.md',
|
||||||
|
'SOUL.local.md',
|
||||||
|
'USER.local.md',
|
||||||
|
'STANDARDS.local.md',
|
||||||
|
'agents/coder0.conf',
|
||||||
|
'policy/custom.md',
|
||||||
|
'memory/note.md',
|
||||||
|
'sources/skills/x.md',
|
||||||
|
'credentials/c.json',
|
||||||
|
'tools/_lib/credentials.json',
|
||||||
|
'fleet/roster.yaml',
|
||||||
|
'fleet/roster.json',
|
||||||
|
'fleet/agents/coder0.env',
|
||||||
|
'fleet/run/coder0.hb',
|
||||||
|
// #797 Runtime Session Ledger — must resolve operator on both paths.
|
||||||
|
'fleet/run/sessions/events.ndjson',
|
||||||
|
'fleet/run/sessions/ledger.json',
|
||||||
|
'fleet/backlog/data.db',
|
||||||
|
'fleet/roles.local/custom.md',
|
||||||
|
// unanticipated → operator (fail-safe)
|
||||||
|
'harvester/sop.md',
|
||||||
|
'unknown-operator-dir/x',
|
||||||
|
'fleet/my-fleet.yaml',
|
||||||
|
'random-root-file.md',
|
||||||
|
'tools/some-new-framework-tool.sh',
|
||||||
|
];
|
||||||
|
|
||||||
|
describe.skipIf(!hasBash)('bash ↔ TS manifest parity (§6.1)', () => {
|
||||||
|
it('the bash resolver CLI exists and is executable', () => {
|
||||||
|
expect(existsSync(MANIFEST_SH)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bash and TS resolve identical ownership for every probe path', () => {
|
||||||
|
const manifest = loadManifest(FRAMEWORK_ROOT);
|
||||||
|
const disagreements: Array<{ path: string; ts: string; bash: string }> = [];
|
||||||
|
for (const p of PROBE_PATHS) {
|
||||||
|
const ts = resolveOwnership(manifest, p);
|
||||||
|
const bash = bashResolve(p);
|
||||||
|
if (ts !== bash) disagreements.push({ path: p, ts, bash });
|
||||||
|
}
|
||||||
|
expect(disagreements).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bash and TS agree on the framework subtree roots', () => {
|
||||||
|
const manifest = loadManifest(FRAMEWORK_ROOT);
|
||||||
|
expect(bashSubtreeRoots().sort()).toEqual(frameworkSubtreeRoots(manifest).sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format-safety parity (#791, Decision 1 — the `.txt` line-oriented format is
|
||||||
|
* accepted only because both resolvers agree on the format edge cases a hand-
|
||||||
|
* edited text file invites: comments, blank lines, stray whitespace, duplicate
|
||||||
|
* and overlapping globs (where deny-wins must resolve), and section ordering.
|
||||||
|
* Each fixture is driven through BOTH resolvers (bash via MANIFEST_FILE, TS via
|
||||||
|
* parseManifest) and must agree AND land on the expected ownership. Any
|
||||||
|
* divergence here means the format itself is unsafe and must be fixed/converted.
|
||||||
|
*/
|
||||||
|
describe.skipIf(!hasBash)('bash ↔ TS manifest format-edge parity (§6.1, Decision 1)', () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'mf-parity-'));
|
||||||
|
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
let fixtureSeq = 0;
|
||||||
|
function writeFixture(text: string): string {
|
||||||
|
const file = join(tmp, `manifest-${fixtureSeq++}.txt`);
|
||||||
|
writeFileSync(file, text);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both resolvers must agree, and on the expected value, for every probe.
|
||||||
|
function expectParity(text: string, cases: ReadonlyArray<readonly [string, string]>): void {
|
||||||
|
const file = writeFixture(text);
|
||||||
|
const manifest = parseManifest(text);
|
||||||
|
for (const [path, expected] of cases) {
|
||||||
|
const ts = resolveOwnership(manifest, path);
|
||||||
|
const bash = bashResolveWith(file, path);
|
||||||
|
expect(bash, `bash disagrees with TS on ${path}`).toBe(ts);
|
||||||
|
expect(ts, `ownership of ${path}`).toBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('tolerates comments, blank lines, and leading/trailing whitespace identically', () => {
|
||||||
|
// Entries and headers are padded with spaces/tabs; comments and blanks are
|
||||||
|
// interleaved. Both resolvers must trim and ignore them the same way.
|
||||||
|
const text = [
|
||||||
|
'# leading comment',
|
||||||
|
' ',
|
||||||
|
'\t[framework] ',
|
||||||
|
' tools/** ',
|
||||||
|
'# mid-section comment',
|
||||||
|
'',
|
||||||
|
'\tguides/**\t',
|
||||||
|
' [operator] ',
|
||||||
|
'\ttools/_lib/credentials.json ',
|
||||||
|
'*.local.md',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
expectParity(text, [
|
||||||
|
['tools/git/pr-create.sh', 'framework'],
|
||||||
|
['guides/E2E-DELIVERY.md', 'framework'],
|
||||||
|
['tools/_lib/credentials.json', 'operator'], // deny-wins carve-out inside tools/**
|
||||||
|
['SOUL.local.md', 'operator'],
|
||||||
|
['nowhere/unknown.md', 'operator'], // negative probe: matches NO rule → operator
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves deny-wins for overlapping and duplicate globs identically', () => {
|
||||||
|
// Framework claims tools/** (twice) and the overlapping tools/git/**;
|
||||||
|
// operator carves out tools/_lib/**. Operator must win the overlap on both.
|
||||||
|
const text = [
|
||||||
|
'[framework]',
|
||||||
|
'tools/**',
|
||||||
|
'tools/**', // duplicate — must not change resolution
|
||||||
|
'tools/git/**', // overlaps tools/**
|
||||||
|
'[operator]',
|
||||||
|
'tools/_lib/**',
|
||||||
|
].join('\n');
|
||||||
|
expectParity(text, [
|
||||||
|
['tools/git/pr-create.sh', 'framework'],
|
||||||
|
['tools/other.sh', 'framework'],
|
||||||
|
['tools/_lib/credentials.json', 'operator'], // deny-wins over both framework globs
|
||||||
|
['tools/_lib/nested/deep.json', 'operator'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is independent of section and glob ordering', () => {
|
||||||
|
// Same rule set, operator section first and entries reordered. Resolution
|
||||||
|
// must be identical because deny-wins checks all operator globs before any
|
||||||
|
// framework glob — order within or between sections cannot matter.
|
||||||
|
const forward = [
|
||||||
|
'[framework]',
|
||||||
|
'guides/**',
|
||||||
|
'tools/**',
|
||||||
|
'[operator]',
|
||||||
|
'tools/_lib/credentials.json',
|
||||||
|
'*.local.md',
|
||||||
|
].join('\n');
|
||||||
|
const reversed = [
|
||||||
|
'[operator]',
|
||||||
|
'*.local.md',
|
||||||
|
'tools/_lib/credentials.json',
|
||||||
|
'[framework]',
|
||||||
|
'tools/**',
|
||||||
|
'guides/**',
|
||||||
|
].join('\n');
|
||||||
|
const probes: ReadonlyArray<readonly [string, string]> = [
|
||||||
|
['tools/git/pr-create.sh', 'framework'],
|
||||||
|
['guides/E2E-DELIVERY.md', 'framework'],
|
||||||
|
['tools/_lib/credentials.json', 'operator'],
|
||||||
|
['SOUL.local.md', 'operator'],
|
||||||
|
['unanticipated/path.md', 'operator'],
|
||||||
|
];
|
||||||
|
expectParity(forward, probes);
|
||||||
|
expectParity(reversed, probes);
|
||||||
|
// And the two orderings agree path-for-path on both resolvers.
|
||||||
|
const fFile = writeFixture(forward);
|
||||||
|
const rFile = writeFixture(reversed);
|
||||||
|
for (const [path] of probes) {
|
||||||
|
expect(bashResolveWith(fFile, path)).toBe(bashResolveWith(rFile, path));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults an unmatched path to operator on both resolvers (UNKNOWN → operator)', () => {
|
||||||
|
// A manifest that names only a narrow framework slice. Everything else —
|
||||||
|
// including paths under no rule at all — must fail safe to operator.
|
||||||
|
const text = ['[framework]', 'guides/**', '[operator]', 'agents/**'].join('\n');
|
||||||
|
expectParity(text, [
|
||||||
|
['guides/x.md', 'framework'],
|
||||||
|
['agents/coder0.conf', 'operator'],
|
||||||
|
['totally/unlisted/file.txt', 'operator'], // negative probe
|
||||||
|
['fleet/run/sessions/ledger.json', 'operator'], // unlisted → operator
|
||||||
|
['README.md', 'operator'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Failure-mode parity (#791 B2/B3). A bad manifest is the dangerous case: if the
|
||||||
|
* two resolvers DISAGREED on rejection — one throwing while the other quietly
|
||||||
|
* resolved everything to operator — an upgrade could fail loud on one code path
|
||||||
|
* and no-op on the other. So for every malformed/empty/missing manifest, BOTH
|
||||||
|
* must reject: TS throws, and the bash CLI exits non-zero with a stderr message.
|
||||||
|
*/
|
||||||
|
describe.skipIf(!hasBash)('bash ↔ TS manifest failure-mode parity (§6.1, B2/B3)', () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'mf-failmode-'));
|
||||||
|
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
function writeFixture(text: string): string {
|
||||||
|
const file = join(tmp, `bad-manifest-${seq++}.txt`);
|
||||||
|
writeFileSync(file, text);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TS throws AND bash CLI exits non-zero with a non-empty stderr — identical rejection.
|
||||||
|
function expectBothReject(label: string, manifestFile: string): void {
|
||||||
|
expect(() => parseManifestFile(manifestFile), `TS accepted ${label}`).toThrow();
|
||||||
|
const cli = bashCli(manifestFile);
|
||||||
|
expect(cli.status, `bash did not exit non-zero for ${label}`).not.toBe(0);
|
||||||
|
expect(cli.stderr.trim().length, `bash was silent for ${label}`).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the file for the TS side the same way loadManifest does, so both halves
|
||||||
|
// see identical bytes (loadManifest keys off a directory, not an arbitrary file).
|
||||||
|
function parseManifestFile(file: string): void {
|
||||||
|
parseManifest(readFileSync(file, 'utf-8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('both reject a completely empty manifest', () => {
|
||||||
|
expectBothReject('empty', writeFixture(''));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both reject a comment/blank-only manifest', () => {
|
||||||
|
expectBothReject('comment-only', writeFixture('# header only\n\n \n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both reject an operator-only manifest (zero framework paths)', () => {
|
||||||
|
expectBothReject('operator-only', writeFixture('[operator]\nSOUL.md\n*.local.md\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both reject a [framework] section with no entries', () => {
|
||||||
|
expectBothReject('empty-framework-section', writeFixture('[framework]\n[operator]\nSOUL.md\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both reject a [framework] entry that normalizes to an empty glob (/)', () => {
|
||||||
|
expectBothReject('root-slash-framework', writeFixture('[framework]\n/\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both reject a [framework] entry that normalizes to nothing (./)', () => {
|
||||||
|
expectBothReject('dot-slash-framework', writeFixture('[framework]\n./\n[operator]\nSOUL.md\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both reject [framework] entries that are only bare dot segments', () => {
|
||||||
|
expectBothReject('bare-dot-framework', writeFixture('[framework]\n.\n..\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both reject an entry that appears before any section header', () => {
|
||||||
|
expectBothReject('entry-before-header', writeFixture('stray.md\n[framework]\nguides/**\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both reject an unknown section header', () => {
|
||||||
|
expectBothReject('unknown-header', writeFixture('[bogus]\nx\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both reject a missing manifest file (fail-closed, not empty result)', () => {
|
||||||
|
const missing = join(tmp, 'does-not-exist.txt');
|
||||||
|
// TS: loadManifest would throw a read error; here read-then-parse throws on read.
|
||||||
|
expect(() => parseManifestFile(missing)).toThrow();
|
||||||
|
const cli = bashCli(missing);
|
||||||
|
expect(cli.status).not.toBe(0);
|
||||||
|
expect(cli.stderr.trim().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
327
packages/mosaic/src/framework/manifest.spec.ts
Normal file
327
packages/mosaic/src/framework/manifest.spec.ts
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readdirSync, statSync } from 'node:fs';
|
||||||
|
import { join, relative } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
parseManifest,
|
||||||
|
loadManifest,
|
||||||
|
matchGlob,
|
||||||
|
resolveOwnership,
|
||||||
|
frameworkSubtreeRoots,
|
||||||
|
planPrune,
|
||||||
|
ManifestError,
|
||||||
|
type FrameworkManifest,
|
||||||
|
} from './manifest.js';
|
||||||
|
|
||||||
|
const FRAMEWORK_ROOT = fileURLToPath(new URL('../../framework', import.meta.url));
|
||||||
|
|
||||||
|
const SAMPLE = `
|
||||||
|
# comment
|
||||||
|
[framework]
|
||||||
|
CONSTITUTION.md
|
||||||
|
guides/**
|
||||||
|
tools/**
|
||||||
|
|
||||||
|
[operator]
|
||||||
|
SOUL.md
|
||||||
|
*.local.md
|
||||||
|
agents/**
|
||||||
|
tools/_lib/credentials.json
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe('parseManifest', () => {
|
||||||
|
it('splits entries into framework and operator sections, ignoring comments/blanks', () => {
|
||||||
|
const m = parseManifest(SAMPLE);
|
||||||
|
expect(m.framework).toEqual(['CONSTITUTION.md', 'guides/**', 'tools/**']);
|
||||||
|
expect(m.operator).toEqual([
|
||||||
|
'SOUL.md',
|
||||||
|
'*.local.md',
|
||||||
|
'agents/**',
|
||||||
|
'tools/_lib/credentials.json',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an entry that appears before any section header', () => {
|
||||||
|
expect(() => parseManifest('stray.md\n[framework]\n')).toThrow(/before any \[section\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown section header', () => {
|
||||||
|
expect(() => parseManifest('[bogus]\nx\n')).toThrow(/Unknown manifest section/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fail-closed parsing/loading (#791 B2/B3). An empty, comment-only, operator-only,
|
||||||
|
// or unreadable manifest must NOT resolve to "framework owns nothing" (which would
|
||||||
|
// make an upgrade a silent no-op). Both must throw so finalizeStage surfaces the
|
||||||
|
// abort instead of reporting "Installation complete". The bash reader rejects the
|
||||||
|
// same inputs — asserted for parity in manifest-parity.spec.ts.
|
||||||
|
describe('parseManifest / loadManifest fail closed on empty or unreadable input', () => {
|
||||||
|
it('throws on a completely empty manifest', () => {
|
||||||
|
expect(() => parseManifest('')).toThrow(/no \[framework\] paths/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on a comment- and blank-only manifest (no entries at all)', () => {
|
||||||
|
expect(() => parseManifest('# just a header comment\n\n \n')).toThrow(
|
||||||
|
/no \[framework\] paths/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when only an [operator] section is present (zero framework paths)', () => {
|
||||||
|
expect(() => parseManifest('[operator]\nSOUL.md\n*.local.md\n')).toThrow(
|
||||||
|
/no \[framework\] paths/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on a [framework] header with no entries beneath it', () => {
|
||||||
|
expect(() => parseManifest('[framework]\n\n[operator]\nSOUL.md\n')).toThrow(
|
||||||
|
/no \[framework\] paths/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Degenerate framework entries that pass the length check but normalize to a
|
||||||
|
// glob matching nothing — the manifest would silently protect the whole tree
|
||||||
|
// as operator (#791 blocker-B). Both `/` and `./` normalize to '' ; `.`/`..`
|
||||||
|
// are bare-dot segments.
|
||||||
|
it.each([['/'], ['./'], ['.'], ['..'], ['/\n./']])(
|
||||||
|
'throws when the only [framework] entry (%j) normalizes to nothing usable',
|
||||||
|
(entry) => {
|
||||||
|
expect(() => parseManifest(`[framework]\n${entry}\n`)).toThrow(
|
||||||
|
/no usable \[framework\] paths/,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('accepts a wildcard-only framework glob (** is usable)', () => {
|
||||||
|
expect(() => parseManifest('[framework]\n**\n')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadManifest throws a clear fail-closed error when the manifest file is missing', () => {
|
||||||
|
const missingRoot = fileURLToPath(new URL('./__no_such_framework_root__', import.meta.url));
|
||||||
|
expect(() => loadManifest(missingRoot)).toThrow(/Cannot read framework manifest/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The distinct error type is what lets finalizeStage tell a pre-sync validation
|
||||||
|
// abort (nothing written) from a mid-sync filesystem failure (#791 blocker-C).
|
||||||
|
it('every fail-closed rejection is a ManifestError', () => {
|
||||||
|
expect(() => parseManifest('')).toThrow(ManifestError);
|
||||||
|
expect(() => parseManifest('[operator]\nSOUL.md\n')).toThrow(ManifestError);
|
||||||
|
expect(() => parseManifest('[framework]\n/\n')).toThrow(ManifestError);
|
||||||
|
expect(() => parseManifest('[bogus]\nx\n')).toThrow(ManifestError);
|
||||||
|
expect(() => parseManifest('stray.md\n[framework]\n')).toThrow(ManifestError);
|
||||||
|
const missingRoot = fileURLToPath(new URL('./__no_such_framework_root__', import.meta.url));
|
||||||
|
expect(() => loadManifest(missingRoot)).toThrow(ManifestError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matchGlob', () => {
|
||||||
|
it('matches an exact file', () => {
|
||||||
|
expect(matchGlob('CONSTITUTION.md', 'CONSTITUTION.md')).toBe(true);
|
||||||
|
expect(matchGlob('CONSTITUTION.md', 'AGENTS.md')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a bare directory entry as covering its descendants', () => {
|
||||||
|
expect(matchGlob('memory', 'memory')).toBe(true);
|
||||||
|
expect(matchGlob('memory', 'memory/notes.md')).toBe(true);
|
||||||
|
expect(matchGlob('memory', 'memoryfoo')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('** matches any depth including the root itself', () => {
|
||||||
|
expect(matchGlob('agents/**', 'agents')).toBe(true);
|
||||||
|
expect(matchGlob('agents/**', 'agents/a.conf')).toBe(true);
|
||||||
|
expect(matchGlob('agents/**', 'agents/nested/deep.conf')).toBe(true);
|
||||||
|
expect(matchGlob('agents/**', 'agentsX')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('* stays within a single segment', () => {
|
||||||
|
expect(matchGlob('*.local.md', 'SOUL.local.md')).toBe(true);
|
||||||
|
expect(matchGlob('*.local.md', 'a/SOUL.local.md')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveOwnership (deny-wins + fail-safe)', () => {
|
||||||
|
const m = parseManifest(SAMPLE);
|
||||||
|
|
||||||
|
it('operator globs win over framework globs (carve-out inside a framework subtree)', () => {
|
||||||
|
expect(resolveOwnership(m, 'tools/_lib/credentials.json')).toBe('operator');
|
||||||
|
expect(resolveOwnership(m, 'tools/git/pr-create.sh')).toBe('framework');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('framework-declared paths resolve to framework', () => {
|
||||||
|
expect(resolveOwnership(m, 'guides/E2E-DELIVERY.md')).toBe('framework');
|
||||||
|
expect(resolveOwnership(m, 'CONSTITUTION.md')).toBe('framework');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UNKNOWN paths default to operator (the #791 root-cause guarantee)', () => {
|
||||||
|
expect(resolveOwnership(m, 'agents/coder0.conf')).toBe('operator'); // declared
|
||||||
|
expect(resolveOwnership(m, 'harvester/sop.md')).toBe('operator'); // undeclared → fail-safe
|
||||||
|
expect(resolveOwnership(m, 'totally-unknown-dir/x')).toBe('operator');
|
||||||
|
expect(resolveOwnership(m, 'random-root-file.md')).toBe('operator');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('planPrune (pure prune planner)', () => {
|
||||||
|
const m = parseManifest(SAMPLE);
|
||||||
|
|
||||||
|
it('prunes a retired framework file inside a shipped subtree', () => {
|
||||||
|
const del = planPrune({
|
||||||
|
manifest: m,
|
||||||
|
targetPaths: ['guides/OLD.md', 'guides/KEEP.md'],
|
||||||
|
sourcePaths: ['guides/KEEP.md'],
|
||||||
|
});
|
||||||
|
expect(del).toEqual(['guides/OLD.md']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never prunes operator-reserved paths even when absent from source', () => {
|
||||||
|
const del = planPrune({
|
||||||
|
manifest: m,
|
||||||
|
targetPaths: ['agents/coder0.conf', 'tools/_lib/credentials.json', 'SOUL.local.md'],
|
||||||
|
sourcePaths: [],
|
||||||
|
});
|
||||||
|
expect(del).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never prunes UNKNOWN paths outside every framework subtree (fail-safe)', () => {
|
||||||
|
const del = planPrune({
|
||||||
|
manifest: m,
|
||||||
|
targetPaths: ['harvester/sop.md', 'my-fleet.yaml', 'unknown-dir/deep/x'],
|
||||||
|
sourcePaths: [],
|
||||||
|
});
|
||||||
|
expect(del).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never prunes single-file framework entries (reconcile-managed, not in subtree)', () => {
|
||||||
|
const del = planPrune({ manifest: m, targetPaths: ['CONSTITUTION.md'], sourcePaths: [] });
|
||||||
|
expect(del).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('property: delete-set ⊆ {framework-owned ∧ in-target ∧ not-in-source} and ∩ operator = ∅', () => {
|
||||||
|
const operatorish = [
|
||||||
|
'agents/a.conf',
|
||||||
|
'policy/p.md',
|
||||||
|
'SOUL.local.md',
|
||||||
|
'memory/m.md',
|
||||||
|
'tools/_lib/credentials.json',
|
||||||
|
'harvester/sop.md',
|
||||||
|
'unknown-top/x',
|
||||||
|
'another-unknown/deep/y.txt',
|
||||||
|
];
|
||||||
|
const frameworkish = ['guides/A.md', 'guides/sub/B.md', 'tools/git/x.sh'];
|
||||||
|
const targetPaths = [...operatorish, ...frameworkish];
|
||||||
|
const del = planPrune({ manifest: m, targetPaths, sourcePaths: [] });
|
||||||
|
|
||||||
|
for (const p of del) {
|
||||||
|
expect(resolveOwnership(m, p)).toBe('framework');
|
||||||
|
expect(targetPaths).toContain(p);
|
||||||
|
}
|
||||||
|
// No operator/unknown path ever appears in the delete-set.
|
||||||
|
for (const p of operatorish) expect(del).not.toContain(p);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The #797 Runtime Session Ledger lives at fleet/run/sessions/. Today it is safe
|
||||||
|
// twice over: it matches the explicit `fleet/run/**` operator carve-out AND, even
|
||||||
|
// without it, the UNKNOWN→operator fail-safe. This test isolates the CARVE-OUT's
|
||||||
|
// load-bearing value by simulating a future framework author who broadens fleet
|
||||||
|
// ownership to `fleet/**`: without the operator carve-out the ledger would resolve
|
||||||
|
// framework and be pruned; deny-wins is what keeps it protected. If deleting the
|
||||||
|
// `fleet/run/**` line ever stops turning this test red, the carve-out has silently
|
||||||
|
// stopped mattering — which is exactly the #797 regression we are gating against.
|
||||||
|
describe('fleet/run/** carve-out is load-bearing for the #797 ledger (deny-wins)', () => {
|
||||||
|
const LEDGER = ['fleet/run/sessions/events.ndjson', 'fleet/run/sessions/ledger.json'];
|
||||||
|
// A framework that (hypothetically) ships all of fleet/** as a subtree.
|
||||||
|
const withoutCarveOut: FrameworkManifest = {
|
||||||
|
framework: ['fleet/**'],
|
||||||
|
operator: [],
|
||||||
|
};
|
||||||
|
const withCarveOut: FrameworkManifest = {
|
||||||
|
framework: ['fleet/**'],
|
||||||
|
operator: ['fleet/run/**'],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('RED without the carve-out: the ledger resolves framework and is pruned', () => {
|
||||||
|
for (const p of LEDGER) expect(resolveOwnership(withoutCarveOut, p)).toBe('framework');
|
||||||
|
const del = planPrune({ manifest: withoutCarveOut, targetPaths: LEDGER, sourcePaths: [] });
|
||||||
|
expect(del.sort()).toEqual([...LEDGER].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GREEN with the carve-out: deny-wins makes the ledger operator and unprunable', () => {
|
||||||
|
for (const p of LEDGER) expect(resolveOwnership(withCarveOut, p)).toBe('operator');
|
||||||
|
const del = planPrune({ manifest: withCarveOut, targetPaths: LEDGER, sourcePaths: [] });
|
||||||
|
expect(del).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the SHIPPED manifest reserves fleet/run/** so the ledger is operator-owned', () => {
|
||||||
|
const shipped = loadManifest(FRAMEWORK_ROOT);
|
||||||
|
for (const p of LEDGER) expect(resolveOwnership(shipped, p)).toBe('operator');
|
||||||
|
// And it is structurally unreachable by pruning even if it were in a subtree.
|
||||||
|
expect(planPrune({ manifest: shipped, targetPaths: LEDGER, sourcePaths: [] })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('frameworkSubtreeRoots', () => {
|
||||||
|
it('returns only the /** subtree roots, not single-file entries', () => {
|
||||||
|
const m = parseManifest(SAMPLE);
|
||||||
|
expect(frameworkSubtreeRoots(m)).toEqual(['guides', 'tools']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── SSOT manifest: shipped-file completeness (§6.2) ──────────────────────────
|
||||||
|
// A newly-shipped framework file must not silently fall outside the manifest —
|
||||||
|
// if it did, the updater could neither guarantee it as framework-owned nor
|
||||||
|
// prune it when retired. Every file the framework actually ships must resolve
|
||||||
|
// to `framework` (except the defaults/{SOUL,USER}.md identity seeds, which are
|
||||||
|
// operator-owned by design).
|
||||||
|
describe('manifest completeness against shipped framework tree', () => {
|
||||||
|
const manifest = loadManifest(FRAMEWORK_ROOT);
|
||||||
|
|
||||||
|
const IGNORED_TOP = new Set(['.git', 'node_modules']);
|
||||||
|
// Framework-shipped files that are operator-owned by design: the identity
|
||||||
|
// seeds under defaults/, and the `.gitkeep` placeholder that lets the empty
|
||||||
|
// operator-owned memory/ directory exist in git.
|
||||||
|
function isOperatorShipped(rel: string): boolean {
|
||||||
|
if (rel === 'defaults/SOUL.md' || rel === 'defaults/USER.md') return true;
|
||||||
|
if (rel.startsWith('memory/')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(dir: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
const abs = join(dir, entry);
|
||||||
|
const rel = relative(FRAMEWORK_ROOT, abs);
|
||||||
|
if (IGNORED_TOP.has(rel)) continue;
|
||||||
|
if (statSync(abs).isDirectory()) out.push(...walk(abs));
|
||||||
|
else out.push(rel);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('every shipped framework file resolves to framework ownership', () => {
|
||||||
|
const shipped = walk(FRAMEWORK_ROOT);
|
||||||
|
const misclassified = shipped.filter(
|
||||||
|
(p) => !isOperatorShipped(p) && resolveOwnership(manifest, p) !== 'framework',
|
||||||
|
);
|
||||||
|
expect(misclassified).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the operator-owned surface from #791 resolves to operator', () => {
|
||||||
|
const operatorPaths = [
|
||||||
|
'agents/coder0.conf',
|
||||||
|
'fleet/agents/coder0.env',
|
||||||
|
'memory/note.md',
|
||||||
|
'policy/custom.md',
|
||||||
|
'SOUL.local.md',
|
||||||
|
'USER.local.md',
|
||||||
|
'STANDARDS.local.md',
|
||||||
|
'tools/_lib/credentials.json',
|
||||||
|
'fleet/roster.yaml',
|
||||||
|
'fleet/roster.json',
|
||||||
|
'fleet/run/coder0.hb',
|
||||||
|
'fleet/backlog/data.db',
|
||||||
|
'fleet/roles.local/custom.md',
|
||||||
|
];
|
||||||
|
for (const p of operatorPaths) {
|
||||||
|
expect(resolveOwnership(manifest, p), p).toBe('operator');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
248
packages/mosaic/src/framework/manifest.ts
Normal file
248
packages/mosaic/src/framework/manifest.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Framework path-ownership manifest (#791).
|
||||||
|
*
|
||||||
|
* The updater must operate from an explicit framework-owned path manifest and
|
||||||
|
* NEVER write outside it. This module is the TypeScript reader for the shared
|
||||||
|
* SSOT manifest (`packages/mosaic/framework/framework-manifest.txt`) that the
|
||||||
|
* bash installer also consumes. Keeping both paths on one data file is what
|
||||||
|
* closes the two-copies-drift failure class (see #631 → #791).
|
||||||
|
*
|
||||||
|
* Everything here is pure (parse + resolve + plan) so the ownership guarantee
|
||||||
|
* is unit- and property-testable without touching the filesystem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Ownership = 'framework' | 'operator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the manifest is missing, empty, or malformed. A distinct type lets
|
||||||
|
* callers (e.g. finalizeStage) tell a pre-sync validation abort — where NO files
|
||||||
|
* were touched — apart from a generic mid-sync filesystem failure, and message
|
||||||
|
* the user accurately (#791 blocker-C).
|
||||||
|
*/
|
||||||
|
export class ManifestError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ManifestError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrameworkManifest {
|
||||||
|
/** Globs the updater MAY create/overwrite, and prune only when retired. */
|
||||||
|
readonly framework: readonly string[];
|
||||||
|
/** Globs the updater must NEVER write over or prune. Win over `framework`. */
|
||||||
|
readonly operator: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Section = 'framework' | 'operator' | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the line-oriented manifest text. `#` comments and blank lines are
|
||||||
|
* ignored; `[framework]` / `[operator]` headers switch the active section.
|
||||||
|
* Lines before any header are rejected — the format must be explicit.
|
||||||
|
*/
|
||||||
|
export function parseManifest(text: string): FrameworkManifest {
|
||||||
|
const framework: string[] = [];
|
||||||
|
const operator: string[] = [];
|
||||||
|
let section: Section = null;
|
||||||
|
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const raw = lines[i] ?? '';
|
||||||
|
const line = raw.trim();
|
||||||
|
if (line === '' || line.startsWith('#')) continue;
|
||||||
|
|
||||||
|
if (line === '[framework]') {
|
||||||
|
section = 'framework';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === '[operator]') {
|
||||||
|
section = 'operator';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('[')) {
|
||||||
|
throw new ManifestError(`Unknown manifest section header on line ${i + 1}: ${line}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === null) {
|
||||||
|
throw new ManifestError(
|
||||||
|
`Manifest entry before any [section] header on line ${i + 1}: ${line}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(section === 'framework' ? framework : operator).push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail CLOSED on an empty or comment-only manifest. A manifest with zero
|
||||||
|
// framework-owned globs would make resolveOwnership() return `operator` for
|
||||||
|
// every path: an upgrade would prune nothing and refresh nothing — a silent
|
||||||
|
// no-op indistinguishable from success. Refuse loudly instead, mirroring the
|
||||||
|
// bash reader's `manifest_load` guard so both halves reject it identically (#791 B2).
|
||||||
|
if (framework.length === 0) {
|
||||||
|
throw new ManifestError(
|
||||||
|
'Framework manifest defines no [framework] paths — refusing to proceed (empty or malformed manifest).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail CLOSED on framework entries that normalize to nothing usable. A manifest
|
||||||
|
// like `[framework]\n/` or `[framework]\n./` passes the length check above but
|
||||||
|
// every entry normalizes to an empty (or bare-dot) glob that matches no real
|
||||||
|
// path — so the compiled framework matcher is empty and every path resolves
|
||||||
|
// `operator`: the same silent no-op as an empty manifest. Require at least one
|
||||||
|
// entry with a real, non-dot character (the bash reader applies the identical
|
||||||
|
// `[^/.]` test, so both halves reject these inputs together — #791 blocker-B).
|
||||||
|
if (!framework.some(isUsableFrameworkGlob)) {
|
||||||
|
throw new ManifestError(
|
||||||
|
'Framework manifest defines no usable [framework] paths (every entry is empty or a bare dot segment) — refusing to proceed (malformed manifest).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { framework, operator };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A framework glob is usable only if, once normalized, it still contains a
|
||||||
|
* character other than `/` or `.` — i.e. it names a real path segment or a
|
||||||
|
* wildcard. `''`, `/`, `./`, `.`, `..` are all unusable (they compile to a glob
|
||||||
|
* that matches nothing). Kept byte-compatible with the bash `[[ =~ [^/.] ]]`
|
||||||
|
* test so TS and bash accept/reject exactly the same manifests.
|
||||||
|
*/
|
||||||
|
function isUsableFrameworkGlob(glob: string): boolean {
|
||||||
|
return /[^/.]/.test(normalizeRel(glob));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read and parse the manifest from a framework root directory. */
|
||||||
|
export function loadManifest(frameworkRoot: string): FrameworkManifest {
|
||||||
|
const file = `${frameworkRoot}/framework-manifest.txt`;
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = readFileSync(file, 'utf-8');
|
||||||
|
} catch (err) {
|
||||||
|
// A missing/unreadable manifest must fail closed with a clear message, not a
|
||||||
|
// raw ENOENT that a caller might mistake for an empty result set (#791 B2/B3).
|
||||||
|
throw new ManifestError(
|
||||||
|
`Cannot read framework manifest at ${file}: ${(err as Error).message} — refusing to sync (fail-closed).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parseManifest(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a mosaic-home-relative POSIX path against one glob.
|
||||||
|
*
|
||||||
|
* Supported: `**` (any depth, including zero segments) and `*` (any run of
|
||||||
|
* characters within a single segment, not crossing `/`). A glob with no
|
||||||
|
* wildcard matches either the exact path OR any path beneath it (so a bare
|
||||||
|
* directory entry like `memory` covers `memory/notes.md`).
|
||||||
|
*/
|
||||||
|
export function matchGlob(glob: string, relPath: string): boolean {
|
||||||
|
const path = normalizeRel(relPath);
|
||||||
|
const pattern = normalizeRel(glob);
|
||||||
|
if (pattern === '') return false;
|
||||||
|
|
||||||
|
if (!pattern.includes('*')) {
|
||||||
|
// Exact file, or any descendant of a bare directory prefix.
|
||||||
|
return path === pattern || path.startsWith(`${pattern}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const re = new RegExp(`^${globToRegExpBody(pattern)}$`);
|
||||||
|
return re.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the path matches any glob in the list. */
|
||||||
|
export function matchesAny(globs: readonly string[], relPath: string): boolean {
|
||||||
|
return globs.some((g) => matchGlob(g, relPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve ownership of a mosaic-home-relative path (deny-wins / fail-safe):
|
||||||
|
* operator globs win, then framework globs, else operator by default.
|
||||||
|
*/
|
||||||
|
export function resolveOwnership(manifest: FrameworkManifest, relPath: string): Ownership {
|
||||||
|
if (matchesAny(manifest.operator, relPath)) return 'operator';
|
||||||
|
if (matchesAny(manifest.framework, relPath)) return 'framework';
|
||||||
|
return 'operator';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The set of `[framework]` subtree roots that pruning is allowed to descend
|
||||||
|
* into (glob entries of the form `dir/**`). Single-file framework entries
|
||||||
|
* (e.g. `CONSTITUTION.md`) are reconcile-managed and never pruned.
|
||||||
|
*/
|
||||||
|
export function frameworkSubtreeRoots(manifest: FrameworkManifest): string[] {
|
||||||
|
const roots: string[] = [];
|
||||||
|
for (const g of manifest.framework) {
|
||||||
|
if (g.endsWith('/**')) roots.push(g.slice(0, -3));
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrunePlanInput {
|
||||||
|
readonly manifest: FrameworkManifest;
|
||||||
|
/** Mosaic-home-relative paths currently present in the target. */
|
||||||
|
readonly targetPaths: readonly string[];
|
||||||
|
/** Mosaic-home-relative paths the framework currently ships (source). */
|
||||||
|
readonly sourcePaths: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure prune planner — the testable seam of the #791 fix.
|
||||||
|
*
|
||||||
|
* Returns the delete-set: target paths that are framework-owned, live inside a
|
||||||
|
* shipped framework subtree, and are absent from the current source (retired
|
||||||
|
* framework files). By construction the result never contains an operator-owned
|
||||||
|
* or unknown path: those either resolve to `operator` or fall outside every
|
||||||
|
* framework subtree root, so they are structurally unreachable by pruning.
|
||||||
|
*/
|
||||||
|
export function planPrune(input: PrunePlanInput): string[] {
|
||||||
|
const { manifest, targetPaths, sourcePaths } = input;
|
||||||
|
const source = new Set(sourcePaths.map(normalizeRel));
|
||||||
|
const roots = frameworkSubtreeRoots(manifest);
|
||||||
|
|
||||||
|
const deleteSet: string[] = [];
|
||||||
|
for (const raw of targetPaths) {
|
||||||
|
const path = normalizeRel(raw);
|
||||||
|
if (source.has(path)) continue; // still shipped — keep
|
||||||
|
if (resolveOwnership(manifest, path) !== 'framework') continue; // operator/unknown — never prune
|
||||||
|
if (!roots.some((root) => path === root || path.startsWith(`${root}/`))) continue; // outside shipped subtrees
|
||||||
|
deleteSet.push(path);
|
||||||
|
}
|
||||||
|
return deleteSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRel(p: string): string {
|
||||||
|
return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Translate a glob body (already normalized) into a RegExp source fragment. */
|
||||||
|
function globToRegExpBody(pattern: string): string {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < pattern.length; i++) {
|
||||||
|
const c = pattern[i];
|
||||||
|
if (c === undefined) continue;
|
||||||
|
if (c === '*') {
|
||||||
|
if (pattern[i + 1] === '*') {
|
||||||
|
// `**` — any depth. `a/**` must also match the bare root `a`, so when a
|
||||||
|
// literal `/` was just emitted, make it optional along with the rest.
|
||||||
|
i++;
|
||||||
|
let trailingSlash = false;
|
||||||
|
if (pattern[i + 1] === '/') {
|
||||||
|
i++;
|
||||||
|
trailingSlash = true;
|
||||||
|
}
|
||||||
|
if (out.endsWith('/')) {
|
||||||
|
out = `${out.slice(0, -1)}(?:/.*)?`;
|
||||||
|
} else if (trailingSlash) {
|
||||||
|
out += '(?:.*/)?';
|
||||||
|
} else {
|
||||||
|
out += '.*';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out += '[^/]*';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -62,16 +62,28 @@ function rotateBackups(filePath: string): void {
|
|||||||
/**
|
/**
|
||||||
* Sync a source directory to a target, with optional preserve paths.
|
* 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: { preserve?: string[]; excludeGit?: boolean } = {},
|
options: {
|
||||||
|
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 {
|
||||||
@@ -86,7 +98,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 (preserveSet.has(relPath) && existsSync(dest)) return;
|
if (relPath !== '' && 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)) {
|
||||||
@@ -101,6 +113,10 @@ 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,9 +488,13 @@ 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 P4
|
// These helpers run the package's own install.sh in sync-only mode. The re-seed
|
||||||
// data-safe reconcile: framework-owned overwrite + backup-once; SOUL/USER/
|
// is manifest-driven (#791): keep mode writes ONLY framework-owned paths from the
|
||||||
// *.local/credentials preserved) and, opt-in, relaunch durable agents.
|
// shared framework-manifest.txt and prunes only retired framework files inside
|
||||||
|
// shipped subtrees — every operator path (SOUL/USER/*.local/credentials, fleet
|
||||||
|
// roster + agents + backlog, and anything the manifest never anticipated) is
|
||||||
|
// left byte-identical. Contract files are still reconciled (overwrite +
|
||||||
|
// backup-once). Opt-in, this also relaunches durable agents.
|
||||||
|
|
||||||
/** Resolve the framework/ directory bundled in the installed package. */
|
/** Resolve the framework/ directory bundled in the installed package. */
|
||||||
export function resolveBundledFrameworkRoot(): string {
|
export function resolveBundledFrameworkRoot(): string {
|
||||||
|
|||||||
136
packages/mosaic/src/stages/finalize-sync-abort.spec.ts
Normal file
136
packages/mosaic/src/stages/finalize-sync-abort.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the framework-sync abort messaging (#791 B2 + blocker-C).
|
||||||
|
*
|
||||||
|
* finalizeStage runs `config.syncFramework()` first, inside a try/catch. If the
|
||||||
|
* sync throws, the wizard must:
|
||||||
|
* 1. NEVER fall through to "Installation complete" — the error is re-raised so
|
||||||
|
* the process exits non-zero (#791 B2).
|
||||||
|
* 2. Classify the failure so recovery advice is accurate (#791 blocker-C):
|
||||||
|
* - ManifestError → a PRE-sync validation abort; nothing was written, so
|
||||||
|
* the message states "no files were changed".
|
||||||
|
* - any other error → may surface mid-copy, so the message must NOT claim
|
||||||
|
* nothing changed; it warns the state "may be partially applied".
|
||||||
|
*
|
||||||
|
* We assert on the spinner's stop() message (the user-visible line) and that the
|
||||||
|
* original error is re-thrown unchanged in both cases.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import type { WizardState } from '../types.js';
|
||||||
|
import type { ConfigService } from '../config/config-service.js';
|
||||||
|
import { ManifestError } from '../framework/manifest.js';
|
||||||
|
|
||||||
|
vi.mock('node:child_process', () => ({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
spawnSync: vi.fn<any>().mockReturnValue({ status: 0, stdout: '', stderr: '' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../platform/detect.js', () => ({
|
||||||
|
getShellProfilePath: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { finalizeStage } from './finalize.js';
|
||||||
|
|
||||||
|
function makeState(mosaicHome: string): WizardState {
|
||||||
|
return {
|
||||||
|
mosaicHome,
|
||||||
|
sourceDir: mosaicHome,
|
||||||
|
mode: 'quick',
|
||||||
|
installAction: 'keep',
|
||||||
|
soul: { agentName: 'TestBot', communicationStyle: 'direct' },
|
||||||
|
user: {},
|
||||||
|
tools: {},
|
||||||
|
runtimes: { detected: [], mcpConfigured: false },
|
||||||
|
selectedSkills: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrompter() {
|
||||||
|
const stop = vi.fn();
|
||||||
|
const update = vi.fn();
|
||||||
|
const prompter = {
|
||||||
|
intro: vi.fn(),
|
||||||
|
outro: vi.fn(),
|
||||||
|
note: vi.fn(),
|
||||||
|
log: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
text: vi.fn(),
|
||||||
|
confirm: vi.fn(),
|
||||||
|
select: vi.fn(),
|
||||||
|
multiselect: vi.fn(),
|
||||||
|
groupMultiselect: vi.fn(),
|
||||||
|
spinner: vi.fn().mockReturnValue({ update, stop }),
|
||||||
|
separator: vi.fn(),
|
||||||
|
};
|
||||||
|
return { prompter, stop };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeConfigService(syncFramework: ConfigService['syncFramework']): ConfigService {
|
||||||
|
return {
|
||||||
|
readSoul: vi.fn().mockResolvedValue({}),
|
||||||
|
readUser: vi.fn().mockResolvedValue({}),
|
||||||
|
readTools: vi.fn().mockResolvedValue({}),
|
||||||
|
writeSoul: vi.fn().mockResolvedValue(undefined),
|
||||||
|
writeUser: vi.fn().mockResolvedValue(undefined),
|
||||||
|
writeTools: vi.fn().mockResolvedValue(undefined),
|
||||||
|
syncFramework,
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
getSection: vi.fn(),
|
||||||
|
} as unknown as ConfigService;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('finalizeStage — framework sync abort (#791 B2 + blocker-C)', () => {
|
||||||
|
let tmp: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmp = mkdtempSync(join(tmpdir(), 'mosaic-sync-abort-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-throws a ManifestError and reports that no files were changed', async () => {
|
||||||
|
const err = new ManifestError('Framework manifest defines no usable [framework] paths');
|
||||||
|
const { prompter, stop } = buildPrompter();
|
||||||
|
const config = makeConfigService(vi.fn().mockRejectedValue(err));
|
||||||
|
|
||||||
|
await expect(finalizeStage(prompter, makeState(tmp), config)).rejects.toBe(err);
|
||||||
|
|
||||||
|
// The abort message must state nothing was written (pre-sync validation).
|
||||||
|
expect(stop).toHaveBeenCalledWith(expect.stringContaining('no files were changed'));
|
||||||
|
// It must NOT fall through to a success line.
|
||||||
|
expect(stop).not.toHaveBeenCalledWith(expect.stringContaining('Installation complete'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-throws a non-ManifestError and warns the state may be partially applied', async () => {
|
||||||
|
const err = new Error('cp: write error mid-sync (disk full)');
|
||||||
|
const { prompter, stop } = buildPrompter();
|
||||||
|
const config = makeConfigService(vi.fn().mockRejectedValue(err));
|
||||||
|
|
||||||
|
await expect(finalizeStage(prompter, makeState(tmp), config)).rejects.toBe(err);
|
||||||
|
|
||||||
|
// A generic mid-sync failure must NOT claim nothing changed…
|
||||||
|
expect(stop).toHaveBeenCalledWith(expect.stringContaining('may be partially applied'));
|
||||||
|
expect(stop).not.toHaveBeenCalledWith(expect.stringContaining('no files were changed'));
|
||||||
|
expect(stop).not.toHaveBeenCalledWith(expect.stringContaining('Installation complete'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not proceed to config writes when the sync aborts', async () => {
|
||||||
|
const err = new ManifestError('malformed manifest');
|
||||||
|
const { prompter } = buildPrompter();
|
||||||
|
const config = makeConfigService(vi.fn().mockRejectedValue(err));
|
||||||
|
|
||||||
|
await expect(finalizeStage(prompter, makeState(tmp), config)).rejects.toBe(err);
|
||||||
|
|
||||||
|
// writeSoul/writeUser/writeTools are only reached after a successful sync.
|
||||||
|
expect(config.writeSoul).not.toHaveBeenCalled();
|
||||||
|
expect(config.writeUser).not.toHaveBeenCalled();
|
||||||
|
expect(config.writeTools).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import type { WizardPrompter } from '../prompter/interface.js';
|
|||||||
import type { ConfigService } from '../config/config-service.js';
|
import type { 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');
|
||||||
@@ -160,7 +161,26 @@ 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...');
|
||||||
await config.syncFramework(state.installAction);
|
try {
|
||||||
|
await config.syncFramework(state.installAction);
|
||||||
|
} catch (err) {
|
||||||
|
// Stop the spinner loudly and re-raise so the process exits non-zero — never
|
||||||
|
// fall through to "Installation complete" on an aborted sync (#791 B2).
|
||||||
|
// A ManifestError is a PRE-sync validation abort: the manifest is loaded and
|
||||||
|
// validated before any file is written, so nothing was touched. Any other
|
||||||
|
// error can surface AFTER files were partially copied, so we must NOT claim
|
||||||
|
// "no files were changed" for it — that would misdirect recovery (#791 blocker-C).
|
||||||
|
if (err instanceof ManifestError) {
|
||||||
|
spin.stop(
|
||||||
|
'Framework sync aborted — the framework manifest is missing, empty, or malformed; no files were changed.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
spin.stop(
|
||||||
|
'Framework sync aborted — the update did not complete and may be partially applied; see the error below.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Write config files (after sync so they aren't overwritten by source templates)
|
// 2. Write config files (after sync so they aren't overwritten by source templates)
|
||||||
if (state.installAction !== 'keep') {
|
if (state.installAction !== 'keep') {
|
||||||
|
|||||||
Reference in New Issue
Block a user