Compare commits
4 Commits
feat/791-u
...
feat/790-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f61d16f16 | ||
|
|
6bef94ea0d | ||
|
|
7f987f52fc | ||
|
|
0e41a5c264 |
@@ -42,18 +42,6 @@ steps:
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
||||
|
||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||
# survives a keep-mode reseed byte-identical (both rsync + cp paths); 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-install-migration.sh
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
commands:
|
||||
@@ -62,7 +50,6 @@ steps:
|
||||
depends_on:
|
||||
- install
|
||||
- sanitization
|
||||
- upgrade-guard
|
||||
|
||||
# lint, format, and test are independent — run in parallel after typecheck
|
||||
lint:
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
# Design — #791: Framework upgrades must not destroy operator-owned config under `~/.config/mosaic`
|
||||
|
||||
- **Issue:** mosaicstack/stack#791
|
||||
- **Branch:** `feat/791-upgrade-config-protection` (off `origin/main` `9745bc3f`)
|
||||
- **Author:** ms-791 worker lane
|
||||
- **Status:** Phase 1 — DESIGN, awaiting MS-LEAD confirmation before implementation
|
||||
- **Ratified scope (Mos-approved, not re-litigated):** deliver **(b) strict ownership separation [PRIMARY]** + **(a) transactional pre-update snapshot [safety net]** + **(d) regeneration-from-SSOT [recovery]**. **(c) periodic backup timer is DEFERRED** — noted as future work only.
|
||||
|
||||
---
|
||||
|
||||
## 1. Current updater behavior + exact wipe mechanism (evidence)
|
||||
|
||||
### 1.1 What runs on `mosaic update`
|
||||
|
||||
`mosaic update` re-seeds the framework by invoking the **bash installer** in sync-only, keep mode:
|
||||
|
||||
- `packages/mosaic/src/runtime/update-checker.ts:509` `buildReseedCommand()` returns
|
||||
`bash <frameworkRoot>/install.sh` with env `MOSAIC_SYNC_ONLY=1`, `MOSAIC_INSTALL_MODE=keep`,
|
||||
`MOSAIC_HOME=<mosaicHome>`.
|
||||
- The same `install.sh` is the direct/`tools/install.sh` upgrade path and the framework-vN migration path.
|
||||
|
||||
So the destructive surface is **`packages/mosaic/framework/install.sh`**.
|
||||
|
||||
### 1.2 The wipe
|
||||
|
||||
`sync_framework()` (`install.sh:177`) performs, in `keep` mode:
|
||||
|
||||
```
|
||||
rsync -a --delete --exclude .git --exclude .framework-version --exclude '*.pre-constitution.bak' \
|
||||
[--exclude "/$path" for each PRESERVE_PATHS entry] SOURCE_DIR/ TARGET_DIR/
|
||||
```
|
||||
|
||||
- `install.sh:199` — `rsync -a --delete`. **`--delete` prunes every path in `~/.config/mosaic`
|
||||
that is NOT present in the shipped framework source**, unless excluded.
|
||||
- `install.sh:47` — `PRESERVE_PATHS` is the **only** thing standing between `--delete` and operator
|
||||
data. It is a _denylist of exclusions_:
|
||||
```
|
||||
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md"
|
||||
"memory" "sources" "credentials" "fleet/roster.yaml" "fleet/roster.json" "fleet/agents"
|
||||
"fleet/run" "fleet/backlog" "fleet/roles.local")
|
||||
```
|
||||
- The cp-fallback (no rsync) is equally destructive: `install.sh:223`
|
||||
`find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ... -exec rm -rf {} +` then re-copies source, restoring
|
||||
only PRESERVE_PATHS globs.
|
||||
|
||||
**Root-cause model:** _"Everything under `~/.config/mosaic` is framework-owned and pruneable UNLESS
|
||||
explicitly preserved."_ Any operator path the list forgets is destroyed on the next upgrade.
|
||||
|
||||
### 1.3 The exact operator paths wiped
|
||||
|
||||
Cross-referencing the issue's operator-owned list against `PRESERVE_PATHS`:
|
||||
|
||||
| Operator path (issue #791) | In PRESERVE_PATHS? | Fate on `mosaic update` |
|
||||
| ----------------------------------------------------------------- | --------------------------------------- | ----------------------- |
|
||||
| `agents/*.conf` (per-agent runtime) | **NO** | **WIPED** |
|
||||
| `policy/*.md` (operator overlays) | **NO** | **WIPED** |
|
||||
| `*.local.md` (SOUL/USER/STANDARDS) | **NO** | **WIPED** |
|
||||
| harvester / SOP artifacts + timers | **NO** | **WIPED** |
|
||||
| `tools/_lib/credentials.json` | **NO** (`credentials/` dir ≠ this path) | **WIPED** |
|
||||
| `fleet/agents/*.env` | yes (`fleet/agents`, added by #631) | survives |
|
||||
| `memory/`, `fleet/roster.*`, `fleet/backlog`, `fleet/roles.local` | yes | survives |
|
||||
|
||||
The `fleet/agents`, `memory`, `fleet/backlog` entries were **retro-added after prior incidents**
|
||||
(#631). This whack-a-mole is the structural signature of a denylist.
|
||||
|
||||
**Stale-comment evidence:** `update-checker.ts:492` claims the reseed preserves
|
||||
"`SOUL/USER/*.local/credentials`" — but `PRESERVE_PATHS` contains **no `*.local` entry**. The code
|
||||
documents protection it does not deliver.
|
||||
|
||||
### 1.4 Second code path (TS) — already non-destructive, but drifted
|
||||
|
||||
`FileConfigAdapter.syncFramework()` (`packages/mosaic/src/config/file-adapter.ts:157`) →
|
||||
`syncDirectory()` (`packages/mosaic/src/platform/file-ops.ts:66`) is a **copy-overlay**: it copies
|
||||
source over target and skips preserved paths, but **never deletes** target paths absent from source
|
||||
(`file-ops.ts:77-109`). It is used by the wizard/init flow, not `mosaic update`.
|
||||
|
||||
Two problems remain:
|
||||
|
||||
1. Its `preservePaths` (`file-adapter.ts:164-185`) has **already diverged** from `install.sh` — it is
|
||||
**missing `fleet/backlog` and `fleet/roles.local`**. Two hand-maintained denylists, drifted. This
|
||||
is direct evidence for a single shared SSOT manifest.
|
||||
2. Even non-destructive, it will happily _overwrite_ an operator file that collides with a
|
||||
framework-shipped path unless that path is on its (incomplete) preserve list.
|
||||
|
||||
### 1.5 Existing snapshot is inadequate for rollback
|
||||
|
||||
`make_snapshot()`/`restore_snapshot()` (`install.sh:76-87`) copy `TARGET_DIR` to `mktemp -d` under
|
||||
`/tmp`, restore **only on `ERR/INT/TERM` trap**, and are **deleted on success** (`cleanup_snapshot`,
|
||||
`install.sh:345`). Consequences: ephemeral `/tmp`, no retention, no post-success rollback, and **no
|
||||
`mosaic restore`**. It is crash-safety only, not the transactional safety net #791 requires.
|
||||
|
||||
---
|
||||
|
||||
## 2. Fix (b) — Strict ownership separation [PRIMARY / root cause]
|
||||
|
||||
### 2.1 Ownership model (invert to allow-list)
|
||||
|
||||
Replace _"framework-owned unless preserved"_ with _"operator-owned unless framework-owned"_, resolved
|
||||
**per target path** with operator carve-outs winning inside shared framework subtrees.
|
||||
|
||||
Two declared lists, one SSOT data file shipped in the framework
|
||||
(`framework/framework-manifest.json`), consumed by **both** bash and TS:
|
||||
|
||||
- **`framework` globs** — paths the updater is entitled to create / overwrite / prune. Authored to
|
||||
match exactly what the framework ships in `packages/mosaic/framework/` (e.g. `CONSTITUTION.md`,
|
||||
`AGENTS.md`, `STANDARDS.md`, `TOOLS.md`, `guides/**`, `constitution/**`, `templates/**`, `tools/**`,
|
||||
`skills/**`, `mcp/**`, `defaults/**`, `fleet/examples/**`, `fleet/roles/**`, `fleet/profiles/**`,
|
||||
`fleet/roster.schema.json`).
|
||||
- **`operatorReserved` globs** — NEVER written or pruned, even nested inside a `framework` subtree;
|
||||
these **win** over `framework` (deny-wins / most-specific-wins). At minimum:
|
||||
`agents/**`, `policy/**`, `memory/**`, `sources/**`, `credentials/**`, `*.local.md`,
|
||||
`tools/_lib/credentials.json`, `fleet/roster.yaml`, `fleet/roster.json`, `fleet/agents/**`,
|
||||
`fleet/run/**`, `fleet/backlog/**`, `fleet/roles.local/**`, plus operator harvester/SOP artifacts.
|
||||
|
||||
### 2.2 Ownership resolution for a target path `P`
|
||||
|
||||
1. `P` matches `operatorReserved` → **operator-owned**: updater MUST NOT write, MUST NOT delete.
|
||||
2. else `P` matches `framework` → **framework-owned**: may overwrite; may prune **only if absent from
|
||||
the current SOURCE** (a genuinely retired framework file).
|
||||
3. else (matches neither) → **UNKNOWN ⇒ operator-owned by default (fail-safe)**: never delete.
|
||||
|
||||
Rule 3 is the actual root-cause fix: an operator path the manifest authors forget is still protected,
|
||||
because _unknown defaults to operator_. A denylist can never provide this guarantee.
|
||||
|
||||
### 2.3 Sync mechanism change (the mechanically-critical part)
|
||||
|
||||
`--delete` cannot express "prune only framework-owned" without re-enumerating every operator path
|
||||
(the denylist trap). So:
|
||||
|
||||
1. **Drop `--delete` from the bulk sync.** Copy `SOURCE → TARGET` non-destructively (writes/overwrites
|
||||
all framework files; deletes nothing). rsync without `--delete`, or the existing overlay copy.
|
||||
2. **Explicit manifest-scoped prune pass.** Iterate the **`framework` manifest** (not the whole tree);
|
||||
for each framework path present in `TARGET` but **absent in `SOURCE`**, delete it — after
|
||||
re-checking it does not match `operatorReserved`. Because the prune iterates only declared
|
||||
framework globs, operator/unknown paths are **structurally unreachable** by deletion.
|
||||
|
||||
This is implemented in both bash `sync_framework()` and TS `syncFramework()` from the shared manifest.
|
||||
A pure **prune-planner** function (TS) computes the delete-set from
|
||||
`(manifest, sourceListing, targetListing)` so the invariant is unit-testable in isolation.
|
||||
`PRESERVE_PATHS` becomes redundant (kept as a defense-in-depth alias mapping to `operatorReserved`, or
|
||||
removed) — either way the two lists stop drifting because they read one file.
|
||||
|
||||
### 2.4 HARD GATE test — "upgrade touches no path outside the manifest"
|
||||
|
||||
Filesystem-observation test in the existing `test-install-migration.sh` harness pattern (mktemp
|
||||
`MOSAIC_HOME`, `MOSAIC_SYNC_ONLY=1`), plus TS specs:
|
||||
|
||||
1. Seed a throwaway `TARGET` with a realistic operator mix — one sentinel per operator class:
|
||||
`agents/x.conf`, `policy/p.md`, `SOUL.local.md`, `memory/m.md`,
|
||||
`tools/_lib/credentials.json` (with a secret value), `fleet/agents/a.env`, `fleet/roster.yaml`,
|
||||
`harvester/sop.md`, **and a deliberately-unanticipated `unknown-operator-dir/x`**.
|
||||
2. Record hash+mtime of every sentinel.
|
||||
3. Run the upgrade from a `SOURCE` containing none of those operator paths.
|
||||
4. **Assert:** every sentinel exists, byte-identical, **mtime unchanged** (not even rewritten). The
|
||||
`unknown-operator-dir` surviving proves the fail-safe default — a denylist could not pass this case.
|
||||
5. **Positive controls:** framework files WERE updated; a retired framework file WAS pruned.
|
||||
6. **Property test** (TS prune-planner): for fuzzed operator paths, `deleteSet ⊆ {matches framework ∧
|
||||
in target ∧ not in source}` and `deleteSet ∩ operatorReserved = ∅`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Fix (a) — Transactional pre-update snapshot [safety net]
|
||||
|
||||
- **Destination:** `${XDG_STATE_HOME:-~/.local/state}/mosaic/backups/pre-update-<UTC-ts>/`.
|
||||
**Outside `~/.config/mosaic`** (so no future sync can sweep it) and outside any repo.
|
||||
- **Perms:** dir `0700`, files `0600` — enforced with `umask 077` around the copy **and** explicit
|
||||
`chmod`. Never world-readable.
|
||||
- **Scope:** the operator-owned surface (`operatorReserved` paths that exist) — bounded; does not copy
|
||||
the framework tree.
|
||||
- **Timing:** taken before ANY mutation in the upgrade flow.
|
||||
- **Post-sync verify + selective restore:** after sync, diff the operator surface against the snapshot;
|
||||
since (b) should never touch operator paths, any diff means a manifest bug — restore the affected
|
||||
paths from the snapshot and warn loudly. This is precisely (a) catching a miss in (b).
|
||||
- **Retention:** keep N most-recent (default 5; `MOSAIC_BACKUP_RETENTION` override); prune older.
|
||||
- **`mosaic restore`:** `--list` (default, dry-run) enumerates snapshots by timestamp;
|
||||
`--from <ts>` restores that snapshot over the operator surface, confirmation-gated. Reports
|
||||
counts/paths only.
|
||||
- **Secret-safety:** snapshot copy and restore never emit file **contents**; only paths/counts.
|
||||
Tests assert `0700/0600` and that no secret value appears in stdout/stderr.
|
||||
|
||||
---
|
||||
|
||||
## 4. Fix (d) — Regeneration-from-SSOT [recovery]
|
||||
|
||||
The incident's live blast radius: `fleet/agents/*.env` (systemd `EnvironmentFile` sources) gone →
|
||||
`mosaic-agent@<name>` boots **unit defaults** on restart (because `EnvironmentFile=-...` is
|
||||
absent-tolerant) → **silent identity/runtime/workdir downgrade**.
|
||||
|
||||
The SSOT for those `.env` files is the roster. The reconciler **already** separates a
|
||||
`regenerate-projections-from-roster` projection phase from lifecycle
|
||||
(`packages/mosaic/src/fleet/fleet-reconciler.ts:93,234`; env rendering in
|
||||
`generated-env-boundary.ts:149-264`).
|
||||
|
||||
**`mosaic fleet regen`** is therefore a **thin recovery-framed wrapper over the existing projection
|
||||
phase** — it does NOT reimplement fleet logic and does NOT preempt in-flight FCM cards (M4/M5):
|
||||
|
||||
- Regenerates derivable config (per-agent `*.env.generated`, unit files) from roster SSOT.
|
||||
- **Preview-first:** dry-run default; `--write` to apply. Idempotent.
|
||||
- **Never restarts agents** (the recovery order forbids restart-before-verify).
|
||||
- Prints the runbook's next step (verify `EnvironmentFile` resolves, THEN restart).
|
||||
|
||||
Alternatively documentable as `install.sh --relink` per the issue; `mosaic fleet regen` is preferred
|
||||
because it reuses the merged reconciler plumbing.
|
||||
|
||||
---
|
||||
|
||||
## 5. Secret-safety approach (secrev surface)
|
||||
|
||||
- Snapshots/backups: `0700`/`0600`, outside any repo, never world-readable. (§3)
|
||||
- No secret **value** ever emitted to logs/stdout/stderr by snapshot, restore, sync, or regen —
|
||||
paths/counts only. Adversarial test: a secret value placed in `tools/_lib/credentials.json` must
|
||||
never appear in installer or command output.
|
||||
- `tools/_lib/credentials.json` is an explicit `operatorReserved` carve-out inside the framework-owned
|
||||
`tools/**` subtree — it is never overwritten or pruned.
|
||||
- The HARD GATE test doubles as a secret-safety test (asserts the credentials sentinel is untouched).
|
||||
|
||||
---
|
||||
|
||||
## 6. Test plan (TDD, tests-first, ≥85% on new code, co-located `*.spec.ts`)
|
||||
|
||||
1. **Manifest SSOT parity** — bash and TS resolve identical framework/operator sets from the one file;
|
||||
a test fails if either path hard-codes a divergent list.
|
||||
2. **Manifest completeness** — every path shipped in `framework/` is covered by a `framework` glob (so
|
||||
a new shipped file cannot silently fall outside the manifest and become un-prunable/undeclared).
|
||||
3. **HARD GATE** — upgrade touches nothing outside the manifest, incl. the unanticipated-path case
|
||||
(§2.4).
|
||||
4. **Prune-planner** unit + property tests (§2.4.6).
|
||||
5. **Snapshot** — perms `0700/0600`, correct destination, retention prune, secret value absent from
|
||||
output.
|
||||
6. **Restore** — `--list` / `--from` round-trip restores operator surface byte-exact; confirmation
|
||||
gate; no secret leakage.
|
||||
7. **Regen** — roster→env projection deterministic + idempotent; dry-run makes no writes; `--write`
|
||||
restores `*.env`; **never** issues a lifecycle/restart call.
|
||||
8. **Cross-path regression** — TS `syncFramework` and bash `install.sh` agree on a shared fixture
|
||||
(closes the current #631-style drift).
|
||||
|
||||
Gates before every push: `pnpm typecheck && pnpm lint && pnpm format:check` + mosaic package tests
|
||||
green. Never `--no-verify`.
|
||||
|
||||
---
|
||||
|
||||
## 7. web1 recovery runbook (operator-agnostic; web1 specifics live in the issue as evidence only)
|
||||
|
||||
For a currently-wiped fleet EnvironmentFile state — **do NOT service-restart while
|
||||
`fleet/agents/*.env` is absent** (a restart boots unit defaults and silently downgrades identity):
|
||||
|
||||
1. **Regenerate:** `mosaic fleet regen --write` — rebuild `~/.config/mosaic/fleet/agents/*.env` from
|
||||
roster SSOT.
|
||||
2. **Verify each unit resolves to the intended runtime/workdir** _before_ any restart:
|
||||
`systemctl --user show mosaic-agent@<name> -p EnvironmentFile` and confirm the generated env exists
|
||||
and carries the intended `MOSAIC_AGENT_*` runtime/workdir values.
|
||||
3. **Only then** `systemctl --user restart mosaic-agent@<name>`, one unit at a time.
|
||||
|
||||
If config (not just fleet env) was lost, `mosaic restore --list` → `mosaic restore --from <ts>` before
|
||||
step 1.
|
||||
|
||||
---
|
||||
|
||||
## 8. Proposed PR split (reviewable; DAG-ordered)
|
||||
|
||||
| PR | Scope | Depends | Review focus |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -------------------------- |
|
||||
| PR1 | **PRIMARY** — shared `framework-manifest.json` + ownership resolver + non-deleting sync + scoped prune (bash + TS) + **HARD GATE** + prune-planner tests | — | correctness (root fix) |
|
||||
| PR2 | **Safety net** — pre-update snapshot (`~/.local/state`, 0700/0600, retention) + post-sync verify/restore + `mosaic restore` | PR1 | **secrev** (backup/secret) |
|
||||
| PR3 | **Recovery** — `mosaic fleet regen` (projection-only, preview-first, no restart) + docs (upgrade-safety + recovery runbook) | PR1 | correctness + docs |
|
||||
|
||||
Rationale: PR1 closes the failure class on its own; if PR2/PR3 slip, the class stays fixed. Each PR is
|
||||
one reviewable unit with its own tests ≥85%. Independent review (author≠reviewer) on all; **secrev** on
|
||||
PR2 (and PR1's secret-sentinel assertions).
|
||||
|
||||
## 9. Deferred (noted per scope)
|
||||
|
||||
**(c) periodic backup timer** — a systemd user timer snapshotting operator dirs on a cadence
|
||||
(defense-in-depth for non-upgrade losses). Explicitly **out of scope now**; future phase.
|
||||
|
||||
## 10. Constraints honored
|
||||
|
||||
- **Framework-PR firewall:** manifest + logic are operator-agnostic; no SOUL/USER/operator specifics
|
||||
in framework code; web1 details are issue evidence only.
|
||||
- **Capacity-fill:** must not preempt M5-001 or #790; `fleet regen` reuses merged FCM-M3 plumbing and
|
||||
does not overlap FCM-M4/M5 migration cards.
|
||||
- **Delivery gates:** TDD tests-first, ≥85% new-code coverage, trunk-based squash PRs, independent
|
||||
review + secrev, completion = merged PR + descendant-main green + #791 closed.
|
||||
|
||||
---
|
||||
|
||||
**Requesting MS-LEAD confirmation of:** (1) the manifest allow-list + non-deleting-sync + scoped-prune
|
||||
approach as the (b) root-cause fix; (2) snapshot destination/retention + `mosaic restore` UX;
|
||||
(3) `mosaic fleet regen` as a projection-only wrapper; (4) the 3-PR split. Implementation begins only
|
||||
on your confirmation.
|
||||
@@ -1,125 +0,0 @@
|
||||
# Scratchpad — #791 Upgrade config protection (ms-791 worker lane)
|
||||
|
||||
**Lane:** web1:ms-791 → reports to MS-LEAD (web1:mosaic-100). Do NOT contact Jason/Mos directly.
|
||||
**Worktree:** `/home/hermes/agent-work/stack-agents-dir-791`, branch `feat/791-upgrade-config-protection`
|
||||
off `origin/main` `9745bc3f` (verified exact head).
|
||||
|
||||
## Mission prompt (verbatim intent)
|
||||
Protect operator-owned config under `~/.config/mosaic` from framework-upgrade wipes. Ratified
|
||||
combination (Mos-approved, do NOT re-litigate): (b) strict ownership separation [PRIMARY] + (a)
|
||||
transactional pre-update snapshot [safety net] + (d) regeneration-from-SSOT [recovery]. (c) periodic
|
||||
timer DEFERRED. HARD GATE: unit test that an upgrade run touches NO path outside the manifest.
|
||||
Design-first: write design doc, send to MS-LEAD, WAIT for confirmation before impl.
|
||||
|
||||
## Session 1 (2026-07-16) — Phase 1 design
|
||||
|
||||
### Evidence gathered (wipe mechanism, file/line)
|
||||
- `mosaic update` → `update-checker.ts:509` `buildReseedCommand` → `bash install.sh`
|
||||
(`MOSAIC_SYNC_ONLY=1`, `MOSAIC_INSTALL_MODE=keep`).
|
||||
- Wipe = `packages/mosaic/framework/install.sh:199` `rsync -a --delete` + `PRESERVE_PATHS` denylist
|
||||
(`install.sh:47`). cp-fallback `install.sh:223` `find ... -exec rm -rf`.
|
||||
- Denylist gaps → WIPED: `agents/*.conf`, `policy/*.md`, `*.local.md`, harvester/SOP,
|
||||
`tools/_lib/credentials.json`.
|
||||
- Stale comment `update-checker.ts:492` claims `*.local` preserved — PRESERVE_PATHS has no such entry.
|
||||
- TS path `file-adapter.ts:157` → `file-ops.ts:66` `syncDirectory` = non-destructive copy-overlay, BUT
|
||||
its preserve list (`file-adapter.ts:164`) already DRIFTED from install.sh (missing `fleet/backlog`,
|
||||
`fleet/roles.local`). Evidence for single shared manifest SSOT.
|
||||
- Existing snapshot (`install.sh:76`) = /tmp, crash-trap only, deleted on success → inadequate; no
|
||||
`mosaic restore`.
|
||||
- `fleet-reconciler.ts:93,234` already has `regenerate-projections-from-roster` phase separate from
|
||||
lifecycle → `mosaic fleet regen` = thin projection-only wrapper (no restart), no FCM-M4/M5 preemption.
|
||||
|
||||
### Design decisions
|
||||
- **(b)** Invert to allow-list: shared `framework/framework-manifest.json` (framework globs +
|
||||
operatorReserved carve-outs); resolve per-path, deny-wins; **UNKNOWN ⇒ operator (fail-safe)**.
|
||||
Mechanism: drop `--delete`; non-deleting bulk copy + explicit manifest-scoped prune pass (iterate
|
||||
framework globs only → operator/unknown structurally unreachable). Pure prune-planner fn for tests.
|
||||
- **(a)** Snapshot to `~/.local/state/mosaic/backups/pre-update-<ts>/` 0700/0600, retention N=5,
|
||||
post-sync verify+restore, `mosaic restore --list/--from`. No secret values in output.
|
||||
- **(d)** `mosaic fleet regen` projection-only, preview-first, never restart.
|
||||
- HARD GATE test includes a deliberately-unanticipated operator path to prove fail-safe default.
|
||||
- **PR split:** PR1 manifest+guard (root fix, ships alone) → PR2 snapshot/restore (secrev) → PR3
|
||||
regen+docs. PR2/PR3 depend on PR1.
|
||||
|
||||
### Status
|
||||
Design doc written: `docs/design/791-upgrade-config-protection.md`. Sent to MS-LEAD.
|
||||
|
||||
## Session 1 (cont.) — MS-LEAD CONFIRMED → Phase 2 GO
|
||||
All 4 asks approved. Binding conditions:
|
||||
- TDD tests-first, red-first proof per PR; ≥85% new-code; co-located `*.spec.ts`; never `--no-verify`.
|
||||
- HARD GATE test (§2.4, unanticipated sentinel survives byte-identical + mtime unchanged) = MERGE-BLOCKING for PR1.
|
||||
- Manifest-completeness test (§6.2) required.
|
||||
- Bash+TS read ONE shared `framework-manifest.json`; parity test (§6.1) required (closes #631 drift class).
|
||||
- UNKNOWN⇒operator (rule 3) non-negotiable. Keep prune-planner PURE.
|
||||
- `fleet regen`: NEVER restart; dry-run default, `--write` to apply; "never issues restart" test mandatory.
|
||||
- Independent review every PR; PR2 dedicated secrev.
|
||||
- One PR at a time through DAG. Report PR1 exact head + red→green evidence for review commission.
|
||||
|
||||
### Now: implementing PR1 (manifest + resolver + non-deleting sync + scoped prune + guard tests).
|
||||
|
||||
## Session 2 (2026-07-16) — PR1 built, tests-first, red→green proven
|
||||
|
||||
Deviation noted to MS-LEAD in PR: manifest is `framework-manifest.txt` (line-oriented), NOT `.json`.
|
||||
Rationale: keep the bash installer free of a python3/jq dependency. The "ONE shared file, parity-
|
||||
tested" requirement is honored — `manifest-parity.spec.ts` drives the bash resolver as a subprocess
|
||||
and asserts byte-identical ownership vs the TS resolver over 34 probe paths spanning every class.
|
||||
|
||||
### PR1 artifacts
|
||||
- SSOT: `packages/mosaic/framework/framework-manifest.txt` ([framework]/[operator], deny-wins, fail-safe).
|
||||
- TS resolver: `src/framework/manifest.ts` (pure: parse/matchGlob/resolveOwnership/frameworkSubtreeRoots/
|
||||
planPrune) + `manifest.spec.ts` (18 tests incl. planPrune property test + §6.2 completeness).
|
||||
- Bash resolver: `framework/tools/_lib/manifest.sh` (compiled globs → fork-free `manifest_is_framework`;
|
||||
CLI `resolve|subtree-roots|classify`). Sourced by install.sh.
|
||||
- HARD GATE (§2.4): `framework/tools/quality/scripts/test-upgrade-manifest-guard.sh` — keep-mode reseed,
|
||||
10 operator sentinels (incl. unanticipated `unknown-operator-dir/x`, `harvester/sop.md`,
|
||||
`fleet/my-fleet.yaml`) survive byte-identical + mtime-unchanged; retired framework file pruned;
|
||||
secret value absent from output. RED=31 fail (orig install.sh) → GREEN=48 pass (fixed).
|
||||
- install.sh: keep mode now manifest-driven (`sync_framework_keep`, no `--delete`); overwrite unchanged.
|
||||
PRESERVE_PATHS denylist deleted.
|
||||
- TS sync: `file-ops.syncDirectory` gains `isOperatorOwned` guard; `file-adapter.syncFramework` derives
|
||||
it from `loadManifest` — hardcoded (drifted) preservePaths deleted. Fixture uses the REAL manifest.
|
||||
- Parity: `manifest-parity.spec.ts` (§6.1) — bash↔TS agree on 34 paths + subtree roots.
|
||||
- Migration matrix `test-install-migration.sh`: F6 flipped — `my-fleet.yaml` now MUST survive (fail-safe).
|
||||
- CI: new merge-blocking `upgrade-guard` step (`.woodpecker/ci.yml`) runs both bash suites (adds rsync).
|
||||
- update-checker.ts reseed comment corrected to the manifest model.
|
||||
|
||||
### Gates (all green)
|
||||
- `pnpm typecheck` ✓ · `pnpm lint` ✓ · `pnpm format:check` ✓
|
||||
- Full mosaic vitest: 1062 passed (cli-smoke needs `pnpm build` first — build-artifact dep, not this change).
|
||||
- HARD GATE 48/48 · migration 21/21 · parity 3/3 · manifest 18/18 · file-adapter 8/8.
|
||||
|
||||
### PR opened + reported (2026-07-16)
|
||||
- **PR #802** http://git.mosaicstack.dev/mosaicstack/stack/pulls/802 — base `main`@`9745bc3f`,
|
||||
head `34e55d4a` (commit `feat(mosaic): manifest-owned upgrade guard…`). 15 files, +1160/-142.
|
||||
- Reported PR head + red→green evidence to MS-LEAD (web1:mosaic-100); queued (lead busy).
|
||||
Standing by for the independent-review commission at head `34e55d4a`.
|
||||
- **TWO items flagged to MS-LEAD for decision (awaiting reply):**
|
||||
1. Deviation `.txt` vs `.json` — confirm accept (parity-tested) or convert to `.json`+jq.
|
||||
2. `pr-create -i 791` appended `Fixes #791` → would auto-close the tracking issue on PR1 merge
|
||||
while PR2/PR3 remain. Recommended edit to `Part of #791`; awaiting go-ahead to patch PR body.
|
||||
- DO NOT start PR2/PR3 until PR1 merges (DAG; one PR at a time).
|
||||
|
||||
### MS-LEAD ruling → #797 ledger-survival sentinel folded into PR1 (2026-07-16)
|
||||
MS-LEAD ruled both my decisions: (1) `.txt` format ACCEPTED (parity must be strict/merge-blocking incl.
|
||||
format edge cases + negative probe); (2) trailer `Fixes #791`→`Part of #791` APPROVED (patched PR #802
|
||||
body via Gitea API — tracking issue no longer auto-closes on PR1 merge). Plus Mos-ELEVATED merge-blocker
|
||||
(spec `~/agent-work/planning/epic-796/791-ledger-survival-sentinel-SPEC.md`): #797 Runtime Session Ledger
|
||||
must survive upgrade. Two coupled deliverables landed in PR1:
|
||||
- (i) Carve-out: `fleet/run/**` was ALREADY an explicit `[operator]` entry — glob matches the spec's
|
||||
pinned `fleet/run/**` EXACTLY, so NO divergence to route back to planner-opus. Strengthened its comment
|
||||
to name the ledger (`fleet/run/sessions/` events.ndjson + ledger.json) so it is unmistakably load-bearing.
|
||||
- (ii) HARD-GATE sentinel: seeded populated ledger (events.ndjson 3 events + ledger.json node+edge+gen,
|
||||
0600 under 0700) into test-upgrade-manifest-guard.sh sentinels; asserts byte-identical + mtime-unchanged
|
||||
+ dir-perms unchanged. Negative control (retired framework file IS pruned) relabeled explicitly.
|
||||
HARD GATE now 58/58 (was 48).
|
||||
- Decision-1 parity hardening: format-edge fixtures (comments/blanks/whitespace, duplicate+overlapping
|
||||
globs deny-wins, section/glob-ordering independence) + explicit UNKNOWN→operator negative probe, driven
|
||||
through BOTH resolvers via MANIFEST_FILE override. Parity 7/7 (was 3).
|
||||
- RED-FIRST honesty note: the bash ledger sentinel stays GREEN even against the pre-fix installer (the
|
||||
ledger was incidentally safe from the rsync --delete bug; overall pre-fix run 30/58 as expected). The
|
||||
carve-out's TRUE load-bearing value (deny-wins if framework ownership ever broadens to `fleet/**`) is
|
||||
isolated by a dedicated resolver-seam red→green in manifest.spec.ts: WITHOUT `fleet/run/**` operator
|
||||
entry + hypothetical `fleet/**` framework → ledger resolves framework and planPrune DELETES it (RED);
|
||||
WITH the carve-out → deny-wins → operator, unprunable (GREEN). manifest.spec.ts 21/21 (was 18).
|
||||
- Gates all green: typecheck ✓ lint ✓ format:check ✓ · full mosaic vitest 1069 passed · HARD GATE 58/58
|
||||
· migration 21/21. Committing FORWARD on the branch (NOT rebasing 34e55d4a out from under review).
|
||||
@@ -1,85 +0,0 @@
|
||||
# Mosaic framework path-ownership manifest — SSOT for the updater.
|
||||
#
|
||||
# This single file is the source of truth consumed by BOTH the bash installer
|
||||
# (packages/mosaic/framework/install.sh) and the TypeScript config adapter
|
||||
# (packages/mosaic/src/config/file-adapter.ts). A parity test asserts both
|
||||
# paths resolve the same ownership from this file, so the two can never drift
|
||||
# (the failure mode that #631 patched by hand in two places).
|
||||
#
|
||||
# Format: one glob per line, relative to the mosaic home (~/.config/mosaic).
|
||||
# - Lines starting with '#' and blank lines are ignored.
|
||||
# - '[framework]' / '[operator]' switch the active section.
|
||||
# - '**' matches any depth; '*' matches within a single path segment.
|
||||
#
|
||||
# Ownership resolution for a path P (deny-wins / fail-safe):
|
||||
# 1. P matches an [operator] glob -> operator-owned.
|
||||
# 2. else P matches a [framework] glob -> framework-owned.
|
||||
# 3. else (matches neither) -> OPERATOR-OWNED BY DEFAULT.
|
||||
#
|
||||
# Rule 3 is the root-cause fix for #791: a path the manifest authors never
|
||||
# anticipated is protected because UNKNOWN defaults to operator. The updater
|
||||
# may only ever create/overwrite framework-owned paths, and may only prune a
|
||||
# framework-owned path that lives inside a shipped framework subtree and is
|
||||
# absent from the current framework source (a genuinely retired file).
|
||||
# Operator-owned and unknown paths are structurally unreachable by pruning.
|
||||
|
||||
[framework]
|
||||
# Top-level framework contract files (also reconciled from defaults/ on upgrade).
|
||||
CONSTITUTION.md
|
||||
AGENTS.md
|
||||
STANDARDS.md
|
||||
# Shipped framework subtrees — pruning is scoped to these roots.
|
||||
adapters/**
|
||||
constitution/**
|
||||
CONTRIBUTING.md
|
||||
defaults/**
|
||||
examples/**
|
||||
guides/**
|
||||
install.sh
|
||||
install.ps1
|
||||
LICENSE
|
||||
profiles/**
|
||||
runtime/**
|
||||
systemd/**
|
||||
templates/**
|
||||
tools/**
|
||||
# Fleet: only the framework-seeded fleet subtrees are framework-owned.
|
||||
fleet/README.md
|
||||
fleet/examples/**
|
||||
fleet/profiles/**
|
||||
fleet/roles/**
|
||||
fleet/roster.schema.json
|
||||
fleet/services/**
|
||||
# The manifest itself is framework-owned.
|
||||
framework-manifest.txt
|
||||
|
||||
[operator]
|
||||
# Identity / user-seeded contract files — generated by the wizard or seeded
|
||||
# once from defaults/, then owned by the operator. Never overwritten on upgrade.
|
||||
SOUL.md
|
||||
USER.md
|
||||
TOOLS.md
|
||||
# Local overlays (tighten-only) authored by the operator.
|
||||
*.local.md
|
||||
# Operator-owned trees the updater must never write over or prune.
|
||||
agents/**
|
||||
policy/**
|
||||
memory/**
|
||||
sources/**
|
||||
credentials/**
|
||||
# Secret-bearing operator file INSIDE the framework-owned tools/ subtree.
|
||||
# Listed explicitly so the deny-wins rule carves it out of tools/**.
|
||||
tools/_lib/credentials.json
|
||||
# Operator-owned fleet state (roster SSOT, per-agent env, heartbeats, backlog,
|
||||
# persona overrides). Losing these silently downgrades a running fleet (#791).
|
||||
fleet/roster.yaml
|
||||
fleet/roster.json
|
||||
fleet/agents/**
|
||||
# Runtime state, incl. the #797 Runtime Session Ledger at fleet/run/sessions/
|
||||
# (events.ndjson journal + ledger.json projection). This carve-out is the
|
||||
# mechanism that makes the ledger upgrade-safe: an upgrade that wiped it would
|
||||
# defeat its reason to exist. The HARD GATE (test-upgrade-manifest-guard.sh)
|
||||
# proves a populated ledger survives byte-identical + mtime-unchanged.
|
||||
fleet/run/**
|
||||
fleet/backlog/**
|
||||
fleet/roles.local/**
|
||||
@@ -19,19 +19,32 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||
|
||||
# Shared framework path-ownership manifest reader (#791). Parity with
|
||||
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
||||
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
||||
# shellcheck source=tools/_lib/manifest.sh
|
||||
source "$SOURCE_DIR/tools/_lib/manifest.sh"
|
||||
|
||||
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
|
||||
# denylist. It is derived from the shared framework-manifest.txt (#791): the
|
||||
# updater only ever creates/overwrites framework-owned paths and only prunes a
|
||||
# retired framework file inside a shipped framework subtree. Everything else —
|
||||
# every operator file, and every path the manifest never anticipated — is
|
||||
# operator-owned by default (fail-safe) and is never written or deleted. See
|
||||
# sync_framework_keep() below and packages/mosaic/src/framework/manifest.ts.
|
||||
# Files/dirs protected from rsync --delete during sync. NOTE: framework-owned
|
||||
# entries (CONSTITUTION/AGENTS/STANDARDS) ARE re-applied afterward by
|
||||
# reconcile_framework_files (overwrite + backup-once); the rest stay user-owned.
|
||||
# User-created content in these paths survives rsync --delete.
|
||||
#
|
||||
# fleet/* — the framework SEEDS fleet/examples, fleet/roles, fleet/profiles, and
|
||||
# fleet/roster.schema.json (synced normally — every fleet/roles/*.md role contract
|
||||
# and fleet/profiles/*.yaml system-type profile lands automatically via this sync,
|
||||
# so no per-file entry is needed; exact preserved roster paths are anchored to
|
||||
# the top level only and do NOT shadow fleet/profiles/*.yaml). The user's
|
||||
# own fleet files MUST
|
||||
# survive `mosaic update` (which runs this sync automatically): the active
|
||||
# rosters (`fleet/roster.yaml` and `fleet/roster.json`), per-agent env
|
||||
# (`fleet/agents/`), heartbeat run dir (`fleet/run/`), and the Mosaic-native
|
||||
# backlog-of-record store (`fleet/backlog/` — embedded PGlite data dir; see
|
||||
# packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update
|
||||
# wipes the operator's fleet AND their backlog. Glob entries are honored by
|
||||
# both the rsync path (`--exclude`) and the glob-aware cp fallback below.
|
||||
#
|
||||
# fleet/roles.local — the persona OVERRIDE layer (H4). Baseline personas in
|
||||
# fleet/roles/ are reseeded normally on every update (delivering new baseline
|
||||
# personas), so any local edit there would be clobbered. User customizations
|
||||
# and user-ADDED personas instead live in fleet/roles.local/ and MUST survive
|
||||
# `mosaic update` — they win over the baseline on merge (AC-NS-7; see
|
||||
# packages/mosaic/src/commands/fleet-personas.ts).
|
||||
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/roster.yaml" "fleet/roster.json" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")
|
||||
|
||||
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
|
||||
# user must not edit them; a divergent copy is backed up once before overwrite).
|
||||
@@ -171,72 +184,63 @@ sync_framework() {
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
# The `mosaic update` path. Manifest-driven, never-deleting-outside-framework:
|
||||
# operator config is structurally protected (#791). No rsync --delete here.
|
||||
manifest_load
|
||||
sync_framework_keep
|
||||
return
|
||||
fi
|
||||
|
||||
# overwrite mode — a full replace, chosen only for a fresh install or when the
|
||||
# operator explicitly asks to replace everything. No operator state to protect.
|
||||
sync_framework_overwrite
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
# 1) Overlay copy — every framework-owned source file, refreshed only when its
|
||||
# bytes changed (no mtime churn on unchanged files, never on operator files).
|
||||
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 < <(find "$src" -type f -print0)
|
||||
|
||||
# 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.
|
||||
while IFS= read -r root; do
|
||||
[[ -n "$root" && -d "$dst/$root" ]] || continue
|
||||
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 < <(find "$dst/$root" -type f -print0)
|
||||
# Drop framework dirs left empty by the prune (never touches a dir that still
|
||||
# holds an operator file — those are never emptied).
|
||||
find "$dst/$root" -type d -empty -delete 2>/dev/null || true
|
||||
done < <(manifest_subtree_roots)
|
||||
}
|
||||
|
||||
# Overwrite-mode sync: full replace. Only reached for a fresh install or an
|
||||
# explicit operator "replace everything" choice, so nothing is preserved.
|
||||
sync_framework_overwrite() {
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a --delete \
|
||||
--exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak" \
|
||||
"$SOURCE_DIR/" "$TARGET_DIR/"
|
||||
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak")
|
||||
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
# Anchor to the transfer root (leading /) so we preserve the TOP-LEVEL
|
||||
# ~/.config/mosaic/<file> without also excluding defaults/<file> from sync
|
||||
# (reconcile_framework_files needs the freshly-synced defaults/ copies).
|
||||
for path in "${PRESERVE_PATHS[@]}"; do
|
||||
rsync_args+=(--exclude "/$path")
|
||||
done
|
||||
fi
|
||||
|
||||
rsync "${rsync_args[@]}" "$SOURCE_DIR/" "$TARGET_DIR/"
|
||||
return
|
||||
fi
|
||||
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 \
|
||||
! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" \
|
||||
-exec rm -rf {} +
|
||||
|
||||
# Fallback: cp-based sync. Exact top-level preserved paths mirror the
|
||||
# root-anchored rsync excludes above.
|
||||
local preserve_tmp=""
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"
|
||||
local match rel
|
||||
for path in "${PRESERVE_PATHS[@]}"; do
|
||||
# Unquoted $path lets the glob expand against TARGET_DIR; nullglob makes a
|
||||
# non-matching pattern vanish instead of staying literal.
|
||||
shopt -s nullglob
|
||||
for match in "$TARGET_DIR/"$path; do
|
||||
[[ -e "$match" ]] || continue
|
||||
rel="${match#"$TARGET_DIR/"}"
|
||||
mkdir -p "$preserve_tmp/$(dirname "$rel")"
|
||||
cp -R "$match" "$preserve_tmp/$rel"
|
||||
done
|
||||
shopt -u nullglob
|
||||
done
|
||||
fi
|
||||
|
||||
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" -exec rm -rf {} +
|
||||
cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/
|
||||
rm -rf "$TARGET_DIR/.git"
|
||||
|
||||
if [[ -n "$preserve_tmp" ]]; then
|
||||
# Restore by re-globbing the SAME patterns against preserve_tmp, so each
|
||||
# preserved item is restored at its own relative path (e.g. only
|
||||
# fleet/roster.yaml is replaced — the freshly-synced fleet/examples stays).
|
||||
for path in "${PRESERVE_PATHS[@]}"; do
|
||||
shopt -s nullglob
|
||||
for match in "$preserve_tmp/"$path; do
|
||||
[[ -e "$match" ]] || continue
|
||||
rel="${match#"$preserve_tmp/"}"
|
||||
rm -rf "$TARGET_DIR/$rel"
|
||||
mkdir -p "$TARGET_DIR/$(dirname "$rel")"
|
||||
cp -R "$match" "$TARGET_DIR/$rel"
|
||||
done
|
||||
shopt -u nullglob
|
||||
done
|
||||
rm -rf "$preserve_tmp"
|
||||
fi
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared bash reader for framework-manifest.txt (#791).
|
||||
#
|
||||
# This is the bash half of the SSOT ownership resolver; the TypeScript half is
|
||||
# packages/mosaic/src/framework/manifest.ts. BOTH read the same
|
||||
# framework-manifest.txt and MUST resolve identical ownership for any path — the
|
||||
# parity test (manifest-parity.spec.ts) invokes this file's `resolve` CLI and
|
||||
# compares it against the TS resolver, so the two can never drift (the #631
|
||||
# two-copies failure class this closes).
|
||||
#
|
||||
# Ownership resolution (deny-wins / fail-safe):
|
||||
# 1. operator glob matches -> operator
|
||||
# 2. else framework glob -> framework
|
||||
# 3. else -> operator (UNKNOWN defaults to operator, #791)
|
||||
#
|
||||
# Globs are compiled once at load into exact-prefix checks or POSIX EREs, so the
|
||||
# hot resolver (manifest_is_framework) forks no subprocesses — the installer
|
||||
# calls it once per file across the whole tree.
|
||||
#
|
||||
# Usage as a library (source it, then):
|
||||
# manifest_load [manifest-file] # populates + compiles the manifest
|
||||
# manifest_is_framework <rel-path> # rc 0 = framework-owned, rc 1 = operator
|
||||
# manifest_resolve <rel-path> # echoes: framework | operator
|
||||
# manifest_subtree_roots # echoes shipped framework `dir/**` roots
|
||||
#
|
||||
# Usage as a CLI (parity harness):
|
||||
# bash manifest.sh resolve <rel-path>
|
||||
# bash manifest.sh subtree-roots
|
||||
# bash manifest.sh classify # reads paths on stdin -> "<own>\t<path>"
|
||||
|
||||
MANIFEST_FRAMEWORK=()
|
||||
MANIFEST_OPERATOR=()
|
||||
|
||||
# Compiled forms (parallel arrays). _*_KIND[i] is "exact" or "re".
|
||||
_MF_KIND=(); _MF_EXACT=(); _MF_RE=()
|
||||
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
|
||||
_MF_ROOTS=()
|
||||
|
||||
_manifest_default_root() { cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd; }
|
||||
|
||||
# Normalize a path/glob: backslashes -> slashes, strip leading ./ and /, strip
|
||||
# trailing / (mirrors normalizeRel in manifest.ts).
|
||||
_manifest_norm() {
|
||||
local p="$1"
|
||||
p="${p//\\//}"
|
||||
p="${p#./}"
|
||||
while [[ "$p" == /* ]]; do p="${p#/}"; done
|
||||
while [[ "$p" == */ ]]; do p="${p%/}"; done
|
||||
printf '%s' "$p"
|
||||
}
|
||||
|
||||
# Translate a normalized glob into a POSIX ERE body (mirrors globToRegExpBody).
|
||||
_manifest_glob_to_ere() {
|
||||
local pattern; pattern="$(_manifest_norm "$1")"
|
||||
local out="" c n i len=${#pattern} trailing
|
||||
for (( i = 0; i < len; i++ )); do
|
||||
c="${pattern:i:1}"
|
||||
if [[ "$c" == "*" ]]; then
|
||||
n="${pattern:i+1:1}"
|
||||
if [[ "$n" == "*" ]]; then
|
||||
i=$((i + 1))
|
||||
trailing=0
|
||||
if [[ "${pattern:i+1:1}" == "/" ]]; then i=$((i + 1)); trailing=1; fi
|
||||
if [[ "$out" == */ ]]; then
|
||||
out="${out%/}(/.*)?"
|
||||
elif [[ "$trailing" -eq 1 ]]; then
|
||||
out="$out(.*/)?"
|
||||
else
|
||||
out="$out.*"
|
||||
fi
|
||||
else
|
||||
out="$out[^/]*"
|
||||
fi
|
||||
else
|
||||
case "$c" in
|
||||
.|+|\?|^|\$|\{|\}|\(|\)|\||\[|\]|\\) out="$out\\$c" ;;
|
||||
*) out="$out$c" ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# Compile one raw glob into (kind, exact, re) appended to the given section.
|
||||
# $1 = raw glob, $2 = section letter (F|O).
|
||||
_manifest_compile_one() {
|
||||
local norm; norm="$(_manifest_norm "$1")"
|
||||
[[ -n "$norm" ]] || return 0
|
||||
if [[ "$norm" == *"*"* ]]; then
|
||||
local re="^$(_manifest_glob_to_ere "$norm")\$"
|
||||
if [[ "$2" == F ]]; then
|
||||
_MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re")
|
||||
else
|
||||
_MO_KIND+=(re); _MO_EXACT+=(""); _MO_RE+=("$re")
|
||||
fi
|
||||
else
|
||||
if [[ "$2" == F ]]; then
|
||||
_MF_KIND+=(exact); _MF_EXACT+=("$norm"); _MF_RE+=("")
|
||||
else
|
||||
_MO_KIND+=(exact); _MO_EXACT+=("$norm"); _MO_RE+=("")
|
||||
fi
|
||||
fi
|
||||
[[ "$2" == F && "$norm" == */"**" ]] && _MF_ROOTS+=("${norm%/**}")
|
||||
return 0
|
||||
}
|
||||
|
||||
_manifest_compile() {
|
||||
_MF_KIND=(); _MF_EXACT=(); _MF_RE=(); _MF_ROOTS=()
|
||||
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
|
||||
local g
|
||||
for g in "${MANIFEST_FRAMEWORK[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" F; done
|
||||
for g in "${MANIFEST_OPERATOR[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" O; done
|
||||
}
|
||||
|
||||
# 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"
|
||||
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"
|
||||
_manifest_compile
|
||||
}
|
||||
|
||||
# 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
|
||||
manifest_load "${MANIFEST_FILE:-}"
|
||||
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
|
||||
@@ -61,10 +61,8 @@ MOSAIC_HOME="$T5" MOSAIC_INSTALL_MODE=bogus MOSAIC_SYNC_ONLY=1 bash "$INSTALL" >
|
||||
chk "F5 failure: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]"
|
||||
chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
|
||||
|
||||
# F6 — keep-mode re-seed (the `mosaic update` path) MUST preserve ALL user-owned
|
||||
# fleet state — including an unanticipated file the manifest never names, which
|
||||
# resolves to operator-owned by the #791 fail-safe — while refreshing the
|
||||
# framework-owned schema/examples.
|
||||
# F6 — keep-mode re-seed (the `mosaic update` path) MUST preserve only the
|
||||
# exact user-owned roster paths while refreshing framework-owned schema/examples.
|
||||
T6=$(mktemp -d); mkdir -p "$T6/fleet/examples" "$T6/fleet/run" "$T6/fleet/agents"
|
||||
printf '# persona\n' > "$T6/SOUL.md" # makes it a recognized existing install (→ keep mode)
|
||||
printf 'version: 1\nagents:\n - name: coder0\n' > "$T6/fleet/roster.yaml"
|
||||
@@ -77,14 +75,13 @@ printf '{"stale":true}\n' > "$T6/fleet/roster.schema.json"
|
||||
E6=$(mktemp -d)
|
||||
cp "$T6/fleet/roster.yaml" "$E6/roster-yaml.expected"
|
||||
cp "$T6/fleet/roster.json" "$E6/roster-json.expected"
|
||||
cp "$T6/fleet/my-fleet.yaml" "$E6/my-fleet.expected"
|
||||
cp "$T6/fleet/run/coder0.hb" "$E6/run.expected"
|
||||
cp "$T6/fleet/agents/coder0.env" "$E6/agent.expected"
|
||||
echo 3 > "$T6/.framework-version"
|
||||
run "$T6" keep
|
||||
chk "F6 reseed: exact roster.yaml bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.yaml' '$E6/roster-yaml.expected'"
|
||||
chk "F6 reseed: exact roster.json bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.json' '$E6/roster-json.expected'"
|
||||
chk "F6 reseed: unanticipated operator fleet file survives (fail-safe, #791)" "cmp -s '$T6/fleet/my-fleet.yaml' '$E6/my-fleet.expected'"
|
||||
chk "F6 reseed: unrelated fleet YAML is not preserved" "[ ! -f '$T6/fleet/my-fleet.yaml' ]"
|
||||
chk "F6 reseed: per-agent env bytes survive" "cmp -s '$T6/fleet/agents/coder0.env' '$E6/agent.expected'"
|
||||
chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'"
|
||||
chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-upgrade-manifest-guard.sh — the #791 HARD GATE.
|
||||
#
|
||||
# Proves that a keep-mode framework upgrade (the `mosaic update` path:
|
||||
# install.sh with MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1) touches NO path
|
||||
# outside the framework-owned manifest. Every operator-owned sentinel — including
|
||||
# a deliberately UNANTICIPATED one the manifest never names — must survive
|
||||
# byte-identical with an unchanged mtime (not even rewritten). Framework files
|
||||
# must still update, and a retired framework file inside a shipped subtree must
|
||||
# still be pruned. No operator secret value may appear in installer output.
|
||||
#
|
||||
# Runs the whole matrix twice: once with rsync available, once forcing the
|
||||
# cp-based fallback (both destructive paths in install.sh must obey the manifest).
|
||||
#
|
||||
# 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; }
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
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) cp fallback path — hide rsync behind a scratch PATH so install.sh takes the
|
||||
# find/rm+cp branch. Provide the coreutils the fallback needs.
|
||||
FBIN=$(mktemp -d)
|
||||
for t in bash cp find mktemp rm mkdir chmod cmp sed grep cat dirname basename stat sha256sum awk tr; do
|
||||
p=$(command -v "$t" 2>/dev/null) && ln -s "$p" "$FBIN/$t"
|
||||
done
|
||||
run_matrix "cp-fallback" env "PATH=$FBIN"
|
||||
rm -rf "$FBIN"
|
||||
|
||||
echo
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
[ "$fail" -eq 0 ]
|
||||
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' };
|
||||
}
|
||||
@@ -1,22 +1,9 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
copyFileSync,
|
||||
} from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { FileConfigAdapter, DEFAULT_SEED_FILES } from './file-adapter.js';
|
||||
|
||||
// The real shipping manifest — the fixture uses it verbatim so these tests
|
||||
// exercise production ownership resolution, not a synthetic copy (#791).
|
||||
const REAL_FRAMEWORK_ROOT = fileURLToPath(new URL('../../framework', import.meta.url));
|
||||
|
||||
/**
|
||||
* Regression tests for the `FileConfigAdapter.syncFramework` seed behavior.
|
||||
*
|
||||
@@ -47,13 +34,6 @@ function makeFixture(): { sourceDir: string; mosaicHome: string; defaultsDir: st
|
||||
mkdirSync(defaultsDir, { recursive: true });
|
||||
mkdirSync(mosaicHome, { recursive: true });
|
||||
|
||||
// #791: syncFramework resolves ownership from the shared manifest under the
|
||||
// source dir. Seed the real one so keep-mode syncs behave as in production.
|
||||
copyFileSync(
|
||||
join(REAL_FRAMEWORK_ROOT, 'framework-manifest.txt'),
|
||||
join(sourceDir, 'framework-manifest.txt'),
|
||||
);
|
||||
|
||||
// Framework-contract defaults we expect the wizard to seed.
|
||||
writeFileSync(join(defaultsDir, 'CONSTITUTION.md'), '# CONSTITUTION default\n');
|
||||
writeFileSync(join(defaultsDir, 'AGENTS.md'), '# AGENTS default\n');
|
||||
@@ -122,10 +102,9 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
});
|
||||
|
||||
it('overwrites framework-owned files (backup-once) but preserves user-seeded files', async () => {
|
||||
// Contract files (CONSTITUTION/AGENTS/STANDARDS) ship only under defaults/ —
|
||||
// reconcile_framework_files is their sole writer (backup-once). The bulk
|
||||
// sync never sees a root-level copy, so a user's edited root file is backed
|
||||
// up, not silently clobbered, on upgrade.
|
||||
// Plant a root-level AGENTS.md in sourceDir so syncDirectory's preserve is exercised.
|
||||
writeFileSync(join(fixture.sourceDir, 'AGENTS.md'), '# shipped AGENTS from source root\n');
|
||||
|
||||
writeFileSync(join(fixture.mosaicHome, 'TOOLS.md'), '# user-customized TOOLS\n');
|
||||
writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n');
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
buildToolsTemplateVars,
|
||||
} from '../template/builders.js';
|
||||
import { atomicWrite, backupFile, syncDirectory } from '../platform/file-ops.js';
|
||||
import { loadManifest, resolveOwnership } from '../framework/manifest.js';
|
||||
|
||||
/**
|
||||
* Parse a SoulConfig from an existing SOUL.md file.
|
||||
@@ -156,22 +155,38 @@ export class FileConfigAdapter implements ConfigService {
|
||||
}
|
||||
|
||||
async syncFramework(action: InstallAction): Promise<void> {
|
||||
// #791: ownership is derived from the shared framework manifest
|
||||
// (packages/mosaic/framework/framework-manifest.txt) — the SAME file the
|
||||
// bash installer reads — so the TS and bash paths can never drift. On an
|
||||
// upgrade (keep/reconfigure) the sync must NEVER write an operator-owned
|
||||
// path: every operator file, and any path the manifest never anticipated
|
||||
// (which resolves to operator by the fail-safe default), is left untouched.
|
||||
// A fresh install ('overwrite'/'reconfigure' onto an empty home) seeds the
|
||||
// full tree, so the guard applies only when preserving an existing home.
|
||||
const guardOwnership = action === 'keep' || action === 'reconfigure';
|
||||
const manifest = guardOwnership ? loadManifest(this.sourceDir) : undefined;
|
||||
// Must match PRESERVE_PATHS in packages/mosaic/framework/install.sh so
|
||||
// the bash and TS install paths have the same upgrade-preservation
|
||||
// semantics. Contract files (AGENTS.md, STANDARDS.md, TOOLS.md) are
|
||||
// seeded from defaults/ on first install and preserved thereafter;
|
||||
// identity files (SOUL.md, USER.md) are generated by wizard stages and
|
||||
// must never be touched by the framework sync.
|
||||
const preservePaths =
|
||||
action === 'keep' || action === 'reconfigure'
|
||||
? [
|
||||
'CONSTITUTION.md',
|
||||
'AGENTS.md',
|
||||
'SOUL.md',
|
||||
'USER.md',
|
||||
'TOOLS.md',
|
||||
'STANDARDS.md',
|
||||
'memory',
|
||||
'sources',
|
||||
'credentials',
|
||||
// User-authored fleet data MUST survive `mosaic update`'s re-seed.
|
||||
// The framework seeds only fleet/examples + fleet/roles +
|
||||
// fleet/roster.schema.json; the operator's roster, per-agent env, and
|
||||
// heartbeat run dir stay user-owned. (Mirror of install.sh PRESERVE_PATHS.)
|
||||
'fleet/roster.yaml',
|
||||
'fleet/roster.json',
|
||||
'fleet/agents',
|
||||
'fleet/run',
|
||||
]
|
||||
: [];
|
||||
|
||||
syncDirectory(this.sourceDir, this.mosaicHome, {
|
||||
preserve: preservePaths,
|
||||
excludeGit: true,
|
||||
isOperatorOwned: manifest
|
||||
? (relPath) => resolveOwnership(manifest, relPath) === 'operator'
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Reconcile framework-contract files from framework/defaults/ into the mosaic
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
import { afterAll, describe, it, expect } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, 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);
|
||||
}
|
||||
|
||||
// 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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,263 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
parseManifest,
|
||||
loadManifest,
|
||||
matchGlob,
|
||||
resolveOwnership,
|
||||
frameworkSubtreeRoots,
|
||||
planPrune,
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchGlob', () => {
|
||||
it('matches an exact file', () => {
|
||||
expect(matchGlob('CONSTITUTION.md', 'CONSTITUTION.md')).toBe(true);
|
||||
expect(matchGlob('CONSTITUTION.md', 'AGENTS.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a bare directory entry as covering its descendants', () => {
|
||||
expect(matchGlob('memory', 'memory')).toBe(true);
|
||||
expect(matchGlob('memory', 'memory/notes.md')).toBe(true);
|
||||
expect(matchGlob('memory', 'memoryfoo')).toBe(false);
|
||||
});
|
||||
|
||||
it('** matches any depth including the root itself', () => {
|
||||
expect(matchGlob('agents/**', 'agents')).toBe(true);
|
||||
expect(matchGlob('agents/**', 'agents/a.conf')).toBe(true);
|
||||
expect(matchGlob('agents/**', 'agents/nested/deep.conf')).toBe(true);
|
||||
expect(matchGlob('agents/**', 'agentsX')).toBe(false);
|
||||
});
|
||||
|
||||
it('* stays within a single segment', () => {
|
||||
expect(matchGlob('*.local.md', 'SOUL.local.md')).toBe(true);
|
||||
expect(matchGlob('*.local.md', 'a/SOUL.local.md')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOwnership (deny-wins + fail-safe)', () => {
|
||||
const m = parseManifest(SAMPLE);
|
||||
|
||||
it('operator globs win over framework globs (carve-out inside a framework subtree)', () => {
|
||||
expect(resolveOwnership(m, 'tools/_lib/credentials.json')).toBe('operator');
|
||||
expect(resolveOwnership(m, 'tools/git/pr-create.sh')).toBe('framework');
|
||||
});
|
||||
|
||||
it('framework-declared paths resolve to framework', () => {
|
||||
expect(resolveOwnership(m, 'guides/E2E-DELIVERY.md')).toBe('framework');
|
||||
expect(resolveOwnership(m, 'CONSTITUTION.md')).toBe('framework');
|
||||
});
|
||||
|
||||
it('UNKNOWN paths default to operator (the #791 root-cause guarantee)', () => {
|
||||
expect(resolveOwnership(m, 'agents/coder0.conf')).toBe('operator'); // declared
|
||||
expect(resolveOwnership(m, 'harvester/sop.md')).toBe('operator'); // undeclared → fail-safe
|
||||
expect(resolveOwnership(m, 'totally-unknown-dir/x')).toBe('operator');
|
||||
expect(resolveOwnership(m, 'random-root-file.md')).toBe('operator');
|
||||
});
|
||||
});
|
||||
|
||||
describe('planPrune (pure prune planner)', () => {
|
||||
const m = parseManifest(SAMPLE);
|
||||
|
||||
it('prunes a retired framework file inside a shipped subtree', () => {
|
||||
const del = planPrune({
|
||||
manifest: m,
|
||||
targetPaths: ['guides/OLD.md', 'guides/KEEP.md'],
|
||||
sourcePaths: ['guides/KEEP.md'],
|
||||
});
|
||||
expect(del).toEqual(['guides/OLD.md']);
|
||||
});
|
||||
|
||||
it('never prunes operator-reserved paths even when absent from source', () => {
|
||||
const del = planPrune({
|
||||
manifest: m,
|
||||
targetPaths: ['agents/coder0.conf', 'tools/_lib/credentials.json', 'SOUL.local.md'],
|
||||
sourcePaths: [],
|
||||
});
|
||||
expect(del).toEqual([]);
|
||||
});
|
||||
|
||||
it('never prunes UNKNOWN paths outside every framework subtree (fail-safe)', () => {
|
||||
const del = planPrune({
|
||||
manifest: m,
|
||||
targetPaths: ['harvester/sop.md', 'my-fleet.yaml', 'unknown-dir/deep/x'],
|
||||
sourcePaths: [],
|
||||
});
|
||||
expect(del).toEqual([]);
|
||||
});
|
||||
|
||||
it('never prunes single-file framework entries (reconcile-managed, not in subtree)', () => {
|
||||
const del = planPrune({ manifest: m, targetPaths: ['CONSTITUTION.md'], sourcePaths: [] });
|
||||
expect(del).toEqual([]);
|
||||
});
|
||||
|
||||
it('property: delete-set ⊆ {framework-owned ∧ in-target ∧ not-in-source} and ∩ operator = ∅', () => {
|
||||
const operatorish = [
|
||||
'agents/a.conf',
|
||||
'policy/p.md',
|
||||
'SOUL.local.md',
|
||||
'memory/m.md',
|
||||
'tools/_lib/credentials.json',
|
||||
'harvester/sop.md',
|
||||
'unknown-top/x',
|
||||
'another-unknown/deep/y.txt',
|
||||
];
|
||||
const frameworkish = ['guides/A.md', 'guides/sub/B.md', 'tools/git/x.sh'];
|
||||
const targetPaths = [...operatorish, ...frameworkish];
|
||||
const del = planPrune({ manifest: m, targetPaths, sourcePaths: [] });
|
||||
|
||||
for (const p of del) {
|
||||
expect(resolveOwnership(m, p)).toBe('framework');
|
||||
expect(targetPaths).toContain(p);
|
||||
}
|
||||
// No operator/unknown path ever appears in the delete-set.
|
||||
for (const p of operatorish) expect(del).not.toContain(p);
|
||||
});
|
||||
});
|
||||
|
||||
// The #797 Runtime Session Ledger lives at fleet/run/sessions/. Today it is safe
|
||||
// twice over: it matches the explicit `fleet/run/**` operator carve-out AND, even
|
||||
// without it, the UNKNOWN→operator fail-safe. This test isolates the CARVE-OUT's
|
||||
// load-bearing value by simulating a future framework author who broadens fleet
|
||||
// ownership to `fleet/**`: without the operator carve-out the ledger would resolve
|
||||
// framework and be pruned; deny-wins is what keeps it protected. If deleting the
|
||||
// `fleet/run/**` line ever stops turning this test red, the carve-out has silently
|
||||
// stopped mattering — which is exactly the #797 regression we are gating against.
|
||||
describe('fleet/run/** carve-out is load-bearing for the #797 ledger (deny-wins)', () => {
|
||||
const LEDGER = ['fleet/run/sessions/events.ndjson', 'fleet/run/sessions/ledger.json'];
|
||||
// A framework that (hypothetically) ships all of fleet/** as a subtree.
|
||||
const withoutCarveOut: FrameworkManifest = {
|
||||
framework: ['fleet/**'],
|
||||
operator: [],
|
||||
};
|
||||
const withCarveOut: FrameworkManifest = {
|
||||
framework: ['fleet/**'],
|
||||
operator: ['fleet/run/**'],
|
||||
};
|
||||
|
||||
it('RED without the carve-out: the ledger resolves framework and is pruned', () => {
|
||||
for (const p of LEDGER) expect(resolveOwnership(withoutCarveOut, p)).toBe('framework');
|
||||
const del = planPrune({ manifest: withoutCarveOut, targetPaths: LEDGER, sourcePaths: [] });
|
||||
expect(del.sort()).toEqual([...LEDGER].sort());
|
||||
});
|
||||
|
||||
it('GREEN with the carve-out: deny-wins makes the ledger operator and unprunable', () => {
|
||||
for (const p of LEDGER) expect(resolveOwnership(withCarveOut, p)).toBe('operator');
|
||||
const del = planPrune({ manifest: withCarveOut, targetPaths: LEDGER, sourcePaths: [] });
|
||||
expect(del).toEqual([]);
|
||||
});
|
||||
|
||||
it('the SHIPPED manifest reserves fleet/run/** so the ledger is operator-owned', () => {
|
||||
const shipped = loadManifest(FRAMEWORK_ROOT);
|
||||
for (const p of LEDGER) expect(resolveOwnership(shipped, p)).toBe('operator');
|
||||
// And it is structurally unreachable by pruning even if it were in a subtree.
|
||||
expect(planPrune({ manifest: shipped, targetPaths: LEDGER, sourcePaths: [] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('frameworkSubtreeRoots', () => {
|
||||
it('returns only the /** subtree roots, not single-file entries', () => {
|
||||
const m = parseManifest(SAMPLE);
|
||||
expect(frameworkSubtreeRoots(m)).toEqual(['guides', 'tools']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── SSOT manifest: shipped-file completeness (§6.2) ──────────────────────────
|
||||
// A newly-shipped framework file must not silently fall outside the manifest —
|
||||
// if it did, the updater could neither guarantee it as framework-owned nor
|
||||
// prune it when retired. Every file the framework actually ships must resolve
|
||||
// to `framework` (except the defaults/{SOUL,USER}.md identity seeds, which are
|
||||
// operator-owned by design).
|
||||
describe('manifest completeness against shipped framework tree', () => {
|
||||
const manifest = loadManifest(FRAMEWORK_ROOT);
|
||||
|
||||
const IGNORED_TOP = new Set(['.git', 'node_modules']);
|
||||
// Framework-shipped files that are operator-owned by design: the identity
|
||||
// seeds under defaults/, and the `.gitkeep` placeholder that lets the empty
|
||||
// operator-owned memory/ directory exist in git.
|
||||
function isOperatorShipped(rel: string): boolean {
|
||||
if (rel === 'defaults/SOUL.md' || rel === 'defaults/USER.md') return true;
|
||||
if (rel.startsWith('memory/')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const abs = join(dir, entry);
|
||||
const rel = relative(FRAMEWORK_ROOT, abs);
|
||||
if (IGNORED_TOP.has(rel)) continue;
|
||||
if (statSync(abs).isDirectory()) out.push(...walk(abs));
|
||||
else out.push(rel);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it('every shipped framework file resolves to framework ownership', () => {
|
||||
const shipped = walk(FRAMEWORK_ROOT);
|
||||
const misclassified = shipped.filter(
|
||||
(p) => !isOperatorShipped(p) && resolveOwnership(manifest, p) !== 'framework',
|
||||
);
|
||||
expect(misclassified).toEqual([]);
|
||||
});
|
||||
|
||||
it('the operator-owned surface from #791 resolves to operator', () => {
|
||||
const operatorPaths = [
|
||||
'agents/coder0.conf',
|
||||
'fleet/agents/coder0.env',
|
||||
'memory/note.md',
|
||||
'policy/custom.md',
|
||||
'SOUL.local.md',
|
||||
'USER.local.md',
|
||||
'STANDARDS.local.md',
|
||||
'tools/_lib/credentials.json',
|
||||
'fleet/roster.yaml',
|
||||
'fleet/roster.json',
|
||||
'fleet/run/coder0.hb',
|
||||
'fleet/backlog/data.db',
|
||||
'fleet/roles.local/custom.md',
|
||||
];
|
||||
for (const p of operatorPaths) {
|
||||
expect(resolveOwnership(manifest, p), p).toBe('operator');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Framework path-ownership manifest (#791).
|
||||
*
|
||||
* The updater must operate from an explicit framework-owned path manifest and
|
||||
* NEVER write outside it. This module is the TypeScript reader for the shared
|
||||
* SSOT manifest (`packages/mosaic/framework/framework-manifest.txt`) that the
|
||||
* bash installer also consumes. Keeping both paths on one data file is what
|
||||
* closes the two-copies-drift failure class (see #631 → #791).
|
||||
*
|
||||
* Everything here is pure (parse + resolve + plan) so the ownership guarantee
|
||||
* is unit- and property-testable without touching the filesystem.
|
||||
*/
|
||||
|
||||
export type Ownership = 'framework' | 'operator';
|
||||
|
||||
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 Error(`Unknown manifest section header on line ${i + 1}: ${line}`);
|
||||
}
|
||||
|
||||
if (section === null) {
|
||||
throw new Error(`Manifest entry before any [section] header on line ${i + 1}: ${line}`);
|
||||
}
|
||||
(section === 'framework' ? framework : operator).push(line);
|
||||
}
|
||||
|
||||
return { framework, operator };
|
||||
}
|
||||
|
||||
/** Read and parse the manifest from a framework root directory. */
|
||||
export function loadManifest(frameworkRoot: string): FrameworkManifest {
|
||||
const text = readFileSync(`${frameworkRoot}/framework-manifest.txt`, 'utf-8');
|
||||
return parseManifest(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a mosaic-home-relative POSIX path against one glob.
|
||||
*
|
||||
* Supported: `**` (any depth, including zero segments) and `*` (any run of
|
||||
* characters within a single segment, not crossing `/`). A glob with no
|
||||
* wildcard matches either the exact path OR any path beneath it (so a bare
|
||||
* directory entry like `memory` covers `memory/notes.md`).
|
||||
*/
|
||||
export function matchGlob(glob: string, relPath: string): boolean {
|
||||
const path = normalizeRel(relPath);
|
||||
const pattern = normalizeRel(glob);
|
||||
if (pattern === '') return false;
|
||||
|
||||
if (!pattern.includes('*')) {
|
||||
// Exact file, or any descendant of a bare directory prefix.
|
||||
return path === pattern || path.startsWith(`${pattern}/`);
|
||||
}
|
||||
|
||||
const re = new RegExp(`^${globToRegExpBody(pattern)}$`);
|
||||
return re.test(path);
|
||||
}
|
||||
|
||||
/** True if the path matches any glob in the list. */
|
||||
export function matchesAny(globs: readonly string[], relPath: string): boolean {
|
||||
return globs.some((g) => matchGlob(g, relPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ownership of a mosaic-home-relative path (deny-wins / fail-safe):
|
||||
* operator globs win, then framework globs, else operator by default.
|
||||
*/
|
||||
export function resolveOwnership(manifest: FrameworkManifest, relPath: string): Ownership {
|
||||
if (matchesAny(manifest.operator, relPath)) return 'operator';
|
||||
if (matchesAny(manifest.framework, relPath)) return 'framework';
|
||||
return 'operator';
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of `[framework]` subtree roots that pruning is allowed to descend
|
||||
* into (glob entries of the form `dir/**`). Single-file framework entries
|
||||
* (e.g. `CONSTITUTION.md`) are reconcile-managed and never pruned.
|
||||
*/
|
||||
export function frameworkSubtreeRoots(manifest: FrameworkManifest): string[] {
|
||||
const roots: string[] = [];
|
||||
for (const g of manifest.framework) {
|
||||
if (g.endsWith('/**')) roots.push(g.slice(0, -3));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
export interface PrunePlanInput {
|
||||
readonly manifest: FrameworkManifest;
|
||||
/** Mosaic-home-relative paths currently present in the target. */
|
||||
readonly targetPaths: readonly string[];
|
||||
/** Mosaic-home-relative paths the framework currently ships (source). */
|
||||
readonly sourcePaths: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure prune planner — the testable seam of the #791 fix.
|
||||
*
|
||||
* Returns the delete-set: target paths that are framework-owned, live inside a
|
||||
* shipped framework subtree, and are absent from the current source (retired
|
||||
* framework files). By construction the result never contains an operator-owned
|
||||
* or unknown path: those either resolve to `operator` or fall outside every
|
||||
* framework subtree root, so they are structurally unreachable by pruning.
|
||||
*/
|
||||
export function planPrune(input: PrunePlanInput): string[] {
|
||||
const { manifest, targetPaths, sourcePaths } = input;
|
||||
const source = new Set(sourcePaths.map(normalizeRel));
|
||||
const roots = frameworkSubtreeRoots(manifest);
|
||||
|
||||
const deleteSet: string[] = [];
|
||||
for (const raw of targetPaths) {
|
||||
const path = normalizeRel(raw);
|
||||
if (source.has(path)) continue; // still shipped — keep
|
||||
if (resolveOwnership(manifest, path) !== 'framework') continue; // operator/unknown — never prune
|
||||
if (!roots.some((root) => path === root || path.startsWith(`${root}/`))) continue; // outside shipped subtrees
|
||||
deleteSet.push(path);
|
||||
}
|
||||
return deleteSet;
|
||||
}
|
||||
|
||||
function normalizeRel(p: string): string {
|
||||
return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** Translate a glob body (already normalized) into a RegExp source fragment. */
|
||||
function globToRegExpBody(pattern: string): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < pattern.length; i++) {
|
||||
const c = pattern[i];
|
||||
if (c === undefined) continue;
|
||||
if (c === '*') {
|
||||
if (pattern[i + 1] === '*') {
|
||||
// `**` — any depth. `a/**` must also match the bare root `a`, so when a
|
||||
// literal `/` was just emitted, make it optional along with the rest.
|
||||
i++;
|
||||
let trailingSlash = false;
|
||||
if (pattern[i + 1] === '/') {
|
||||
i++;
|
||||
trailingSlash = true;
|
||||
}
|
||||
if (out.endsWith('/')) {
|
||||
out = `${out.slice(0, -1)}(?:/.*)?`;
|
||||
} else if (trailingSlash) {
|
||||
out += '(?:.*/)?';
|
||||
} else {
|
||||
out += '.*';
|
||||
}
|
||||
} else {
|
||||
out += '[^/]*';
|
||||
}
|
||||
} else {
|
||||
out += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -62,28 +62,16 @@ function rotateBackups(filePath: string): void {
|
||||
/**
|
||||
* Sync a source directory to a target, with optional preserve paths.
|
||||
* Replaces the rsync/cp logic from install.sh.
|
||||
*
|
||||
* `isOperatorOwned` is the #791 ownership guard: when supplied, any source path
|
||||
* it flags as operator-owned is never copied (the framework must never write an
|
||||
* operator path). Callers derive it from the shared framework manifest so the TS
|
||||
* and bash sync paths obey one source of truth. This copy is non-destructive —
|
||||
* it never deletes a target file — so honoring the guard is sufficient to leave
|
||||
* operator config untouched.
|
||||
*/
|
||||
export function syncDirectory(
|
||||
source: string,
|
||||
target: string,
|
||||
options: {
|
||||
preserve?: string[];
|
||||
excludeGit?: boolean;
|
||||
isOperatorOwned?: (relPath: string) => boolean;
|
||||
} = {},
|
||||
options: { preserve?: string[]; excludeGit?: boolean } = {},
|
||||
): void {
|
||||
// Guard: source and target are the same directory — nothing to sync
|
||||
if (resolve(source) === resolve(target)) return;
|
||||
|
||||
const preserveSet = new Set(options.preserve ?? []);
|
||||
const isOperatorOwned = options.isOperatorOwned ?? (() => false);
|
||||
|
||||
// Collect files from source
|
||||
function copyRecursive(src: string, dest: string, relBase: string): void {
|
||||
@@ -98,7 +86,7 @@ export function syncDirectory(
|
||||
if (options.excludeGit && (dirName === '.git' || relPath.includes('/.git'))) return;
|
||||
|
||||
// Skip preserved paths at top level
|
||||
if (relPath !== '' && preserveSet.has(relPath) && existsSync(dest)) return;
|
||||
if (preserveSet.has(relPath) && existsSync(dest)) return;
|
||||
|
||||
mkdirSync(dest, { recursive: true });
|
||||
for (const entry of readdirSync(src)) {
|
||||
@@ -113,10 +101,6 @@ export function syncDirectory(
|
||||
// Skip preserved files at top level
|
||||
if (preserveSet.has(relPath) && existsSync(dest)) return;
|
||||
|
||||
// #791: never write an operator-owned path (the framework owns only its
|
||||
// own files; unknown paths resolve to operator and are skipped too).
|
||||
if (isOperatorOwned(relPath)) return;
|
||||
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
copyFileSync(src, dest);
|
||||
}
|
||||
|
||||
@@ -488,13 +488,9 @@ export function getInstallAllCommand(outdated: PackageUpdateResult[]): string {
|
||||
// `mosaic update` installs the new npm CLI but, on its own, leaves the framework
|
||||
// files in ~/.config/mosaic/ stale — so shipped launcher/runtime changes (e.g.
|
||||
// the agent-name export + native heartbeat) never ACTIVATE until a re-seed.
|
||||
// These helpers run the package's own install.sh in sync-only mode. The re-seed
|
||||
// is manifest-driven (#791): keep mode writes ONLY framework-owned paths from the
|
||||
// shared framework-manifest.txt and prunes only retired framework files inside
|
||||
// shipped subtrees — every operator path (SOUL/USER/*.local/credentials, fleet
|
||||
// roster + agents + backlog, and anything the manifest never anticipated) is
|
||||
// left byte-identical. Contract files are still reconciled (overwrite +
|
||||
// backup-once). Opt-in, this also relaunches durable agents.
|
||||
// These helpers run the package's own install.sh in sync-only mode (the P4
|
||||
// data-safe reconcile: framework-owned overwrite + backup-once; SOUL/USER/
|
||||
// *.local/credentials preserved) and, opt-in, relaunch durable agents.
|
||||
|
||||
/** Resolve the framework/ directory bundled in the installed package. */
|
||||
export function resolveBundledFrameworkRoot(): string {
|
||||
|
||||
Reference in New Issue
Block a user